Qual é a vantagem do __builtin_expect do GCC nas declarações if else?

144

Me deparei com um #define em que eles usam __builtin_expect.

A documentação diz:

Função interna: long __builtin_expect (long exp, long c)

Você pode usar __builtin_expectpara fornecer ao compilador informações de previsão de ramificação. Em geral, você deve preferir usar o feedback real do perfil para isso (-fprofile-arcs ), pois os programadores são notoriamente ruins em prever o desempenho de seus programas. No entanto, existem aplicativos em que esses dados são difíceis de coletar.

O valor de retorno é o valor de exp, que deve ser uma expressão integral. A semântica do built-in é que é esperado isso exp == c. Por exemplo:

      if (__builtin_expect (x, 0))
        foo ();

indicaria que não esperamos ligar foo, pois esperamos xser zero.

Então, por que não usar diretamente:

if (x)
    foo ();

em vez da sintaxe complicada com __builtin_expect?

kingsmasher1
fonte
3
Eu acho que seu código direto deveria ter sido if ( x == 0) {} else foo();.. ou simplesmente o if ( x != 0 ) foo();que é equivalente ao código da documentação do GCC.
Nawaz

Respostas:

187

Imagine o código do assembly que seria gerado a partir de:

if (__builtin_expect(x, 0)) {
    foo();
    ...
} else {
    bar();
    ...
}

Eu acho que deveria ser algo como:

  cmp   $x, 0
  jne   _foo
_bar:
  call  bar
  ...
  jmp   after_if
_foo:
  call  foo
  ...
after_if:

Você pode ver que as instruções estão dispostas em uma ordem que o barcaso precede ofoo caso (em oposição ao código C). Isso pode utilizar melhor o pipeline da CPU, pois um salto frustra as instruções já obtidas.

Antes do salto ser executado, as instruções abaixo (o barcaso) são enviadas ao pipeline. Como o foocaso é improvável, saltar também é improvável; portanto, é improvável que tropeçar no pipeline.

Blagovest Buyukliev
fonte
1
Realmente funciona assim? Por que a definição foo não pode vir primeiro? A ordem das definições das funções é irrelevante, na medida em que você possui um protótipo, certo?
precisa saber é o seguinte
63
Não se trata de definições de função. Trata-se de reorganizar o código da máquina de uma maneira que cause uma menor probabilidade de a CPU buscar instruções que não serão executadas.
Blagovest Buyukliev
4
Ohh eu entendo. Então você quer dizer que existe uma alta probabilidade de x = 0que a barra seja dada primeiro. E foo, é definido mais tarde, pois suas chances (em vez de probabilidade de uso) são menores, certo?
precisa saber é o seguinte
1
Ahhh .. obrigado. Essa é a melhor explicação. O código de montagem realmente fez o truque :)
kingsmasher1
5
Isso também pode incorporar sugestões para a CPU previsor de ramos , melhorando pipelining
Hasturkun
50

Vamos descompilar para ver o que o GCC 4.8 faz com ele

Blagovest mencionou a inversão de ramificação para melhorar o pipeline, mas os compiladores atuais realmente o fazem? Vamos descobrir!

Sem __builtin_expect

#include "stdio.h"
#include "time.h"

int main() {
    /* Use time to prevent it from being optimized away. */
    int i = !time(NULL);
    if (i)
        puts("a");
    return 0;
}

Compile e descompile com o GCC 4.8.2 x86_64 Linux:

gcc -c -O3 -std=gnu11 main.c
objdump -dr main.o

Resultado:

0000000000000000 <main>:
   0:       48 83 ec 08             sub    $0x8,%rsp
   4:       31 ff                   xor    %edi,%edi
   6:       e8 00 00 00 00          callq  b <main+0xb>
                    7: R_X86_64_PC32        time-0x4
   b:       48 85 c0                test   %rax,%rax
   e:       75 0a                   jne    1a <main+0x1a>
  10:       bf 00 00 00 00          mov    $0x0,%edi
                    11: R_X86_64_32 .rodata.str1.1
  15:       e8 00 00 00 00          callq  1a <main+0x1a>
                    16: R_X86_64_PC32       puts-0x4
  1a:       31 c0                   xor    %eax,%eax
  1c:       48 83 c4 08             add    $0x8,%rsp
  20:       c3                      retq

A ordem das instruções na memória permaneceu inalterada: primeiro o putse depois retqretornamos.

Com __builtin_expect

Agora substitua if (i)por:

if (__builtin_expect(i, 0))

e temos:

0000000000000000 <main>:
   0:       48 83 ec 08             sub    $0x8,%rsp
   4:       31 ff                   xor    %edi,%edi
   6:       e8 00 00 00 00          callq  b <main+0xb>
                    7: R_X86_64_PC32        time-0x4
   b:       48 85 c0                test   %rax,%rax
   e:       74 07                   je     17 <main+0x17>
  10:       31 c0                   xor    %eax,%eax
  12:       48 83 c4 08             add    $0x8,%rsp
  16:       c3                      retq
  17:       bf 00 00 00 00          mov    $0x0,%edi
                    18: R_X86_64_32 .rodata.str1.1
  1c:       e8 00 00 00 00          callq  21 <main+0x21>
                    1d: R_X86_64_PC32       puts-0x4
  21:       eb ed                   jmp    10 <main+0x10>

