gpt4 book ai didi

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

转载 作者:行者123 更新时间:2023-12-01 19:34:18 25 4
gpt4 key购买 nike

完全知道these completely artificial benchmarks don't mean much,我对“四大”编译器选择的几种方式进行编译感到有些惊讶。

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存入“small”寄存器,而是将 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

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

但是,实际上是在第一个分配中执行比较(有趣的是,同时使用 cmpsete和内存操作数,因此可以说中间寄存器是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毕竟是对这样的读/写进行重新排序的权利,还是其他编译器目前似乎更松散的方法也能执行同样的操作?
  • 最佳答案

    TL:DR:gcc的版本在所有x86 uarch上都是最可靠的,避免了虚假的依赖关系或多余的内容。 它们都不是最佳选择。一次加载两个字节应该更好。

    这里的两个关键点是:

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

    没有顺序的arche仍然与tune=generic相关。 (Knight's Corner的较早的Xeon Phi使用基于Pentium P54C的改进型有序内核,而有序Atom系统可能仍然存在,但现在也已过时。在这种情况下,在两次测试之后都进行存储很重要加载,以允许在加载中进行内存并行化。)
  • 8和16位部分寄存器存在问题,并且可能导致错误的依赖关系。 Why doesn't GCC use partial registers?解释了各种x86 uarches的不同行为。


  • 部分寄存器重命名以避免错误的依赖关系:

  • 在IvyBridge之前,英特尔与RAX(P6系列和SnB本身,但后来不是SnB系列)分别重命名了AL。 在所有其他架构(包括Haswell/Skylake,所有AMD和Silvermont/KNL)上,编写AL合并为RAX 。有关现代Intel(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 regs的仅写指令(例如 alax)并非仅写至有关微体系结构。)

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

    在所有其他uarch上,Clang的代码都有潜在的危险。 如果RAX是调用程序中某个较早的缓存丢失负载或其他长依赖链的目标,则此asm将使存储依赖于其他dep链,将它们耦合在一起,并消除了CPU查找的机会。 ILP。

    负载仍然是独立的,因为负载是从合并中分离出来的,并且一旦在乱序内核中知道了负载地址 rdi,负载就会立即发生。存储地址也是已知的,因此可以执行存储地址指令(因此以后的加载/存储可以检查重叠),但是存储数据指令被卡住以等待合并指令。 (Intel上的商店始终是2个独立的uops,但它们可以在前端一起微熔断。)

    Clang似乎不太了解部分寄存器,并且有时会因无缘无故而产生虚假的dep和部分注册惩罚,即使它没有使用狭窄的 or al,dl而不是 or eax,edx保存任何代码大小,例如。

    在这种情况下,每次加载可节省一个代码大小的字节( movzx具有2字节的操作码)。
  • 为什么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?

    自Zen以来,在Intel和AMD上, movzx eax, m8/m16完全在加载端口中处理,而不是作为负载+ ALU-零扩展处理。唯一的额外费用是1个字节的代码大小。 (Zen之前的AMD对于movzx负载有1个额外的延迟周期,显然,它们必须在ALU和负载端口上运行。现代的做法是将符号/零扩展或广播作为负载的一部分,而没有额外的延迟不过)

    gcc对于打破错误的依赖关系非常狂热,例如 pxor xmm0,xmm0cvtsi2ss/sd xmm0, eax之前,因为Intel的设计不良的指令集合并到了目标XMM寄存器的低位qword中。 (PIII的短视设计将128位寄存器存储为2个64位的一半,因此如果Intel在 future 的CPU中对其进行设计,则int-> FP转换指令将对PIII产生额外的影响,以将高位一半归零。心神。)

    问题通常不在单个函数中,而是当这些错误的依赖项最终在不同函数中跨调用/ret创建循环承载的依赖项链时,您可能会意外地大幅度降低速度。

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

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

  • cmp/setcc:MSVC/ICC只是愚蠢的

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

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

    在C++抽象机中, x == truex必须与 bool x相同,因此(除非实现在结构vs. extern bool中使用了不同的对象表示规则),它始终可以仅复制对象表示(即字节)。

    如果实现要为 bool使用一个1字节的0/非0(而不是0/1)对象表示形式,则需要使用 cmp byte ptr [rcx], 0(int)(x == true)中实现 bool 化,但是在这里您要分配给另一个 bool,因此它可以复制。而且我们知道它没有 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 uops。 显然在Ryzen上, setcc m8是1 uop,但每2个时钟吞吐量1个。所以这很奇怪。甚至是Agner的错字? ( https://agner.org/optimize/)。在Steamroller上,每个时钟1 uop/1。

    如您所料,在Intel上, setcc m8是2个融合域uops,每时钟吞吐量1个。
  • setz之前ICC的异或归零

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

    但是无论如何,如果要将 setcc放入寄存器,则首先对其进行异或为零不是一个坏主意,因为 movzx eax,memmov al,mem更好。即使您不需要结果零扩展到32位。

    这可能是ICC根据比较结果创建 bool 整数的固定顺序。

    使用 xor -zero/cmp/setcc进行比较几乎没有意义,但使用 mov al, [m8]进行非比较则毫无意义。 xor-zero是直接使用 movzx加载来打破错误的依赖关系的等效项。

    ICC擅长自动向量化(例如,它可以自动向量化 while(*ptr++ != 0){}之类的搜索循环,而gcc/clang只能以第一次迭代之前已知的跳闸计数自动vec循环)。 但是,ICC在像这样的小微优化方面并不出色;它的asm输出通常看起来比gcc或clang更像是源(不利于它)。
  • 在对结果进行任何处理之前都读为“started”(开始),因此这种交织实际上仍然很重要吗?

  • 这不是一件坏事。消除内存歧义通常可以使存储在存储区中尽早运行。现代的x86 CPU甚至可以动态预测何时负载不会与早期的未知地址存储区重叠。

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

    将负载提前转移到存储区肯定使CPU变得更容易。尽可能做到这一点。

    另外,前端按顺序向核心的无序部分发出请求,因此将负载放在第一位可以使第二个负载开始的周期可能更早。立即开设第一家商店没有任何好处。它必须等待加载结果后才能执行。

    重复使用同一调节器确实会降低调节器压力。 GCC希望始终避免寄存器压力,即使没有压力,例如在此非内联独立功能版本中。以我的经验,gcc倾向于倾向于首先生成较小寄存器压力的代码生成方式,而不是仅在内联后存在实际寄存器压力时才限制寄存器的使用。

    因此,gcc有时没有2种处理方式,甚至没有内联时也仅具有较少的注册压力方式。例如,GCC过去几乎总是使用 setcc al/ movzx eax,al进行 bool 化,但最近的更改已使其在存在可以提前归零的自由寄存器时使用 xor eax,eax/set-flags/ setcc al消除了关键路径的零扩展。任何设置标志。 (异或归零还会写入标志)。

    passing through al as there's no memory to memory 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还具有有效的未对齐负载,并且通常可以通过合并狭窄的负载/存储来节省成本,这是一个胜利。 (gcc7和更高版本通常在初始化多个结构成员时执行此操作,即使在不知道它不会越过缓存行边界的情况下也是如此。)

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

    如果您只有2个指向 bool的指针,那么您将不知道(没有 bool *__restrict out1)它们没有指向 In对象的成员。但是静态 bool out2不能为静态 In对象的成员添加别名。然后,除非先检查重叠,否则在编写 in2之前先阅读 out1并不安全。

    关于c++ - 将 bool 值从参数复制到全局值-比较编译器输出,我们在Stack Overflow上找到一个类似的问题: https://stackoverflow.com/questions/52321370/

    25 4 0
    Copyright 2021 - 2024 cfsdn All Rights Reserved 蜀ICP备2022000587号
    广告合作:1813099741@qq.com 6ren.com