Você é vítima de falha na previsão de ramificação .
O que é Previsão de Filial?
Considere um entroncamento ferroviário:
Imagem de Mecanismo, via Wikimedia Commons. Usado sob o CC-By-SA 3.0 .
Agora, por uma questão de argumento, suponha que isso esteja de volta nos anos 1800 - antes da comunicação interurbana ou por rádio.
Você é o operador de um cruzamento e ouve um trem chegando. Você não tem idéia de qual caminho deve seguir. Você para o trem para perguntar ao motorista qual direção eles querem. E então você define o interruptor adequadamente.
Os trens são pesados e têm muita inércia. Então eles levam uma eternidade para iniciar e desacelerar.
Existe uma maneira melhor? Você adivinha qual direção o trem seguirá!
- Se você adivinhou certo, continua.
- Se você adivinhou errado, o capitão irá parar, recuar e gritar com você para apertar o botão. Em seguida, ele pode reiniciar no outro caminho.
Se você acertar sempre , o trem nunca terá que parar.
Se você adivinhar errado com muita frequência , o trem passará muito tempo parando, fazendo backup e reiniciando.
Considere uma instrução if: no nível do processador, é uma instrução de ramificação:
Você é um processador e vê uma ramificação. Você não tem idéia de qual caminho seguirá. O que você faz? Você interrompe a execução e aguarda até que as instruções anteriores sejam concluídas. Então você continua no caminho correto.
Os processadores modernos são complicados e têm pipelines longos. Então eles levam uma eternidade para "aquecer" e "desacelerar".
Existe uma maneira melhor? Você adivinha em qual direção o ramo irá!
- Se você acertou, continua executando.
- Se você adivinhou errado, precisa liberar o oleoduto e reverter para o ramo. Em seguida, você pode reiniciar no outro caminho.
Se você acertar sempre , a execução nunca terá que parar.
Se você adivinhar errado com muita frequência , passa muito tempo parando, revertendo e reiniciando.
Esta é a previsão do ramo. Admito que não é a melhor analogia, já que o trem pode apenas sinalizar a direção com uma bandeira. Mas em computadores, o processador não sabe em qual direção uma ramificação irá até o último momento.
Então, como você adivinharia estrategicamente minimizar o número de vezes que o trem deve recuar e seguir o outro caminho? Você olha para a história passada! Se o trem sai à esquerda 99% das vezes, você acha que saiu. Se alternar, você alterna suas suposições. Se seguir um caminho a cada três vezes, você adivinha o mesmo ...
Em outras palavras, você tenta identificar um padrão e segui-lo.É mais ou menos assim que os preditores de ramificações funcionam.
A maioria dos aplicativos possui ramificações bem comportadas. Assim, os preditores modernos de agências normalmente atingem taxas de acerto> 90%. Porém, quando confrontados com ramificações imprevisíveis sem padrões reconhecíveis, os preditores de ramificação são praticamente inúteis.
Leitura adicional: artigo "Preditor de filial" na Wikipedia .
Como sugerido acima, o culpado é esta declaração if:
if (data[c] >= 128)
sum += data[c];
Observe que os dados são distribuídos igualmente entre 0 e 255. Quando os dados são classificados, aproximadamente a primeira metade das iterações não inserirá a instrução if. Depois disso, todos eles inserirão a instrução if.
Isso é muito amigável com o preditor de ramificação, pois a ramificação consecutivamente segue a mesma direção várias vezes. Mesmo um simples contador de saturação preverá corretamente a ramificação, exceto as poucas iterações após a troca de direção.
Visualização rápida:
T = branch taken
N = branch not taken
data[] = 0, 1, 2, 3, 4, ... 126, 127, 128, 129, 130, ... 250, 251, 252, ...
branch = N N N N N ... N N T T T ... T T T ...
= NNNNNNNNNNNN ... NNNNNNNTTTTTTTTT ... TTTTTTTTTT (easy to predict)
No entanto, quando os dados são completamente aleatórios, o preditor de ramificação é inútil, porque não pode prever dados aleatórios. Portanto, provavelmente haverá cerca de 50% de erros de previsão (nada melhor do que suposições aleatórias).
data[] = 226, 185, 125, 158, 198, 144, 217, 79, 202, 118, 14, 150, 177, 182, 133, ...
branch = T, T, N, T, T, T, T, N, T, N, N, T, T, T, N ...
= TTNTTTTNTNNTTTN ... (completely random - hard to predict)
Então, o que pode ser feito?
Se o compilador não puder otimizar a ramificação em uma movimentação condicional, você poderá tentar alguns hacks se desejar sacrificar a legibilidade pelo desempenho.
Substituir:
if (data[c] >= 128)
sum += data[c];
com:
int t = (data[c] - 128) >> 31;
sum += ~t & data[c];
Isso elimina a ramificação e a substitui por algumas operações bit a bit.
(Observe que esse hack não é estritamente equivalente à instrução if original. Mas, neste caso, é válido para todos os valores de entrada de data[]
.)
Benchmarks: Core i7 920 a 3,5 GHz
C ++ - Visual Studio 2010 - versão x64
// Branch - Random
seconds = 11.777
// Branch - Sorted
seconds = 2.352
// Branchless - Random
seconds = 2.564
// Branchless - Sorted
seconds = 2.587
Java - NetBeans 7.1.1 JDK 7 - x64
// Branch - Random
seconds = 10.93293813
// Branch - Sorted
seconds = 5.643797077
// Branchless - Random
seconds = 3.113581453
// Branchless - Sorted
seconds = 3.186068823
Observações:
- Com a ramificação: há uma enorme diferença entre os dados classificados e não classificados.
- Com o Hack: Não há diferença entre dados classificados e não classificados.
- No caso do C ++, o hack é na verdade um pouco mais lento do que na ramificação quando os dados são classificados.
Uma regra geral é evitar ramificações dependentes de dados em loops críticos (como neste exemplo).
Atualizar:
O GCC 4.6.1 com -O3
ou -ftree-vectorize
no x64 pode gerar uma movimentação condicional. Portanto, não há diferença entre os dados classificados e os não classificados - ambos são rápidos.
(Ou um pouco rápido: para o caso já classificado, cmov
pode ser mais lento, especialmente se o GCC o colocar no caminho crítico, e não apenas add
, especialmente na Intel antes da Broadwell, onde cmov
há latência de 2 ciclos: sinalizador de otimização do gcc -O3 torna o código mais lento que -O2 )
O VC ++ 2010 não pode gerar movimentos condicionais para esse ramo, mesmo em /Ox
.
O Intel C ++ Compiler (ICC) 11 faz algo milagroso. Ele intercambia os dois loops , elevando o ramo imprevisível ao loop externo. Portanto, não apenas é imune às previsões errôneas, como também é duas vezes mais rápido do que o VC ++ e o GCC podem gerar! Em outras palavras, a ICC aproveitou o ciclo de teste para derrotar o benchmark ...
Se você der ao compilador Intel o código sem ramificação, ele o vetoriza com a direita ... e é tão rápido quanto com a ramificação (com o intercâmbio de loop).
Isso mostra que mesmo os compiladores modernos maduros podem variar muito em sua capacidade de otimizar o código ...