为什么当另一个进程共享同一个 HT 核心时,一个进程的执行时间更短
Why is execution time of a process shorter when another process shares the same HT core
我有一个 Intel CPU,有 4 个 HT 内核(8 个逻辑 CPUs),我构建了两个简单的进程。
第一个:
int main()
{
for(int i=0;i<1000000;++i)
for(int j=0;j<100000;++j);
}
第二个:
int main()
{
while(1);
}
两者都是用 gcc
编译的,没有特殊选项。 (即默认 -O0
:无优化调试模式,将变量保存在内存中而不是寄存器中。)
当我运行第一个逻辑上的第一个CPU(CPU0),而当其他逻辑CPU上的负载电荷接近0 %,这第一个进程的执行时间是:
real 2m42,625s
user 2m42,485s
sys 0m0,070s
然而,当我 运行 CPU4 上的第二个进程(无限循环)时(CPU0 和 CPU4 在同一个核心上但不是在同一个硬件线程上),第一个进程的执行时间是
real 2m25,412s
user 2m25,291s
sys 0m0,047s
我预计需要更长的时间,因为同一个核心上有两个进程,而不是只有一个。但它实际上更快。
为什么会这样?
编辑:
P 状态驱动程序是 intel_pstate。使用 processor.max_cstate=1 intel_idle.max_cstate=0
固定 C 状态。
频率调节器设置为性能 (cpupower frequency-set -g performance
) 并禁用涡轮 (cat /sys/devices/system/cpu/intel_pstate/no_turbo
给出 1)
Both are compiled with gcc without special options. (I.e. with the default of -O0: no optimization debug mode, keeping variables in memory instead of registers.)
与普通程序不同,具有 int i,j
循环的版本完全解决了存储转发延迟的瓶颈,而不是前端吞吐量或后端执行资源或任何共享资源。
这就是为什么您永远不想使用 -O0
调试模式进行真正的基准测试:瓶颈与正常优化 不同(-O2
在最少,最好 -O3 -march=native
).
在英特尔 Sandybridge 系列(包括@uneven_mark 的 Kaby Lake CPU)上,存储转发延迟 更低如果重新加载不会在存储后立即尝试 运行,而是在几个周期后 运行。 and also 都在未优化的编译器中证明了这种效果输出。
让另一个超线程竞争前端带宽显然使这种情况有时会发生。
或者存储缓冲区的静态分区可以加速存储转发?在另一个核心上尝试微创循环 运行ning 可能会很有趣,像这样:
// 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 个周期,高于之前 CPUs 的大约 5 个周期。
因此,如果存储转发的好处是来自另一个必须 issue/execute 的线程的微指令,则此循环将做更少的事情并且 运行-time 将更接近它的时间在单线程模式下有一个物理内核。
但如果收益仅来自于对 ROB 和存储缓冲区进行分区(这可能会加快负载探测存储的时间),我们仍然会看到全部收益。
更新:@uneven_mark 在 Kaby Lake 上进行测试,发现这将 "speedup" 从 ~8% 降低到~2%。因此,显然争夺前端/后端资源是无限循环的重要组成部分,它可以阻止另一个循环过早地重新加载。
也许用完 BOB(分支顺序缓冲区)槽是阻止其他线程的分支 uops 发出到乱序后端的主要机制。现代 x86 CPUs 对 RAT 和其他后端状态进行快照,以便在它们检测到分支预测错误时允许快速恢复,允许回滚到错误预测的分支,而无需等待它退休。
这避免了在分支之前等待独立工作,并在恢复时继续乱序执行。但这意味着可以运行的分支更少。至少 conditional/indirect 个分支? IDK 如果直接 jmp
将使用 BOB 条目;它的有效性在解码期间建立。所以这个猜测可能站不住脚。
while(1){}
循环在循环中没有局部变量,因此它不会在存储转发上出现瓶颈。它只是一个 top: jmp top
循环,每次迭代可以 运行 1 个循环。那是 Intel 上的单 uop 指令。
i5-8250U is a Kaby Lake, and (unlike Coffee Lake) still has its loop buffer (LSD) disabled by microcode like Skylake. So it can't /IDQ(为 issue/rename 阶段提供队列)并且每个周期都必须从 uop 缓存中单独获取 jmp
uop。但是 IDQ 确实缓冲了这一点,每 4 个周期只需要一个 issue/rename 周期来为该逻辑核心发出一组 4 个 jmp 微指令。
但是无论如何,在 SKL/KBL 上,这两个线程一起超过饱和 uop 缓存获取带宽,并且确实以这种方式相互竞争。在启用了 LSD(环回缓冲区)的 CPU 上(例如 Haswell / Broadwell,或 Coffee Lake 及更高版本),他们不会。 Sandybridge/Ivybridge 不要展开微小的循环来使用更多的 LSD,这样你就会有同样的效果。我不确定这是否重要。 在 Haswell 或 Coffee Lake 上进行测试会很有趣。
(一个无条件的 jmp
总是结束一个 uop-cache 行,而且它不是跟踪缓存,所以一个 uop-cache 获取不能给你超过一个 jmp
uop。)
I have to correct my confirmation from above: I compiled all programs as C++ (g++), which gave the roughly 2% difference. If I compile everything as C, I get about 8%, which is closer to OPs roughly 10%.
这很有趣,gcc -O0
和 g++ -O0
确实以不同的方式编译循环。这是 GCC 的 C 与 C++ 前端向 GCC 的后端提供不同 GIMPLE/RTL 或类似内容的一个怪癖,并且 -O0
没有让后端修复效率低下的问题。 这不是关于 C 与 C++ 的任何基本内容,也不是您对其他编译器的期望。
C 版本仍然转换为惯用的 do{}while()
样式循环,在循环底部有一个 cmp/jle
,在内存目标之后 right添加。 (this Godbolt compiler explorer link).
上的左窗格
但 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 可能还是 micro and/or macro-fuse,但我忘记是哪个了。 IDK 如果这是相关的,但如果循环更多 uops,它就不能那么快发出。尽管如此,由于每 5 或 6 个周期 1 次迭代的执行瓶颈(内存目标 add
延迟),前端很容易保持领先于后端,即使它必须与另一个超线程竞争.
我有一个 Intel CPU,有 4 个 HT 内核(8 个逻辑 CPUs),我构建了两个简单的进程。
第一个:
int main()
{
for(int i=0;i<1000000;++i)
for(int j=0;j<100000;++j);
}
第二个:
int main()
{
while(1);
}
两者都是用 gcc
编译的,没有特殊选项。 (即默认 -O0
:无优化调试模式,将变量保存在内存中而不是寄存器中。)
当我运行第一个逻辑上的第一个CPU(CPU0),而当其他逻辑CPU上的负载电荷接近0 %,这第一个进程的执行时间是:
real 2m42,625s
user 2m42,485s
sys 0m0,070s
然而,当我 运行 CPU4 上的第二个进程(无限循环)时(CPU0 和 CPU4 在同一个核心上但不是在同一个硬件线程上),第一个进程的执行时间是
real 2m25,412s
user 2m25,291s
sys 0m0,047s
我预计需要更长的时间,因为同一个核心上有两个进程,而不是只有一个。但它实际上更快。 为什么会这样?
编辑:
P 状态驱动程序是 intel_pstate。使用 processor.max_cstate=1 intel_idle.max_cstate=0
固定 C 状态。
频率调节器设置为性能 (cpupower frequency-set -g performance
) 并禁用涡轮 (cat /sys/devices/system/cpu/intel_pstate/no_turbo
给出 1)
Both are compiled with gcc without special options. (I.e. with the default of -O0: no optimization debug mode, keeping variables in memory instead of registers.)
与普通程序不同,具有 int i,j
循环的版本完全解决了存储转发延迟的瓶颈,而不是前端吞吐量或后端执行资源或任何共享资源。
这就是为什么您永远不想使用 -O0
调试模式进行真正的基准测试:瓶颈与正常优化 不同(-O2
在最少,最好 -O3 -march=native
).
在英特尔 Sandybridge 系列(包括@uneven_mark 的 Kaby Lake CPU)上,存储转发延迟 更低如果重新加载不会在存储后立即尝试 运行,而是在几个周期后 运行。
让另一个超线程竞争前端带宽显然使这种情况有时会发生。
或者存储缓冲区的静态分区可以加速存储转发?在另一个核心上尝试微创循环 运行ning 可能会很有趣,像这样:
// 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 个周期,高于之前 CPUs 的大约 5 个周期。
因此,如果存储转发的好处是来自另一个必须 issue/execute 的线程的微指令,则此循环将做更少的事情并且 运行-time 将更接近它的时间在单线程模式下有一个物理内核。
但如果收益仅来自于对 ROB 和存储缓冲区进行分区(这可能会加快负载探测存储的时间),我们仍然会看到全部收益。
更新:@uneven_mark 在 Kaby Lake 上进行测试,发现这将 "speedup" 从 ~8% 降低到~2%。因此,显然争夺前端/后端资源是无限循环的重要组成部分,它可以阻止另一个循环过早地重新加载。
也许用完 BOB(分支顺序缓冲区)槽是阻止其他线程的分支 uops 发出到乱序后端的主要机制。现代 x86 CPUs 对 RAT 和其他后端状态进行快照,以便在它们检测到分支预测错误时允许快速恢复,允许回滚到错误预测的分支,而无需等待它退休。
这避免了在分支之前等待独立工作,并在恢复时继续乱序执行。但这意味着可以运行的分支更少。至少 conditional/indirect 个分支? IDK 如果直接 jmp
将使用 BOB 条目;它的有效性在解码期间建立。所以这个猜测可能站不住脚。
while(1){}
循环在循环中没有局部变量,因此它不会在存储转发上出现瓶颈。它只是一个 top: jmp top
循环,每次迭代可以 运行 1 个循环。那是 Intel 上的单 uop 指令。
i5-8250U is a Kaby Lake, and (unlike Coffee Lake) still has its loop buffer (LSD) disabled by microcode like Skylake. So it can't jmp
uop。但是 IDQ 确实缓冲了这一点,每 4 个周期只需要一个 issue/rename 周期来为该逻辑核心发出一组 4 个 jmp 微指令。
但是无论如何,在 SKL/KBL 上,这两个线程一起超过饱和 uop 缓存获取带宽,并且确实以这种方式相互竞争。在启用了 LSD(环回缓冲区)的 CPU 上(例如 Haswell / Broadwell,或 Coffee Lake 及更高版本),他们不会。 Sandybridge/Ivybridge 不要展开微小的循环来使用更多的 LSD,这样你就会有同样的效果。我不确定这是否重要。 在 Haswell 或 Coffee Lake 上进行测试会很有趣。
(一个无条件的 jmp
总是结束一个 uop-cache 行,而且它不是跟踪缓存,所以一个 uop-cache 获取不能给你超过一个 jmp
uop。)
I have to correct my confirmation from above: I compiled all programs as C++ (g++), which gave the roughly 2% difference. If I compile everything as C, I get about 8%, which is closer to OPs roughly 10%.
这很有趣,gcc -O0
和 g++ -O0
确实以不同的方式编译循环。这是 GCC 的 C 与 C++ 前端向 GCC 的后端提供不同 GIMPLE/RTL 或类似内容的一个怪癖,并且 -O0
没有让后端修复效率低下的问题。 这不是关于 C 与 C++ 的任何基本内容,也不是您对其他编译器的期望。
C 版本仍然转换为惯用的 do{}while()
样式循环,在循环底部有一个 cmp/jle
,在内存目标之后 right添加。 (this Godbolt compiler explorer link).
但 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 可能还是 micro and/or macro-fuse,但我忘记是哪个了。 IDK 如果这是相关的,但如果循环更多 uops,它就不能那么快发出。尽管如此,由于每 5 或 6 个周期 1 次迭代的执行瓶颈(内存目标 add
延迟),前端很容易保持领先于后端,即使它必须与另一个超线程竞争.