Логические значения как 8-битные в компиляторах. Операции на них неэффективны?

Я читаю " Оптимизацию программного обеспечения в C++" Агнера Фога (специально для процессоров x86 для Intel, AMD и VIA), и это говорится на странице 34

Булевы переменные хранятся в виде 8-битных целых чисел со значением 0 для false и 1 для true. Булевы переменные переопределяются в том смысле, что все операторы, которые имеют булевы переменные в качестве входных данных, проверяют, имеют ли входы любое другое значение, кроме 0 или 1, но операторы, которые имеют логические переменные в качестве выходных данных, не могут давать никакого другого значения, кроме 0 или 1. Это делает операции Булевы переменные в качестве входных данных менее эффективны, чем необходимо.

Это все еще верно сегодня и на каких компиляторах? Можете ли вы привести пример? Автор заявляет

Логические операции можно сделать намного более эффективными, если с уверенностью известно, что у операндов нет других значений, кроме 0 и 1. Причина, по которой компилятор не делает такое предположение, состоит в том, что переменные могут иметь другие значения, если они неинициализированы или приходят из неизвестных источников.

Означает ли это, что если я возьму указатель на функцию bool(*)() например, и назовите это, тогда операции над ним производят неэффективный код? Или это тот случай, когда я получаю доступ к логическому значению путем разыменования указателя или чтения из ссылки, а затем работаю с ней?

3 ответа

Решение

TL: DR: текущие компиляторы все еще имеют bool Пропущенные оптимизации при выполнении таких вещей, как
(a&&b) ? x : y, Но причина не в том, что они не принимают 0/1, они просто сосут.

Многие использования bool для локальных или встроенных функций, так что 0 / 1 можно оптимизировать и разветвить (или CMOV или что-то) на исходное состояние. Беспокоиться только об оптимизации bool входы / выходы, когда они должны быть переданы / возвращены через что-то, что не встроено или действительно сохранено в памяти.

Возможные рекомендации по оптимизации: объединить bool s из внешних источников (функция args / memory) с побитовыми операторами, такими как a&b, MSVC и ICC справляются с этим лучше. ИДК, если это будет еще хуже для местных bool s. Остерегайтесь этого a&b только эквивалентно a&&b за bool, а не целочисленные типы. 2 && 1 верно, но 2 & 1 0, что неверно. Побитовое ИЛИ не имеет этой проблемы.

IDK, если это руководство будет когда-либо вредить местным жителям, которые были установлены из сравнения внутри функции (или в чем-то, что встроено). Например, это может привести к тому, что компилятор на самом деле сделает целочисленные логические значения, а не просто использует результаты сравнения напрямую, когда это возможно. Также обратите внимание, что это не помогает с текущими gcc и clang.


Да, реализации C++ в x86 store bool в байте это всегда 0 или 1 (по крайней мере, через границы вызова функции, где компилятор должен соблюдать соглашение ABI / вызова, которое требует этого.)

Компиляторы иногда пользуются этим, например, для bool -> int преобразование даже в gcc 4.4 просто ноль расширяется до 32 бит (movzx eax, dil). Clang и MSVC тоже делают это. Правила C и C++ требуют, чтобы это преобразование производило 0 или 1, поэтому такое поведение безопасно только в том случае, если всегда можно предположить, что bool Функция arg или глобальная переменная имеет значение 0 или 1.

Даже старые компиляторы обычно использовали это для bool -> int, но не в других случаях. Таким образом, Агнер ошибается в отношении причины, когда он говорит:

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


MSVC CL19 делает код, который предполагает bool Аргументы функции равны 0 или 1, поэтому ABI Windows x86-64 должен это гарантировать.

В x86-64 System V ABI (используется всем, кроме Windows), в журнале изменений для версии 0.98 написано "Укажите, что _Bool (ака bool) - это логическое выражение для вызывающей стороны. "Я думаю, что даже до этого изменения компиляторы предполагали это, но это только документирует, на что уже полагались компиляторы. Текущий язык в x86-64 SysV ABI:

3.1.2 Представление данных

Логические значения, когда они хранятся в объекте памяти, хранятся как однобайтовые объекты, значение которых всегда равно 0 (ложь) или 1 (истина). При хранении в целочисленных регистрах (за исключением передачи в качестве аргументов) все 8 байтов регистра имеют значение; любое ненулевое значение считается истинным.

