两者都是用 gcc 编译的,没有特殊选项。 (即默认为 -O0:无优化调试模式,将变量保存在内存中而不是寄存器中。)
与普通程序不同,带有int i,j 循环计数器的版本完全解决了存储转发延迟的瓶颈,而不是前端吞吐量或后端执行资源或任何共享资源。
这就是为什么您永远不想使用-O0 debug-mode 进行真正的基准测试:瓶颈与普通优化(至少-O2,最好是-O3 -march=native)相比不同。
在 Intel Sandybridge 系列(包括 @uneven_mark 的 Kaby Lake CPU)上,如果重新加载不尝试在存储后立即运行,而是相反,存储转发延迟会更低几个周期后运行。 Adding a redundant assignment speeds up code when compiled without optimization 和 Loop with function call faster than an empty loop 都在未优化的编译器输出中展示了这种效果。
让另一个超线程争夺前端带宽显然有时会导致这种情况发生。
或者存储缓冲区的静态分区可以加快存储转发速度? 尝试在另一个内核上运行微创循环可能会很有趣,如下所示:
// compile this with optimization enabled
// and run it on the HT sibling of the debug-mode nested loop
#include <immintrin.h>
int main(void) {
while(1) {
_mm_pause(); _mm_pause();
_mm_pause(); _mm_pause();
}
}
pause 在 Skylake 上阻塞大约 100 个周期,而早期 CPU 大约为 5 个。
因此,如果存储转发的好处在于必须发出/执行的其他线程的微指令,则此循环将做的更少,并且运行时间将更接近单线程中的物理内核模式。
但如果好处仅仅是对 ROB 和存储缓冲区进行分区(这可能会加快负载探测存储的时间),我们仍然会看到全部好处。
更新:@uneven_mark 在 Kaby Lake 上进行了测试,发现这将“加速”从约 8% 降至约 2%。因此,显然争夺前端/后端资源是无限循环中阻止另一个循环过早重新加载的重要部分。
也许用尽 BOB (branch-order-buffer) 插槽是阻止其他线程的分支微指令发出到无序后端的主要机制。现代 x86 CPU 对 RAT 和其他后端状态进行快照,以便在检测到分支错误预测时实现快速恢复,从而允许回滚到错误预测的分支,而无需等待其退休。
这避免了在分支之前等待独立工作,并在恢复时继续无序执行它。但这意味着可以飞行的分支更少。至少更少的条件/间接分支? IDK 如果直接jmp 将使用 BOB 条目;它的有效性在解码期间建立。所以也许这个猜测不成立。
while(1){} 循环在循环中没有本地变量,因此它不会成为存储转发的瓶颈。这只是一个top: jmp top 循环,每次迭代可以运行 1 个循环。这是 Intel 上的单指令。
i5-8250U is a Kaby Lake,并且(与 Coffee Lake 不同)其循环缓冲区 (LSD) 仍被 Skylake 等微码禁用。所以它不能unroll itself in the LSD/IDQ(队列提供问题/重命名阶段)并且必须在每个周期从uop缓存中单独获取jmpuop。但是 IDQ 确实缓冲了这一点,只需要每 4 个周期发出一个问题/重命名周期来为该逻辑核心发出一组 4 个 jmp 微指令。
但无论如何,在 SKL/KBL 上,这两个线程一起超过了 uop 缓存获取带宽,并且以这种方式相互竞争。在启用了 LSD(环回缓冲区)的 CPU 上(例如 Haswell / Broadwell 或 Coffee Lake 及更高版本),它们不会。 Sandybridge/Ivybridge 不会展开微小的循环以使用更多的 LSD,因此您在那里会有相同的效果。我不确定这是否重要。 在 Haswell 或 Coffee Lake 上进行测试会很有趣。
(一个无条件的jmp 总是结束一个uop-cache 行,而且它不是一个跟踪缓存,所以一个uop-cache 提取不能给你超过一个jmp uop。)
我必须更正上面的确认:我将所有程序编译为 C++ (g++),这给出了大约 2% 的差异。如果我将所有内容编译为 C,我会得到大约 8%,这更接近于 OP 大约 10%。
这很有趣,gcc -O0 和 g++ -O0 编译循环的方式不同。这是 GCC 的 C 与 C++ 前端为 GCC 的后端提供不同的 GIMPLE/RTL 或类似的东西的一个怪癖,而-O0 并没有使后端解决效率低下的问题。 这不是 C 与 C++ 的基本内容,也不是您对其他编译器的期望。
C 版本仍然转换为惯用的do{}while() 样式循环,循环底部有一个cmp/jle,右 在添加内存目标之后。 (this Godbolt compiler explorer link 的左窗格)。 Why are loops always compiled into "do...while" style (tail jump)?
但 C++ 版本使用 if(break) 循环样式,条件在顶部,然后是内存目标添加。 有趣的是,仅通过一条 jmp 指令将内存目标 add 与 cmp 重新加载分开会产生很大的不同。
# inner loop, gcc9.2 -O0. (Actually g++ -xc but same difference)
jmp .L3
.L4: # do {
add DWORD PTR [rbp-8], 1 # j++
.L3: # loop entry point for first iteration
cmp DWORD PTR [rbp-8], 99999
jle .L4 # }while(j<=99999)
显然 add/cmp 背靠背使这个版本更受 Skylake / Kaby/Coffee Lake 上较慢的存储转发的影响
对比这个没有受到太大影响:
# inner loop, g++9.2 -O0
.L4: # do {
cmp DWORD PTR [rbp-8], 99999
jg .L3 # if(j>99999) break
add DWORD PTR [rbp-8], 1 # j++
jmp .L4 # while(1)
.L3:
cmp [mem], imm / jcc 可能仍然是微观和/或宏观熔断器,但我忘记了哪个。 IDK 如果这是相关的,但如果循环更多 uops,它就不能尽快发布。尽管如此,由于每 5 或 6 个周期 1 次迭代的执行瓶颈(内存目标add 延迟),前端很容易保持领先于后端,即使它必须与另一个超线程竞争。