Qual é o efeito de ordenar as declarações if… else if por probabilidade?


187

Especificamente, se eu tenho uma série de if... else ifinstruçõ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 ifdeclaraçõ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.


35
você pode querer adicionar uma nota que as condições são mutuamente exclusivas, caso contrário, os dois versão não são equivalentes
idclev 463035818

28
É bem interessante como uma pergunta auto-respondida recebeu mais de 20 votos positivos com uma resposta bastante ruim em uma hora. Não chamando nada de OP, mas os promotores devem tomar cuidado para não pular no vagão da banda. A pergunta pode ser interessante, mas os resultados são duvidosos.
Luk32

3
Acredito que isso possa ser descrito como uma forma de avaliação de curto-circuito, porque atingir uma comparação nega atingir uma comparação diferente. Pessoalmente, sou a favor de uma implementação como essa, quando uma comparação rápida, digamos booleana, pode me impedir de fazer uma comparação diferente, que pode envolver uma manipulação de seqüência de caracteres com excesso de recursos, regex ou interação com o banco de dados.
MonkeyZeus

11
Alguns compiladores oferecem a capacidade de coletar estatísticas sobre ramificações obtidas e enviá-las de volta ao compilador para permitir otimizações melhores.

11
Se o desempenho como este é importante para você, você provavelmente deve tentar perfil de otimização guiada e comparar o seu resultado manual com o resultado do compilador
Justin

Respostas:


96

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 ifbloco 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.


19
Também vale a pena considerar o quanto os testes são caros: se um teste é apenas um pouco mais provável, mas muito mais caro, pode valer a pena colocar o outro teste em primeiro lugar, porque as economias de não fazer o teste caro provavelmente superam a poupança de previsão de desvios etc.
psmears

O link que você forneceu não suporta sua conclusão Como regra geral, a maioria, se não todos, os CPUs Intel assumem que ramificações para frente não são obtidas na primeira vez em que as veem . De fato, isso é verdade apenas para a CPU Arrendale relativamente obscura, cujos resultados são mostrados primeiro. Os principais resultados da Ivy Bridge e Haswell não suportam isso. Haswell parece muito próximo de "sempre prever queda" para galhos invisíveis, e Ivy Bridge não é clara.
BeeOnRope 4/17/17

É geralmente entendido que as CPUs não estão realmente usando previsões estáticas, como usavam no passado. De fato, a Intel moderna provavelmente está usando algo como um preditor probabilístico de TAGE. Você apenas mescla o histórico de ramificação em várias tabelas de histórico e escolhe um que corresponda ao histórico mais longo. Ele usa uma "tag" para tentar evitar aliases, mas a tag possui apenas alguns bits. Se você errar em todos os comprimentos do histórico, provavelmente é feita alguma previsão padrão que não depende necessariamente da direção da ramificação (em Haswell, podemos dizer que claramente não).
BeeOnRope 4/17/17

44

Fiz o seguinte teste para cronometrar a execução de dois blocos if... diferentes else if, um classificado em ordem de probabilidade e outro em ordem inversa:

#include <chrono>
#include <iostream>
#include <random>
#include <algorithm>
#include <iterator>
#include <functional>

using namespace std;

int main()
{
    long long sortedTime = 0;
    long long reverseTime = 0;

    for (int n = 0; n != 500; ++n)
    {
        //Generate a vector of 5000 random integers from 1 to 100
        random_device rnd_device;
        mt19937 rnd_engine(rnd_device());
        uniform_int_distribution<int> rnd_dist(1, 100);
        auto gen = std::bind(rnd_dist, rnd_engine);
        vector<int> rand_vec(5000);
        generate(begin(rand_vec), end(rand_vec), gen);

        volatile int nLow, nMid, nHigh;
        chrono::time_point<chrono::high_resolution_clock> start, end;

        //Sort the conditional statements in order of increasing likelyhood
        nLow = nMid = nHigh = 0;
        start = chrono::high_resolution_clock::now();
        for (int& i : rand_vec) {
            if (i >= 95) ++nHigh;               //Least likely branch
            else if (i < 20) ++nLow;
            else if (i >= 20 && i < 95) ++nMid; //Most likely branch
        }
        end = chrono::high_resolution_clock::now();
        reverseTime += chrono::duration_cast<chrono::nanoseconds>(end-start).count();

        //Sort the conditional statements in order of decreasing likelyhood
        nLow = nMid = nHigh = 0;
        start = chrono::high_resolution_clock::now();
        for (int& i : rand_vec) {
            if (i >= 20 && i < 95) ++nMid;  //Most likely branch
            else if (i < 20) ++nLow;
            else if (i >= 95) ++nHigh;      //Least likely branch
        }
        end = chrono::high_resolution_clock::now();
        sortedTime += chrono::duration_cast<chrono::nanoseconds>(end-start).count();

    }

    cout << "Percentage difference: " << 100 * (double(reverseTime) - double(sortedTime)) / double(sortedTime) << endl << endl;
}

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.