Второе предложение бессмысленно: у ABI нет бизнес-компиляторов, говорящих о том, как хранить вещи в регистрах внутри функции, только на границах между различными блоками компиляции (аргументы памяти / функции и возвращаемые значения). Я сообщил об этом дефекте ABI некоторое время назад на странице github, где он поддерживается.

3.2.3 Передача параметров:

Когда значение типа _Bool возвращается или передается в регистре или в стеке, бит 0 содержит значение истинности, а биты с 1 по 7 должны быть равны нулю 16.

(сноска 16): Остальные биты остаются неуказанными, поэтому потребительская сторона этих значений может полагаться на то, что они равны 0 или 1 при усечении до 8 бит.

Язык в i386 System V ABI такой же, IIRC.


Любой компилятор, который принимает 0/1 для одной вещи (например, преобразование в int) но не может воспользоваться этим, в других случаях пропущена оптимизация. К сожалению, такие пропущенные оптимизации все еще существуют, хотя они встречаются реже, чем когда Агнер писал, что параграф о компиляторах всегда перебулеванизируется.

(Source + asm в проводнике компилятора Godbolt для gcc4.6 / 4.7 и clang/MSVC. См. Также доклад Мэтта Годболта CppCon2017 " Что мой компилятор сделал для меня за последнее время? Снятие крышки компилятора")

bool logical_or(bool a, bool b) { return a||b; }

 # gcc4.6.4 -O3 for the x86-64 System V ABI
    test    dil, dil            # test a against itself (for non-zero)
    mov     eax, 1
    cmove   eax, esi            # return   a ? 1 : b;
    ret

Таким образом, даже gcc4.6 не переустанавливал b, но он пропустил оптимизацию, которую выполняет gcc4.7: (и компиляторы clang и более поздние, как показано в других ответах):

    # gcc4.7 -O3 to present: looks ideal to me.
    mov     eax, esi
    or      eax, edi
    ret

(Clang-х or dil, sil / mov eax, edi глупо: при чтении гарантированно будет происходить частичная регистрация на Nehalem или более ранней версии Intel edi после написания dil и он имеет худший размер кода из-за необходимости использования префикса REX для использования младшей части edi. Лучший выбор может быть or dil,sil / movzx eax, dil если вы хотите избежать чтения каких-либо 32-битных регистров на случай, если вызывающая сторона оставила некоторые регистры передачи аргументов с "грязными" частичными регистрами.)

MSVC испускает этот код, который проверяет a затем b отдельно, совершенно не в состоянии воспользоваться чем-либо, и даже используя xor al,al вместо xor eax,eax, Таким образом, он имеет ложную зависимость от старого значения eax на большинстве процессоров ( включая Haswell/Skylake, которые не переименовывают частичные регистры с низким уровнем 8 отдельно от всего регистра, только AH / BH /...). Это просто глупо. Единственная причина когда-либо использовать xor al,al это когда вы явно хотите сохранить старшие байты.

logical_or PROC                     ; x86-64 MSVC CL19
    test     cl, cl                 ; Windows ABI passes args in ecx, edx
    jne      SHORT $LN3@logical_or
    test     dl, dl
    jne      SHORT $LN3@logical_or
    xor      al, al                 ; missed peephole: xor eax,eax is strictly better
    ret      0
$LN3@logical_or:
    mov      al, 1
    ret      0
logical_or ENDP

ICC18 также не использует известную природу входов 0/1, он просто использует or инструкция для установки флагов в соответствии с побитовым ИЛИ двух входов, и setcc производить 0/1.

logical_or(bool, bool):             # ICC18
    xor       eax, eax                                      #4.42
    movzx     edi, dil                                      #4.33
    movzx     esi, sil                                      #4.33
    or        edi, esi                                      #4.42
    setne     al                                            #4.42
    ret                                                     #4.42

ICC испускает тот же код даже для bool bitwise_or(bool a, bool b) { return a|b; }, Это способствует intmovzx) и использует or установить флаги в соответствии с побитовым ИЛИ. Это глупо по сравнению с or dil,sil / setne al,

За bitwise_or MSVC просто использует or инструкция (после movzx на каждом входе), но в любом случае не переустанавливает.


Пропущенные оптимизации в текущем gcc/clang:

Только ICC/MSVC делали тупой код с помощью простой функции, описанной выше, но эта функция по-прежнему создает проблемы с gcc и clang:

int select(bool a, bool b, int x, int y) {
    return (a&&b) ? x : y;
}

