繁体   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