9
O OP deve ter cuidado, pois alterar uma if... else ifinstrução pode ter um efeito substancial sobre como a lógica flui através do código. A unlikelyverificação pode não ocorrer com frequência, mas pode ser necessário que a empresa verifique a unlikelycondição antes de verificar se há outras.
Luke T Brooks

21
30% mais rápido? Você quer dizer que foi mais rápido aproximadamente o% de extra se as instruções não precisaram ser executadas? Parece um resultado bastante razoável.
UKMonkey

5
Como você fez o benchmark? Qual compilador, CPU, etc.? Tenho certeza de que esse resultado não é portátil.
Luk32

12
Um problema com essa marca de microbench é que a CPU descobrirá qual das ramificações é mais provável e a armazenará em cache quando você repetir a repetição. Se as ramificações não forem examinadas em um loop pequeno e apertado, o cache de previsão de ramificação poderá não contê-las, e os custos poderão ser muito maiores se a CPU considerar errado com a orientação de cache de previsão de ramificação zero.
Yakk - Adam Nevraumont

6
Esta referência não é muito confiável. Compilar com o gcc 6.3.0 : g++ -O2 -march=native -std=c++14dá 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 que ifnão vale a pena se preocupar em encomendar as coisas assim; Provavelmente, o PGO lidará completamente com esses casos #
Justin Justin

30

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:

#include <chrono>
#include <iostream>
#include <random>
#include <algorithm>
#include <iterator>
#include <functional>

using namespace std;

int main()
{
    //Generate a vector of random integers from 1 to 100
    random_device rnd_device;
    mt19937 rnd_engine(rnd_device());
    uniform_int_distribution<int> rnd_dist(1, 100);
    auto gen = std::bind(rnd_dist, rnd_engine);
    vector<int> rand_vec(5000);
    generate(begin(rand_vec), end(rand_vec), gen);
    volatile int nLow, nMid, nHigh;

    //Count the number of values in each of three different ranges
    //Run the test a few times
    for (int n = 0; n != 10; ++n) {

        //Run the test again, but now sort the conditional statements in reverse-order of likelyhood
        {
          nLow = nMid = nHigh = 0;
          auto start = chrono::high_resolution_clock::now();
          for (int& i : rand_vec) {
              if (i >= 95) ++nHigh;               //Least likely branch
              else if (i < 20) ++nLow;
              else if (i >= 20 && i < 95) ++nMid; //Most likely branch
          }
          auto end = chrono::high_resolution_clock::now();
          cout << "Reverse-sorted: \t" << chrono::duration_cast<chrono::nanoseconds>(end-start).count() << "ns" << endl;
        }

        {
          //Sort the conditional statements in order of likelyhood
          nLow = nMid = nHigh = 0;
          auto start = chrono::high_resolution_clock::now();
          for (int& i : rand_vec) {
              if (i >= 20 && i < 95) ++nMid;  //Most likely branch
              else if (i < 20) ++nLow;
              else if (i >= 95) ++nHigh;      //Least likely branch
          }
          auto end = chrono::high_resolution_clock::now();
          cout << "Sorted:\t\t\t" << chrono::duration_cast<chrono::nanoseconds>(end-start).count() << "ns" << endl;
        }
        cout << endl;
    }
}

Eu recebo a mesma diferença de ~ 30% no desempenho quando alterno a ordem dos blocos if classificados e reversos, como foi feito no seu código. Não sei por que Ideone e Coliru não mostram diferença.
Carlton #

Certamente interessante. Vou tentar obter alguns dados para outros sistemas, mas pode levar até um dia até que eu tenha que brincar com eles. A pergunta é interessante, especialmente à luz dos seus resultados, mas eles são tão espetaculares que eu tive que checá-la.
luk32