Source + asm в проводнике компилятора Godbolt (тот же источник, разные компиляторы, выбранные по сравнению с прошлым разом).

Выглядит достаточно просто; вы бы надеялись, что умный компилятор сделает это без единого test / cmov, x86-х test инструкция устанавливает флаги в соответствии с побитовым И. Это инструкция AND, которая на самом деле не записывает пункт назначения. (Как cmp это sub это не пишет пункт назначения).

# hand-written implementation that no compilers come close to making
select:
    mov     eax, edx      # retval = x
    test    edi, esi      # ZF =  ((a & b) == 0)
    cmovz   eax, ecx      # conditional move: return y if ZF is set
    ret

Но даже ежедневные сборки gcc и clang в проводнике компилятора Godbolt создают гораздо более сложный код, проверяя каждый логический тип отдельно. Они знают, как оптимизировать bool ab = a&&b; если ты вернешься ab, но даже написав его таким образом (с отдельной логической переменной для хранения результата) не удается удержать их вручную для создания кода, который не сосет.

Обратите внимание, что test same,same в точности эквивалентно cmp reg, 0 и меньше, так что это то, что используют компиляторы.

Версия Кланга строго хуже моей рукописной версии. (Обратите внимание, что это требует, чтобы вызывающий bool args to 32-bit, как это делается для узких целочисленных типов, как неофициальная часть ABI, которую он и gcc реализуют, но зависит только от clang).

select:  # clang 6.0 trunk 317877 nightly build on Godbolt
    test    esi, esi
    cmove   edx, ecx         # x = b ? y : x
    test    edi, edi
    cmove   edx, ecx         # x = a ? y : x
    mov     eax, edx         # return x
    ret

gcc 8.0.0 20171110 nightly создает для этого ветвистый код, аналогично тому, что делают старые версии gcc.

select(bool, bool, int, int):   # gcc 8.0.0-pre   20171110
    test    dil, dil
    mov     eax, edx          ; compiling with -mtune=intel or -mtune=haswell would keep test/jcc together for macro-fusion.
    je      .L8
    test    sil, sil
    je      .L8
    rep ret
.L8:
    mov     eax, ecx
    ret

MSVC x86-64 CL19 делает очень похожий ветвистый код. Он нацелен на соглашение о вызовах Windows, где целочисленные аргументы находятся в rcx, rdx, r8, r9.

select PROC
        test     cl, cl         ; a
        je       SHORT $LN3@select
        mov      eax, r8d       ; retval = x
        test     dl, dl         ; b
        jne      SHORT $LN4@select
$LN3@select:
        mov      eax, r9d       ; retval = y
$LN4@select:
        ret      0              ; 0 means rsp += 0 after popping the return address, not C return 0.
                                ; MSVC doesn't emit the `ret imm16` opcode here, so IDK why they put an explicit 0 as an operand.
select ENDP

ICC18 также делает ветвистый код, но с обоими mov инструкции после веток.

select(bool, bool, int, int):
        test      dil, dil                                      #8.13
        je        ..B4.4        # Prob 50%                      #8.13
        test      sil, sil                                      #8.16
        jne       ..B4.5        # Prob 50%                      #8.16
..B4.4:                         # Preds ..B4.2 ..B4.1
        mov       edx, ecx                                      #8.13
..B4.5:                         # Preds ..B4.2 ..B4.4
        mov       eax, edx                                      #8.13
        ret                                                     #8.13

Попытка помочь компилятору с помощью

int select2(bool a, bool b, int x, int y) {
    bool ab = a&&b;
    return (ab) ? x : y;
}

приводит MSVC к созданию смешно плохого кода:

;; MSVC CL19  -Ox  = full optimization
select2 PROC
    test     cl, cl
    je       SHORT $LN3@select2
    test     dl, dl
    je       SHORT $LN3@select2
    mov      al, 1              ; ab = 1

    test     al, al             ;; and then test/cmov on an immediate constant!!!
    cmovne   r9d, r8d
    mov      eax, r9d
    ret      0
$LN3@select2:
    xor      al, al            ;; ab = 0

    test     al, al            ;; and then test/cmov on another path with known-constant condition.
    cmovne   r9d, r8d
    mov      eax, r9d
    ret      0
select2 ENDP

Это только с MSVC (и ICC18 имеет ту же пропущенную оптимизацию test/cmov для регистра, который был только что установлен на константу).

