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

问题描述 投票:13回答:2

完全知道these completely artificial benchmarks don't mean much,我仍然对“4大”编译器选择编写一个简单片段的几种方式感到有些惊讶。

struct In {
    bool in1;
    bool in2;
};

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

注意:所有编译器都设置为x64模式,具有最高的“通用”(=没有指定特定的处理器体系结构)“优化速度”设置;你可以自己看看结果/在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

而不是简单的mov到“小”寄存器,它做movzx到完整的eax。为什么?这是否完全重置了寄存器重命名器中的eax和子寄存器的状态,以避免部分寄存器停顿?


带有/ O2的MSVC 19又增加了一个怪癖:

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)。

  • 这个VC ++是明确地发挥它的安全性(程序员要求这个,也许他知道我不知道关于那个bool)或者是由于一些已知的固有限制 - 例如bool被视为一个普通字节,在前端后立即没有特定的属性?
  • 因为它不是一个“真正的”分支(代码路径不会被cmp的结果改变)我希望这不会花费那么多,特别是与访问内存相比。错过优化的成本有多高?

最后,带有-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
  • 第一个赋值进行比较,与VC ++代码完全相同,但sete通过al而不是直接记忆;有什么理由喜欢这个吗?
  • 在对结果做任何事情之前,所有的读取都是“开始”的 - 所以这种交错仍然很重要吗?
  • 为什么eax在函数开始时归零?部分注册再次停止?但是后来dl没有得到这种治疗......

只是为了好玩,我尝试删除== 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归零,但仍然使用两个寄存器并“首先并行开始读取,以后使用所有结果”。

  • sete有什么特别之处让ICC认为之前值得将eax归零?
  • ICC是否正确重新排序这样的读/写,或者其他编译器目前执行相同的显然更邋approach的方法?
c++ assembly x86 compiler-optimization micro-optimization
2个回答
15
投票

TL:DR:gcc的版本是所有x86搜索中最强大的版本,可以避免错误依赖或额外的uops。它们都不是最佳的;用一个负载加载两个字节应该更好。

