为什么memcmp(a,b,4)有时只针对uint32比较进行优化?

Joh*_*nck 68 c gcc x86-64 clang compiler-optimization

鉴于此代码:

#include <string.h>

int equal4(const char* a, const char* b)
{
    return memcmp(a, b, 4) == 0;
}

int less4(const char* a, const char* b)
{
    return memcmp(a, b, 4) < 0;
}
Run Code Online (Sandbox Code Playgroud)

x86_64上的GCC 7引入了第一种情况的优化(Clang已经做了很长时间):

    mov     eax, DWORD PTR [rsi]
    cmp     DWORD PTR [rdi], eax
    sete    al
    movzx   eax, al
Run Code Online (Sandbox Code Playgroud)

但第二种情况仍然是memcmp():

    sub     rsp, 8
    mov     edx, 4
    call    memcmp
    add     rsp, 8
    shr     eax, 31
Run Code Online (Sandbox Code Playgroud)

是否可以对第二种情况应用类似的优化?什么是最好的装配,有没有明确的理由为什么它没有完成(由GCC或Clang)?

在Godbolt的Compiler Explorer上看到它:https://godbolt.org/g/jv8fcf

das*_*ght 73

如果为little-endian平台生成代码,则将memcmp不等式的四字节优化为单个DWORD比较是无效的.

memcmp比较各个字节时,无论平台如何,它都从低寻址字节变为高寻址字节.

为了memcmp返回零,所有四个字节必须相同.因此,比较的顺序无关紧要.因此,DWORD优化是有效的,因为您忽略了结果的符号.

