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

时间:2014-07-31 20:07:29

标签: c performance loops

在尝试知道一行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

为什么空循环比使用指令的第二个循环使用更多的时间?当然,我尝试了很多变种,但每次,空循环所需的时间比单个指令中的变量要多。

请注意,我已尝试交换循环顺序并添加一些预热代码,但它根本没有改变我的问题。

我正在使用代码块作为IDE使用GNU gcc编译器,linux ubuntu 14.04并且拥有2.3GHz的四核intel i5(我尝试在单核上运行程序,这不会改变结果)。< / p>

4 个答案:

答案 0 :(得分:78)

假设您的代码使用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
}

请注意,对{strong>任何一种情况的clock()调用之间都有操作。所以他们都编译成完全相同的东西

答案 1 :(得分:45)

事实是现代处理器很复杂。执行的所有指令将以复杂和有趣的方式相互交互。感谢"that other guy" for posting the code.

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指令从存储指令中获取数据。不幸的是,我们现在还不确定对i的写入是否真的发生了!所以这可以在这里引入一个摊位。

这里的问题是条件跳转,导致条件存储的addq和不确定从何处获取数据的cmpq都非常接近。他们异常紧密地联系在一起。可能是它们如此靠近,处理器此时无法弄清楚是从存储指令中取出i还是从存储器读取它。并从内存中读取它,这是较慢的,因为它必须等待商店完成。只添加一个nop就可以给处理器足够的时间。

通常你认为有RAM,并且有缓存。在现代英特尔处理器上,读取内存可以读取(从最慢到最快):

  1. 内存(RAM)
  2. L3缓存(可选)
  3. 二级缓存
  4. L1缓存
  5. 尚未写入L1缓存的先前存储指令。
  6. 那么处理器在短,慢的循环内部做什么:

    1. 从L1缓存中读取i
    2. 将{1添加到i
    3. i写入L1缓存
    4. 等到i写入L1缓存
    5. 从L1缓存中读取i
    6. i与INT_MAX
    7. 进行比较
    8. 如果不是,则转到(1)。
    9. 在漫长而快速的循环中,处理器执行:

      1. 很多东西
      2. 从L1缓存中读取i
      3. 将{1添加到i
      4. 执行将i写入L1缓存
      5. 的“存储”指令
      6. 直接从“store”指令读取i,而不触及L1缓存
      7. i与INT_MAX
      8. 进行比较
      9. 如果不是,则转到(1)。

答案 2 :(得分:30)

这个答案假设您已经理解并解决了有关his answer中未定义行为的优秀观点。他还指出了编译器可能对您的代码起作用的技巧。您应该采取措施确保编译器不会将整个循环识别为无用。例如,将迭代器声明更改为volatile uint64_t i;将阻止删除循环,volatile int A;将确保第二个循环实际上比第一个循环更多的工作。但即使你做了所有这些,你仍然可以发现:

程序中的代码可能比早期代码执行得更快。

clock()库函数可能在读取计时器之后和返回之前导致icache未命中。这将在第一个测量间隔中产生一些额外的时间。 (对于以后的调用,代码已经在缓存中)。然而,这种影响很小,对clock()进行测量肯定太小,即使它是一直到磁盘的页面错误。随机上下文切换可以添加到任一时间间隔。

更重要的是,你有一个i5 CPU,它具有动态时钟。当程序开始执行时,时钟速率很可能很低,因为CPU一直处于空闲状态。只运行程序会使CPU不再空闲,因此在短暂延迟后,时钟速度将会增加。空闲和TurboBoosted CPU时钟频率之间的比率可能很大。 (在我的超极本的Haswell i5-4200U上,前者为8,后者为26,使启动代码的运行速度低于后期代码的30%!&#34; Calibrated&#34;循环用于实现在现代计算机上延迟是一个可怕的想法!)

包括一个预热阶段(反复运行基准测试,抛出第一个结果)以获得更精确的计时,不仅适用于使用JIT编译器的托管框架!

答案 3 :(得分:27)

我可以使用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

我想这显示了调整的重要性,但我担心我无法具体说明:|