为什么 for 循环体中的一个基本算术运算比两个算术运算执行得慢?
Why is ONE basic arithmetic operation in for loop body executed SLOWER THAN TWO arithmetic operations?
当我尝试测量算术运算的执行时间时,我遇到了非常奇怪的行为。包含 for
循环并在循环体中进行一次算术运算的代码块 始终 比相同的代码块执行得更慢,但在 [=11= 中进行了两次算术运算] 循环体。这是我最终测试的代码:
#include <iostream>
#include <chrono>
#define NUM_ITERATIONS 100000000
int main()
{
// Block 1: one operation in loop body
{
int64_t x = 0, y = 0;
auto start = std::chrono::high_resolution_clock::now();
for (long i = 0; i < NUM_ITERATIONS; i++) {x+=31;}
auto end = std::chrono::high_resolution_clock::now();
std::chrono::duration<double> diff = end-start;
std::cout << diff.count() << " seconds. x,y = " << x << "," << y << std::endl;
}
// Block 2: two operations in loop body
{
int64_t x = 0, y = 0;
auto start = std::chrono::high_resolution_clock::now();
for (long i = 0; i < NUM_ITERATIONS; i++) {x+=17; y-=37;}
auto end = std::chrono::high_resolution_clock::now();
std::chrono::duration<double> diff = end-start;
std::cout << diff.count() << " seconds. x,y = " << x << "," << y << std::endl;
}
return 0;
}
我用不同级别的代码优化(-O0
、-O1
、-O2
、-O3
)和不同的在线编译器(例如onlinegdb.com),在我的工作机器上,在我的 hame PC 和笔记本电脑上,在 RaspberryPi 和我同事的电脑上。我重新排列了这两个代码块,重复了它们,改变了常量,改变了操作(+
,-
,<<
,=
等),改变了整数类型。但我总是得到类似的结果:循环中一行的块比两行的块慢:
1.05681 seconds. x,y = 3100000000,0
0.90414 seconds. x,y = 1700000000,-3700000000
我检查了 https://godbolt.org/ 上的汇编输出,但一切看起来都像我预期的那样:第二个块在汇编输出中又进行了一次操作。
Three 操作始终按预期运行:它们比 one 慢但比 four.那么为什么两次操作会产生这样的异常呢?
编辑:
让我重复一遍:我的所有 Windows 和 Unix 机器上都有这样的行为,代码没有经过优化。我查看了我执行的程序集 (Visual Studio, Windows),我看到了我想在那里测试的指令。不管怎样,如果循环被优化掉了,我在剩下的代码中就没有任何问题了。我在问题中添加了优化通知以避免 "do not measure not optimized code" 答案,因为优化不是我要问的。问题实际上是为什么我的计算机执行两个操作比一个更快,首先是在这些操作没有被优化掉的代码中。在我的测试中,执行时间的差异是 5-25%(非常明显)。
预计到达时间: 这是一个猜测,Peter Cordes 对它不正确的原因进行了很好的论证。为彼得的回答点赞。
我在这里留下我的答案,因为有些人发现这些信息很有用。虽然这不能正确解释 OP 中看到的行为,但它突出了一些问题,这些问题使得在现代处理器上尝试测量特定指令的速度变得不可行(且毫无意义)。
有根据的猜测:
这是流水线化、部分内核断电和 dynamic frequency scaling 的综合效果。
现代处理器流水线使得多条指令可以同时执行。这是可能的,因为处理器实际上是在微操作上工作,而不是我们通常认为是机器语言的汇编级指令。处理器 "schedule" 微操作,将它们分派到芯片的不同部分,同时跟踪指令之间的依赖关系。
假设您的代码的核心 运行 有两个 arithmetic/logic 单元 (ALU)。一遍又一遍重复的单个算术指令只需要一个 ALU。使用两个 ALU 没有帮助,因为下一个操作取决于当前操作的完成,因此第二个 ALU 只能等待。
但是在你的双表达式测试中,表达式是独立的。要计算 y
的下一个值,您不必等待 x
上的当前操作完成。现在,由于省电功能,第二个 ALU 可能会首先断电。在意识到它可以使用第二个 ALU 之前,核心可能 运行 几次迭代。到那时,它可以启动第二个 ALU,并且大多数双表达式循环将 运行 与单表达式循环一样快。因此,您可能希望这两个示例花费的时间大致相同。
最后,许多现代处理器使用动态频率缩放。当处理器检测到它没有 运行ning 时,它实际上会稍微减慢时钟以节省电量。但是当它被大量使用时(并且芯片的当前温度允许),它可能会将实际时钟速度提高到其额定速度。
我假设这是通过启发式方法完成的。在第二个 ALU 保持断电的情况下,试探法可能会决定不值得提高时钟。在两个 ALU 上电并以最高速度 运行ning 的情况下,它可能会决定提高时钟。因此,双表达式的情况,应该已经和一个表达式的情况一样快,实际上 运行s 在更高的平均时钟频率下,使其能够在稍微更少的时间内完成两倍的工作。
根据您的数字,差异约为 14%。我的 Windows 机器闲置在 3.75 GHz 左右,如果我通过在 Visual Studio 中构建解决方案稍微推动它,时钟会攀升到大约 4.25GHz(观察任务管理器中的性能选项卡)。时钟速度有 13% 的差异,所以我们在正确的范围内。
@PeterCordes 在许多假设中证明这个答案是错误的,但它仍然可以作为对问题的一些盲目研究尝试。
我设置了一些快速基准测试,认为它可能以某种方式与代码内存对齐有关,真是一个疯狂的想法。
但@Adrian McCarthy 似乎在动态频率缩放方面做对了。
无论如何,基准测试表明插入一些 NOP 可以帮助解决这个问题,在块 1 中的 x+=31 之后有 15 个 NOP 导致与块 2 几乎相同的性能。真正令人震惊的是单指令循环中的 15 个 NOP body 提高性能。
http://quick-bench.com/Q_7HY838oK5LEPFt-tfie0wy4uA
我也试过 -OFast 思维编译器可能足够聪明,可以丢弃一些插入此类 NOP 的代码内存,但似乎并非如此。
http://quick-bench.com/so2CnM_kZj2QEWJmNO2mtDP9ZX0
编辑:感谢@PeterCordes,很明显优化在上面的基准测试中从未像预期的那样工作(因为全局变量需要添加指令来访问内存),新基准http://quick-bench.com/HmmwsLmotRiW9xkNWDjlOxOTShE 清楚地表明块 1 和块 2 的性能对于堆栈变量是相等的。但是 NOP 仍然可以帮助 single-threaded 应用程序循环访问全局变量,在这种情况下你可能不应该使用它,而只是在循环后将全局变量分配给局部变量。
编辑 2:实际上优化从未起作用,因为 quick-benchmark 宏使变量访问不稳定,阻止了重要的优化。只加载一次变量是合乎逻辑的,因为我们只是在循环中修改它,所以不稳定或禁用优化是瓶颈。所以这个答案基本上是错误的,但至少它显示了 NOP 如何 speed-up 未优化的代码执行,如果它在现实世界中有意义(有更好的方法,如分桶计数器)。
我将代码拆分为 C++ 和汇编。我只是想测试循环,所以我没有 return 总和。我在 Windows 上 运行,调用约定是 rcx, rdx, r8, r9,
,循环计数在 rcx
中。该代码将立即值添加到堆栈上的 64 位整数。
两个循环的时间相似,变化小于 1%,相同或其中一个比另一个快 1%。
这里有一个明显的依赖因素:每次添加到内存都必须等待先前添加到同一位置的内存完成,因此两次添加到内存基本上可以并行执行。
将 test2 更改为执行 3 添加到内存,最终慢了大约 6%,4 添加到内存,慢了 7.5%。
我的系统是 Intel 3770K 3.5 GHz CPU,Intel DP67BG 主板,DDR3 1600 9-9-9-27 内存,Win 7 Pro 64 位,Visual Studio 2015.
.code
public test1
align 16
test1 proc
sub rsp,16
mov qword ptr[rsp+0],0
mov qword ptr[rsp+8],0
tst10: add qword ptr[rsp+8],17
dec rcx
jnz tst10
add rsp,16
ret
test1 endp
public test2
align 16
test2 proc
sub rsp,16
mov qword ptr[rsp+0],0
mov qword ptr[rsp+8],0
tst20: add qword ptr[rsp+0],17
add qword ptr[rsp+8],-37
dec rcx
jnz tst20
add rsp,16
ret
test2 endp
end
我还测试了向寄存器添加立即数,1% 以内的 1 或 2 个寄存器(两者都可能更快,但我们希望它们都在 Ivy Bridge 上以 1 次迭代/时钟执行,因为它有 3 个整数 ALU端口;What considerations go into predicting latency for operations on modern superscalar processors and how can I calculate them by hand?).
3 个寄存器的长度是其 1.5 倍,比理想的 1.333 周期/4 微指令迭代(包括循环计数器宏融合 dec/jnz)对于具有完美调度的 3 个后端 ALU 端口要差一些。
4 个寄存器,2.0 倍长,前端瓶颈:。 Haswell 和后来的微体系结构会更好地处理这个问题。
.code
public test1
align 16
test1 proc
xor rdx,rdx
xor r8,r8
xor r9,r9
xor r10,r10
xor r11,r11
tst10: add rdx,17
dec rcx
jnz tst10
ret
test1 endp
public test2
align 16
test2 proc
xor rdx,rdx
xor r8,r8
xor r9,r9
xor r10,r10
xor r11,r11
tst20: add rdx,17
add r8,-37
dec rcx
jnz tst20
ret
test2 endp
public test3
align 16
test3 proc
xor rdx,rdx
xor r8,r8
xor r9,r9
xor r10,r10
xor r11,r11
tst30: add rdx,17
add r8,-37
add r9,47
dec rcx
jnz tst30
ret
test3 endp
public test4
align 16
test4 proc
xor rdx,rdx
xor r8,r8
xor r9,r9
xor r10,r10
xor r11,r11
tst40: add rdx,17
add r8,-37
add r9,47
add r10,-17
dec rcx
jnz tst40
ret
test4 endp
end
现在的处理器太复杂了,我们只能猜测。
编译器发出的程序集并不是真正执行的程序集。 CPU 的 microcode/firmware/whatever 将解释它并将其转化为执行引擎的指令,就像 C# 或 java 等 JIT 语言所做的那样。
这里要考虑的一件事是,对于每个循环,没有 1 或 2 条指令,而是 n + 2,因为您还递增并将 i 与迭代次数进行比较。在绝大多数情况下,这无关紧要,但在这里却很重要,因为循环体非常简单。
让我们看看程序集:
部分定义:
#define NUM_ITERATIONS 1000000000ll
#define X_INC 17
#define Y_INC -31
C/C++ :
for (long i = 0; i < NUM_ITERATIONS; i++) { x+=X_INC; }
ASM :
mov QWORD PTR [rbp-32], 0
.L13:
cmp QWORD PTR [rbp-32], 999999999
jg .L12
add QWORD PTR [rbp-24], 17
add QWORD PTR [rbp-32], 1
jmp .L13
.L12:
C/C++ :
for (long i = 0; i < NUM_ITERATIONS; i++) {x+=X_INC; y+=Y_INC;}
ASM:
mov QWORD PTR [rbp-80], 0
.L21:
cmp QWORD PTR [rbp-80], 999999999
jg .L20
add QWORD PTR [rbp-64], 17
sub QWORD PTR [rbp-72], 31
add QWORD PTR [rbp-80], 1
jmp .L21
.L20:
所以两个组件看起来很相似。但是让我们三思而后行:现代 CPUs 的 ALU 对比其寄存器大小更宽的值进行运算。所以有可能比第一种情况,对 x 和 i 的操作是在同一个计算单元上完成的。但是当你对这个操作的结果设置条件时,你必须再次阅读 i 。而阅读意味着等待。
因此,在第一种情况下,要迭代 x,CPU 可能必须与 i 上的迭代同步。
在第二种情况下,x 和 y 可能与处理 i 的单位不同。所以实际上,你的循环体与驱动它的条件并行运行。然后你的 CPU 计算和计算,直到有人告诉它停止。走得太远也没关系,回头几个循环比刚刚获得的时间还是可以的。
因此,为了比较我们想要比较的内容(一次操作与两次操作),我们应该尽量让 i 离开。
一种解决方案是使用 while 循环完全摆脱它:
C/C++:
while (x < (X_INC * NUM_ITERATIONS)) { x+=X_INC; }
ASM:
.L15:
movabs rax, 16999999999
cmp QWORD PTR [rbp-40], rax
jg .L14
add QWORD PTR [rbp-40], 17
jmp .L15
.L14:
另一种是使用过时的 "register" C 关键字:
C/C++:
register long i;
for (i = 0; i < NUM_ITERATIONS; i++) { x+=X_INC; }
ASM:
mov ebx, 0
.L17:
cmp rbx, 999999999
jg .L16
add QWORD PTR [rbp-48], 17
add rbx, 1
jmp .L17
.L16:
这是我的结果:
x1 用于:10.2985 秒。 x,y = 17000000000,0
x1 同时:8.00049 秒。 x,y = 17000000000,0
x1 注册:7.31426 秒。 x,y = 17000000000,0
x2 用于:9.30073 秒。 x,y = 17000000000,-31000000000
x2 同时:8.88801 秒。 x,y = 17000000000,-31000000000
x2 注册时间:8.70302 秒。 x,y = 17000000000,-31000000000
此效果仅发生在 -O0
(或 volatile
),并且是编译器将您的变量保存在内存中(而非寄存器)的结果。 您希望通过 i
、x
和 y
将固定数量的额外延迟引入到循环携带的依赖链中,但现代 CPU没那么简单
在 Intel Sandybridge 系列 CPUs 上,存储转发延迟 更低 当负载 uop 运行s 一段时间在重新加载其数据的存储之后,而不是立即。 因此,内存中有循环计数器的空循环是最坏的情况。我不明白什么 CPU 设计选择会导致这种微架构怪癖,但这是真实存在的。
这基本上是 的副本,至少对于英特尔 Sandybridge 系列 CPUs。
这是主要原因之一 : the bottlenecks are different than in realistically optimized code. See Why does clang produce inefficient asm with -O0 (for this simple floating point sum)? 更多关于为什么编译器故意制造如此糟糕的 asm。
微基准测试很难;只有当你能让编译器为你试图测量的东西发出实际优化的 asm 循环时,你才能正确地测量某些东西。 (即使那样,您也只是测量吞吐量 或 延迟,而不是两者;对于无序流水线 CPUs 上的单个操作,这些是单独的事情:What considerations go into predicting latency for operations on modern superscalar processors and how can I calculate them by hand?)
请参阅 进行测量 + 解释将变量保存在寄存器中的循环会发生什么。
使用 clang,benchmark::DoNotOptimize(x1 += 31)
也取消优化以将 x
保留在内存中,但使用 GCC,它只会保留在寄存器中。不幸的是, 在 QuickBench 上使用 clang,而不是 gcc,以获得类似于 -O0
asm 的结果。它确实显示了通过内存被瓶颈隐藏的大量短 NOP 的成本,并且当这些 NOP 延迟重新加载下一次迭代的时间刚好足以使存储转发达到较低延迟的良好情况时,速度会略有提高。 (我认为 QuickBench 运行s 在 Intel Xeon 服务器 CPUs 上,每个 CPU 核心内部的微体系结构与同代桌面版本相同。)
据推测,您测试的所有 x86 计算机在过去 10 年中都使用了 Intel CPUs,否则对 AMD 也有类似的影响。如果您的测量值确实有意义,那么对您的 RPi 使用的任何 ARM CPU 都有类似的影响是合理的。否则,可能会看到您所期望的另一种情况 (confirmation bias),特别是如果您在此处启用了优化进行测试。
I tested this with different levels of code optimization (-O0
,-O1
,-O2
,-O3
) [...] But I always got similar result
I added that optimizations notice in the question to avoid "do not measure not optimized code" answers because optimizations is not what I ask about.
(later from comments) About optimizations: yes, I reproduced that with different optimization levels, but as the loops were optimized away, the execution time was too fast to say for sure.
所以实际上你没有重现这个效果-O1
或更高,你只是看到了你想要的参见(确认偏差)并且主要是声称效果相同。如果您准确地报告了您的数据([=14= 的可衡量效果,-O1
及更高的空白时间区域),我可以立即回答。
请参阅 - 如果您的次数没有随着重复次数的增加而线性增加,那么您没有衡量您认为正在衡量的内容。此外,启动效果(如冷缓存、软页面错误、惰性动态链接和动态 CPU 频率)很容易导致第一个空定时区域比第二个慢。
我假设你只在 -O0
测试时交换了循环,否则你会排除在 -O1
或更高版本使用该测试代码有任何影响。
启用优化的循环:
如您所见on Godbolt,gcc 在启用优化的情况下完全删除了循环。有时 GCC 会单独留下空循环,就像它认为延迟是故意的一样,但在这里它甚至根本不循环。时间不随任何时间缩放,两个时间区域看起来都像这样:
orig_main:
...
call std::chrono::_V2::system_clock::now() # demangled C++ symbol name
mov rbp, rax # save the return value = start
call std::chrono::_V2::system_clock::now()
# end in RAX
因此,定时区域中唯一的指令是将 start
保存到调用保留寄存器中。您实际上没有对源代码进行任何测量。
通过 Google Benchmark,我们可以获得不会优化工作但不会 store/reload 引入新瓶颈的 asm :
#include <benchmark/benchmark.h>
static void TargetFunc(benchmark::State& state) {
uint64_t x2 = 0, y2 = 0;
// Code inside this loop is measured repeatedly
for (auto _ : state) {
benchmark::DoNotOptimize(x2 += 31);
benchmark::DoNotOptimize(y2 += 31);
}
}
// Register the function as a benchmark
BENCHMARK(TargetFunc);
# just the main loop, from gcc10.1 -O3
.L7: # do{
add rax, 31 # x2 += 31
add rdx, 31 # y2 += 31
sub rbx, 1
jne .L7 # }while(--count != 0)
我假设 benchmark::DoNotOptimize
类似于 asm volatile("" : "+rm"(x) )
(GNU C inline asm) 使编译器在寄存器或内存中具体化 x
,并假设左值已经由那个空的 asm 语句修改。 (即忘记它所知道的关于价值的任何事情,阻止常量传播,CSE 等等。)这可以解释为什么当 GCC 选择寄存器时 clang stores/reloads 到内存:这是一个长期存在的 clang 内联错误优化错误汇编支持。它喜欢在给定选择时选择内存,有时您可以使用 "+r,m"
等多种替代约束来解决这个问题。但不是在这里;我不得不放弃内存替代品;我们不希望编译器 spill/reload 到内存。
对于 GNU C 兼容的编译器,我们可以手动使用 asm volatile
,仅使用 "+r"
寄存器约束,让 clang 生成良好的标量 asm (Godbolt),如 GCC。我们得到一个本质上相同的内部循环,有 3 个加法指令,最后一个是可以宏融合的 add rbx, -1
/ jnz
。
static void TargetFunc(benchmark::State& state) {
uint64_t x2 = 0, y2 = 0;
// Code inside this loop is measured repeatedly
for (auto _ : state) {
x2 += 16;
y2 += 17;
asm volatile("" : "+r"(x2), "+r"(y2));
}
}
所有这些都应该 运行 在现代英特尔和 AMD CPUs 上每次迭代 1 个时钟周期,再次查看@rcgldr 的回答。
当然,这也会禁用 SIMD 的自动矢量化,编译器在许多实际用例中都会这样做。或者,如果您完全 在循环之外 使用结果,它可能会将重复增量优化为单个乘法。
您无法衡量 C++ 中 +
运算符的成本 - 根据上下文/周围代码,它的编译可能会有很大差异。即使不考虑提升工作的循环不变的东西。例如x + (y<<2) + 4
可以编译为 x86 的单个 LEA 指令。
The question is actually why my computers execute two operations faster than one, first of all in code where these operations are not optimized away
TL:DR: 这不是操作,而是通过内存的循环携带的依赖链停止了 CPU 从 运行 在每次迭代 1 个时钟周期循环,完成所有 3在单独的执行端口上并行添加。
请注意,循环计数器增量与您对 x
(有时 y
)所做的操作一样多。
当我尝试测量算术运算的执行时间时,我遇到了非常奇怪的行为。包含 for
循环并在循环体中进行一次算术运算的代码块 始终 比相同的代码块执行得更慢,但在 [=11= 中进行了两次算术运算] 循环体。这是我最终测试的代码:
#include <iostream>
#include <chrono>
#define NUM_ITERATIONS 100000000
int main()
{
// Block 1: one operation in loop body
{
int64_t x = 0, y = 0;
auto start = std::chrono::high_resolution_clock::now();
for (long i = 0; i < NUM_ITERATIONS; i++) {x+=31;}
auto end = std::chrono::high_resolution_clock::now();
std::chrono::duration<double> diff = end-start;
std::cout << diff.count() << " seconds. x,y = " << x << "," << y << std::endl;
}
// Block 2: two operations in loop body
{
int64_t x = 0, y = 0;
auto start = std::chrono::high_resolution_clock::now();
for (long i = 0; i < NUM_ITERATIONS; i++) {x+=17; y-=37;}
auto end = std::chrono::high_resolution_clock::now();
std::chrono::duration<double> diff = end-start;
std::cout << diff.count() << " seconds. x,y = " << x << "," << y << std::endl;
}
return 0;
}
我用不同级别的代码优化(-O0
、-O1
、-O2
、-O3
)和不同的在线编译器(例如onlinegdb.com),在我的工作机器上,在我的 hame PC 和笔记本电脑上,在 RaspberryPi 和我同事的电脑上。我重新排列了这两个代码块,重复了它们,改变了常量,改变了操作(+
,-
,<<
,=
等),改变了整数类型。但我总是得到类似的结果:循环中一行的块比两行的块慢:
1.05681 seconds. x,y = 3100000000,0
0.90414 seconds. x,y = 1700000000,-3700000000
我检查了 https://godbolt.org/ 上的汇编输出,但一切看起来都像我预期的那样:第二个块在汇编输出中又进行了一次操作。
Three 操作始终按预期运行:它们比 one 慢但比 four.那么为什么两次操作会产生这样的异常呢?
编辑:
让我重复一遍:我的所有 Windows 和 Unix 机器上都有这样的行为,代码没有经过优化。我查看了我执行的程序集 (Visual Studio, Windows),我看到了我想在那里测试的指令。不管怎样,如果循环被优化掉了,我在剩下的代码中就没有任何问题了。我在问题中添加了优化通知以避免 "do not measure not optimized code" 答案,因为优化不是我要问的。问题实际上是为什么我的计算机执行两个操作比一个更快,首先是在这些操作没有被优化掉的代码中。在我的测试中,执行时间的差异是 5-25%(非常明显)。
预计到达时间: 这是一个猜测,Peter Cordes 对它不正确的原因进行了很好的论证。为彼得的回答点赞。
我在这里留下我的答案,因为有些人发现这些信息很有用。虽然这不能正确解释 OP 中看到的行为,但它突出了一些问题,这些问题使得在现代处理器上尝试测量特定指令的速度变得不可行(且毫无意义)。
有根据的猜测:
这是流水线化、部分内核断电和 dynamic frequency scaling 的综合效果。
现代处理器流水线使得多条指令可以同时执行。这是可能的,因为处理器实际上是在微操作上工作,而不是我们通常认为是机器语言的汇编级指令。处理器 "schedule" 微操作,将它们分派到芯片的不同部分,同时跟踪指令之间的依赖关系。
假设您的代码的核心 运行 有两个 arithmetic/logic 单元 (ALU)。一遍又一遍重复的单个算术指令只需要一个 ALU。使用两个 ALU 没有帮助,因为下一个操作取决于当前操作的完成,因此第二个 ALU 只能等待。
但是在你的双表达式测试中,表达式是独立的。要计算 y
的下一个值,您不必等待 x
上的当前操作完成。现在,由于省电功能,第二个 ALU 可能会首先断电。在意识到它可以使用第二个 ALU 之前,核心可能 运行 几次迭代。到那时,它可以启动第二个 ALU,并且大多数双表达式循环将 运行 与单表达式循环一样快。因此,您可能希望这两个示例花费的时间大致相同。
最后,许多现代处理器使用动态频率缩放。当处理器检测到它没有 运行ning 时,它实际上会稍微减慢时钟以节省电量。但是当它被大量使用时(并且芯片的当前温度允许),它可能会将实际时钟速度提高到其额定速度。
我假设这是通过启发式方法完成的。在第二个 ALU 保持断电的情况下,试探法可能会决定不值得提高时钟。在两个 ALU 上电并以最高速度 运行ning 的情况下,它可能会决定提高时钟。因此,双表达式的情况,应该已经和一个表达式的情况一样快,实际上 运行s 在更高的平均时钟频率下,使其能够在稍微更少的时间内完成两倍的工作。
根据您的数字,差异约为 14%。我的 Windows 机器闲置在 3.75 GHz 左右,如果我通过在 Visual Studio 中构建解决方案稍微推动它,时钟会攀升到大约 4.25GHz(观察任务管理器中的性能选项卡)。时钟速度有 13% 的差异,所以我们在正确的范围内。
@PeterCordes 在许多假设中证明这个答案是错误的,但它仍然可以作为对问题的一些盲目研究尝试。
我设置了一些快速基准测试,认为它可能以某种方式与代码内存对齐有关,真是一个疯狂的想法。
但@Adrian McCarthy 似乎在动态频率缩放方面做对了。
无论如何,基准测试表明插入一些 NOP 可以帮助解决这个问题,在块 1 中的 x+=31 之后有 15 个 NOP 导致与块 2 几乎相同的性能。真正令人震惊的是单指令循环中的 15 个 NOP body 提高性能。
http://quick-bench.com/Q_7HY838oK5LEPFt-tfie0wy4uA
我也试过 -OFast 思维编译器可能足够聪明,可以丢弃一些插入此类 NOP 的代码内存,但似乎并非如此。 http://quick-bench.com/so2CnM_kZj2QEWJmNO2mtDP9ZX0
编辑:感谢@PeterCordes,很明显优化在上面的基准测试中从未像预期的那样工作(因为全局变量需要添加指令来访问内存),新基准http://quick-bench.com/HmmwsLmotRiW9xkNWDjlOxOTShE 清楚地表明块 1 和块 2 的性能对于堆栈变量是相等的。但是 NOP 仍然可以帮助 single-threaded 应用程序循环访问全局变量,在这种情况下你可能不应该使用它,而只是在循环后将全局变量分配给局部变量。
编辑 2:实际上优化从未起作用,因为 quick-benchmark 宏使变量访问不稳定,阻止了重要的优化。只加载一次变量是合乎逻辑的,因为我们只是在循环中修改它,所以不稳定或禁用优化是瓶颈。所以这个答案基本上是错误的,但至少它显示了 NOP 如何 speed-up 未优化的代码执行,如果它在现实世界中有意义(有更好的方法,如分桶计数器)。
我将代码拆分为 C++ 和汇编。我只是想测试循环,所以我没有 return 总和。我在 Windows 上 运行,调用约定是 rcx, rdx, r8, r9,
,循环计数在 rcx
中。该代码将立即值添加到堆栈上的 64 位整数。
两个循环的时间相似,变化小于 1%,相同或其中一个比另一个快 1%。
这里有一个明显的依赖因素:每次添加到内存都必须等待先前添加到同一位置的内存完成,因此两次添加到内存基本上可以并行执行。
将 test2 更改为执行 3 添加到内存,最终慢了大约 6%,4 添加到内存,慢了 7.5%。
我的系统是 Intel 3770K 3.5 GHz CPU,Intel DP67BG 主板,DDR3 1600 9-9-9-27 内存,Win 7 Pro 64 位,Visual Studio 2015.
.code
public test1
align 16
test1 proc
sub rsp,16
mov qword ptr[rsp+0],0
mov qword ptr[rsp+8],0
tst10: add qword ptr[rsp+8],17
dec rcx
jnz tst10
add rsp,16
ret
test1 endp
public test2
align 16
test2 proc
sub rsp,16
mov qword ptr[rsp+0],0
mov qword ptr[rsp+8],0
tst20: add qword ptr[rsp+0],17
add qword ptr[rsp+8],-37
dec rcx
jnz tst20
add rsp,16
ret
test2 endp
end
我还测试了向寄存器添加立即数,1% 以内的 1 或 2 个寄存器(两者都可能更快,但我们希望它们都在 Ivy Bridge 上以 1 次迭代/时钟执行,因为它有 3 个整数 ALU端口;What considerations go into predicting latency for operations on modern superscalar processors and how can I calculate them by hand?).
3 个寄存器的长度是其 1.5 倍,比理想的 1.333 周期/4 微指令迭代(包括循环计数器宏融合 dec/jnz)对于具有完美调度的 3 个后端 ALU 端口要差一些。
4 个寄存器,2.0 倍长,前端瓶颈:
.code
public test1
align 16
test1 proc
xor rdx,rdx
xor r8,r8
xor r9,r9
xor r10,r10
xor r11,r11
tst10: add rdx,17
dec rcx
jnz tst10
ret
test1 endp
public test2
align 16
test2 proc
xor rdx,rdx
xor r8,r8
xor r9,r9
xor r10,r10
xor r11,r11
tst20: add rdx,17
add r8,-37
dec rcx
jnz tst20
ret
test2 endp
public test3
align 16
test3 proc
xor rdx,rdx
xor r8,r8
xor r9,r9
xor r10,r10
xor r11,r11
tst30: add rdx,17
add r8,-37
add r9,47
dec rcx
jnz tst30
ret
test3 endp
public test4
align 16
test4 proc
xor rdx,rdx
xor r8,r8
xor r9,r9
xor r10,r10
xor r11,r11
tst40: add rdx,17
add r8,-37
add r9,47
add r10,-17
dec rcx
jnz tst40
ret
test4 endp
end
现在的处理器太复杂了,我们只能猜测。
编译器发出的程序集并不是真正执行的程序集。 CPU 的 microcode/firmware/whatever 将解释它并将其转化为执行引擎的指令,就像 C# 或 java 等 JIT 语言所做的那样。
这里要考虑的一件事是,对于每个循环,没有 1 或 2 条指令,而是 n + 2,因为您还递增并将 i 与迭代次数进行比较。在绝大多数情况下,这无关紧要,但在这里却很重要,因为循环体非常简单。
让我们看看程序集:
部分定义:
#define NUM_ITERATIONS 1000000000ll
#define X_INC 17
#define Y_INC -31
C/C++ :
for (long i = 0; i < NUM_ITERATIONS; i++) { x+=X_INC; }
ASM :
mov QWORD PTR [rbp-32], 0
.L13:
cmp QWORD PTR [rbp-32], 999999999
jg .L12
add QWORD PTR [rbp-24], 17
add QWORD PTR [rbp-32], 1
jmp .L13
.L12:
C/C++ :
for (long i = 0; i < NUM_ITERATIONS; i++) {x+=X_INC; y+=Y_INC;}
ASM:
mov QWORD PTR [rbp-80], 0
.L21:
cmp QWORD PTR [rbp-80], 999999999
jg .L20
add QWORD PTR [rbp-64], 17
sub QWORD PTR [rbp-72], 31
add QWORD PTR [rbp-80], 1
jmp .L21
.L20:
所以两个组件看起来很相似。但是让我们三思而后行:现代 CPUs 的 ALU 对比其寄存器大小更宽的值进行运算。所以有可能比第一种情况,对 x 和 i 的操作是在同一个计算单元上完成的。但是当你对这个操作的结果设置条件时,你必须再次阅读 i 。而阅读意味着等待。
因此,在第一种情况下,要迭代 x,CPU 可能必须与 i 上的迭代同步。
在第二种情况下,x 和 y 可能与处理 i 的单位不同。所以实际上,你的循环体与驱动它的条件并行运行。然后你的 CPU 计算和计算,直到有人告诉它停止。走得太远也没关系,回头几个循环比刚刚获得的时间还是可以的。
因此,为了比较我们想要比较的内容(一次操作与两次操作),我们应该尽量让 i 离开。
一种解决方案是使用 while 循环完全摆脱它: C/C++:
while (x < (X_INC * NUM_ITERATIONS)) { x+=X_INC; }
ASM:
.L15:
movabs rax, 16999999999
cmp QWORD PTR [rbp-40], rax
jg .L14
add QWORD PTR [rbp-40], 17
jmp .L15
.L14:
另一种是使用过时的 "register" C 关键字: C/C++:
register long i;
for (i = 0; i < NUM_ITERATIONS; i++) { x+=X_INC; }
ASM:
mov ebx, 0
.L17:
cmp rbx, 999999999
jg .L16
add QWORD PTR [rbp-48], 17
add rbx, 1
jmp .L17
.L16:
这是我的结果:
x1 用于:10.2985 秒。 x,y = 17000000000,0
x1 同时:8.00049 秒。 x,y = 17000000000,0
x1 注册:7.31426 秒。 x,y = 17000000000,0
x2 用于:9.30073 秒。 x,y = 17000000000,-31000000000
x2 同时:8.88801 秒。 x,y = 17000000000,-31000000000
x2 注册时间:8.70302 秒。 x,y = 17000000000,-31000000000
此效果仅发生在 -O0
(或 volatile
),并且是编译器将您的变量保存在内存中(而非寄存器)的结果。 您希望通过 i
、x
和 y
将固定数量的额外延迟引入到循环携带的依赖链中,但现代 CPU没那么简单
在 Intel Sandybridge 系列 CPUs 上,存储转发延迟 更低 当负载 uop 运行s 一段时间在重新加载其数据的存储之后,而不是立即。 因此,内存中有循环计数器的空循环是最坏的情况。我不明白什么 CPU 设计选择会导致这种微架构怪癖,但这是真实存在的。
这基本上是
这是主要原因之一
微基准测试很难;只有当你能让编译器为你试图测量的东西发出实际优化的 asm 循环时,你才能正确地测量某些东西。 (即使那样,您也只是测量吞吐量 或 延迟,而不是两者;对于无序流水线 CPUs 上的单个操作,这些是单独的事情:What considerations go into predicting latency for operations on modern superscalar processors and how can I calculate them by hand?)
请参阅
使用 clang,benchmark::DoNotOptimize(x1 += 31)
也取消优化以将 x
保留在内存中,但使用 GCC,它只会保留在寄存器中。不幸的是,-O0
asm 的结果。它确实显示了通过内存被瓶颈隐藏的大量短 NOP 的成本,并且当这些 NOP 延迟重新加载下一次迭代的时间刚好足以使存储转发达到较低延迟的良好情况时,速度会略有提高。 (我认为 QuickBench 运行s 在 Intel Xeon 服务器 CPUs 上,每个 CPU 核心内部的微体系结构与同代桌面版本相同。)
据推测,您测试的所有 x86 计算机在过去 10 年中都使用了 Intel CPUs,否则对 AMD 也有类似的影响。如果您的测量值确实有意义,那么对您的 RPi 使用的任何 ARM CPU 都有类似的影响是合理的。否则,可能会看到您所期望的另一种情况 (confirmation bias),特别是如果您在此处启用了优化进行测试。
I tested this with different levels of code optimization (
-O0
,-O1
,-O2
,-O3
) [...] But I always got similar resultI added that optimizations notice in the question to avoid "do not measure not optimized code" answers because optimizations is not what I ask about.
(later from comments) About optimizations: yes, I reproduced that with different optimization levels, but as the loops were optimized away, the execution time was too fast to say for sure.
所以实际上你没有重现这个效果-O1
或更高,你只是看到了你想要的参见(确认偏差)并且主要是声称效果相同。如果您准确地报告了您的数据([=14= 的可衡量效果,-O1
及更高的空白时间区域),我可以立即回答。
请参阅
我假设你只在 -O0
测试时交换了循环,否则你会排除在 -O1
或更高版本使用该测试代码有任何影响。
启用优化的循环:
如您所见on Godbolt,gcc 在启用优化的情况下完全删除了循环。有时 GCC 会单独留下空循环,就像它认为延迟是故意的一样,但在这里它甚至根本不循环。时间不随任何时间缩放,两个时间区域看起来都像这样:
orig_main:
...
call std::chrono::_V2::system_clock::now() # demangled C++ symbol name
mov rbp, rax # save the return value = start
call std::chrono::_V2::system_clock::now()
# end in RAX
因此,定时区域中唯一的指令是将 start
保存到调用保留寄存器中。您实际上没有对源代码进行任何测量。
通过 Google Benchmark,我们可以获得不会优化工作但不会 store/reload 引入新瓶颈的 asm :
#include <benchmark/benchmark.h>
static void TargetFunc(benchmark::State& state) {
uint64_t x2 = 0, y2 = 0;
// Code inside this loop is measured repeatedly
for (auto _ : state) {
benchmark::DoNotOptimize(x2 += 31);
benchmark::DoNotOptimize(y2 += 31);
}
}
// Register the function as a benchmark
BENCHMARK(TargetFunc);
# just the main loop, from gcc10.1 -O3
.L7: # do{
add rax, 31 # x2 += 31
add rdx, 31 # y2 += 31
sub rbx, 1
jne .L7 # }while(--count != 0)
我假设 benchmark::DoNotOptimize
类似于 asm volatile("" : "+rm"(x) )
(GNU C inline asm) 使编译器在寄存器或内存中具体化 x
,并假设左值已经由那个空的 asm 语句修改。 (即忘记它所知道的关于价值的任何事情,阻止常量传播,CSE 等等。)这可以解释为什么当 GCC 选择寄存器时 clang stores/reloads 到内存:这是一个长期存在的 clang 内联错误优化错误汇编支持。它喜欢在给定选择时选择内存,有时您可以使用 "+r,m"
等多种替代约束来解决这个问题。但不是在这里;我不得不放弃内存替代品;我们不希望编译器 spill/reload 到内存。
对于 GNU C 兼容的编译器,我们可以手动使用 asm volatile
,仅使用 "+r"
寄存器约束,让 clang 生成良好的标量 asm (Godbolt),如 GCC。我们得到一个本质上相同的内部循环,有 3 个加法指令,最后一个是可以宏融合的 add rbx, -1
/ jnz
。
static void TargetFunc(benchmark::State& state) {
uint64_t x2 = 0, y2 = 0;
// Code inside this loop is measured repeatedly
for (auto _ : state) {
x2 += 16;
y2 += 17;
asm volatile("" : "+r"(x2), "+r"(y2));
}
}
所有这些都应该 运行 在现代英特尔和 AMD CPUs 上每次迭代 1 个时钟周期,再次查看@rcgldr 的回答。
当然,这也会禁用 SIMD 的自动矢量化,编译器在许多实际用例中都会这样做。或者,如果您完全 在循环之外 使用结果,它可能会将重复增量优化为单个乘法。
您无法衡量 C++ 中 +
运算符的成本 - 根据上下文/周围代码,它的编译可能会有很大差异。即使不考虑提升工作的循环不变的东西。例如x + (y<<2) + 4
可以编译为 x86 的单个 LEA 指令。
The question is actually why my computers execute two operations faster than one, first of all in code where these operations are not optimized away
TL:DR: 这不是操作,而是通过内存的循环携带的依赖链停止了 CPU 从 运行 在每次迭代 1 个时钟周期循环,完成所有 3在单独的执行端口上并行添加。
请注意,循环计数器增量与您对 x
(有时 y
)所做的操作一样多。