Se a pergunta for: qual é o efeito? a resposta não pode ser não !
PJTraill

Sim. Mas não recebo notificações de atualizações da pergunta original. Eles tornaram a formulação da resposta obsoleta. Desculpe. Editarei o conteúdo mais tarde, para apontar que ele respondeu à pergunta original e mostrou alguns resultados que provaram o ponto original.
Luk32

Vale a pena repetir: "Por padrão, vá pela legibilidade". Escrever código legível geralmente oferece melhores resultados do que tentar obter um pequeno aumento de desempenho (em termos absolutos), tornando seu código mais difícil para os humanos analisarem.
Andrew Brēza 5/07/19

26

Apenas meus 5 centavos. Parece o efeito de ordenar se as instruções devem depender de:

  1. Probabilidade de cada declaração if.

  2. Número de iterações, para que o preditor de ramificação possa entrar em ação.

  3. Dicas de compilador prováveis ​​/ improváveis, ou seja, layout de código.

Para explorar esses fatores, comparei as seguintes funções:

orders_ifs ()

for (i = 0; i < data_sz * 1024; i++) {
    if (data[i] < check_point) // highly likely
        s += 3;
    else if (data[i] > check_point) // samewhat likely
        s += 2;
    else if (data[i] == check_point) // very unlikely
        s += 1;
}

reversed_ifs ()

for (i = 0; i < data_sz * 1024; i++) {
    if (data[i] == check_point) // very unlikely
        s += 1;
    else if (data[i] > check_point) // samewhat likely
        s += 2;
    else if (data[i] < check_point) // highly likely
        s += 3;
}

orders_ifs_with_hints ()

for (i = 0; i < data_sz * 1024; i++) {
    if (likely(data[i] < check_point)) // highly likely
        s += 3;
    else if (data[i] > check_point) // samewhat likely
        s += 2;
    else if (unlikely(data[i] == check_point)) // very unlikely
        s += 1;
}

reversed_ifs_with_hints ()

for (i = 0; i < data_sz * 1024; i++) {
    if (unlikely(data[i] == check_point)) // very unlikely
        s += 1;
    else if (data[i] > check_point) // samewhat likely
        s += 2;
    else if (likely(data[i] < check_point)) // highly likely
        s += 3;
}

dados

A matriz de dados contém números aleatórios entre 0 e 100:

const int RANGE_MAX = 100;
uint8_t data[DATA_MAX * 1024];

static void data_init(int data_sz)
{
    int i;
        srand(0);
    for (i = 0; i < data_sz * 1024; i++)
        data[i] = rand() % RANGE_MAX;
}

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).

---------------------------------------------------------------------
Benchmark                              Time           CPU Iterations
---------------------------------------------------------------------
ordered_ifs/50/4                    4660 ns       4658 ns     150948
ordered_ifs/50/8                   25636 ns      25635 ns      27852
ordered_ifs/75/4                    4326 ns       4325 ns     162613
ordered_ifs/75/8                   18242 ns      18242 ns      37931
ordered_ifs/100/4                   1673 ns       1673 ns     417073
ordered_ifs/100/8                   3381 ns       3381 ns     207612
reversed_ifs/50/4                   5342 ns       5341 ns     126800
reversed_ifs/50/8                  26050 ns      26050 ns      26894
reversed_ifs/75/4                   3616 ns       3616 ns     193130
reversed_ifs/75/8                  15697 ns      15696 ns      44618
reversed_ifs/100/4                  3738 ns       3738 ns     188087
reversed_ifs/100/8                  7476 ns       7476 ns      93752
ordered_ifs_with_hints/50/4         5551 ns       5551 ns     125160
ordered_ifs_with_hints/50/8        23191 ns      23190 ns      30028
ordered_ifs_with_hints/75/4         3165 ns       3165 ns     218492
ordered_ifs_with_hints/75/8        13785 ns      13785 ns      50574
ordered_ifs_with_hints/100/4        1575 ns       1575 ns     437687
ordered_ifs_with_hints/100/8        3130 ns       3130 ns     221205
reversed_ifs_with_hints/50/4        6573 ns       6572 ns     105629
reversed_ifs_with_hints/50/8       27351 ns      27351 ns      25568
reversed_ifs_with_hints/75/4        3537 ns       3537 ns     197470
reversed_ifs_with_hints/75/8       16130 ns      16130 ns      43279
reversed_ifs_with_hints/100/4       3737 ns       3737 ns     187583
reversed_ifs_with_hints/100/8       7446 ns       7446 ns      93782

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%:

