clang vs gcc在x86_64上复制3个字节 - mov的数量

ein*_*ica 5 assembly gcc clang memcpy compiler-optimization

应该优化编译代码,从一个地方到另一个地方复制3个字节,比如使用memcpy(,,3)看起来像汇编指令?

考虑以下程序:

#include <string.h>
int main() {
  int* p = (int*) 0x10;
  int x = 0;
  memcpy(&x, p, 4);
  x = x * (x > 1 ? 2 : 3);
  memcpy(p, &x, 4);  
  return 0;
}
Run Code Online (Sandbox Code Playgroud)

它有点人为,会导致分段违规,但我需要这些指令,以便编译-O3不会使所有这些都消失.当我编译它(GodBolt,GCC 6.3 -O3)时,我得到:

main:
        mov     edx, DWORD PTR ds:16
        xor     eax, eax
        cmp     edx, 1
        setle   al
        add     eax, 2
        imul    eax, edx
        mov     DWORD PTR ds:16, eax
        xor     eax, eax
        ret
Run Code Online (Sandbox Code Playgroud)

great - mov从内存到寄存器的单个DWORD(= 4个字节).很好,优化.现在让我们改变memcpy(&x, p1, 4)memcpy(&x, p1, 3)?编译结果变为:

main:
        mov     DWORD PTR [rsp-4], 0
        movzx   eax, WORD PTR ds:16
        mov     WORD PTR [rsp-4], ax
        movzx   eax, BYTE PTR ds:18
        mov     BYTE PTR [rsp-2], al
        mov     edx, DWORD PTR [rsp-4]
        xor     eax, eax
        cmp     edx, 1
        setle   al
        add     eax, 2
        imul    eax, edx
        mov     DWORD PTR ds:16, eax
        xor     eax, eax
        ret
Run Code Online (Sandbox Code Playgroud)

我对英特尔X86_64程序集的解释不多(阅读:当它很复杂时我甚至无法正确阅读它),所以 - 我不太明白.我的意思是,我得到了前6条指令中发生的事情以及为什么这么多指令是必要的.为什么两个动作不够?一个mov WORD PTRINT almov BYTE PTRah

......所以,我来这里问.在我写这个问题的时候,我注意到GodBolt也有铿锵作为选项.嗯,clang(3.9.0 -O3)这样做:

main:                                   # @main
        movzx   eax, byte ptr [18]
        shl     eax, 16
        movzx   ecx, word ptr [16]
        or      ecx, eax
        cmp     ecx, 2
        sbb     eax, eax
        and     eax, 1
        or      eax, 2
        imul    eax, ecx
        mov     dword ptr [16], eax
        xor     eax, eax
        ret
Run Code Online (Sandbox Code Playgroud)

看起来更像我的预期.是什么解释了差异?

笔记:

  • 如果我不初始化,它本质上是相同的行为x = 0.
  • 其他GCC版本与GCC 6.3大致相同,但GCC 7降至5而不是6 mov.
  • 其他版本的clang(从3.4开始)做同样的事情.
  • 如果我们放弃memcpy'为以下行为,行为是相似的:

    #include <string.h>
    
    typedef struct {
      unsigned char data[3];
    }  uint24_t;
    
    int main() {
      uint24_t* p = (uint24_t*) 0x30;
      int x = 0;
      *((uint24_t*) &x) = *p;
      x = x * (x > 1 ? 2 : 3);
      *p = *((uint24_t*) &x);
      return 0;
    } 
    
    Run Code Online (Sandbox Code Playgroud)
  • 如果你想对相关代码在函数中的情况进行对比,请看一下这个uint24_t结构版本(GodBolt).然后看看4字节值会发生什么.

Pet*_*des 7

你应该从复制4个字节和掩盖顶部字节中获得更好的代码,例如使用x & 0x00ffffff.这让编译器知道它允许读取4个字节,而不仅仅是C源读取的3个字节.

是的,这有点帮助:它保存gcc和clang从存储4B零,然后复制三个字节并重新加载4.它们只加载4,掩码,存储和使用仍在寄存器中的值.部分原因可能是不知道*p别名*q.

int foo(int *p, int *q) {
  //*p = 0;
  //memcpy(p, q, 3);
  *p = (*q)&0x00ffffff;
  return *p;
}

    mov     eax, DWORD PTR [rsi]     # load
    and     eax, 16777215            # mask
    mov     DWORD PTR [rdi], eax     # store
    ret                              # and leave it in eax as return value
Run Code Online (Sandbox Code Playgroud)

为什么两个动作不够?一个mov WORD PTR al跟随一个mov BYTE PTR进入ah