这里的两个关键点是:

  • 主流编译器只关心无序的x86搜索,用于指令选择和调度的默认调整。当前销售的所有x86搜索都使用寄存器重命名执行无序执行(至少对于完整寄存器,如RAX)。 没有有序的搜索仍然与tune=generic相关。 (较旧的Xeon Phi,Knight's Corner,使用改进的基于Pentium P54C的有序内核,并且有序的Atom系统可能仍然存在,但现在也已经过时了。在这种情况下,在两者之后进行商店都很重要加载,以允许负载中的内存并行。)
  • 8位和16位部分寄存器存在问题,可能导致错误的依赖性。 Why doesn't GCC use partial registers?解释了各种x86搜索的不同行为。

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

英特尔在IvyBridge之前将AL重命名为RAX(P6系列和SnB本身,但后来不再是SnB系列)。在所有其他搜索(包括Haswell / Skylake,所有AMD和Silvermont / KNL)上,编写AL合并到RAX中。有关现代英特尔(HSW及更高版本)与P6系列和第一代Sandybridge的更多信息,请参阅此问答:How exactly do partial registers on Haswell/Skylake perform? Writing AL seems to have a false dependency on RAX, and AH is inconsistent

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

它的表现与add al, [rdi]add rax, [rdi]完全相同。 (它只是一个8位负载,但它依赖于RAX中旧值的全宽。对低-8 /低-16寄存器(如alax)的只写指令不是只写的关于微体系结构。)

在P6系列(PPro到Nehalem)和Sandybridge(第一代Sandybridge系列)上,clang的代码非常好。寄存器重命名使加载/存储对完全相互独立,就像它们使用不同的架构寄存器一样。

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

负载仍然是独立的,因为负载与合并是分开的,并且只要在无序核心中已知负载地址rdi就可以发生。存储地址也是已知的,因此存储地址uop可以执行(因此以后加载/存储可以检查重叠),但是存储数据uops被卡住等待合并uops。 (英特尔上的商店总是有2个独立的微处理器,但它们可以在前端微熔合在一起。)

Clang似乎并不能很好地理解部分寄存器,并且有时会无缘无故地创建错误的deps和部分reg惩罚,例如,即使它不使用窄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-zero-extend,在Intel上,以及在Zen之后的AMD上。唯一的额外成本是1字节的代码大小。 (在Zen之前,AMD为movzx加载提供了1个周期的额外延迟,显然它们必须在ALU和加载端口上运行。在没有额外延迟的情况下进行符号/零扩展或广播作为负载的一部分是现代的但是,方式。)

gcc非常狂热地打破了错误的依赖关系,例如pxor xmm0,xmm0之前的cvtsi2ss/sd xmm0, eax,因为英特尔设计糟糕的指令集合并到目标XMM寄存器的低qword中。 (PIII的短视设计将128位寄存器存储为2个64位半部分,因此如果英特尔设计了未来的CPU,则int-> FP转换指令会对PIII采取额外的uop以使高半部分为零。心神。)

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

例如,存储数据吞吐量每个时钟只有1个(在所有当前的x86搜索中),因此2个加载+2个存储至少需要2个时钟。

但是,如果结构在高速缓存行边界上被拆分,并且第一次加载未命中但第二次命中,则避免使用false dep会让第二次存储在第一次高速缓存未命中之前将数据写入存储缓冲区。这将允许此核心上的负载通过存储转发从out2读取。 (x86强大的内存排序规则通过在商店前面的商店缓冲区提交到out1来防止后来的商店变得全局可见,但是核心/线程中的存储转发仍然有效。)


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

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

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

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

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

这看起来像是错过优化。一般来说编译器在bool上通常都不是很棒。 Boolean values as 8 bit in compilers. Are operations on them inefficient?。有些人比其他人好。

MSVC的setcc直接用于内存也不错,但cmp + setcc是2个额外的不必要的ALU uop,不需要发生。显然在Ryzen上,setcc m8是1 uop但每2个时钟吞吐量一个。这太奇怪了。也许甚至是Agner的拼写错误? (https://agner.org/optimize/)。在Steamroller上,每个时钟1个uop / 1。

在英特尔,setcc m8是2个融合域uops和1个时钟吞吐量,就像你期望的那样。

  1. ICC在setz之前的xor-zeroing

我不确定在ISO C ++的抽象机器中是否有隐式转换到int,或者如果为==操作数定义了bool

但无论如何,如果你要将setcc变成一个寄存器,那么将qaz-zero首先归零并不是一个坏主意,因为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 CPU甚至可以动态预测负载何时不会与早期的未知地址存储重叠。

如果加载和存储地址恰好相差4k,则它们在Intel CPU上别名,并且错误地检测到负载依赖于存储。

在商店之前移动负载肯定会使CPU更容易;尽可能这样做。

此外,前端按顺序向核心的无序部分发出uops,因此首先放置负载可以让第二个启动可能提前一个周期。第一家商店马上完成是没有好处的;它必须等待加载结果才能执行。

重复使用相同的寄存器会降低寄存器压力。 GCC喜欢一直避免注册压力,即使没有注册压力,就像在这个没有内联的独立版本的功能中一样。根据我的经验,gcc倾向于倾向于生成代码,这些代码首先创建较少的寄存器压力,而不是仅仅在内联后存在实际寄存器压力时控制其寄存器使用。

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


穿过al,因为没有记忆mov

无论如何,没有值得用于单字节副本。一种可能的(但次优的)实现是:

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可能会有一个额外的延迟周期,但这对于吞吐量和代码大小来说非常有用。如果您关心延迟,请首先避免存储/重新加载并使用寄存器。 (通过内联此功能)。

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

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

如果你只有2个指向bool,你不会知道(没有bool *__restrict out1)他们没有指向In对象的成员。但静态bool out2不能别名静态In对象的成员。然后在写in2之前阅读out1是不安全的,除非你先检查重叠。


6
投票

我在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)。另一种可能的比较度量是代码大小。

enter image description here

enter image description here

© www.soinside.com 2019 - 2024. All rights reserved.