Подтвердить что ты не робот

Получает ли GCC субоптимальный код для предсказания статической ветки?

Из моего университетского курса я слышал, что по соглашению лучше разместить более вероятное условие в if, а не в else, что может помочь предсказателю статической ветки. Например:

if (check_collision(player, enemy)) { // very unlikely to be true
    doA();
} else {
    doB();
}

можно переписать как:

if (!check_collision(player, enemy)) {
    doB();
} else {
    doA();
}

Я нашел сообщение в блоге Филиалы с использованием GCC, что объясняет это явление более подробно:

Форвардные ветки генерируются для операторов if. Обоснование что их вряд ли удастся, так это то, что процессор может преимущество того, что инструкции, следующие за ветвью инструкция уже может быть помещена в буфер команд внутри Блок инструкций.

рядом с ним, он говорит (внимание мое):

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

В конечном счете, есть статья, написанная Intel, Реорганизация ветвей и циклов для предотвращения Mispredicts, которая суммирует это с двумя правилами:

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

  • Перемещение по умолчанию по умолчанию не принято
  • Отключенная ветка по умолчанию принята

Чтобы эффективно писать свой код, чтобы воспользоваться этими правила при написании операторов if-else или switch, проверьте наиболее общие случаи сначала и работать постепенно вниз до наименее общего.

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

Однако, похоже, что GCC не соблюдает эти правила. С учетом кода:

extern void foo();
extern void bar();

int some_func(int n)
{
    if (n) {
        foo();
    }
    else {
        bar();
    }
    return 0;
}

он генерирует (версия 6.3.0 с -O3 -mtune=intel):

some_func:
        lea     rsp, [rsp-8]
        xor     eax, eax
        test    edi, edi
        jne     .L6            ; here, forward branch if (n) is (conditionally) taken
        call    bar
        xor     eax, eax
        lea     rsp, [rsp+8]
        ret
.L6:
        call    foo
        xor     eax, eax
        lea     rsp, [rsp+8]
        ret

Единственный способ, который я нашел, чтобы заставить желаемое поведение, - переписать условие if, используя __builtin_expect следующим образом:

if (__builtin_expect(n, 1)) { // force n condition to be treated as true

поэтому код сборки станет следующим:

some_func:
        lea     rsp, [rsp-8]
        xor     eax, eax
        test    edi, edi
        je      .L2             ; here, backward branch is (conditionally) taken
        call    foo
        xor     eax, eax
        lea     rsp, [rsp+8]
        ret
.L2:
        call    bar
        xor     eax, eax
        lea     rsp, [rsp+8]
        ret
4b9b3361

Ответ 1

Короткий ответ: нет, это не так.

GCC делает метрики тонны нетривиальной оптимизации, а одна из них - гадать вероятности ветвления, судя по графу потока управления.

В соответствии с Руководство GCC:

FNo предугадывать Гиса-вероятность

Не угадывайте вероятности ветвей, используя эвристики.

GCC использует эвристику, чтобы угадать вероятности ветвей, если они не обеспечиваемое профилирующей обратной связью (-fprofile-arcs). Эти эвристики основанный на графике потока управления. Если некоторые вероятности ветвления указанный __builtin_expect, тогда эвристика используется для угадывания вероятности ветвления для остальной части графика потока управления, принимая во вкладке __builtin_expec t. Взаимодействие между эвристика и __builtin_expect могут быть сложными, а в некоторых случаях может быть полезно отключить эвристику, чтобы эффекты __builtin_expect легче понять.

-freorder-blocks может также обмениваться ветками.

Кроме того, как упоминалось в OP, поведение может быть переопределено с помощью __builtin_expect.

Proof

Посмотрите следующий список.

void doA() { printf("A\n"); }
void doB() { printf("B\n"); }
int check_collision(void* a, void* b)
{ return a == b; }

void some_func (void* player, void* enemy) {
    if (check_collision(player, enemy)) {
        doA();
    } else {
        doB();
    }
}

int main() {
    // warming up gcc statistic
    some_func((void*)0x1, NULL);
    some_func((void*)0x2, NULL);
    some_func((void*)0x3, NULL);
    some_func((void*)0x4, NULL);
    some_func((void*)0x5, NULL);
    some_func(NULL, NULL);
    return 0;
}

Очевидно, что check_collision вернет 0 большую часть времени. Таким образом, ветвь doB() вероятна, и GCC может угадать это:

gcc -O main.c -o opt.a
objdump -d opt.a

Asm some_func:

sub    $0x8,%rsp
cmp    %rsi,%rdi
je     6c6 <some_func+0x18>
mov    $0x0,%eax
callq  68f <doB>
add    $0x8,%rsp
retq   
mov    $0x0,%eax
callq  67a <doA>
jmp    6c1 <some_func+0x13>

Но, конечно, мы можем заставить GCC быть слишком умным:

gcc -fno-guess-branch-probability main.c -o non-opt.a
objdump -d non-opt.a

И мы получим:

push   %rbp
mov    %rsp,%rbp
sub    $0x10,%rsp
mov    %rdi,-0x8(%rbp)
mov    %rsi,-0x10(%rbp)
mov    -0x10(%rbp),%rdx
mov    -0x8(%rbp),%rax
mov    %rdx,%rsi
mov    %rax,%rdi
callq  6a0 <check_collision>
test   %eax,%eax
je     6ef <some_func+0x33>
mov    $0x0,%eax
callq  67a <doA>
jmp    6f9 <some_func+0x3d>
mov    $0x0,%eax
callq  68d <doB>
nop
leaveq 
retq  

Таким образом, GCC оставит ветки в исходном порядке.

Я использовал gcc 7.1.1 для этих тестов.

Ответ 2

Думаю, что вы нашли ошибку "

Самое смешное, что оптимизация для space и нет оптимизации - это случаи только, в которых генерируется "оптимальный" код команды: gcc -S [-O0 | -Os] source.c

some_func:
FB0:
       pushl   %ebp
       movl    %esp, %ebp
       subl    $8, %esp
       cmpl    $0, 8(%ebp)
       je      L2
       call    _foo
       jmp     L3
2:
       call    _bar
3:
       movl    $0, %eax
       # Or, for -Os:
       # xorl    %eax, %eax
       leave
       ret

Могу сказать, что...


some_func:
FB0:
       pushl   %ebp
       movl    %esp, %ebp
       subl    $8, %esp
       cmpl    $0, 8(%ebp)
       je      L2
       call    _foo

... до и после вызова foo все является "оптимальным", в традиционном смысле, независимо от стратегии выхода.

Оптимальность в конечном итоге определяется процессором, конечно.