Почему memcmp (a, b, 4) иногда оптимизируется только для сравнения uint32?

С учетом этого кода:

#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;
}

GCC 7 на x86_64 ввел оптимизацию для первого случая (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

Ответ 1

Если вы создаете код для платформы little-endian, оптимизация четырехбайтного memcmp для неравенства для одного сравнения DWORD недопустима.

Когда memcmp сравнивает отдельные байты, он переходит из низкозаданных байтов в байты с высоким адресом, независимо от платформы.

Для возврата memcmp к нулю все четыре байта должны быть одинаковыми. Следовательно, порядок сравнения не имеет значения. Поэтому оптимизация DWORD действительна, поскольку вы игнорируете знак результата.

Однако, когда memcmp возвращает положительное число, порядок байтов имеет значение. Следовательно, для реализации такого же сравнения с использованием 32-битного сравнения DWORD требуется конкретная специфика: платформа должна быть большой, иначе результат сравнения будет неправильным.

Ответ 2

Конкретность - проблема здесь. Рассмотрим этот вход:

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

Если вы сравниваете эти два массива, рассматривая их как 32-битные целые числа, вы обнаружите, что a больше (потому что 0x03000001 > 0x02000002). На машине большого конца этот тест, вероятно, будет работать, как ожидалось.

Ответ 3

Как обсуждалось в других ответах/комментариях, использование memcmp(a,b,4) < 0 эквивалентно сравнению с unsigned сравнением целых чисел. Он не мог входить так эффективно, как == 0 в little-endian x86.

Что еще более важно, текущая версия этого поведения в gcc7/8 ищет только memcmp() == 0 или != 0. Даже в случае с большим энтиантом, где это может быть так же эффективно для < или >, gcc этого не сделает. (Новейшие компиляторы Godbolt - PowerPC 64 gcc6.3, а MIPS/MIPS64 - gcc5.4. mips - MIPS с большим энтузиазмом, в то время как mipsel является малорисковым MIPS.) Если вы тестируете это с будущим gcc, используйте a = __builtin_assume_align(a, 4), чтобы убедиться, что gcc не нужно беспокоиться о производительности/правильности невыложенной загрузки на не-x86. (Или просто используйте const int32_t* вместо const char*.)

Если/gcc учится на inline memcmp для случаев, отличных от EQ/NE, возможно, gcc сделает это на little-endian x86, когда его эвристика скажет, что дополнительный размер кода будет стоить того. например в горячем цикле при компиляции с -fprofile-use (оптимизация с помощью профиля).


Если вы хотите, чтобы компиляторы выполняли хорошую работу для этого случая, вам, вероятно, следует назначить uint32_t и использовать функцию преобразования endian, например ntohl. Но убедитесь, что вы выбрали тот, который может быть встроен; по-видимому Windows имеет ntohl, который компилируется для вызова DLL. См. Другие ответы на этот вопрос для некоторых переносимых статей, а также некоторая несовершенная попытка в 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;
}

Я проверил Godbolt, и это скомпилировано для эффективного кода (в основном идентичного тому, что я написал в asm ниже), особенно на big-endian платформ, даже со старым gcc. Он также делает гораздо лучший код, чем ICC17, который заключает в себе memcmp, но только в цикле сравнения байтов (даже для случая == 0.


Я думаю, что эта последовательность, созданная вручную, является оптимальной реализацией less4() (для соглашения о вызове x86-64 SystemV, например, используемого в вопросе, с const char *a в rdi и b в 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 movzx, see below
    cmp   edi, esi
    setb  al         # eax=1 if *a was Below(unsigned) *b, else 0
    ret

Все это одноуровневые инструкции для процессоров Intel и AMD с K8 и Core2 (http://agner.org/optimize/).

Наличие bswap обоих операндов имеет дополнительную стоимость размера кода и случай == 0: мы не можем сбросить одну из загрузок в операнд памяти для cmp. (Это сохраняет размер кода и поддерживает микро-фьюжн.) Это находится над двумя дополнительными инструкциями bswap.

В процессорах, поддерживающих movbe, он может сохранить размер кода: movbe ecx, [rsi] - это load + bswap. На Хасуэле это 2 раза, поэтому, по-видимому, он декодирует те же uops, что и mov ecx, [rsi]/bswap ecx. В Atom/Silvermont он обрабатывается прямо в портах загрузки, поэтому он уменьшает количество оборотов, а также уменьшает размер кода.

Посмотрите часть моего ответа xor-zeroing, чтобы узнать больше о том, почему xor/cmp/setcc (который использует clang) лучше, чем cmp/setcc/movzx (типичный для gcc).

В обычном случае, когда это встраивается в код, который веткится на результат, setcc + zero-extend заменяются на jcc; компилятор оптимизирует создание логического возвращаемого значения в регистре. Это еще одно преимущество вложения: библиотека memcmp должна создать целочисленное значение логического возврата, которое вызывающий тестер, потому что ни одно соглашение x86 ABI/call не позволяет возвращать логические условия во флагах. (Я не знаю каких-либо соглашений о вызовах, отличных от x86, которые это делают). Для большинства реализаций библиотеки memcmp также существуют значительные накладные расходы при выборе стратегии в зависимости от длины и, возможно, проверки выравнивания. Это может быть довольно дешево, но для размера 4 это будет больше, чем стоимость всей реальной работы.

Ответ 4

Endianness - одна проблема, но подписанная char - другая. Например, учтите, что четыре байта, которые вы сравниваете, - 0x207f2020 и 0x20802020. 80, подписанный char равен -128, 7f, как подписанный char равен +127. Но если вы сравните четыре байта, сравнение не даст вам правильного порядка.

Конечно, вы можете сделать xor с 0x80808080, а затем вы можете просто использовать сравнение без знака.