簡體   English   中英

空循環比C中的非空循環慢

[英]Empty loop is slower than a non-empty one in C

當試圖知道要執行多長時間的C代碼時,我注意到了這個奇怪的事情:

int main (char argc, char * argv[]) {
    time_t begin, end;
    uint64_t i;
    double total_time, free_time;
    int A = 1;
    int B = 1;

    begin = clock();
    for (i = 0; i<(1<<31)-1; i++);
    end = clock();
    free_time = (double)(end-begin)/CLOCKS_PER_SEC;
    printf("%f\n", free_time);

    begin = clock();
    for (i = 0; i<(1<<31)-1; i++) {
        A += B%2;
    }
    end = clock();
    free_time = (double)(end-begin)/CLOCKS_PER_SEC;
    printf("%f\n", free_time);

    return(0);
}

執行時顯示:

5.873425
4.826874

為什么空循環比第二個空循環使用更多的時間? 當然,我已經嘗試了許多變體,但是每次空循環都比在其中執行一條指令的循環花費更多的時間。

請注意,我已經嘗試交換循環順序並添加一些熱身代碼,但它根本沒有改變我的問題。

我將代碼塊與GNU gcc編譯器,Linux ubuntu 14.04和IDE一起使用,並具有2.3GHz的四核Intel i5(我曾嘗試在單核上運行programm,但這不會改變結果)。

假設您的代碼使用32位整數int類型(系統可能會這樣做),那么從您的代碼中將無法確定任何內容。 而是, 它表現出未定義的行為。

