当另一个进程共享相同的HT内核时,为什么一个进程的执行时间更短

seb*_*hat 4 linux performance x86 intel hyperthreading

我有一个带有4个HT内核(8个逻辑CPU)的Intel CPU,并构建了两个简单的进程。

第一个:

int main()
{
  for(int i=0;i<1000000;++i)
    for(int j=0;j<100000;++j);
}
Run Code Online (Sandbox Code Playgroud)

第二个:

int main()
{
  while(1);
}
Run Code Online (Sandbox Code Playgroud)

两者都编译时gcc没有特殊选项。(即默认值为-O0:无优化调试模式,将变量保留在内存中而不是寄存器中。)

当我在第一个逻辑CPU(CPU0)上运行第一个时,并且当其他逻辑CPU的负载费用接近0%时,此第一个进程的执行时间为:

real    2m42,625s
user    2m42,485s
sys     0m0,070s
Run Code Online (Sandbox Code Playgroud)

但是,当我在CPU4上运行第二个进程(无限循环)时(CPU0和CPU4在同一内核上,但不在同一硬件线程上),第一个进程的执行时间为

real    2m25,412s
user    2m25,291s
sys     0m0,047s
Run Code Online (Sandbox Code Playgroud)

我期望更长的时间,因为在同一核心上有两个进程,而不是只有一个。但这实际上更快。为什么会这样?

编辑:P状态驱动程序是intel_pstate。使用来固定C状态processor.max_cstate=1 intel_idle.max_cstate=0。将调速器设置为性能(cpupower frequency-set -g performance),禁用涡轮增压(cat /sys/devices/system/cpu/intel_pstate/no_turbo给出1)

Pet*_*des 5

两者都是使用gcc编译的,没有特殊选项。(即,默认值为-O0:无优化调试模式,将变量保留在内存中而不是寄存器中。)

与普通程序不同,带有int i,j循环计数器的版本完全在存储转发延迟上出现瓶颈,而不是前端吞吐量或后端执行资源或任何共享资源出现瓶颈。

这就是为什么您永远都不想使用-O0调试模式进行真正的基准测试的原因:瓶颈与普通优化不同-O2至少最好是这样-O3 -march=native)。


在Intel Sandybridge系列(包括@uneven_mark的Kaby Lake CPU)上,如果重新加载不是立即尝试在存储后立即运行,而是稍后运行几个周期,则存储转发延迟会降低 在没有优化的情况下进行编译时,添加冗余分配可以加快代码的速度,并且使用函数调用的循环要比空循环更快,这两者都可以在未优化的编译器输出中证明这种效果。

显然,让另一个超线程竞争前端带宽显然会使这种情况有时发生。

还是存储缓冲区的静态分区可以加速存储转发? 尝试在另一个内核上运行的最小侵入性循环可能会很有趣,如下所示:

// 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();
    }
}
Run Code Online (Sandbox Code Playgroud)

pause Skylake在大约100个周期内处于阻塞状态,而早期CPU大约为5个周期。

因此,如果存储转发的好处是来自必须发出/执行的其他线程的操作,则此循环将执行较少的操作,并且运行时将接近具有单线程模式的物理核心时的运行时间。

但是,如果好处仅在于对ROB和存储缓冲区进行分区(这可以合理地加快负载为存储进行探测的时间),我们仍然会看到全部好处。

更新: @uneven_mark在Kaby Lake上进行了测试,发现这将“加速”从〜8%降低到〜2%。因此,显然,争夺前端/后端资源是无限循环的重要组成部分,它可以阻止另一个循环过早重新加载。

也许用完BOB(分支顺序缓冲区)插槽是阻止其他线程的分支指令发布到无序后端的主要机制。现代x86 CPU对RAT和其他后端状态进行快照,以在检测到分支预测错误时允许快速恢复,从而允许回滚到预测错误的分支,而无需等待其退出。

这样可以避免在分支之前等待独立的工作,并避免在恢复过程中无序地继续执行它。但这意味着可以使用的分支较少。至少更少的条件/间接分支?IDK(如果直接jmp将使用BOB条目);其有效性在解码期间确定。因此,也许这种猜测不能成立。


while(1){}循环在循环中没有局部变量,因此它不会成为存储转发的瓶颈。这只是一个top: jmp top循环,每次迭代可以运行1个周期。那是Intel的单指令。

i5-8250U是一个Kaby Lake,并且(不同于Coffee Lake)仍然被Skylake等微码禁用了其循环缓冲区(LSD)。因此,它无法在LSD / IDQ(将其提供给问题/重命名阶段)中展开,而必须在jmp每个周期中从uop缓存中分别获取uop。但是IDQ确实缓冲了这一点,只需要每4个发行/重命名周期就可以为该逻辑核心发出一组4个jmp指令。

但是无论如何,在SKL / KBL上,这两个线程的结合超过饱和uop缓存的获取带宽,并且确实以这种方式相互竞争。在启用了LSD(回送缓冲区)的CPU(例如Haswell / Broadwell或Coffee Lake及更高版本)上,它们不会。Sandybridge / Ivybridge不会展开微小的循环来使用更多的LSD,因此您在此处会获得相同的效果。我不确定这是否很重要。 在Haswell或Coffee Lake上进行测试将很有趣。

(无条件jmp总会结束一个uop缓存行,而且无论如何它都不是跟踪缓存,因此一次uop缓存访存不会给您一个以上的jmpuop。)


我必须从上面更正我的确认:我将所有程序编译为C ++(g ++),这产生了大约2%的差异。如果将所有内容编译为C,我将得到大约8%的收益,而OP则大约为10%。

这很有趣,gcc -O0并且g++ -O0编译循环的方式也有所不同。这是GCC的C vs. C ++前端的怪癖,它为GCC的后端提供了不同的GIMPLE / RTL或类似的东西,-O0而不会使后端修复效率低下的问题。 对于C vs. C ++而言,这不是根本的东西,也不是其他编译器所期望的。

C版本仍然转变成一个地道的do{}while()风格回路用cmp/jle在循环的底部,一个内存目的地添加之后。(此Godbolt编译器资源管理器链接的左窗格)。 为什么循环总是编译成“ do ... while”样式(尾跳)?

但是C ++版本使用一种if(break)循环的方式,条件在顶部,然后添加内存目标。 有趣的是,仅通过一条指令将内存目标addcmp重载分开就jmp可以带来很大的不同。

# 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)
Run Code Online (Sandbox Code Playgroud)

显然,背靠背的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:
Run Code Online (Sandbox Code Playgroud)

cmp [mem], imm/ jcc可能仍然具有微型和/或宏保险丝,但我忘记了。如果这是相关的IDK,但如果循环更多,则无法快速发布。尽管如此,由于每5或6个周期(内存目标add延迟)有1次迭代的执行瓶颈,即使前端前端必须与另一个超线程竞争,它也很容易领先后端后端。