AL和AH是8位寄存器.你不能把16位字放入AL.这就是为什么你的最后一个clang-output块加载两个独立的寄存器并与shift +合并的原因,or如果它知道它允许混乱所有4个字节x.

如果要合并两个单独的单字节值,可以将它们加载到AL和AH然后使用AX,但这会导致Intel Has-Haswell上的部分寄存器停顿.

您可以通过各种原因(包括正确性和避免错误依赖EAX的旧值),左移EAX,然后将字节加载到AL中,对AX进行单词加载(或者最好将movzx加载到eax中).

但编译器并不倾向于这样做,因为部分注册的东西多年来一直是非常糟糕的juju,并且只对最近的CPU(Haswell,也许是IvyBridge)有效.这将导致Nehalem和Core2严重失速.(参见Agner Fog的microarch pdf ;搜索部分寄存器或在索引中查找.请参阅标签wiki 中的其他链接.)也许在几年内,-mtune=haswell将启用部分寄存器技巧来保存clang使用的OR指令合并.


而不是写这样一个人为的功能:

编写带有args并返回一个值的函数,这样你就不必为了不优化而使它们变得非常奇怪.例如,一个函数,它接受两个int*args并在它们之间进行3字节memcpy.

这是在Godbolt(与gcc和clang),颜色突出

void copy3(int *p, int *q) { memcpy(p, q, 3); }

 clang3.9 -O3 does exactly what you expected: a byte and a word copy.
    mov     al, byte ptr [rsi + 2]
    mov     byte ptr [rdi + 2], al
    movzx   eax, word ptr [rsi]
    mov     word ptr [rdi], ax
    ret
Run Code Online (Sandbox Code Playgroud)

为了获得您设法生成的愚蠢,首先将目标归零,然后在三字节副本后将其读回:

int foo(int *p, int *q) {
  *p = 0;
  memcpy(p, q, 3);
  return *p;
}

  clang3.9 -O3
    mov     dword ptr [rdi], 0       # *p = 0
    mov     al, byte ptr [rsi + 2]
    mov     byte ptr [rdi + 2], al   # byte copy
    movzx   eax, word ptr [rsi]
    mov     word ptr [rdi], ax       # word copy
    mov     eax, dword ptr [rdi]     # read the whole thing, causing a store-forwarding stall
    ret
Run Code Online (Sandbox Code Playgroud)

gcc没有做得更好(除了不重命名部分regs的CPU,因为它通过使用movzx字节副本避免了对旧的EAX值的错误依赖).


Mar*_*oom 4

大小 3 是一个丑陋的大小,编译器并不完美。

编译器无法生成对您未请求的内存位置的访问,因此需要两次移动。

虽然这对您来说似乎微不足道,但请记住您要求的是从内存到内存的memcpy(&x, p, 4);副本。 显然,GCC 和旧版本的 Clang 不够聪明,无法弄清楚没有理由在内存中传递临时值。

GCC 对前六个指令所做的基本上是[rsp-4]按照您的要求用三个字节构造一个 DWORD

mov     DWORD PTR [rsp-4], 0              ;DWORD is 0

movzx   eax, WORD PTR ds:16               ;EAX = byte 0 and byte 1
mov     WORD PTR [rsp-4], ax              ;DWORD has byte 0 and byte 1

movzx   eax, BYTE PTR ds:18               ;EAX = byte 2
mov     BYTE PTR [rsp-2], al              ;DWORD has byte 0, byte 1 and byte 2

mov     edx, DWORD PTR [rsp-4]            ;As previous from henceon
Run Code Online (Sandbox Code Playgroud)

它用于movzx eax, ...防止部分寄存器停顿。

编译器已经做得很好,省略了对 and 的调用,memcpy正如您所说,这个例子“有点做作”,即使对于人类来说也是如此。优化memcpy必须适用于任何大小,包括那些无法容纳寄存器的大小。每次都做对并不容易。

考虑到 L1 访问延迟在最近的架构中已经大大降低,并且[rsp-4]很可能在缓存中,我不确定是否值得弄乱 GCC 源代码中的优化代码。
确实值得为错过的优化提交错误并看看开发人员怎么说。

  • 根据@MargaretBloom的建议,我已提交[GCC Bug 78963:错过复制小型、非对齐大小数据的优化机会](https://gcc.gnu.org/bugzilla/show_bug.cgi?id=78963)。感谢您提供有用的答案和建议。 (4认同)
  • @einpoklum:如果您知道读取第 4 个字节是安全的,那么读取 4 个字节并与“0x00FFFFFF”进行 AND 操作会更快。普通并不意味着它不那么丑陋。欢迎使用汇编语言。 (2认同)