空循环比 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
来写入它,而不是读取i
,cmpq
指令从存储指令中获取数据。不幸的是,我们目前不确定写到i
是否真的发生了!所以这可能会在这里引入一个摊位。
这里的问题是条件跳转,导致条件存储的addq
,以及不确定从哪里获取数据的cmpq
,都非常接近。它们异常靠近。可能是它们靠得太近了,处理器此时无法确定是从存储指令中获取i
还是从内存中读取它。并从内存中读取它,这比较慢,因为它必须等待存储完成。并且只添加一个nop
可以为处理器提供足够的时间。
通常你认为有内存,有缓存。在现代英特尔处理器上,读取内存可以从(最慢到最快)读取:
-
内存 (RAM)
L3 缓存(可选)
二级缓存
一级缓存
尚未写入 L1 高速缓存的先前存储指令。
那么处理器在短而慢的循环内部做了什么:
-
从一级缓存中读取
i
给i
加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 中的非空文件夹?
Django - 列中的空值违反了 Django Admin 中的非空约束