Especificamente, se eu tenho uma série de if
... else if
instruções, e de alguma forma sei de antemão a probabilidade relativa que cada instrução avaliará true
, quanta diferença no tempo de execução faz para classificá-las em ordem de probabilidade? Por exemplo, devo preferir isso:
if (highly_likely)
//do something
else if (somewhat_likely)
//do something
else if (unlikely)
//do something
para isso?:
if (unlikely)
//do something
else if (somewhat_likely)
//do something
else if (highly_likely)
//do something
Parece óbvio que a versão classificada seria mais rápida; no entanto, para facilitar a leitura ou a existência de efeitos colaterais, podemos solicitá-los de maneira não ideal. Também é difícil dizer o desempenho da CPU com a previsão de ramificação até que você realmente execute o código.
Portanto, durante o experimento, acabei respondendo à minha própria pergunta para um caso específico, mas gostaria de ouvir outras opiniões / insights também.
Importante: esta pergunta pressupõe que as if
declarações possam ser reordenadas arbitrariamente sem causar nenhum outro efeito no comportamento do programa. Na minha resposta, os três testes condicionais são mutuamente exclusivos e não produzem efeitos colaterais. Certamente, se as declarações devem ser avaliadas em uma determinada ordem para alcançar o comportamento desejado, a questão da eficiência é discutível.
Respostas:
Como regra geral, a maioria, se não todas, as CPUs Intel assumem ramificações avançadas não são obtidas na primeira vez em que as veem. Veja o trabalho de Godbolt .
Depois disso, a ramificação entra em um cache de previsão da ramificação e o comportamento passado é usado para informar a previsão futura da ramificação.
Assim, em um loop restrito, o efeito da falta de ordem será relativamente pequeno. O preditor de ramificação aprenderá qual conjunto de ramificações é mais provável e, se você tiver uma quantidade não trivial de trabalho no loop, as pequenas diferenças não serão muito importantes.
Em código geral, a maioria dos compiladores por padrão (sem outro motivo) solicitará o código de máquina produzido aproximadamente da maneira que você solicitou em seu código. Portanto, se as instruções são ramificações para a frente quando falham.
Portanto, você deve ordenar seus galhos na ordem decrescente de probabilidade para obter a melhor previsão de galho de um "primeiro encontro".
Uma marca de microssegura que faz loop muitas vezes em um conjunto de condições e faz um trabalho trivial será dominada por pequenos efeitos da contagem de instruções e similares, e pouco em termos de problemas relativos de previsão de ramificação. Portanto, nesse caso, você deve criar um perfil , pois as regras práticas não serão confiáveis.
Além disso, a vetorização e muitas outras otimizações se aplicam a pequenos loops apertados.
Portanto, no código geral, coloque o código mais provável dentro do
if
bloco e isso resultará no menor número de erros de previsão de ramificação não armazenada em cache. Em loops apertados, siga a regra geral para começar e, se você precisar saber mais, terá pouca escolha a não ser criar um perfil.Naturalmente, tudo isso sai pela janela se alguns testes forem muito mais baratos que outros.
fonte
Fiz o seguinte teste para cronometrar a execução de dois blocos
if
... diferenteselse if
, um classificado em ordem de probabilidade e outro em ordem inversa:Usando o MSVC2017 com / O2, os resultados mostram que a versão classificada é consistentemente cerca de 28% mais rápida que a versão não classificada. Pelo comentário do luk32, também mudei a ordem dos dois testes, o que faz uma diferença notável (22% vs 28%). O código foi executado no Windows 7 em um Intel Xeon E5-2697 v2. É claro que isso é muito específico do problema e não deve ser interpretado como uma resposta conclusiva.
fonte
if... else if
instrução pode ter um efeito substancial sobre como a lógica flui através do código. Aunlikely
verificação pode não ocorrer com frequência, mas pode ser necessário que a empresa verifique aunlikely
condição antes de verificar se há outras.g++ -O2 -march=native -std=c++14
dá uma leve vantagem às instruções condicionais classificadas, mas na maioria das vezes, a diferença percentual entre as duas execuções foi de ~ 5%. Várias vezes, na verdade era mais lento (devido a variações). Tenho certeza de queif
não vale a pena se preocupar em encomendar as coisas assim; Provavelmente, o PGO lidará completamente com esses casos #Não, você não deve, a menos que tenha certeza de que o sistema de destino é afetado. Por padrão, vá pela legibilidade.
Eu duvido muito dos seus resultados. Como modifiquei um pouco o seu exemplo, é mais fácil reverter a execução. A ideia mostra consistentemente que a ordem inversa é mais rápida, embora não muito. Em certas corridas, mesmo isso ocasionalmente virou. Eu diria que os resultados são inconclusivos. O coliru também não reporta nenhuma diferença real. Posso verificar a CPU Exynos5422 no meu odroid xu4 posteriormente.
O problema é que as CPUs modernas têm preditores de ramificação. Há muita lógica dedicada à busca prévia de dados e instruções, e as modernas CPUs x86 são bastante inteligentes quando se trata disso. Algumas arquiteturas mais finas, como ARMs ou GPUs, podem estar vulneráveis a isso. Mas é realmente altamente dependente do compilador e do sistema de destino.
Eu diria que a otimização de pedidos de filiais é bastante frágil e efêmera. Faça isso apenas como uma etapa realmente precisa.
Código:
fonte
Apenas meus 5 centavos. Parece o efeito de ordenar se as instruções devem depender de:
Probabilidade de cada declaração if.
Número de iterações, para que o preditor de ramificação possa entrar em ação.
Dicas de compilador prováveis / improváveis, ou seja, layout de código.
Para explorar esses fatores, comparei as seguintes funções:
orders_ifs ()
reversed_ifs ()
orders_ifs_with_hints ()
reversed_ifs_with_hints ()
dados
A matriz de dados contém números aleatórios entre 0 e 100:
Os resultados
Os seguintes resultados são para Intel i5 a 3,2 GHz e G ++ 6.3.0. O primeiro argumento é o check_point (ou seja, probabilidade em %% para a declaração if altamente provável), o segundo argumento é data_sz (ou seja, número de iterações).
Análise
1. A Ordem Importa
Para iterações 4K e (quase) 100% de probabilidade de uma declaração muito apreciada, a diferença é enorme: 223%:
Para iterações 4K e 50% de probabilidade de uma declaração muito apreciada, a diferença é de cerca de 14%:
2. O número de iterações é importante
A diferença entre iterações 4K e 8K para (quase) 100% de probabilidade de uma declaração muito apreciada é cerca de duas vezes (conforme o esperado):
Mas a diferença entre iterações 4K e 8K, para uma probabilidade de 50% de uma declaração muito apreciada, é de 5,5 vezes:
Por que é assim? Devido a erros do preditor de ramificação. Aqui estão as falhas do ramo para cada caso mencionado acima:
Portanto, no i5, o preditor de ramificações falha espetacularmente para ramificações não tão prováveis e grandes conjuntos de dados.
3. Dicas ajudam um pouco
Para iterações 4K, os resultados são um pouco piores para 50% de probabilidade e um pouco melhores para quase 100% de probabilidade:
Mas para iterações de 8K, os resultados são sempre um pouco melhores:
Portanto, as dicas também ajudam, mas um pouquinho.
A conclusão geral é: sempre faça uma referência do código, pois os resultados podem surpreender.
Espero que ajude.
fonte
g++ -O2
ou-O3 -fno-tree-vectorize
, mas você deveria dizer.Com base em algumas das outras respostas aqui, parece que a única resposta real é: depende . Depende de pelo menos o seguinte (embora não necessariamente nessa ordem de importância):
A única maneira de saber com certeza é avaliar o seu caso específico, de preferência em um sistema idêntico (ou muito semelhante ao) ao sistema pretendido no qual o código finalmente será executado. Se se destina a executar em um conjunto de sistemas variados, com hardware, sistema operacional, etc. diferentes, é uma boa ideia fazer uma comparação entre várias variações para ver qual é o melhor. Pode até ser uma boa idéia compilar o código com uma ordem em um tipo de sistema e outra com outro tipo de sistema.
Minha regra pessoal (na maioria dos casos, na ausência de um benchmark) é pedir com base em:
fonte
A maneira como costumo ver isso resolvido para código de alto desempenho é manter a ordem mais legível, mas fornecer dicas para o compilador. Aqui está um exemplo do kernel do Linux :
Aqui, a suposição é de que a verificação de acesso será aprovada e que nenhum erro será retornado
res
. Tentando reordenar qualquer uma dessas cláusulas if apenas confundiria o código, mas olikely()
eunlikely()
macros realmente ajudam na legibilidade, apontando qual é o caso normal e qual é a exceção.A implementação do Linux dessas macros usa recursos específicos do GCC . Parece que o clang e o compilador Intel C suportam a mesma sintaxe, mas o MSVC não possui esse recurso .
fonte
likely()
eunlikely()
são definidas e incluir algumas informações sobre o recurso de compilador correspondente.else if
se o compilador não for inteligente o suficiente para saber que as condições são mutuamente exclusivas.Também depende do seu compilador e da plataforma para a qual você está compilando.
Em teoria, a condição mais provável deve fazer o controle saltar o menos possível.
Normalmente, a condição mais provável deve ser a primeira:
Os asm mais populares são baseados em ramificações condicionais que pulam quando a condição é verdadeira . Esse código C provavelmente será traduzido para esse pseudo asm:
Isso ocorre porque os saltos fazem com que a CPU cancele o pipeline de execução e pare porque o contador do programa foi alterado (para arquiteturas que suportam pipelines realmente comuns). Depois, trata-se do compilador, que pode ou não aplicar algumas otimizações sofisticadas sobre ter a condição estatisticamente mais provável de obter o controle com menos saltos.
fonte
clang
, na verdade, teve uma abordagem diferente paratest2
etest3
: por causa de heurísticas que indicam que um< 0
ou== 0
teste é provável que seja falsa, decidiu clonar o restante da função em ambos os caminhos, por isso é capaz de fazer acondition == false
queda através do caminho. Isso é viável apenas porque o restante da função é curto:test4
adicionei mais uma operação e retornamos à abordagem descrita acima.jmp
não são útil para que a largura de banda de busca / decodificação seja desperdiçada (2), mesmo com previsão de grandes núcleos modernos, apenas uma busca por ciclo, colocando um limite rígido de 1 ramificação / ciclo de captura (a OTOH moderna Intel pode fazer 2 não captura / ciclo) (3 ) é mais difícil para previsão de desvios de lidar com tomadas ramos consecutivas e, no caso de rápidas + preditores lentos ...Decidi executar novamente o teste em minha própria máquina usando o código Lik32. Eu tive que mudar isso devido ao meu windows ou compilador pensando que a alta resolução é de 1ms, usando
mingw32-g ++. exe -O3 -Wall -std = c ++ 11 -exceções -g
O GCC fez a mesma transformação nos dois códigos originais.
Observe que apenas as duas primeiras condições são testadas, pois a terceira sempre deve ser verdadeira; o GCC é uma espécie de Sherlock aqui.
Reverter
Portanto, isso não nos diz muito, exceto que o último caso não precisa de uma previsão de ramificação.
Agora eu tentei todas as 6 combinações dos if's, os 2 primeiros são o reverso original e classificados. high é> = 95, low é <20, mid é 20-94 com 10000000 iterações cada.
Então, por que o pedido é alto, baixo, médio e mais rápido (marginalmente)
Porque o mais imprevisível é o último e, portanto, nunca é executado através de um preditor de ramificação.
Portanto, os galhos serão preditos tomados, tomados e restantes com
6% + (0,94 *) 20% de imprevistos.
"Ordenado"
Os galhos serão previstos com não capturados, não capturados e Sherlock.
25% + (0,75 *) 24% de imprevistos
Dando diferença de 18 a 23% (diferença medida de ~ 9%), mas precisamos calcular ciclos em vez de% imprevisível.
Vamos assumir uma pena de 17 ciclos de imprevisibilidade na minha CPU Nehalem e que cada verificação leva 1 ciclo para emitir (4-5 instruções) e o loop também leva um ciclo. As dependências de dados são os contadores e as variáveis de loop, mas uma vez que os erros de previsão estão fora do caminho, isso não deve influenciar o tempo.
Portanto, para "reverso", obtemos os horários (essa deve ser a fórmula usada em Arquitetura de Computadores: Uma Abordagem Quantitativa IIRC).
e o mesmo para "classificado"
(8,26-7,24) / 8,26 = 13,8% vs. ~ 9% medido (próximo ao medido!?!).
Portanto, o óbvio do OP não é óbvio.
Com esses testes, outros testes com código mais complicado ou mais dependências de dados certamente serão diferentes; portanto, avalie seu caso.
Alterar a ordem do teste alterou os resultados, mas isso pode ser devido a diferentes alinhamentos do início do loop, que devem idealmente ser alinhados em 16 bytes em todas as CPUs Intel mais recentes, mas não são neste caso.
fonte
Coloque-os na ordem lógica que desejar. Claro, a ramificação pode ser mais lenta, mas a ramificação não deve ser a maior parte do trabalho que seu computador está executando.
Se você estiver trabalhando em uma parte crítica do desempenho, certamente use ordem lógica, otimização guiada por perfil e outras técnicas, mas para o código geral, acho que é realmente uma escolha estilística.
fonte
i++
quando++i
, porque sei quei++
para alguns iteradores é difícil otimizar++i
e a diferença (para mim) não importa. Trata-se de evitar a pessimização; colocar o bloco mais provável em primeiro lugar como hábito padrão não causará uma redução perceptível na legibilidade (e pode realmente ajudar!), resultando em um código que é favorável à previsão de ramificação (e, portanto, oferece um pequeno aumento uniforme no desempenho que não pode ser recuperado por micro otimização posterior)Se você já conhece a probabilidade relativa da instrução if-else, então, para fins de desempenho, seria melhor usar a maneira classificada, pois ela verificará apenas uma condição (a verdadeira).
De uma maneira não classificada, o compilador verificará todas as condições desnecessariamente e levará tempo.
fonte