foo.c:5:5: error: first parameter of 'main' (argument count) must be of type 'int'
int main (char argc, char * argv[]) {
    ^
foo.c:13:26: warning: overflow in expression; result is 2147483647 with type 'int' [-Winteger-overflow]
    for (i = 0; i<(1<<31)-1; i++);
                         ^
foo.c:19:26: warning: overflow in expression; result is 2147483647 with type 'int' [-Winteger-overflow]
    for (i = 0; i<(1<<31)-1; i++) {
                         ^

讓我們嘗試解決該問題:

#include <stdint.h>
#include <stdio.h>
#include <time.h>
#include <limits.h>

int main (int argc, char * argv[]) {
    time_t begin, end;
    uint64_t i;
    double total_time, free_time;
    int A = 1;
    int B = 1;

    begin = clock();
    for (i = 0; i<INT_MAX; i++);
    end = clock();
    free_time = (double)(end-begin)/CLOCKS_PER_SEC;
    printf("%f\n", free_time);

    begin = clock();
    for (i = 0; i<INT_MAX; i++) {
        A += B%2;
    }
    end = clock();
    free_time = (double)(end-begin)/CLOCKS_PER_SEC;
    printf("%f\n", free_time);

    return(0);
}

現在,讓我們看一下這段代碼的匯編輸出。 就個人而言,我發現LLVM的內部程序集非常易讀,因此我將進行演示。 我將通過運行它來產生它:

clang -O3 foo.c -S -emit-llvm -std=gnu99

這是輸出的相關部分(主要功能):

define i32 @main(i32 %argc, i8** nocapture readnone %argv) #0 {
  %1 = tail call i64 @"\01_clock"() #3
  %2 = tail call i64 @"\01_clock"() #3
  %3 = sub nsw i64 %2, %1
  %4 = sitofp i64 %3 to double
  %5 = fdiv double %4, 1.000000e+06
  %6 = tail call i32 (i8*, ...)* @printf(i8* getelementptr inbounds ([4 x i8]* @.str, i64 0, i64 0), double %5) #3
  %7 = tail call i64 @"\01_clock"() #3
  %8 = tail call i64 @"\01_clock"() #3
  %9 = sub nsw i64 %8, %7
  %10 = sitofp i64 %9 to double
  %11 = fdiv double %10, 1.000000e+06
  %12 = tail call i32 (i8*, ...)* @printf(i8* getelementptr inbounds ([4 x i8]* @.str, i64 0, i64 0), double %11) #3
  ret i32 0
}

請注意,在兩種情況下,clock()的調用之間都沒有任何操作。 因此它們都被編譯為完全相同的東西

事實是現代處理器很復雜。 所有執行的指令將以復雜而有趣的方式相互交互。 感謝“那個其他人”發布代碼。

OP和“另一個家伙”顯然都發現短循環需要11個周期,而長循環則需要9個周期。 對於長循環,即使有很多操作,也需要9個周期的時間。 對於短循環,必須有一些攤位由它造成如此短,只是增加一個nop使得循環足夠長的時間,以避免失速。

如果我們看一下代碼,將會發生一件事:

0x00000000004005af <+50>:    addq   $0x1,-0x20(%rbp)
0x00000000004005b4 <+55>:    cmpq   $0x7fffffff,-0x20(%rbp)
0x00000000004005bc <+63>:    jb     0x4005af <main+50>

我們讀i並將其寫回( addq )。 我們立即再次閱讀它,並進行比較( cmpq )。 然后我們循環。 但是循環使用分支預測。 因此,在執行addq時,處理器並不確定是否允許寫入i (因為分支預測可能是錯誤的)。

然后我們與i進行比較。 處理器將嘗試避免從內存中讀取i ,因為讀取需要很長時間。 取而代之的是,一些硬件會記住我們只是通過添加給i寫的,而不是讀取icmpq指令從store指令獲取數據。 不幸的是,我們目前還不確定是否真的寫信給i 因此,這可能會在這里造成停滯。

這里的問題是,將條件跳轉, addq導致有條件存儲,並且cmpq這是不知道到什么地方,都非常非常接近獲得數據。 他們異常靠近。 可能是因為它們之間的距離太近,因此處理器目前無法確定是從存儲指令中獲取i還是從內存中讀取i 並從內存中讀取數據,這很慢,因為它必須等待存儲完成。 並且僅添加一次nop給處理器足夠的時間。

通常,您認為有RAM,並且有緩存。 在現代英特爾處理器上,讀取內存可以從(最慢到最快)讀取:

  1. 記憶體(RAM)
  2. 三級緩存(可選)
  3. L2快取
  4. L1快取
  5. 尚未寫入L1緩存的先前存儲指令。

因此,處理器在短而緩慢的循環中內部執行的操作:

  1. 從L1緩存讀取i
  2. i加1
  3. i寫入L1緩存
  4. 等到i被寫入L1緩存
  5. 從L1緩存讀取i
  6. i與INT_MAX進行比較
  7. 如果小於則轉到(1)。

在長而快速的循環中,處理器會執行以下操作:

  1. 很多東西
  2. 從L1緩存讀取i
  3. i加1
  4. 進行“存儲”指令,將i寫入L1緩存
  5. 直接從“存儲”指令讀取i ,而無需觸摸L1緩存
  6. i與INT_MAX進行比較
  7. 如果小於則轉到(1)。

該答案假設您已經了解並解決了有關他的答案中未定義行為褻行為的要點。 他還指出了編譯器可能在您的代碼上播放的技巧。 您應采取步驟確保編譯器不會將整個循環識別為無用的。 例如,將迭代器聲明更改為volatile uint64_t i; 將防止移除循環和volatile int A; 將確保第二個循環實際上比第一個循環做更多的工作。 但是即使您做了所有這些,您仍然可能會發現:

程序中較晚的代碼比早期的代碼執行得更快。

在讀取計時器之后和返回之前, clock()庫函數可能導致icache丟失。 這會在第一個測量間隔中導致一些額外的時間。 (對於以后的調用,該代碼已在緩存中)。 但是,即使是頁面錯誤一直到磁盤,這種影響也很小,對於clock()來說,它肯定太小了。 隨機上下文切換可以增加任何一個時間間隔。

更重要的是,您擁有一個具有動態時鍾的i5 CPU。 當程序開始執行時,時鍾頻率很可能很低,因為CPU一直處於空閑狀態。 僅運行程序會使CPU不再處於空閑狀態,因此經過短暫的延遲后,時鍾速度將提高。 空閑和TurboBoosted CPU時鍾頻率之間的比率可能很重要。 (在我的超極本的Haswell i5-4200U上,前一個乘數是8,而后者是26,這使得啟動代碼的運行速度不及后來的代碼快30%!在現代計算機上實現延遲的“校准”循環是一個糟糕的主意! )

包括預熱階段(反復運行基准測試,並丟棄第一個結果)以進行更精確的計時不僅適用於使用JIT編譯器的托管框架!

我可以使用GCC 4.8.2-19ubuntu1進行重現,而無需進行優化:

$ ./a.out 
4.780179
3.762356

這是一個空循環:

0x00000000004005af <+50>:    addq   $0x1,-0x20(%rbp)
0x00000000004005b4 <+55>:    cmpq   $0x7fffffff,-0x20(%rbp)
0x00000000004005bc <+63>:    jb     0x4005af <main+50>

這是非空的:

0x000000000040061a <+157>:   mov    -0x24(%rbp),%eax
0x000000000040061d <+160>:   cltd   
0x000000000040061e <+161>:   shr    $0x1f,%edx
0x0000000000400621 <+164>:   add    %edx,%eax
0x0000000000400623 <+166>:   and    $0x1,%eax
0x0000000000400626 <+169>:   sub    %edx,%eax
0x0000000000400628 <+171>:   add    %eax,-0x28(%rbp)
0x000000000040062b <+174>:   addq   $0x1,-0x20(%rbp)
0x0000000000400630 <+179>:   cmpq   $0x7fffffff,-0x20(%rbp)
0x0000000000400638 <+187>:   jb     0x40061a <main+157>

讓我們在空循環中插入一個nop

0x00000000004005af <+50>:    nop
0x00000000004005b0 <+51>:    addq   $0x1,-0x20(%rbp)
0x00000000004005b5 <+56>:    cmpq   $0x7fffffff,-0x20(%rbp)
0x00000000004005bd <+64>:    jb     0x4005af <main+50>

現在,它們運行得同樣快:

$ ./a.out 
3.846031
3.705035

我想這顯示了對齊的重要性,但是我恐怕無法具體說明如何:|

暫無
暫無

聲明:本站的技術帖子網頁,遵循CC BY-SA 4.0協議,如果您需要轉載,請注明本站網址或者原文地址。任何問題請咨詢:yoyou2525@163.com.

 
粵ICP備18138465號  © 2020-2024 STACKOOM.COM