【问题标题】:Why is execution time of a process shorter when another process shares the same HT core为什么当另一个进程共享同一个HT核心时,一个进程的执行时间会更短
【发布时间】:2020-01-26 02:55:49
【问题描述】:

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

第一个:

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。 C 状态通过使用processor.max_cstate=1 intel_idle.max_cstate=0 进行修复。 频率调节器设置为性能 (cpupower frequency-set -g performance) 并禁用 turbo (cat /sys/devices/system/cpu/intel_pstate/no_turbo 给出 1)

【问题讨论】:

  • 是的。 CPU 频率固定为performance intel_pstate 的调速器,并且禁用了涡轮增压。我编辑我的消息以添加此重要信息
  • 我可以确认这发生在 i5-8250U 上,虽然不是那么极端。第二个程序在同一个内核上运行时我有大约 2% 的改进,但当第二个程序在不同内核上运行时没有任何改进。它一定是cpu内部的一个效果,可能与流水线的细节有关。 perf 没有表示任何上下文切换,我的内核时间在每种情况下测量为 0,000 秒。
  • 我必须更正上面的确认:我将所有程序编译为 C++ (g++),这给出了大约 2% 的差异。如果我将所有内容编译为 C,我会得到大约 8%,这接近 OP 大约 10%。
  • @uneven_mark: 哦,很奇怪,gcc -O0g++ -O0 编译循环的方式不同,C 版本在循环底部放置了一个cmp/jle 在内存目的地添加之后。 (在godbolt.org/z/Ik0Wkp 的左侧窗格中)。但 C++ 版本使用if(break) 循环样式,条件位于顶部。有趣的是,仅通过一条 jmp 指令将内存目标 addcmp 重新加载分开会产生很大的不同。

标签: linux performance x86 intel hyperthreading


【解决方案1】:

两者都是用 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 optimizationLoop 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 -O0g++ -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 指令将内存目标 addcmp 重新加载分开会产生很大的不同。

# 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 延迟),前端很容易保持领先于后端,即使它必须与另一个超线程竞争。

【讨论】:

  • 我测试了您的替代程序,发现当所有内容都编译为 C 时,它可以将效果的幅度从大约 8% 降低到 2%。正如我在对该问题的新评论中提到的那样,我错误地编译为 C++,这给了我与 OP 程序大约 1.9% 的差异,然后你的变化实际上将效果增加到 2.2%。所以我猜是这两种效果的结合?
  • 我也应该提到,在我的测试中,C++ 变体的基线(没有同级线程)的性能比 C 版本差大约 3%。因此,C 版本遭受的损失似乎并非如此。 (而且我的测试并不严格,只是在我的笔记本上进行了几次快速迭代。)
  • @uneven_mark:IDK 为什么代码布局的微小差异会产生影响。也许它与内存目标添加通过采取的跳转与重新加载分开有关。用更多的猜测/大声思考更新了我的答案。 无论真正的答案是什么,它都将是非常技术性的,而不是适用于其他任何事情的任何简单的经验法则。 存储转发延迟对何时尝试重新加载(我们认为),但时间取决于乱序调度,因此您无法直接控制它。
  • 另外,就像我在问题顶部所说的那样,这通常是不相关的,因为大多数循环不会成为涉及存储/重新加载的循环携带依赖项的瓶颈。这就是优化将值保存在寄存器中的原因。所以它主要来自 CPU 内部 POV,而不是为了让大多数普通代码运行得更快。
猜你喜欢
  • 1970-01-01
  • 2016-03-03
  • 2021-03-20
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 2018-04-21
  • 2019-04-02
相关资源
最近更新 更多