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

В общем случае, компилятор генерирует ассемблер с таким предположением, что условие внутри if маловероятно и после кода самой инструкции if ставит условный переход (je, jne и прочие) на случай если когда-нибудь условие будет истинным. После строки с переходом идут следующие за оператором if инструкции. Метка блока кода, соответствующего истинному решению if, обычно расположен по тексту где-то дальше от самого оператора if. Поэтому процессору, загрузив в кэш часть кода функции где расположен блок if и близлежащий код придется, в случае положительного решения, if отбрасывать то что он уже загрузил и загружать код по адресу метки перехода с положительным решением.  А уже после его выполнения возвращаться обратно, отбрасывая то, что есть сейчас и загружая то место где он был, анализируя блок if. Так же это может привести к остановке конвейера процессора, следовательно, увеличит время выполнения кода.

Помимо описанного случая может быть и по другому (об этом и статья). Для того, чтобы подсказать компилятору какую ветвь if считать приоритетной есть инструкция __builtin_expect(long, long). Где в первый операнд ставится  анализируемое условие, а вторым 1 или 0 - приоритетное или нет.

Пример: https://godbolt.org/z/7PnrWPvxe

Из примера функция b():

bool b(bool ptr) {
    if(__builtin_expect(ptr, 1)){
            std::cout << " true )" << std::endl;
            return true;
        }
    std::cout << " false (" << std::endl;
    return false;
}

Ее ASM:

Hidden text
.LC0:
        .string " true )"
.LC1:
        .string " false ("
b(bool):
        push    rbp
        mov     ebp, edi
        push    rbx
        sub     rsp, 8
        test    dil, dil
        je      .L12
        mov     edx, 7
        mov     esi, OFFSET FLAT:.LC0
.L32:
        mov     edi, OFFSET FLAT:std::cout
        call    std::basic_ostream<char, std::char_traits<char> >& std::__ostream_insert<char, std::char_traits<char> >(std::basic_ostream<char, std::char_traits<char> >&, char const*, long)
        mov     rax, QWORD PTR std::cout[rip]
        mov     rax, QWORD PTR [rax-24]
        mov     rbx, QWORD PTR std::cout[rax+240]
        test    rbx, rbx
        je      .L33
        cmp     BYTE PTR [rbx+56], 0
        je      .L18
        movsx   esi, BYTE PTR [rbx+67]
.L19:
        mov     edi, OFFSET FLAT:std::cout
        call    std::basic_ostream<char, std::char_traits<char> >::put(char)
        mov     rdi, rax
        call    std::basic_ostream<char, std::char_traits<char> >::flush()
        add     rsp, 8
        mov     eax, ebp
        pop     rbx
        pop     rbp
        ret
.L18:
        mov     rdi, rbx
        call    std::ctype<char>::_M_widen_init() const
        mov     rax, QWORD PTR [rbx]
        mov     esi, 10
        mov     rax, QWORD PTR [rax+48]
        cmp     rax, OFFSET FLAT:std::ctype<char>::do_widen(char) const
        je      .L19
        mov     rdi, rbx
        call    rax
        movsx   esi, al
        jmp     .L19
.L12:
        mov     edx, 8
        mov     esi, OFFSET FLAT:.LC1
        jmp     .L32
.L33:
        call    std::__throw_bad_cast()

Интересует следующая часть:

        test    dil, dil
        je      .L12
        mov     edx, 7
        mov     esi, OFFSET FLAT:.LC0 

Если вам неизвестна инструкция test то сюда. Если условие false то прыгаем на метку .L12 (которая очень далеко, расположены после тела функции). Если true  то продолжаем двигаться дальше по строкам  - загружаем .string " true )", выводим на экран и возвращаем результат. Если все будет как мы задумали, то есть  ptr будет true то весь наш код - это набор последовательных строк, которые достаточно один раз загрузить в кеш и последовательно исполнять.

Однако, если посмотреть аналог этой функции - функцию c():

bool c(bool ptr) {
    if(ptr){
            std::cout << " true )" << std::endl;
            return true;
        }
    std::cout << " false (" << std::endl;
    return false;
}

Ее ASM уже другой:

Hidden text
c(bool):
        push    rbp
        mov     edx, 8
        mov     ebp, edi
        mov     esi, OFFSET FLAT:.LC1
        push    rbx
        sub     rsp, 8
        test    dil, dil
        je      .L55
        mov     edx, 7
        mov     esi, OFFSET FLAT:.LC0
.L55:
        mov     edi, OFFSET FLAT:std::cout
        call    std::basic_ostream<char, std::char_traits<char> >& std::__ostream_insert<char, std::char_traits<char> >(std::basic_ostream<char, std::char_traits<char> >&, char const*, long)
        mov     rax, QWORD PTR std::cout[rip]
        mov     rax, QWORD PTR [rax-24]
        mov     rbx, QWORD PTR std::cout[rax+240]
        test    rbx, rbx
        je      .L56
        cmp     BYTE PTR [rbx+56], 0
        je      .L41
        movsx   esi, BYTE PTR [rbx+67]
