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

Posted

技术标签:

【中文标题】空循环比 C 中的非空循环慢【英文标题】:Empty loop is slower than a non-empty one in C 【发布时间】:2014-09-23 22:29:31 【问题描述】:

当我想知道一行 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 的四核英特尔 i5(我尝试在单核上运行该程序,这不会改变结果)。

【问题讨论】:

您的编译器选项是什么?程序集对此有何看法? 您认为有多少 C 编译器在优化空循环上花费了大量精力? 如果您对代码的性能感兴趣,请放弃 clock() 并在发布模式优化位上放松真正的 profiler 由于从未使用过A 的值,因此编译器很可能也将第二个循环设为空,在这种情况下,问题是为什么这两个循环不完全相同。如果在第二个循环之后打印 A 的值,时间会发生什么变化? 这是一个不切实际的“玩具”箱,测量不严。没有任何意义可以推导出来。 【参考方案1】:

假设您的代码使用 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() 的调用之间没有没有操作。所以它们都被编译成完全相同的东西

【讨论】:

我不知道谁会因为你指出未定义的行为而对你投反对票;尽管如此,这个答案可以通过显示生成它的代码和编译器命令行来改进,而不是模棱两可的“如果我们解决这些问题” 至少告诉你做了什么来“修复”UB。你用过INT_MAX 吗? 0x7FFFFFFF ?以及编译选项。 -1:我还是不知道为什么空循环比较慢。此外,您修改了代码,使用了-O3 和不同的编译器。从这个other answer 可以看出,未定义的行为对性能差异负责。 @jmiserez:他当然用过-O3。问题没有另外说,启用优化是衡量性能时最明智的做法。 (我们可以争论 -O3-O2-Os 是最好的,但解释 OP 观察到的内容的“正确”级别是 OP 使用的内容,问题没有揭示这一点) @jmiserez:在你的观点中,我唯一可以认真对待的是我使用了不同的编译器。所以我只是用 gcc 4.8.2 和 gcc 4.9.0 做了一个测试,除了它报告了我错过的 另一个 未定义的行为,它还将循环优化为 nop。【参考方案2】:

事实上,现代处理器很复杂。执行的所有指令将以复杂而有趣的方式相互交互。感谢"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) L3 缓存(可选) 二级缓存 一级缓存 尚未写入 L1 高速缓存的先前存储指令。

那么处理器在短而慢的循环内部做了什么:

    从一级缓存中读取ii加1 将i 写入一级缓存 等到i 被写入一级缓存 从一级缓存中读取i 比较 i 与 INT_MAX 如果小于,则分支到 (1)。

在长、快、处理器的循环中:

    很多东西 从一级缓存中读取i 将 1 加到 i 执行“存储”指令,将i 写入一级缓存 直接从“存储”指令中读取i,无需触及一级缓存 比较 i 和 INT_MAX 如果小于,则分支到 (1)。

【讨论】:

感谢这个非常完整的答案。由于我从未编写过任何汇编代码,因此这种级别的细节确实很有帮助。你应该把你的答案链接到 OP 和“那个人”的答案:) 下面的答案似乎使这个无效。这个答案的要点也只是猜测“可能是那个”。 由于“以上”和“以下”是相对的,请解释一下这个答案是如何无效的。 intel.com/content/dam/www/public/us/en/documents/manuals/… 解释了您可能遇到的各种类型的停顿以及如何避免它们。基本上,NOP 通过给处理器时间将更改提交到寄存器来防止资源停顿。缓存在这里不一定相关,但档位是真实的;写入寄存器仍然是写入某种类型的内存,其写入速度仍然有限​​,当 CPU 注意到一条指令需要一个已在使用的寄存器时会导致停顿。 优化器是否足够聪明以获得良好的调度?除非优化器生成受此影响的代码,否则 OP 从这种效果得出的结论是没有根据的。【参考方案3】:

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

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

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

更重要的是,您有一个 i5 CPU,它具有动态时钟。当您的程序开始执行时,时钟频率很可能很低,因为 CPU 一直处于空闲状态。仅仅运行程序就可以使 CPU 不再空闲,因此在短暂的延迟后时钟速度会增加。空闲和 TurboBoosted CPU 时钟频率之间的比率可能很大。 (在我的超极本的 Haswell i5-4200U 上,前一个乘数是 8,后一个是 26,这使得启动代码的运行速度低于后来代码的 30%!用于实现延迟的“校准”循环在现代计算机上是一个糟糕的主意! )

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

【讨论】:

我已经尝试过添加一些热身阶段,甚至没有成功切换循环的位置。我应该把这个放在我的答案中,我会编辑。 实际上,我认为给托管框架和 JIT 编译器预热时间是不公平的 ;) 除非他们永远不会看到重新启动并快速启动。就像给他们免费的 VM 加载时间等是不公平的,因为这些很可能是不可忽略的延迟。每天重启,非常缓慢的未缓存行为,缓慢的缓存,很多事情都可能使“热身”状态变得不切实际。 @Morg.:这不是要对框架之间的差异“公平”,而是要测试特定语言/框架的代码变体获得最佳结果。 @BenVoigt 我的错。那么关于该主题:特定语言/框架的代码变体获得最佳结果还取决于该变体是否需要热身、实际热身或不切实际的热身。很容易缓存所有内容,然后假装一个变体比所有其他变体都快。好在这是关于 C 的事情,我只是认为你用粗体表示的句子的含义会使基准变得不那么相关,而不是更多。 @Morg.:热身是将所有变体置于公平竞争环境中的最简单方法。如果暖执行不是您感兴趣的比较,那么我的全部意思是,测量启动时间和缓存未命中,但确保您为所有版本招致它们。您可以从头开始执行可执行文件,但这并不意味着数据文件不在磁盘缓存中等。测量“冷”运行时间确实非常复杂。【参考方案4】:

我可以在没有优化的情况下使用 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

我想这表明了对齐的重要性,但恐怕我不能具体说明如何:|

【讨论】:

@PlasmaHH 最重要的是,它表明空循环比具有一条空指令的循环慢。也许你错过了重点? @kritzikratzi:也许你错过了评估组装速度没有意义的观点生成速度不快 @kritzikratzi:管道调度是一个优化器功能,就像在循环外提升独立子表达式一样。如果您禁用优化以避免循环展开等,您也将禁用管道调度。 在这种情况下,-O2 和 -O3 都优化了循环。使用-O,两个循环都变成mov $0x7fffffff, %edx; L: sub $0x1,%rdx; jne L,耗时0.6s。 为什么我不能对 PlasmaHH 的评论投反对票?这是关于了解处理器内部发生的情况,我们有一个案例,其中包含三个汇编指令的循环比添加相同指令和添加 nop 的循环运行速度慢,后者的运行速度与添加七个指令的循环相同。如果我正在编写编译器,我会发现这段代码非常有趣。我不是在写编译器,我仍然觉得它非常非常有趣。

以上是关于空循环比 C 中的非空循环慢的主要内容,如果未能解决你的问题,请参考以下文章

如何使用 PowerShell 删除 OneDrive 中的非空文件夹?

颤振测试中的非空变量

Kotlin 中的非空值产生空指针异常

Django - 列中的空值违反了 Django Admin 中的非空约束

Google表格中的查询功能-根据另一列获取一列中的非空最后一个单元格

matlab for i=1:length(y) 啥意思