O putsfoi movido para o final da função, o retqretorno!

O novo código é basicamente o mesmo que:

int i = !time(NULL);
if (i)
    goto puts;
ret:
return 0;
puts:
puts("a");
goto ret;

Essa otimização não foi concluída -O0.

Mas boa sorte em escrever um exemplo que seja mais rápido do __builtin_expectque sem, as CPUs são realmente inteligentes naqueles dias . Minhas tentativas ingênuas estão aqui .

C ++ 20 [[likely]]e[[unlikely]]

O C ++ 20 padronizou os recursos internos do C ++: Como usar o atributo provável / improvável do C ++ 20 na instrução if-else Eles provavelmente (um trocadilho!) Farão a mesma coisa.

Ciro Santilli adicionou uma nova foto
fonte
1
Confira a função dispatch_once do libdispatch, que usa __builtin_expect para uma otimização prática. O caminho lento é executado uma vez e explora __builtin_expect para sugerir ao preditor de ramificação que o caminho rápido deve ser seguido. O caminho rápido é executado sem usar nenhum bloqueio! mikeash.com/pyblog/…
Adam Kaplan
Não parece fazer qualquer diferença no GCC 9.2: gcc.godbolt.org/z/GzP6cx (na verdade, já em 8.1)
Ruslan
40

A idéia de __builtin_expecté informar ao compilador que você normalmente descobrirá que a expressão é avaliada como c, para que o compilador possa otimizar para esse caso.

Eu acho que alguém pensou que eles estavam sendo espertos e que eles estavam acelerando as coisas fazendo isso.

Infelizmente, a menos que a situação seja muito bem compreendida (é provável que eles não tenham feito isso), pode ter piorado as coisas. A documentação ainda diz:

Em geral, você deve preferir usar o feedback real do perfil para isso ( -fprofile-arcs), pois os programadores são notoriamente ruins em prever o desempenho de seus programas. No entanto, existem aplicativos em que esses dados são difíceis de coletar.

Em geral, você não deve usar a __builtin_expectmenos que:

  • Você tem um problema de desempenho muito real
  • Você já otimizou os algoritmos no sistema adequadamente
  • Você tem dados de desempenho para fazer backup de sua afirmação de que um caso específico é o mais provável
Michael Kohne
fonte
7
@ Michael: Isso não é realmente uma descrição da previsão do ramo.
Oliver Charlesworth
3
"a maioria dos programadores é RUIM" ou mesmo assim não é melhor que o compilador. Qualquer idiota pode dizer que, em um loop for, é provável que a condição de continuação seja verdadeira, mas o compilador também sabe disso, então não há nenhum benefício em dizer isso. Se por algum motivo você escreveu um loop que quase sempre quebrar imediatamente, e se você não pode fornecer dados de perfil para o compilador para PGO, em seguida, talvez o programador sabe algo que o compilador não faz.
9788 Steve Jobs (
15
Em algumas situações, não importa qual ramificação é mais provável, mas qual ramificação é importante. Se a ramificação inesperada levar a abortar (), a probabilidade não importa, e a ramificação esperada deve ter prioridade de desempenho ao otimizar.
Neowizard
1
O problema com sua reivindicação é que as otimizações que a CPU pode executar com relação à probabilidade de ramificação são praticamente limitadas a uma: previsão de ramificação e essa otimização acontece se você usar__builtin_expect ou não . Por outro lado, o compilador pode executar muitas otimizações com base na probabilidade da ramificação, como organizar o código para que o atalho seja contíguo, movendo o código com pouca probabilidade de ser otimizado ainda mais ou reduzindo seu tamanho, tomando decisões sobre quais ramificações vetorizar, agendar melhor o caminho mais ativo e assim por diante.
BeeOnRope 18/01
1
... sem informações do desenvolvedor, ele é cego e escolhe uma estratégia neutra. Se o desenvolvedor estiver certo sobre as probabilidades (e em muitos casos, é trivial entender que uma ramificação geralmente é tomada / não tomada) - você obtém esses benefícios. Se você não é, recebe alguma penalidade, mas não é de alguma forma muito maior que os benefícios e, mais importante, nada disso substitui de alguma forma a previsão de ramificação da CPU.
BeeOnRope 18/01
13

Bem, como diz a descrição, a primeira versão adiciona um elemento preditivo à construção, dizendo ao compilador que o x == 0 ramo é o mais provável - ou seja, é o ramo que será utilizado com mais frequência pelo seu programa.

Com isso em mente, o compilador pode otimizar o condicional para que exija a menor quantidade de trabalho quando a condição esperada for mantida, às custas de talvez ter que fazer mais trabalho em caso de condição inesperada.

Veja como os condicionais são implementados durante a fase de compilação e também no assembly resultante, para ver como um ramo pode ser menos trabalhoso que o outro.

No entanto, eu esperaria que essa otimização tivesse um efeito perceptível se o condicional em questão fizer parte de um loop interno apertado que é chamado muito , pois a diferença no código resultante é relativamente pequena. E se você otimizá-lo da maneira errada, poderá diminuir seu desempenho.

Kerrek SB
fonte
Mas no final, trata-se de verificar a condição pelo compilador, você quer dizer que o compilador sempre assume esse ramo e continua e, posteriormente, se não houver uma correspondência? O que acontece? Eu acho que há algo mais sobre esse material de previsão de ramificação no design do compilador e como ele funciona.
precisa saber é o seguinte
2
Esta é realmente uma micro-otimização. Veja como os condicionais são implementados, há um pequeno viés em relação a um ramo. Como um exemplo hipotético, suponha que uma condicional se torne um teste mais um salto na montagem. Em seguida, o ramo que salta é mais lento que o que não salta, portanto, você prefere tornar o ramo esperado o que não salta.
Kerrek SB
Obrigado, seu e Michael eu acho que têm visões semelhantes, mas colocar em palavras diferentes :-) Eu entendo a parte interna do compilador exatos sobre Test-e-filial não são possíveis para explicar aqui :)
kingsmasher1
Eles também são muito fáceis de aprender sobre, pesquisando na internet :-)
Kerrek SB
Melhor eu voltar para o meu livro faculdade de compiler design - Aho, Ullmann, Sethi:-)
kingsmasher1
1

