GManNickG 提問:
由於某些怪異的原因,下面這段C++程式碼表現的異乎尋常—-當這段程式碼作用於有序資料時其速度可以提高將近6倍,這真是令人驚奇。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 |
#include <algorithm> #include <ctime> #include <iostream> int _tmain (int argc , _TCHAR * argv []) { //Generate data const unsigned arraySize = 32768; int data[arraySize]; for ( unsigned c = 0; c < arraySize; ++c) data[c] = std::rand() % 256; //!!! With this, the next loop runs faster std::sort(data, data + arraySize); //Test clock_t start = clock(); long long sum = 0; for ( unsigned i = 0; i < 100000; ++i){ //Primary loop for ( unsigned c = 0; c < arraySize; ++c){ if (data[c] >= 128) sum += data[c]; } } double eclapsedTime = static_cast<double >(clock() - start) / CLOCKS_PER_SEC; std::cout << eclapsedTime << std::endl; std::cout << "sum = " << sum << std::endl; return 0; } |
- 如果把 std::sort(data, data+arraySize) 去掉,這段程式碼耗時11.54秒。
- 對於有序資料,這段程式碼耗時1.93秒
起初我以為這可能是某一種語言或某一個編譯器發生的異常的事件,後來我在java語言寫了個例子,如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 |
import java.util.Arrays; import java.util.Random; public class Test_Sorted_UnSorted_Array { public static void main(String[] args) { //Generate data int arraySize = 32768; int data[] = new int[arraySize]; Random rnd = new Random(0); for( int c = 0; c<arraySize; ++c) data[c] = rnd.nextInt()%256; //!!! With this, the next loop runs faster Arrays. sort(data); //Test long start = System. nanoTime(); long sum = 0; for( int i=0; i<100000; ++i){ //Primary loop for( int c = 0; c<arraySize; ++c){ if(data[c] >=128) sum += data[c]; } } System. out.println((System. nanoTime() - start) / 1000000000.0); System. out.println( "sum = " + sum); } } |
上述例子執行結果和前面C++例子執行的結果差異,雖然沒有C++中那麼大,但是也有幾分相似。
對於上面的問題,我首先想的原因是排序可能會導致資料有快取,但是轉念一想之前原因有點不切實際,因為上面的陣列都是剛剛生成的,所以我的問題是:
- 上述程式碼執行時到底發生了什麼?
- 為什麼執行排好序的陣列會比亂序陣列快?
- 上述程式碼求和都是獨立的,而順序不應該會產生影響。
來自 Mysticial 的最佳回覆
你是分支預測(branch prediction )失敗的受害者。
什麼是分支預測?
考慮一個鐵路樞紐:
Imageby Mecanismo, via Wikimedia Commons. Used under theCC-By-SA 3.0license.
為了便於討論,假設現在是1800年,這時候還沒有出現遠端或廣播通訊工具。
你是一個鐵路樞紐的工人。當你聽到火車開來時,你不知道這個火車要走哪一條路,只有讓火車停下來詢問列車長火車要開往哪,最後你將軌道切換到相應的方向。
火車的質量非常大,固慣性很大,因此火車需要經常性的加速減速。
有沒有更好的方法喃?可以猜火車將行駛的方向應該是可行的!
- 如果猜對了,火車繼續往前走;
- 如果猜錯了,列車長會讓火車停下來,並後退,然後告訴你正確的方向,然後火車重新啟動開往正確的方向。
考慮一個if語句:在處理器級別上,他是一個分支指令:
你來扮演處理器,當你遇到一個分支,你不知道它要走哪條路,該怎麼辦?你可以停止執行並等待直到之前的指令執行完。然後繼續執行正確路徑的指令。
有沒有更好的方法喃?可以猜測哪個分支將要被執行!
- 如果猜對了,繼續執行;
- 如果猜錯了,你需要重新整理管道並且回退到該分支,重新啟動執行正確的方向。
如果每次都能猜對,整個執行過程就不會停止。
如果經常猜錯,就需要在停止、回退、重新執行上花費非常多的時間。
這就是分支預測。不得不承認這不是一個最好的比喻因為火車可以僅僅使用一個標誌表示其前進的方向。但是對於計算機,直到最後時刻,處理器是不知道哪條分支被執行。
想想可以使用什麼預測策略使得火車回退的次數最少?哈哈,可以利用歷史資料!如果火車100次有99次都是向左,那麼下次預測結果仍向左。如果過去資料是交替的,那麼預測結果也是交替的。如果它每3次都換一個方向,那麼預測也採用相同的方法。
簡而言之,你需要嘗試尋找出一個規則(模式)然後按照它進行預測就可以了。分支預測基本上就是這樣工作的。
大部分應用程式的分支是很規律的。這也是為什麼現代的分支預測的準確率基本上都在90%以上。但是當沒有規律、不可預測的分支時候,分支預測就顯得比較拙雞了。
關於分支預測更多詳細的內容可參閱:維基百科
從上面可以得到啟發,這個問題的“罪魁禍首”就是 if 語句
1 2 |
if (data[c] >= 128) um += data[c]; |
注意到資料是在0到255均勻分佈的。當排好序後,小於等於128的前半部分是不會執行if語句的,大於128的後半部分都會進入if語句。
這是非常有好的分支預測因為分支會連續多次執行相同的分支。即使是一個簡單的飽和計數器也會預測正確除去當變換方向後的少數幾個。
快速視覺化
1 2 3 4 5 6 7 |
T = branch taken N = branch not taken data[] = 0, 1, 2, 3, 4, ... 126, 127, 128, 129, 130, ... 250, 251, 252, ... branch = N N N N N ... N N T T T ... T T T ... = NNNNNNNNNNNN ... NNNNNNNTTTTTTTTT ... TTTTTTTTTT (easy to predict)</span></code> |
然而,如果資料是完全隨機的,分支預測則毫無用處因為它不能預測隨機資料。這種情況下可能會有50%的錯誤預測。
1 2 3 |
data[]= 226, 185, 125, 158, 198, 144, 217, 79, 202, 118, 14, 150, 177, 182, 133, ... branch= T, T, N, T, T, T, T, N, T, N, N, T, T, T, N ... = TTNTTTTNTNNTTTN ... (completely random - hard to predict) |
那這種情況下該怎麼做呢?
如果編譯器不能將分支優化為有條件的移動,這時候可以嘗試一些 Hacks ,如果能夠可以犧牲可讀性的表現。
將下面程式碼
1 2 |
if (data[c] >= 128) sum += data[c]; |
替換為:
1 2 |
int t = (data[c] - 128) >> 31; sum += ~t & data[c]; |
用一些按位操作取代分支判斷,這樣就去除了分支。(注意:這個 hacks 並不是和if語句嚴格相等,但是在我們這個例子裡,對輸入陣列data的所有值都是正確的)
Benchmarks: Core i7 920 @ 3.5 GHz
C++ – Visual Studio 2010 – x64 Release
1 2 3 4 5 6 7 8 9 10 11 |
// Branch - Random seconds = 11.777 // Branch - Sorted seconds = 2.352 // Branchless - Random seconds = 2.564 // Branchless - Sorted seconds = 2.587</span></code> |
Java – Netbeans 7.1.1 JDK 7 – x64
1 2 3 4 5 6 7 8 9 10 11 |
// Branch - Random seconds = 10.93293813 // Branch - Sorted seconds = 5.643797077 // Branchless - Random seconds = 3.113581453 // Branchless - Sorted seconds = 3.186068823</span></code> |
觀察可得:
- 在分支情況下:排序陣列和亂序陣列之間的結果有著巨大的差異。
- 在 Hack 方式下:對於排序和亂序的結果則沒有差異。
- 在C++中,對於排序陣列,Hack 會比分支有一點點慢。
一般的經驗法則是避免資料依賴分支在一些特殊的迴圈中。
64位機器下,GCC 4.6.1附帶選項-O3或者-ftree-vectorize可以產生一個條件移動。因此對於有序和亂序資料都是一樣快。
VC++2010不能夠產生條件移動對於這樣的分支。
英特爾編譯器11同樣可以做一些神奇的事。它通過互換兩個迴圈,從而提升了不可預測的分支外迴圈。因此,它不但能夠避免誤預測,而且速度上可以達到VC++和GCC的兩個快。換句話說,ICC利用了測試迴路打破了benchmark。
如果用英特爾編譯器執行沒有分支的程式碼,它僅僅出右向量化(out-right vectorizes it),並且和帶分支同樣快。
通過上面說明,即使比較成熟的現代編譯器在優化程式碼的上可以有很大的不同。