---------------------------------------------------------------------
Benchmark                              Time           CPU Iterations
---------------------------------------------------------------------
ordered_ifs/100/4                   1673 ns       1673 ns     417073
reversed_ifs/100/4                  3738 ns       3738 ns     188087

Para iterações 4K e 50% de probabilidade de uma declaração muito apreciada, a diferença é de cerca de 14%:

---------------------------------------------------------------------
Benchmark                              Time           CPU Iterations
---------------------------------------------------------------------
ordered_ifs/50/4                    4660 ns       4658 ns     150948
reversed_ifs/50/4                   5342 ns       5341 ns     126800

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):

---------------------------------------------------------------------
Benchmark                              Time           CPU Iterations
---------------------------------------------------------------------
ordered_ifs/100/4                   1673 ns       1673 ns     417073
ordered_ifs/100/8                   3381 ns       3381 ns     207612

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:

---------------------------------------------------------------------
Benchmark                              Time           CPU Iterations
---------------------------------------------------------------------
ordered_ifs/50/4                    4660 ns       4658 ns     150948
ordered_ifs/50/8                   25636 ns      25635 ns      27852

Por que é assim? Devido a erros do preditor de ramificação. Aqui estão as falhas do ramo para cada caso mencionado acima:

ordered_ifs/100/4    0.01% of branch-misses
ordered_ifs/100/8    0.01% of branch-misses
ordered_ifs/50/4     3.18% of branch-misses
ordered_ifs/50/8     15.22% of branch-misses

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:

---------------------------------------------------------------------
Benchmark                              Time           CPU Iterations
---------------------------------------------------------------------
ordered_ifs/50/4                    4660 ns       4658 ns     150948
ordered_ifs/100/4                   1673 ns       1673 ns     417073
ordered_ifs_with_hints/50/4         5551 ns       5551 ns     125160
ordered_ifs_with_hints/100/4        1575 ns       1575 ns     437687

Mas para iterações de 8K, os resultados são sempre um pouco melhores:

---------------------------------------------------------------------
Benchmark                              Time           CPU Iterations
---------------------------------------------------------------------
ordered_ifs/50/8                   25636 ns      25635 ns      27852
ordered_ifs/100/8                   3381 ns       3381 ns     207612
ordered_ifs_with_hints/50/8        23191 ns      23190 ns      30028
ordered_ifs_with_hints/100/8        3130 ns       3130 ns     221205

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.


1
i5 Nehalem? i5 Skylake? Apenas dizer "i5" não é muito específico. Além disso, suponho que você tenha usado g++ -O2ou -O3 -fno-tree-vectorize, mas você deveria dizer.
Peter Cordes

Interessante que with_hints ainda é diferente para ordenado vs. invertido. Seria bom se você ligasse à fonte em algum lugar. (por exemplo, uma ligação Godbolt, de preferência, uma ligação-cheia tão ligação-encurtamento pode não podridão.)
Pedro Cordes

1
O fato de que o preditor de ramificação é capaz de prever bem, mesmo no tamanho dos dados de entrada de 4K, ou seja, é capaz de "quebrar" o benchmark lembrando os resultados das ramificações em um loop com um período na casa dos milhares é um testemunho do poder dos modernos preditores de ramificação. Lembre-se de que os preditores são bastante sensíveis em alguns casos a coisas como alinhamento, por isso é difícil tirar conclusões fortes sobre algumas mudanças. Por exemplo, você notou um comportamento oposto à dica em diferentes casos, mas isso pode ser explicado pela mudança aleatória do layout do código que afetou o preditor.
BeeOnRope

1
@ PeterCordes, meu ponto principal é que podemos tentar prever os resultados de uma mudança, ainda assim medimos melhor o desempenho antes e depois da mudança ... E você está certo, eu deveria ter mencionado que foi otimizado com -O3 e o processador é i5-4460 @ 3.20GHz
Andriy Berestovskyy

