在x86_64架构下复制3个字节,clang和gcc哪个更好?需要移动的次数是多少个mov?

5

如果使用 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;
}

这段代码有点牵强,会导致分段错误,但我需要这些指令,以便在使用-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

很好 - 从内存到寄存器的单个DWORD(= 4字节)mov。非常优化。现在让我们将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

我不是Intel X86_64汇编的专家(也就是说,当它很复杂时,我甚至无法正确阅读它),因此我不太理解这个问题。我的意思是,我知道前6条指令中发生了什么以及为什么需要这么多指令。为什么两个mov不足够呢?一个mov WORD PTR int al和一个mov BYTE PTR进入ah不行吗?
......所以,我来这里问。当我写问题时,我注意到GodBolt也有clang选项。好吧,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

看起来更像我预期的样子。是什么原因导致了这种差异?

注:

  • It's the same behavior essentially if I don't initialize x = 0.
  • Other GCC versions do about the same thing as GCC 6.3, but GCC 7 is down to 5 instead of 6 mov's.
  • Other versions of clang (starting from 3.4) do about the same thing.
  • The behavior is similar if we forego memcpy'ing for the following:

    #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;
    } 
    
  • If you want to contrast with what happens when the relevant code is in a function, have a look at this or the uint24_t struct version (GodBolt). Then have a look at what happens for 4-byte values.


编写函数以接受参数并返回值,这样您就不必使它们变得超级奇怪以避免优化。例如,一个函数接受两个 int* 参数并在它们之间执行 3 字节的 memcpy。void foo(int *p, int *q) { memcpy(p, q, 3); }。https://godbolt.org/g/NR4Lcc。或者在 3 字节的 memcpy 之前添加 *p = 0,以获得像您所看到的代码。或者可能让它返回 *p,这样它需要内存中和寄存器中的结果。 - Peter Cordes
@PeterCordes:通过你的链接,我没有看到我想要的那种代码。那个函数里面应该有两个mov进去,两个mov出来(除非你可以直接在地址之间移动,但我记得这是不可能的)。不过你可以看看这个链接 https://godbolt.org/g/5EDSvC - einpoklum
那最后的评论还有用吗?我做了一些扩展以作为答案,甚至那个评论也解释了要获得你所看到的结果,需要先 *p=0 然后再 return *p - Peter Cordes
你评论中的godbolt链接存在未定义行为:在初始化其最后一个字节之前读取了x。除此之外,没有什么新的可看的。只是一些将3个字节复制为2 + 1个字节的操作。 - Peter Cordes
“x > 1” 对于3字节输入是如何定义的?3字节值是24位有符号还是无符号的?如果是24位有符号的,那么“and eax,0x00FFFFFF”就不正确了。如果是无符号的,则f(n)为:0->0,1->3,n=2..(2^24-1)->n*2(截断为24位,如果写回3B)。 - Ped7g
@Ped7g:那是一条完全任意的指令,我并不在乎它产生了什么结果。关键是它取决于值并且可能会修改它。 - einpoklum
3个回答

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

为什么两个操作不够?将WORD PTR移动到al,然后再移动BYTE PTR到ah?AL和AH是8位寄存器。无法将16位字放入AL中。这就是为什么您的最后一个clang输出块加载了两个单独的寄存器并使用移位+ or合并的原因,在它知道可以处理x的所有4个字节的情况下。如果要合并两个单独的一字节值,则可以将它们加载到AL和AH中,然后使用AX,但在Intel pre-Haswell上会导致部分寄存器停顿。您可以将字加载到AX中(或更好地将movzx加载到eax中,出于正确性和避免对EAX旧值产生假依赖等各种原因),左移EAX,然后将字节加载到AL中。
但是编译器通常不会这样做,因为多年来部分寄存器的问题一直很严重,并且只有在最近的CPU(Haswell和可能的IvyBridge)上才有效率。这将在Nehalem和Core2上导致严重的停顿。(请参见Agner Fog的微体系结构pdf;搜索partial-register或在索引中查找。请参见标签wiki中的其他链接。)也许在未来几年,-mtune=haswell将启用部分寄存器技巧来保存clang用于合并的OR指令。

不要编写这样的人为函数:

编写接受参数并返回值的函数,这样您就不必使它们变得超级奇怪以避免优化。例如,一个接受两个int*参数并在它们之间执行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

为了得到您所生成的愚蠢内容,首先将目标清零,然后在三个字节的复制后读取它:
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

除了在不重命名部分寄存器的CPU上,gcc并没有做得更好(因为它使用movzx进行字节复制,避免了对EAX旧值的误依赖)。


好的,这很有帮助。是的,我把寄存器大小搞混了。谢谢你提供的帮助和简化的触发器。 - einpoklum

4

数字三在大小上很奇怪,而编译器也不是完美的。

编译器无法访问您未请求的内存位置,因此需要进行两次移动操作。

虽然这对您来说似乎微不足道,但请记住,您要求执行的是 memcpy(&x, p, 4);,这是从内存到内存的复制操作。
显然,GCC和旧版本的Clang还不够聪明,无法理解为什么需要在内存中传递临时变量。

GCC使用前六个指令基本上构建了一个DWORD,其中包含了您请求的三个字节,位于[rsp-4]

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

这里使用movzx eax, ...来避免寄存器部分停顿。

编译器已经很好地省略了对memcpy的调用,正如你所说,即使对于人类来说,这个示例也有点牵强。 memcpy优化必须适用于任何大小,包括那些无法适应寄存器的大小。每次都做到完全正确并不容易。

考虑到最近架构中L1访问延迟已经大大降低,而[rsp-4]很可能已经在缓存中,我不确定值得去搞GCC源代码中的优化代码。
肯定值得提交错误报告以便发现遗漏的优化,并查看开发人员的想法。


2
@einpoklum:如果您知道读取第四个字节是安全的,则读取4个字节并与“0x00FFFFFF”进行AND运算会更快。 常见并不意味着它不丑陋。 欢迎来到汇编语言的世界。 - Peter Cordes
1
@einpoklum,大小为 3 的因为它总是 不对齐 所以很丑陋,也就是说它不遵循本机 CPU 字长的边界对齐(例如在 C 中的 int,注意不要与 WORDDWORD 这样来自 16 位架构的类型混淆)。并非所有 CPU 架构都能进行未对齐读写操作(即使 x86 可以)。 - user268396
1
@user268396:并不完全是说它总是未对齐的。一个3B对象可以对齐到你想要的任何边界。这使得您可以将其作为4B加载的一部分加载,而无需担心读取到未映射页面或越过缓存行边界。然而,一个3B元素的紧凑数组只有每4个元素对齐在4B边界上,这可能是您所指的。这肯定很不方便处理,特别是使用SIMD。 - Peter Cordes
1
@einpoklum: 这是真的。在某些微架构上,分支选择读取方式可能值得考虑,以便将所需的3B作为4B加载的最后3B或第一个3B进行读取(然后进行移位或AND操作以隔离所需的3B)。除非对象本身需要跨越页面边界(并且可能需要这样做以确保正确性),否则您始终可以避免跨越页面边界。另请参阅https://dev59.com/Hek6XIcBkEYKwwoYBvbx - Peter Cordes
4
根据@MargaretBloom的建议,我已经提交了GCC Bug 78963:在复制小型、非对齐大小数据时错过了优化机会。感谢有用的答案和建议。 - einpoklum
显示剩余5条评论

0

(这不是真正的答案,因为我无法添加任何东西来回答其他人已经回答的问题,所以只是展示我如何手写这样的代码的例子...可能主要是出于我的好奇心)

如果函数是:

f(24b unsigned n)

  • f(0) → 0
  • f(1) → 3
  • f(n) → n*2, n > 1

(从您的问题中看起来是这样的)。

那么我会用手写汇编语言(nasm语法)来实现:

    mov     eax,[16]    ; reads 4 bytes from address 16

    ; f(n) starts here, n = low 24b of eax, modifies edx
    xor     edx,edx
    and     eax,0x00FFFFFF
    dec     eax
    setz    dl
    lea     eax,[edx+2*eax+2]
    ; output = low 24b of eax, b24..b31 undefined

    ; writes 3 bytes back to address 16
    mov     [16],ax
    shr     eax,16
    mov     [18],al

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接