将 bool 从参数复制到全局 - 比较编译器输出

Copying a bool from a parameter to a global - comparing compilers output

尽管我完全知道 these completely artificial benchmarks don't mean much,但我还是对 "big 4" 编译器选择编译一个小片段的几种方式感到有点惊讶。

struct In {
    bool in1;
    bool in2;
};

void foo(In &in) {
    extern bool out1;
    extern bool out2;
    out1 = (in.in1 == true);
    out2 = in.in2;
}

注意:所有编译器都设置为具有最高 "general purpose"(= 未指定特定处理器架构)"optimize for speed" 设置的 x64 模式;你可以通过 yourself/play 查看结果,他们在 https://gcc.godbolt.org/z/K_i8h9)


带 -O3 的 Clang 6 似乎产生了最直接的输出:

foo(In&):                             # @foo(In&)
        mov     al, byte ptr [rdi]
        mov     byte ptr [rip + out1], al
        mov     al, byte ptr [rdi + 1]
        mov     byte ptr [rip + out2], al
        ret

在符合标准的 C++ 程序中,== true 比较是多余的,因此两个赋值都成为从一个内存位置到另一个内存位置的直接副本,通过 al 因为没有内存到内存 mov.

然而,由于这里没有寄存器压力,我希望它使用两个不同的寄存器(以完全避免两个分配之间的错误依赖链),可能首先开始所有读取,然后进行所有写入之后,帮助指令级并行; 由于寄存器重命名和极度乱序的 CPU,这种优化对于最近的 CPU 是否完全过时了? (稍后会详细介绍)


带有 -O3 的 GCC 8.2 几乎 做同样的事情,但有一点不同:

foo(In&):
        movzx   eax, BYTE PTR [rdi]
        mov     BYTE PTR out1[rip], al
        movzx   eax, BYTE PTR [rdi+1]
        mov     BYTE PTR out2[rip], al
        ret

它不是对 "small" 寄存器执行普通 mov,而是对 eax 执行 movzx为什么?这是为了完全重置寄存器重命名器中 eax 和子寄存器的状态以避免部分寄存器停顿吗?


MSVC 19 with /O2 又增加了一个怪癖:

in$ = 8
void foo(In & __ptr64) PROC                ; foo, COMDAT
        cmp     BYTE PTR [rcx], 1
        sete    BYTE PTR bool out1         ; out1
        movzx   eax, BYTE PTR [rcx+1]
        mov     BYTE PTR bool out2, al     ; out2
        ret     0
void foo(In & __ptr64) ENDP                ; foo

除了调用约定不同外,这里的第二个赋值几乎相同。

然而,实际上执行了第一个赋值中的比较(有趣的是,同时使用 cmp 和带有内存操作数的 sete,所以你可以说中间寄存器是 FLAGS)。


最后,带 -O3 的 ICC 18 是最奇怪的:

foo(In&):
        xor       eax, eax                                      #9.5
        cmp       BYTE PTR [rdi], 1                             #9.5
        mov       dl, BYTE PTR [1+rdi]                          #10.12
        sete      al                                            #9.5
        mov       BYTE PTR out1[rip], al                        #9.5
        mov       BYTE PTR out2[rip], dl                        #10.5
        ret                                                     #11.1

只是为了好玩,我尝试删除 == true,现在 ICC 可以了

foo(In&):
        mov       al, BYTE PTR [rdi]                            #9.13
        mov       dl, BYTE PTR [1+rdi]                          #10.12
        mov       BYTE PTR out1[rip], al                        #9.5
        mov       BYTE PTR out2[rip], dl                        #10.5
        ret                                                     #11.1

所以,eax 没有归零,但仍然使用两个寄存器和 "start reading in parallel first, use all the results later"。

TL:DR: gcc 的版本是所有 x86 uarches 中最健壮的,避免了错误的依赖或额外的 uops。 None 其中是最佳的;一次加载两个字节应该更好。