19

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):

  • Probabilidade relativa de cada ramo. Esta é a pergunta original que foi feita. Com base nas respostas existentes, parece haver algumas condições sob as quais a ordenação por probabilidade ajuda, mas nem sempre parece ser esse o caso. Se as probabilidades relativas não forem muito diferentes, é improvável que faça diferença em que ordem elas estão. No entanto, se a primeira condição ocorrer 99,999% do tempo e a próxima for uma fração do que resta, então eu faria suponha que colocar o mais provável em primeiro lugar seria benéfico em termos de tempo.
  • Custo do cálculo da condição verdadeira / falsa de cada filial. Se o custo do tempo para testar as condições for realmente alto para uma filial em relação a outra, é provável que isso tenha um impacto significativo no tempo e na eficiência. Por exemplo, considere uma condição que leva 1 unidade de tempo para calcular (por exemplo, verificar o estado de uma variável booleana) versus outra condição que leva dezenas, centenas, milhares ou até milhões de unidades de tempo para calcular (por exemplo, verificar o conteúdo de um arquivo em disco ou executando uma consulta SQL complexa em um banco de dados grande). Supondo que o código verifique as condições na ordem de cada vez, as condições mais rápidas provavelmente devem ser as primeiras (a menos que dependam de outras condições que falhem primeiro).
  • Compilador / Intérprete Alguns compiladores (ou intérpretes) podem incluir otimizações de um tipo de outro que podem afetar o desempenho (e algumas delas estão presentes apenas se determinadas opções forem selecionadas durante a compilação e / ou execução). Portanto, a menos que você esteja comparando duas compilações e execuções de código idêntico no mesmo sistema, usando exatamente o mesmo compilador, onde a única diferença é a ordem das ramificações em questão, você terá que dar uma margem de manobra para variações do compilador.
  • Sistema operacional / hardware Conforme mencionado por luk32 e Yakk, várias CPUs têm suas próprias otimizações (assim como os sistemas operacionais). Portanto, os benchmarks são novamente suscetíveis a variações aqui.
  • Frequência de execução do bloco de código Se o bloco que inclui as ramificações raramente é acessado (por exemplo, apenas uma vez durante a inicialização), provavelmente é muito pouco a ordem em que você coloca as ramificações. Por outro lado, se o seu código estiver martelando neste bloco de código durante uma parte crítica do seu código, o pedido poderá ser muito importante (dependendo dos benchmarks).

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:

  1. Condições que dependem do resultado de condições anteriores,
  2. Custo de calcular a condição, então
  3. Probabilidade relativa de cada ramo.

13

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 :

if (likely(access_ok(VERIFY_READ, from, n))) {
    kasan_check_write(to, n);
    res = raw_copy_from_user(to, from, n);
}
if (unlikely(res))
    memset(to + (n - res), 0, res);

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 o likely()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 .


4
Isso seria mais útil se você pudesse explicar como as macros likely()e unlikely()são definidas e incluir algumas informações sobre o recurso de compilador correspondente.
Node Eldredge #

1
AFAIK, essas dicas "apenas" alteram o layout da memória dos blocos de código e se um sim ou não levará a um salto. Isso pode ter vantagens de desempenho, por exemplo, pela necessidade (ou falta dela) de ler as páginas de memória. Mas isso não quer reorganizar a ordem em que são avaliadas as condições dentro de uma longa lista de Else-ifs
Hagen von Eitzen

@HagenvonEitzen Hmm, sim, esse é um bom ponto, não pode afetar a ordem de else ifse o compilador não for inteligente o suficiente para saber que as condições são mutuamente exclusivas.
jpa

7

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:

if (most_likely) {
     // most likely instructions
} else 

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:

jump to ELSE if not(most_likely)
// most likely instructions
jump to end
ELSE:

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.


2
Você declarou que o ramo condicional ocorre quando a condição é verdadeira, mas o exemplo "pseudo asm" mostra o contrário. Além disso, não se pode dizer que os saltos condicionais (muito menos todos os saltos) paralisam o pipeline porque as CPUs modernas geralmente têm previsão de ramificação. De fato, se a ramificação estiver prevista para ser realizada, mas não realizada, o pipeline ficará parado. Eu ainda tentaria classificar as condições em ordem decrescente de probabilidade, mas o que o compilador e a CPU fazem disso é altamente dependente da implementação.
Arne Vogel

1
Eu coloquei “not (most_likely)”, então se most_likely for verdadeiro, o controle continuará sem pular.
NoImaginationGuy