但是,当memcmp返回正数时,字节顺序很重要.因此,使用32位DWORD比较实现相同的比较需要特定的字节序:平台必须是big-endian,否则比较的结果将是不正确的.

  • @Kevin:你不会想要交换内存中的字节(然后恢复它们)!最佳asm可能是这样的:将4B块加载到寄存器中,将它们进行字节交换.所以它是前端发出的解码器和额外融合域uops的额外指令,因为你不能像`== 0`那样使用`cmp`的内存操作数.例如`mov edi,[rdi]`/`mov esi,[rsi]`/`bswap edi` /`bswap esi` /`cmp edi,esi`/seta和movzx.这些都是所有最近的Intel和AMD CPU上的单指令(http://agner.org/optimize/) (13认同)
  • 在x86中有一个`bswap`指令,ARM有`rev`.但是还有一条额外的指令. (12认同)
  • @Kevin和const-correct仅适用于源.只要最终结果相同,CPU就可以做任何喜欢的事情. (5认同)
  • @CodyGray:正如dasblinkenlight指出的那样,这足以告诉`<0`和`> 0`.在算术上,CMP寻找最重要的位差,而`memcmp`在内存顺序中寻找第一个不同的字节.在big-endian系统上,第一个字节保存MSB.`bswap`将原生的little-endian位模式转换为big-endian,这就是原因. (4认同)
  • @OrangeDog - 不是真的.如果参数声明为`const char*`,它们很可能也被定义为`const`,这意味着它们可能是只读的并且尝试修改它们会导致错误.在现实世界中,这恰好是声明为`const char*`的事情:它们被放置在`.rodata`段中,该段在没有写权限的情况下被加载.在asm级别工作无助于缓解这个问题. (2认同)
  • @BeeOnRope你假设写入主内存,而CPU仍然可以在内部做任何想做的事情. (2认同)

squ*_*age 24

字节序是这里的问题.考虑这个输入:

a = 01 00 00 03
b = 02 00 00 02
Run Code Online (Sandbox Code Playgroud)

如果通过将它们视为32位整数来比较这两个数组,那么您会发现它a更大(因为0x03000001> 0x02000002).在大端机器上,这个测试可能会按预期工作.

  • @Ruslan:编译器充满了这么小的优化; 我很确定编译器开发人员会很乐意收到一个补丁来覆盖这个......如果真的有效的话. (13认同)
  • 没错,但是问题是关于优化`memcmp()`调用。仍然可以通过在比较之前发出字节交换指令来完成,对吗? (3认同)
  • @JohnZwinck我认为对此进行字节交换将是处理一个_very special_ case,编译器编写者不会理会这种情况. (3认同)

Pet*_*des 13

正如其他答案/评论中所讨论的,使用memcmp(a,b,4) < 0等同于unsigned大端整数之间的比较.它无法像== 0little-endian x86 那样高效地内联.

更重要的是,gcc7/8中此行为的当前版本仅查找memcmp() == 0!= 0.即使在一个大端目标上,这可能会有效地内联,<或者>gcc也不会这样做.(Godbolt的最新大端编译器是PowerPC 64 gcc6.3,而MIPS/MIPS64 gcc5.4. mips是big-endian MIPS,mipsel而是小端MIPS.)如果用未来的gcc测试这个,请使用a = __builtin_assume_align(a, 4)以确保gcc没有我不得不担心非x86上的未对齐加载性能/正确性.(或者只是使用const int32_t*而不是const char*.)

如果/当gcc学会内联memcmp除了EQ/NE之外的其他情况,gcc会在小端x86上执行它,当它的启发式告诉它额外的代码大小是值得的.例如,在使用-fprofile-use(轮廓引导优化)进行编译时的热循环中.


如果你希望编译器在这种情况下做得很好,你应该分配给a uint32_t并使用endian-conversion函数ntohl.但请确保选择一个可以内联的内容; 显然Windows有一个ntohl编译DLL调用.对于某些便携式端点的东西,请参阅该问题的其他答案,以及某人对a的一个不完美的尝试portable_endian.h,以及它的这个分支.我正在研究一个版本一段时间,但从未完成/测试或发布它.

指针转换可能是Undefined Behavior,具体取决于您编写字节的方式以及char*指向的内容.如果你不确定严格别名和/或对齐,请memcpy进入abytes.大多数编译器都擅长优化小型固定大小memcpy.

// I know the question just wonders why gcc does what it does,
// not asking for how to write it differently.
// Beware of alignment performance or even fault issues outside of x86.

#include <endian.h>
#include <stdint.h>

int equal4_optim(const char* a, const char* b) {
    uint32_t abytes = *(const uint32_t*)a;
    uint32_t bbytes = *(const uint32_t*)b;

    return abytes == bbytes;
}


int less4_optim(const char* a, const char* b) {
    uint32_t a_native = be32toh(*(const uint32_t*)a);
    uint32_t b_native = be32toh(*(const uint32_t*)b);

    return a_native < b_native;
}
Run Code Online (Sandbox Code Playgroud)

我检查了Godbolt,它编译成有效的代码(基本上与我在下面的asm中写的相同),特别是在big-endian平台上,即使是旧的gcc.它也比ICC17提供了更好的代码,ICC17内联memcmp但仅限于字节比较循环(即使是这种== 0情况).


我认为这个手工制作的序列是less4()(对于x86-64 SystemV调用约定,如问题中使用的const char *ain in rdibin rsi)的最佳实现.

less4:
    mov   edi, [rdi]
    mov   esi, [rsi]
    bswap edi
    bswap esi
    # data loaded and byte-swapped to native unsigned integers
    xor   eax,eax    # solves the same problem as gcc's movzx, see below
    cmp   edi, esi
    setb  al         # eax=1 if *a was Below(unsigned) *b, else 0
    ret
Run Code Online (Sandbox Code Playgroud)

自K8和Core2(http://agner.org/optimize/)以来,这些都是关于Intel和AMD CPU的单指令.

必须bswap两个操作数都有一个额外的代码大小成本与== 0情况:我们不能将其中一个加载折叠到内存操作数cmp.(这可以节省代码大小,并且可以通过微融合来实现.)这是最重要的两条bswap指令.

在支持的CPU上movbe,它可以保存代码大小: movbe ecx, [rsi]是一个load + bswap.在Haswell上,它是2 uops,所以可能它解码为与mov ecx, [rsi]/ 相同的uops bswap ecx.在Atom/Silvermont上,它在加载端口处理,因此它的uops更少,代码更小.

setcc我XOR归零部分答案更多关于为什么XOR/CMP/setcc(其中铛用途)比CMP好/ setcc/MOVZX(典型的GCC).

在通常的情况下,这内联到分支结果的代码中,setcc + zero-extend被替换为jcc ; 编译器优化在寄存器中创建布尔返回值. 这是内联的另一个优点:库memcmp必须创建一个调用程序测试的整数布尔返回值,因为没有x86 ABI /调用约定允许在flags中返回布尔条件.(我不知道任何非x86调用约定也可以这样做).对于大多数库memcmp实现,根据长度和可能的对齐检查选择策略也会产生很大的开销.这可能相当便宜,但对于4号,它将超过所有实际工作的成本.

  • 我挖了一下GCC源代码.这个优化是通过[`handle_builtin_memcmp`在一个有点不准确命名的`tree-ssa-strlen.c`中实现的](https://gcc.gnu.org/git/?p=gcc.git;a=blob;f= gcc/tree-ssa-strlen.c#l2078)如果我正确读它,它只实现`==`和`!=`情况:2102-3和2108-9行的检查导致它如果比较不是"EQ_EXPR"或"NE_EXPR",则不做任何事情就会挽救,这意味着它们的声音.如果`!SLOW_UNALIGNED_ACCESS(mode,align)`这意味着"我们可以在不担心对齐的情况下进行加载吗?" (4认同)
  • 顺便说一下,别名规则是不对称的:`char*`可以别名,但是`int*`正式_can't_别名`char`,至少当`char`被声明为这样; 请参阅/sf/ask/2167721321/ (2认同)