gcc и clang, как обычно, не делают код настолько плохим, как MSVC; они делают то же самое, что и для select() что все еще не хорошо, но, по крайней мере, попытка помочь им не усугубляет ситуацию, как в MSVC.


скомбинировать bool с побитовыми операторами помогает MSVC и ICC

В моем очень ограниченном тестировании, | а также & кажется, работает лучше, чем || а также && для MSVC и ICC. Посмотрите на вывод компилятора для вашего собственного кода с опциями компилятор + компиляция, чтобы увидеть, что происходит.

int select_bitand(bool a, bool b, int x, int y) {
    return (a&b) ? x : y;
}

Gcc еще разветвляется отдельно на отдельные test s из двух входов, тот же код, что и другие версии select, Clang по-прежнему делает два отдельных test/cmov То же, что и для других версий исходного кода.

MSVC проходит и оптимизирует правильно, опережая все другие компиляторы (по крайней мере, в автономном определении):

select_bitand PROC            ;; MSVC
    test     cl, dl           ;; ZF =  !(a & b)
    cmovne   r9d, r8d
    mov      eax, r9d         ;; could have done the mov to eax in parallel with the test, off the critical path, but close enough.
    ret      0

ICC18 тратит впустую два movzx инструкции ноль расширения bool с int, но затем делает тот же код, что и MSVC

select_bitand:          ## ICC18
    movzx     edi, dil                                      #16.49
    movzx     esi, sil                                      #16.49
    test      edi, esi                                      #17.15
    cmovne    ecx, edx                                      #17.15
    mov       eax, ecx                                      #17.15
    ret                                                     #17.15

Я думаю, что это не так.

Прежде всего, это рассуждение совершенно неприемлемо:

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

Давайте проверим некоторый код (скомпилированный с помощью clang 6, но GCC 7 и MSVC 2017 производят похожий код).

Логическое или:

bool fn(bool a, bool b) {
    return a||b;
}

0000000000000000 <fn(bool, bool)>:
   0:   40 08 f7                or     dil,sil
   3:   40 88 f8                mov    al,dil
   6:   c3                      ret    

Как видно, здесь нет проверки 0/1, просто or,

Преобразовать bool в int:

int fn(bool a) {
    return a;
}

0000000000000000 <fn(bool)>:
   0:   40 0f b6 c7             movzx  eax,dil
   4:   c3                      ret    

Опять не чек, простой ход.

Конвертировать char в bool:

bool fn(char a) {
    return a;
}

0000000000000000 <fn(char)>:
   0:   40 84 ff                test   dil,dil
   3:   0f 95 c0                setne  al
   6:   c3                      ret    

Здесь char проверяется, является ли он 0 или нет, и значение bool устанавливается равным 0 или 1 соответственно.

Поэтому я думаю, что можно с уверенностью сказать, что компилятор использует bool таким образом, чтобы он всегда содержал 0/1. Он никогда не проверяет его действительность.

Об эффективности: я думаю, что bool является оптимальным. Единственный случай, который я могу себе представить, где этот подход не является оптимальным, это преобразование char->bool. Эта операция может быть простым mov, если значение bool не будет ограничено 0/1. Для всех других операций текущий подход одинаково хорош или лучше.


РЕДАКТИРОВАТЬ: Питер Кордес упомянул ABI. Вот соответствующий текст из System V ABI для AMD64 (текст для i386 аналогичен):

Логические значения, когда они хранятся в объекте памяти, хранятся как однобайтовые объекты, значение которых всегда равно 0 (ложь) или 1 (истина). При хранении в целочисленных регистрах (за исключением передачи в качестве аргументов) все 8 байтов регистра имеют значение; любое ненулевое значение считается истинным

Таким образом, для платформ, которые следуют SysV ABI, мы можем быть уверены, что bool имеет значение 0/1.

Я искал документ ABI для MSVC, но, к сожалению, я ничего не нашел о bool,

Я скомпилировал следующее с помощью clang++ -O3 -S

bool andbool(bool a, bool b)
{
    return a && b;
}

bool andint(int a, int b)
{
    return a && b;
}

.s файл содержит:

andbool(bool, bool):                           # @andbool(bool, bool)
    andb    %sil, %dil
    movl    %edi, %eax
    retq

andint(int, int):                            # @andint(int, int)
    testl   %edi, %edi
    setne   %cl
    testl   %esi, %esi
    setne   %al
    andb    %cl, %al
    retq

Очевидно, что версия bool делает меньше.

Другие вопросы по тегам