1
"Os ASMs mais populares são baseados em ramificações condicionais que saltam quando a condição é verdadeira" .. quais seriam os ISAs? Certamente não é verdade para x86 nem para ARM. Inferno para CPUs ARM básicas (e x86 muito antigas, mesmo para bps complexos, elas geralmente ainda começam com essa suposição e depois se adaptam) o preditor de ramificação assume que uma ramificação direta não é obtida e as ramificações inversas sempre são, portanto, o oposto da reivindicação é verdade.
Voo

1
Os compiladores que tentei usaram principalmente a abordagem mencionada acima para um teste simples. Note-se que clang, na verdade, teve uma abordagem diferente para test2e test3: por causa de heurísticas que indicam que um < 0ou == 0teste é provável que seja falsa, decidiu clonar o restante da função em ambos os caminhos, por isso é capaz de fazer a condition == falsequeda através do caminho. Isso é viável apenas porque o restante da função é curto: test4adicionei mais uma operação e retornamos à abordagem descrita acima.
BeeOnRope 4/17/17

1
@ArneVogel - corretamente ramos tomadas previstos não parar totalmente o gasoduto em CPUs modernas, mas eles ainda são muitas vezes significativamente pior do que não tomadas: (1) eles significam o fluxo de controle não é contíguo para que o resto das instruções após o jmpnã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 ...
BeeOnRope

6

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

vector<int> rand_vec(10000000);

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

.L233:
        mov     DWORD PTR [rsp+104], 0
        mov     DWORD PTR [rsp+100], 0
        mov     DWORD PTR [rsp+96], 0
        call    std::chrono::_V2::system_clock::now()
        mov     rbp, rax
        mov     rax, QWORD PTR [rsp+8]
        jmp     .L219
.L293:
        mov     edx, DWORD PTR [rsp+104]
        add     edx, 1
        mov     DWORD PTR [rsp+104], edx
.L217:
        add     rax, 4
        cmp     r14, rax
        je      .L292
.L219:
        mov     edx, DWORD PTR [rax]
        cmp     edx, 94
        jg      .L293 // >= 95
        cmp     edx, 19
        jg      .L218 // >= 20
        mov     edx, DWORD PTR [rsp+96]
        add     rax, 4
        add     edx, 1 // < 20 Sherlock
        mov     DWORD PTR [rsp+96], edx
        cmp     r14, rax
        jne     .L219
.L292:
        call    std::chrono::_V2::system_clock::now()

.L218: // further down
        mov     edx, DWORD PTR [rsp+100]
        add     edx, 1
        mov     DWORD PTR [rsp+100], edx
        jmp     .L217

And sorted

        mov     DWORD PTR [rsp+104], 0
        mov     DWORD PTR [rsp+100], 0
        mov     DWORD PTR [rsp+96], 0
        call    std::chrono::_V2::system_clock::now()
        mov     rbp, rax
        mov     rax, QWORD PTR [rsp+8]
        jmp     .L226
.L296:
        mov     edx, DWORD PTR [rsp+100]
        add     edx, 1
        mov     DWORD PTR [rsp+100], edx
.L224:
        add     rax, 4
        cmp     r14, rax
        je      .L295
.L226:
        mov     edx, DWORD PTR [rax]
        lea     ecx, [rdx-20]
        cmp     ecx, 74
        jbe     .L296
        cmp     edx, 19
        jle     .L297
        mov     edx, DWORD PTR [rsp+104]
        add     rax, 4
        add     edx, 1
        mov     DWORD PTR [rsp+104], edx
        cmp     r14, rax
        jne     .L226
.L295:
        call    std::chrono::_V2::system_clock::now()

.L297: // further down
        mov     edx, DWORD PTR [rsp+96]
        add     edx, 1
        mov     DWORD PTR [rsp+96], edx
        jmp     .L224

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.

high, low, mid: 43000000ns
mid, low, high: 46000000ns
high, mid, low: 45000000ns
low, mid, high: 44000000ns
mid, high, low: 46000000ns
low, high, mid: 44000000ns

high, low, mid: 44000000ns
mid, low, high: 47000000ns
high, mid, low: 44000000ns
low, mid, high: 45000000ns
mid, high, low: 46000000ns
low, high, mid: 45000000ns

high, low, mid: 43000000ns
mid, low, high: 47000000ns
high, mid, low: 44000000ns
low, mid, high: 45000000ns
mid, high, low: 46000000ns
low, high, mid: 44000000ns