.L42:
        mov     edi, OFFSET FLAT:std::cout
        call    std::basic_ostream<char, std::char_traits<char> >::put(char)
        mov     rdi, rax
        call    std::basic_ostream<char, std::char_traits<char> >::flush()
        add     rsp, 8
        mov     eax, ebp
        pop     rbx
        pop     rbp
        ret
.L41:
        mov     rdi, rbx
        call    std::ctype<char>::_M_widen_init() const
        mov     rax, QWORD PTR [rbx]
        mov     esi, 10
        mov     rax, QWORD PTR [rax+48]
        cmp     rax, OFFSET FLAT:std::ctype<char>::do_widen(char) const
        je      .L42
        mov     rdi, rbx
        call    rax
        movsx   esi, al
        jmp     .L42
.L56:
        call    std::__throw_bad_cast()

Интересны следующие строки:

        mov     esi, OFFSET FLAT:.LC1
        push    rbx
        sub     rsp, 8
        test    dil, dil
        je      .L55
        mov     edx, 7
        mov     esi, OFFSET FLAT:.LC0
.L55:
        mov     edi, OFFSET FLAT:std::cout

В самом начале функции, даже до блока if подгружается строка .LC1, соответствующая. string " false (". После выполняется анализ условия и в случае false перепрыгиваем две строки и делаем вывод на экран, а в случае true заменяем загруженное ранее условие на метку .LC0 и двигаемся дальше, выводя на экран. В этой функции ничего критичного не случиться при любой раскладке, разве что будет лишняя перезапись регистра в случае положительного исхода. Это при условии, что функция из примера довольно мала. Видим так же, что компилятор здесь выбрал другую ветвь if в качестве приоритета.

Посмотрим функцию main где разница между двумя условными переходами будет более существенна.

int main(int argc, char **argv) {
    std::cout << " main" << std::endl;    
    if(__builtin_expect(b(true),1))
    {
        std::cout << " main true" << std::endl;
    }

    if(c(true))
    {
         std::cout << " main false" << std::endl;
    }
    return EXIT_SUCCESS;
}

Начало функции main и первый if генерит такой ASM код:

Hidden text
.LC2:
        .string " main"
.LC3:
        .string " main true"
.LC4:
        .string " main false"
main:
        sub     rsp, 8
        mov     esi, OFFSET FLAT:.LC2
        mov     edi, OFFSET FLAT:std::cout
        call    std::basic_ostream<char, std::char_traits<char> >& std::operator<< <std::char_traits<char> >(std::basic_ostream<char, std::char_traits<char> >&, char const*)
        mov     rdi, rax
        call    std::basic_ostream<char, std::char_traits<char> >& std::endl<char, std::char_traits<char> >(std::basic_ostream<char, std::char_traits<char> >&) [clone .isra.0]
        mov     edi, 1
        call    b(bool)
        test    al, al
        je      .L58
        mov     edi, OFFSET FLAT:std::cout
        mov     esi, OFFSET FLAT:.LC3
        call    std::basic_ostream<char, std::char_traits<char> >& std::operator<< <std::char_traits<char> >(std::basic_ostream<char, std::char_traits<char> >&, char const*)
        mov     rdi, rax
        call    std::basic_ostream<char, std::char_traits<char> >& std::endl<char, std::char_traits<char> >(std::basic_ostream<char, std::char_traits<char> >&) [clone .isra.0]
.L58:
        mov     edi, 1
        call    c(bool)

Суть самой проверки:

        call    b(bool)
        test    al, al
        je      .L58
        mov     edi, OFFSET FLAT:std::cout
		mov     esi, OFFSET FLAT:.LC3

Если условие true то двигаемся построчно выводим результат и переходим к следующему условию.

Если условие false то перепрыгиваем несколько строк и идем к следующему условию. Все логично и предсказуемо.

Посмотрим второе условие:

.L58:
        mov     edi, 1
        call    c(bool)
        test    al, al
        jne     .L67
.L59:
        xor     eax, eax
        add     rsp, 8
        ret
.L67:
        mov     esi, OFFSET FLAT:.LC4
        mov     edi, OFFSET FLAT:std::cout
        call    std::basic_ostream<char, std::char_traits<char> >& std::operator<< <std::char_traits<char> >(std::basic_ostream<char, std::char_traits<char> >&, char const*)
        mov     rdi, rax
        call    std::basic_ostream<char, std::char_traits<char> >& std::endl<char, std::char_traits<char> >(std::basic_ostream<char, std::char_traits<char> >&) [clone .isra.0]
        jmp     .L59

Проверяем условие, в случае false двигаемся дальше и завершаем программу. В случае true прыгаем сначала на .L67 где подгружаем и выводим текст, затем прыгаем обратно на третью строку после if где завершаем программу. В данном случае тело блока с true было маленьким, поэтому его метка .L67 не очень далеко от условия, но в реальной программе все блоки могут быть больше.

Однако не все так однозначно в современных оптимизациях. Возьмем пример: https://godbolt.org/z/3W9r1s3d8 . Две простейшие функции foo() и bar() почти с одинаковым телом и одним условным оператором if в каждой:

bool foo(int i)
{
    if(i != 1)
    {
        fprintf(stderr, "error foo");
        return false;
    }
    
    fprintf(stdout, "good foo");
    return true;
}

bool bar(int i)
{
    if(i == 1)
    {
        fprintf(stderr, "error bar");
        return false;
    }
    
    fprintf(stdout, "good bar");
    return true;
}

Основная часть ассемблера первой:

.LC0:
        .string "error foo"
.LC1:
        .string "good foo"
foo(int):
        subq    $8, %rsp
        cmpl    $1, %edi
        je      .L2
        movl    $9, %edx
        movl    $1, %esi
        movl    $.LC0, %edi

Здесь условие «!=» считается наиболее вероятным. В функции bar() наоборот:

.LC2:
        .string "error bar"
.LC3:
        .string "good bar"
bar(int):
        subq    $8, %rsp
        cmpl    $1, %edi
        je      .L10
        movl    $8, %edx
        movl    $1, %esi
        movl    $.LC3, %edi

Условие «==» считается менее вероятным, чем все остальные случаи.

Такой же логикой обладают условия в функции main():

.LC4:
        .string "Unexpected number"
.LC5:
        .string "--error"
.LC6:
        .string "error"
.LC7:
        .string "fail"
.LC8:
        .string "main good"
main:
        pushq   %rbx
        cmpl    $2, %edi
        jne     .L24
        movq    8(%rsi), %rdi
        movl    $.LC5, %esi
        call    strcmp
        testl   %eax, %eax
        je      .L25
        movl    $1, %edi
        call    foo(int)
        movl    $2, %edi
        movl    %eax, %ebx
        call    bar(int)
        testb   %bl, %bl
        je      .L16
        testb   %al, %al
        je      .L16
        movl    $4, %edx
        movl    $1, %esi
        movl    $.LC7, %edi

Здесь вперемешку: какие-то условия считаются компилятором как наиболее частые, какие-то нет. В процессе выполнения «штатного» режима работы не получится последовательно бежать по строкам.

Таким образом компилятор пытается предугадать логику обработки условий, в общем случае считает, что если в условии идет сравнение с каким-либо значением, то это маловероятный исход и скорее всего будут далее выполнятся инструкции после условного оператора. Но как видно из примера функций foo() и bar() компилятор может построить логику отличную от нашего замысла, поэтому лучше ему явно сказать при помощи __builtin_expect какая ветка условного перехода является наиболее часто выбираемой с нашей точки зрения. С применением этого макроса в примере (та часть где определен -DUSE_EXPECTED) видим, что сначала функций поставлены условия и следующие за ними инструкции, а обработка срабатывания условий вынесена в конец функций, чтобы в нормальном режиме выполняя код функций компилятор не спотыкался о блоки с обработкой редких ошибок.

Комментарии (10)


  1. jpegqs
    07.08.2023 09:27
    +9

    А еще можно делать профилирование (PGO, Profile-guided optimization), и компилятор учтёт статистику переходов. Это опции компилятора -fprofile-generate и -fprofile-use. Расставлять подсказки для компилятора вручную не очень эффективно.


    1. lehiss Автор
      07.08.2023 09:27
      +2

      Спасибо, интересная идея!


  1. byman
    07.08.2023 09:27
    +4

    Я так понял, что процессор примитивный (хотя и с кэшем) и в нем нет никакого предсказателя ветвлений. Тогда подсказывать компилятору иногда полезно, если код активно используется.


    1. SamOwaR
      07.08.2023 09:27

      нет никакого предсказателя ветвлений.

      Вот и я на это обратил внимание. Что для современного процессора это не будет играть большого значения, так как у них очень развитые алгоритмы предсказания переходов.

      Разве что для старых Atom-ов.


      1. lehiss Автор
        07.08.2023 09:27

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


        1. SamOwaR
          07.08.2023 09:27

          На эту тему тут была хорошая статья
          https://habr.com/ru/articles/337000/

          Получается что для примитивных процессоров - такой "хинт" компилятору это плюс. Т.к. он разместит вероятный кода сразу за if и не будет никаких промахов и перезагрузки конвеера. С другой стороны, у этих самых примитивных процессоров конвеер или короткий или его нет вообще, поэтому и плата за промах минимальная.

          Для современных процессоров, такой "хинт" подскажет процессору куда идти при первом срабатывании, а дальше он уже сам.


  1. klirichek
    07.08.2023 09:27
    +8

    зачем какой-то непонятный интринсик, если уже завезли [[likely]] и [[unlikely]]?


    1. lehiss Автор
      07.08.2023 09:27
      +2

      Не все поддерживают 20 стандарт. Даже используя [[likely]] и [[unlikely]] механика будет подобна описанному. Статья об использовании этого механизма)


      1. neon1ks
        07.08.2023 09:27
        +3

        А все ли компиляторы поддерживают __builtin_expect ? Без упоминания [[likely]] и [[unlikely]] всё таки тема не до конца раскрыта.


  1. emusic
    07.08.2023 09:27
    +6

    Москвичи указывают номер телефона без кода города, американцы - без кода страны, а линуксоиды считают, что "компилятор" бывает только gcc. :)