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

Mat*_*lia 15 c++ x86 assembly micro-optimization compiler-optimization

完全知道这些完全人为的基准测试并不重要,但我对"大4"编译器选择编写一个简单的片段的几种方式感到有点惊讶.

struct In {
    bool in1;
    bool in2;
};

void foo(In &in) {
    extern bool out1;
    extern bool out2;
    out1 = (in.in1 == true);
    out2 = in.in2;
}
Run Code Online (Sandbox Code Playgroud)

注意:所有编译器都设置为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
Run Code Online (Sandbox Code Playgroud)

在符合标准的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
Run Code Online (Sandbox Code Playgroud)

它不是一个简单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
Run Code Online (Sandbox Code Playgroud)

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

但是,第一次赋值中的比较实际上是执行的(有趣的是,使用a cmp和a都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
Run Code Online (Sandbox Code Playgroud)
  • 第一个赋值完成了比较,就像在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
Run Code Online (Sandbox Code Playgroud)

所以,没有归零eax,但仍然使用两个寄存器并"首先并行开始读取,以后使用所有结果".

  • 有什么特别之处sete让ICC认为eax之前值得归零?
  • ICC是否正确重新排序这样的读/写,或者其他编译器目前执行相同的显然更邋approach的方法?

Pet*_*des 15

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

这里的两个关键点是:

  • 主流编译器只关心无序的x86搜索,用于指令选择和调度的默认调整.当前销售的所有x86搜索都使用寄存器重命名执行无序执行(至少对于完整寄存器,如RAX).

    没有任何有序的搜索仍然相关tune=generic.(较旧的Xeon Phi,Knight's Corner,使用改进的基于奔腾P54C的有序内核,并且有序的Atom系统可能仍然存在,但现在也已经过时了.在这种情况下,在两者之后进行商店都很重要加载,以允许负载中的内存并行.)

  • 8位和16位部分寄存器存在问题,可能导致错误的依赖性. 为什么GCC不使用部分寄存器?解释了各种x86搜索的不同行为.


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

英特尔在IvyBridge之前将AL重命名为RAX(P6系列和SnB本身,但后来不再是SnB系列). 在所有其他搜索(包括Haswell/Skylake,所有AMD和Silvermont/KNL)上,编写AL合并到RAX中.有关现代英特尔(HSW及更高版本)与P6系列和第一代Sandybridge的更多信息,请参阅此问答:Haswell/Skylake的部分寄存器究竟如何执行?写AL似乎对RAX有假依赖,而AH是不一致的.

在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个独立的微处理器,但它们可以在前端微熔合在一起.)

锵似乎并不了解局部寄存器非常好,有时无缘无故地产生虚假DEPS和部分REG处罚,甚至当它不使用任何窄代码大小节省or al,dl代替or eax,edx,例如.

在这种情况下,它为每个加载保存一个字节的代码大小(movzx具有2字节的操作码).

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

编写EAX零扩展到完整的RAX,因此它始终是只写的,不会对任何CPU上的旧RAX值产生错误依赖. 为什么32位寄存器上的x86-64指令归零整个64位寄存器的上半部分?.

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必须与xa 相同bool x,所以(除非实现在结构中使用不同的对象表示规则extern bool),它总是只能复制对象表示(即字节).

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

这看起来像是错过优化.编译器通常不是很棒bool. 编译器中的布尔值为8位.对他们的操作是否效率低下?.有些人比其他人好.

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

我不确定int在ISO C++的抽象机器中是否存在隐式转换到此处的任何位置,或者是否==bool操作数定义.

但无论如何,如果你要setcc进入一个寄存器,那么首先将其归零并不是一个坏主意,因为同样的原因movzx eax,mem要好于mov al,mem.即使您不需要将结果零扩展到32位.

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

使用xor-zero/cmp/setcc进行比较没什么意义,但是mov al, [m8]对于非比较.xor-zero直接等效于使用movzxload来打破这里的错误依赖.

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

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

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

读取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,除非您先检查重叠.


Had*_*ais 6

我在Haswell上循环运行了所有代码。下图显示了三种情况下每十亿次迭代的执行时间:

  • 有一个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
Run Code Online (Sandbox Code Playgroud)

这是摆在一个循环,rdirsi永远不会改变。没有内存别名。结果清楚地表明,部分寄存器依赖性使Clang降低了7.5%的速度。就绝对性能而言,Peter,MSVC和gcc都是明显的赢家。还要注意,在第二种情况下,Peter的代码做得更好(gcc和msvc每次迭代为2.02c,icc为2.04c,而Peter只有2.00c)。比较的另一可能量度是代码大小。

在此处输入图片说明

在此处输入图片说明

  • 只是想说我并没有忘记此质量检查,只是我有点忙,我想在一天结束之前更系统地完成测试。不过,我可以预见,至少在“完全依赖”方案中,我测试的几乎所有计算机的行为都几乎相同,并且在@PeterCordes的解决方案还是gcc的解决方案实际上更好方面存在细微的差异。 (2认同)

归档时间:

查看次数:

439 次

最近记录:

6 年,7 月 前