Não vejo nenhuma das respostas abordando a pergunta que acho que você estava fazendo, parafraseada:

Existe uma maneira mais portátil de sugerir a previsão de ramificação para o compilador.

O título da sua pergunta me fez pensar dessa maneira:

if ( !x ) {} else foo();

Se o compilador assumir que 'true' é mais provável, ele poderá otimizar para não chamar foo().

O problema aqui é que, em geral, você não sabe o que o compilador assumirá - portanto, qualquer código que use esse tipo de técnica precisará ser cuidadosamente medido (e possivelmente monitorado ao longo do tempo, se o contexto mudar).

Brent Bradburn
fonte
De fato, isso pode ter sido exatamente o que o OP originalmente pretendia digitar (como indicado pelo título) - mas, por alguma razão, o uso elsefoi deixado de fora do corpo do post.
Brent Bradburn
1

Eu testo no Mac de acordo com @Blagovest Buyukliev e @Ciro. As assembléias parecem claras e eu adiciono comentários;

Os comandos são gcc -c -O3 -std=gnu11 testOpt.c; otool -tVI testOpt.o

Quando eu uso -O3, parece o mesmo, independentemente de o __builtin_expect (i, 0) existir ou não.

testOpt.o:
(__TEXT,__text) section
_main:
0000000000000000    pushq   %rbp     
0000000000000001    movq    %rsp, %rbp    // open function stack
0000000000000004    xorl    %edi, %edi       // set time args 0 (NULL)
0000000000000006    callq   _time      // call time(NULL)
000000000000000b    testq   %rax, %rax   // check time(NULL)  result
000000000000000e    je  0x14           //  jump 0x14 if testq result = 0, namely jump to puts
0000000000000010    xorl    %eax, %eax   //  return 0   ,  return appear first 
0000000000000012    popq    %rbp    //  return 0
0000000000000013    retq                     //  return 0
0000000000000014    leaq    0x9(%rip), %rdi  ## literal pool for: "a"  // puts  part, afterwards
000000000000001b    callq   _puts
0000000000000020    xorl    %eax, %eax
0000000000000022    popq    %rbp
0000000000000023    retq

Ao compilar com -O2, parece diferente com e sem __builtin_expect (i, 0)

Primeiro sem

testOpt.o:
(__TEXT,__text) section
_main:
0000000000000000    pushq   %rbp
0000000000000001    movq    %rsp, %rbp
0000000000000004    xorl    %edi, %edi
0000000000000006    callq   _time
000000000000000b    testq   %rax, %rax
000000000000000e    jne 0x1c       //   jump to 0x1c if not zero, then return
0000000000000010    leaq    0x9(%rip), %rdi ## literal pool for: "a"   //   put part appear first ,  following   jne 0x1c
0000000000000017    callq   _puts
000000000000001c    xorl    %eax, %eax     // return part appear  afterwards
000000000000001e    popq    %rbp
000000000000001f    retq

Agora com __builtin_expect (i, 0)

testOpt.o:
(__TEXT,__text) section
_main:
0000000000000000    pushq   %rbp
0000000000000001    movq    %rsp, %rbp
0000000000000004    xorl    %edi, %edi
0000000000000006    callq   _time
000000000000000b    testq   %rax, %rax
000000000000000e    je  0x14   // jump to 0x14 if zero  then put. otherwise return 
0000000000000010    xorl    %eax, %eax   // return appear first 
0000000000000012    popq    %rbp
0000000000000013    retq
0000000000000014    leaq    0x7(%rip), %rdi ## literal pool for: "a"
000000000000001b    callq   _puts
0000000000000020    jmp 0x10

Para resumir, __builtin_expect funciona no último caso.

Victor Choy
fonte