high, low, mid: 42000000ns
mid, low, high: 46000000ns
high, mid, low: 46000000ns
low, mid, high: 45000000ns
mid, high, low: 46000000ns
low, high, mid: 43000000ns

high, low, mid: 43000000ns
mid, low, high: 47000000ns
high, mid, low: 44000000ns
low, mid, high: 44000000ns
mid, high, low: 46000000ns
low, high, mid: 44000000ns

high, low, mid: 43000000ns
mid, low, high: 48000000ns
high, mid, low: 44000000ns
low, mid, high: 44000000ns
mid, high, low: 45000000ns
low, high, mid: 45000000ns

high, low, mid: 43000000ns
mid, low, high: 47000000ns
high, mid, low: 45000000ns
low, mid, high: 45000000ns
mid, high, low: 46000000ns
low, high, mid: 44000000ns

high, low, mid: 43000000ns
mid, low, high: 47000000ns
high, mid, low: 45000000ns
low, mid, high: 45000000ns
mid, high, low: 46000000ns
low, high, mid: 44000000ns

high, low, mid: 43000000ns
mid, low, high: 46000000ns
high, mid, low: 45000000ns
low, mid, high: 45000000ns
mid, high, low: 45000000ns
low, high, mid: 44000000ns

high, low, mid: 42000000ns
mid, low, high: 46000000ns
high, mid, low: 44000000ns
low, mid, high: 45000000ns
mid, high, low: 45000000ns
low, high, mid: 44000000ns

1900020, 7498968, 601012

Process returned 0 (0x0)   execution time : 2.899 s
Press any key to continue.

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.

          if (i >= 95) ++nHigh;               // most predictable with 94% taken
          else if (i < 20) ++nLow; // (94-19)/94% taken ~80% taken
          else if (i >= 20 && i < 95) ++nMid; // never taken as this is the remainder of the outfalls.

Portanto, os galhos serão preditos tomados, tomados e restantes com

6% + (0,94 *) 20% de imprevistos.

"Ordenado"

          if (i >= 20 && i < 95) ++nMid;  // 75% not taken
          else if (i < 20) ++nLow;        // 19/25 76% not taken
          else if (i >= 95) ++nHigh;      //Least likely branch

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).

mispredict*penalty+count+loop
0.06*17+1+1+    (=3.02)
(propability)*(first check+mispredict*penalty+count+loop)
(0.19)*(1+0.20*17+1+1)+  (= 0.19*6.4=1.22)
(propability)*(first check+second check+count+loop)
(0.75)*(1+1+1+1) (=3)
= 7.24 cycles per iteration

e o mesmo para "classificado"

0.25*17+1+1+ (=6.25)
(1-0.75)*(1+0.24*17+1+1)+ (=.25*7.08=1.77)
(1-0.75-0.19)*(1+1+1+1)  (= 0.06*4=0.24)
= 8.26

(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.


4

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.


6
As falhas de previsão de ramificação são caras. Nas marcas de micropigmentação , elas são subestimadas , porque os x86s têm uma grande tabela de preditores de ramificação. Um loop apertado nas mesmas condições resulta na CPU sabendo melhor do que você qual é a mais provável. Mas se você tiver ramificações em todo o código, poderá fazer com que o cache de previsão de ramificação fique sem slots, e a cpu assume o padrão. Saber qual é esse palpite padrão pode salvar ciclos em toda a sua base de código.
Yakk - Adam Nevraumont

A resposta de @Yakk Jack é a única correta aqui. Não faça otimizações que reduzam a legibilidade se o seu compilador puder fazer essa otimização. Você não faria dobragem constante, eliminação de código morto, desenrolamento de loop ou qualquer outra otimização se o seu compilador fizer isso por você, faria? Escreva seu código, use a otimização guiada por perfil (que é o design para resolver esse problema porque os codificadores são ruins em adivinhar) e veja se o seu compilador o otimiza ou não. No final, você não deseja ter nenhuma ramificação no código crítico de desempenho.
Christoph Diegelmann

@Christoph Eu não incluiria código que sabia estar morto. Eu não usaria i++quando ++i, porque sei que i++para alguns iteradores é difícil otimizar ++ie 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)
Yakk - Adam Nevraumont 20/10

3

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.

Ao utilizar nosso site, você reconhece que leu e compreendeu nossa Política de Cookies e nossa Política de Privacidade.
Licensed under cc by-sa 3.0 with attribution required.