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

时间:2017-07-12 08:25:13

标签: c gcc clang x86-64 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;
}

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

    mov     eax, DWORD PTR [rsi]
    cmp     DWORD PTR [rdi], eax
    sete    al
    movzx   eax, al

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

    sub     rsp, 8
    mov     edx, 4
    call    memcmp
    add     rsp, 8
    shr     eax, 31

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

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

4 个答案:

答案 0 :(得分:73)

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

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

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

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

答案 1 :(得分:24)

Endianness是这里的问题。考虑一下这个输入:

a = 01 00 00 03
b = 02 00 00 02

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

答案 2 :(得分:13)

正如其他答案/评论中所讨论的,使用memcmp(a,b,4) < 0相当于大端整数之间的unsigned比较。它无法像小端x86上的== 0一样有效内联。

更重要的是,gcc7 / 8 only looks for memcmp() == 0 or != 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(配置文件引导优化)进行编译时的热循环中。

如果您希望编译器在这种情况下做得很好,您应该分配给uint32_t并使用像ntohl这样的字节序转换函数。但请确保选择一个可以内联的内容;显然是Windows has an ntohl that compiles to a DLL call。对于某些便携式端口内容,请参阅该问题的其他答案,还有someone's imperfect attempt at a portable_endian.h和此fork of it。我正在研究一个版本一段时间,但从未完成/测试或发布它。

指针转换可能是未定义的行为,depending on how you wrote the bytes and what the char* points to。如果您不确定严格别名和/或对齐,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;
}

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

我认为这个手工制作的序列是less4() 的最佳实现(对于x86-64 SystemV调用约定,就像问题中使用的const char *a一样rdi中的brsi

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

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

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

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

有关为什么xor / cmp / setcc(哪个clang使用)优于cmp / setcc / movzx(gcc的典型值),请参阅the setcc part of my xor-zeroing answer

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

答案 3 :(得分:-2)

Endianness是一个问题,但是signed char是另一个。例如,考虑您比较的四个字节是0x207f2020和0x20802020。 80作为签名字符是-128,7f作为签名字符是+127。但是如果你比较四个字节,没有比较会给你正确的顺序。

当然你可以使用0x80808080进行xor,然后你就可以使用无符号比较。