这里的2个重点是:

  • 主流编译器只关心无序的 x86 uarches 来默认调整指令选择和调度。当前出售的所有 x86 uarches 都会通过寄存器重命名进行乱序执行(至少对于 full 寄存器,例如 RAX)。

    没有有序的 uarches 仍然与 tune=generic 相关。 (较旧的 Xeon Phi,Knight's Corner,使用经过修改的基于 Pentium P54C 的有序内核,并且有序的 Atom 系统可能仍然存在,但现在也已经过时了。在那种情况下,在两者之后进行存储很重要负载,以允许负载中的内存并行。)

  • 8 位和 16 位 Partial 寄存器存在问题,可能导致错误的依赖关系。 解释了各种 x86 uarche 的不同行为。


  1. 部分寄存器重命名以避免错误依赖:

Intel 在 IvyBridge 之前将 AL 与 RAX 分开重命名(P6 系列和 SnB 本身,但不是后来的 SnB 系列)。 在所有其他 uarches(包括 Haswell/Skylake、所有 AMD 和 Silvermont / KNL)上,写入 AL 合并到 RAX。有关现代英特尔(HSW 及更高版本)与 P6 系列和第一代 Sandybridge 的更多信息,请参阅此问答:

在 Haswell/Skylake 上,mov al, [rdi] 解码为微融合 ALU + 加载 uop,将加载结果合并到 RAX 中。 (这对位域合并很好,而不是让前端在读取完整寄存器时插入一个稍后的合并 uop 需要额外的成本)。

它的执行方式与 add al, [rdi]add rax, [rdi] 相同。 (它只是一个 8 位加载,但它依赖于 RAX 中旧值的全宽度。只写指令到 low-8/low-16 regs 像 alax 不是就微体系结构而言是只写的。)

在 P6 系列(PPro 到 Nehalem)和 Sandybridge(第一代 Sandybridge 系列)上,clang 的代码非常好。寄存器重命名使 load/store 对彼此完全独立,就好像它们使用了不同的体系结构寄存器一样。

在所有其他 uarches 上,Clang 的代码有潜在的危险。如果 RAX 是调用者或其他一些长依赖链中某些早期缓存未命中加载的目标,则此asm 将使商店依赖于其他 dep-chain,将它们耦合在一起并消除 CPU 找到 ILP 的机会。

加载仍然是独立的,因为加载与合并是分开的,一旦加载地址rdi在外部已知时就会发生-订单核心。存储地址也是已知的,所以存储地址微指令可以执行(所以稍后 loads/stores 可以检查重叠),但是存储数据微指令被卡住等待合并微指令。 (Intel 上的存储始终是 2 个独立的 uops,但它们可以在前端微融合在一起。)

Clang 似乎不太了解部分寄存器,有时会无缘无故地创建错误的 deps 和部分寄存器惩罚,即使它不保存任何代码-例如,使用窄 or al,dl 而不是 or eax,edx 来调整大小。

在这种情况下,它每次加载节省一个字节的代码大小(movzx 有一个 2 字节的操作码)。

  1. 为什么 gcc 使用 movzx eax, byte ptr [mem]?

写 EAX 零扩展到完整的 RAX,所以它总是只写的,没有对任何 CPU 上 RAX 的旧值的错误依赖。 Why do x86-64 instructions on 32-bit registers zero the upper part of the full 64-bit register?.

movzx eax, m8/m16 纯粹在加载端口中处理,而不是作为加载 + ALU 零扩展,在 Intel 上,以及自 Zen 以来的 AMD 上。唯一的额外成本是 1 字节的代码大小。 (在 Zen 之前的 AMD 有 1 个周期的额外延迟用于 movzx 加载,显然他们必须 运行 在 ALU 和加载端口上。做 sign/zero-extension 或广播作为负载的一部分没有额外不过,延迟是现代方式。)

gcc 非常热衷于打破错误的依赖关系,例如pxor xmm0,xmm0cvtsi2ss/sd xmm0, eax 之前,因为 Intel 设计不当的指令集合并到目标 XMM 寄存器的低 qword。 (PIII 的短视设计将 128 位寄存器存储为 2 个 64 位的一半,因此 int->FP 转换指令将在 PIII 上采取额外的 uop 以将高半部分归零,如果英特尔将其设计为未来 CPU记住了。)

问题通常不在单个函数中,而是当这些错误的依赖关系最终在 call/ret 中的不同函数中创建循环携带的依赖链时,您可能会意外地大幅减速。

例如,存储数据吞吐量仅为每个时钟 1 个(在所有当前的 x86 uarche 上),因此 2 个加载 + 2 个存储已经至少需要 2 个时钟。

但是,如果结构跨缓存行边界拆分,并且第一次加载未命中但第二次命中,则避免错误的 dep 将使第二次存储在第一次缓存未命中完成之前将数据写入存储缓冲区.这将使该核心上的负载通过存储转发从 out2 读取。 (x86 的强内存排序规则通过提交到 out1 之前的存储缓冲区来防止后来的存储变得全局可见,但是 core/thread 中的存储转发仍然有效。)


  1. cmp/setcc: MSVC / ICC 只是愚蠢

这里的一个优点是将值放入 ZF 可以避免任何部分寄存器恶作剧,但 movzx 是避免它的更好方法。

我很确定 MS 的 x64 ABI 与 x86-64 System V ABI 一致,内存中的 bool 保证为 0 或 1,而不是 0/非零。

在 C++ 抽象机中,对于 bool xx == true 必须与 x 相同,因此(除非实现在结构与结构中使用不同的对象表示规则。 extern bool), 它总是可以只复制对象表示(即字节)。

如果一个实现要为 bool 使用单字节 0/非 0(而不是 0/1)对象表示,则需要 cmp byte ptr [rcx], 0 来实现布尔化在 (int)(x == true) 中,但在这里您要分配给另一个 bool,因此它可以直接复制。我们知道它不是布尔化 0 / 非零,因为它与 1 进行比较。我不认为它是有意防御无效的 bool 值,否则它为什么不为 out2 = in.in2 这样做?

这看起来像是一个优化失误。一般来说,编译器在 bool 时并不出色。 。有些比其他的好。

MSVC 的 setcc 直接到内存还不错,但是 cmp + setcc 是不需要发生的 2 个额外的不必要的 ALU 微指令。 对于 Ryzen,setcc m8 是 1 uop,1/时钟吞吐量(https://uops.info/). (Agner Fog reports one per 2 clocks for it, https://agner.org/optimize/. That's probably a typo, or maybe different measurement methodology, because automated testing/reporting by https://uops.info/ 发现 setcc [mem] 是 1/时钟吞吐量。在 Steamroller 上,它是 1 uop / 1 每个时钟,而 Zen 并没有赚多少比 Bulldozer 家族还糟糕。)

在 Intel 上,setcc m8 是 2 个融合域微指令(ALU + 微融合存储,用于 3 个后端微指令)和每个时钟吞吐量 1 个,如您所料。 (或者优于 Ice Lake 上的 1/clock 及其额外的存储端口,但仍然比 register 差。)

请注意 setcc 只能在 Intel 上的“复杂”解码器中解码,因为 setbe / seta 是(不幸的是)2 微码。

  1. ICC 在 setz 之前的异或归零

我不确定在 ISO C++ 的抽象机中的任何地方是否存在到 int 的隐式转换,或者是否为 bool 操作数定义了 ==

但是无论如何,如果你要 setcc 进入一个寄存器,出于同样的原因先将它异或零是个不错的主意 movzx eax,memmov al,mem .即使您不需要将结果零扩展到 32 位。

这可能是 ICC 用于根据比较结果创建布尔整数的固定序列。

比较用xor-zero / cmp / setcc 意义不大,非比较用mov al, [m8]。 xor-zero 直接等效于使用 movzx 加载来打破此处的错误依赖。

ICC 非常擅长自动向量化(例如,它可以像 while(*ptr++ != 0){} 一样自动向量化搜索循环,而 gcc/clang 只能自动向量化具有提前知道的行程计数的循环第一次迭代)。 但 ICC 并不擅长像这样的微优化;它的 asm 输出通常看起来比 gcc 或 clang 更像源代码(对它不利)。

  1. 在对结果做任何事情之前,所有人都读“开始” - 所以这种交错实际上仍然很重要?

这不是坏事。无论如何,内存消歧通常允许在存储后加载 运行 。现代 x86 CPUs 甚至动态预测加载何时不会与早期的未知地址存储重叠。

如果加载和存储地址恰好相距 4k,它们将在 Intel CPUs 上使用别名,并且加载被错误地检测为依赖于存储。

在商店之前移动负载肯定会让 CPU 的事情变得更容易;尽可能这样做。

此外,前端将有序的uops发送到核心的无序部分,因此将负载放在第一位可以让第二个开始可能更早一个周期。立即完成第一家商店没有任何好处;它必须等待加载结果才能执行。

重复使用同一个寄存器确实减少了寄存器压力。 GCC 喜欢一直避免寄存器压力,即使在没有任何寄存器压力的情况下,就像在这个函数的非内联独立版本中一样。根据我的经验,gcc 倾向于倾向于生成代码的方法,这些代码首先会产生较少的寄存器压力,而不是在内联后存在实际寄存器压力时才控制其寄存器使用。

因此,gcc 有时甚至没有内联时也只使用较少寄存器压力的方式,而不是有 2 种做事方式。例如,GCC 过去几乎 总是 使用 setcc al / movzx eax,al 进行布尔化,但最近的变化让它使用 xor eax,eax / set-flags / setcc al 当有一个空闲寄存器可以在任何设置标志之前清零时,将零扩展从关键路径上移开。 (异或归零也写入标志)。


passing through al as there's no memory to memory mov.

无论如何,

None 值得用于单字节副本。一种可能的(但不是最佳的)实现是:

foo(In &):
    mov   rsi, rdi
    lea   rdi, [rip+out1]
    movsb               # read in1
    lea   rdi, [rip+out2]
    movsb               # read in2

可能比发现的任何编译器都更好的实现是:

foo(In &):
    movzx  eax, word ptr [rdi]      # AH:AL = in2:in1
    mov    [rip+out1], al
    mov    [rip+out2], ah
    ret

读取 AH 可能会有一个额外的延迟周期,但这对于吞吐量和代码大小来说非常有用。如果您关心延迟,请首先避免 store/reload 并使用寄存器。 (通过内联这个函数)。

如果这两个结构成员是最近分开编写的,这个 2 字节的加载将导致存储转发停顿。 (只是此存储转发的额外延迟,实际上并没有使管道停止,直到存储缓冲区耗尽。)

另一个微架构危险是负载上的缓存行拆分(如果 in.in2 是新缓存行的第一个字节)。这可能需要额外的 10 个周期。或者在 Skylake 之前的版本上,如果它也跨 4k 边界拆分,则惩罚可能是 100 个周期的额外延迟。但除此之外,x86 具有高效的未对齐负载,并且通常可以通过组合窄负载/存储来节省微指令。 (gcc7 和更高版本通常在初始化多个结构成员时这样做,即使在它不知道它不会跨越缓存行边界的情况下也是如此。)

编译器应该可以证明In &in不能别名extern bool out1, out2,因为它们有静态存储和不同的类型。

如果您只有 2 个 指针 指向 bool,您不会知道(没有 bool *__restrict out1)它们不指向In 对象。但是 static bool out2 不能为 static In 对象的成员起别名。那么在写 out1 之前读 in2 是不安全的,除非你先检查重叠。

我已经 运行 在 Haswell 上一个循环中的所有代码。下图展示了三种情况下每10亿次迭代的执行时间:

  • 每次迭代的开始都有一个mov rax, qword [rdi+64]。这可能会产生错误的寄存器依赖性(在图中称为 dep)。
  • 在每次迭代的开始都有一个add eax, eax(图中称为fulldep)。这会创建一个循环携带的依赖项和一个错误的依赖项。另请参见下图,了解 add eax, eax 的所有真假依赖关系的说明,这也解释了为什么它在两个方向上序列化执行。
  • 只有部分寄存器依赖(图中称为nodep,代表没有虚假依赖)。所以这个案例每次迭代比前一个少了一个指令。

在这两种情况下,每次迭代都访问相同的内存位置。例如,我测试的类似 Clang 的代码如下所示:

mov     al, byte [rdi]
mov     byte [rsi + 4], al
mov     al, byte [rdi + 1]
mov     byte [rsi + 8], al

这被放置在一个循环中,其中 rdirsi 永远不会改变。没有内存别名。结果清楚地表明,部分寄存器依赖性导致 Clang 性能下降 7.5%。就绝对性能而言,Peter、MSVC 和 gcc 都是明显的赢家。另请注意,对于第二种情况,Peter 的代码稍好一些(gcc 和 msvc 每次迭代 2.02c,icc 2.04c,但 Peter 仅 2.00c)。另一个可能的比较指标是代码大小。