web-dev-qa-db-pt.com

É <mais rápido que <=?

Estou lendo um livro onde o autor diz que if( a < 901 ) é mais rápido que if( a <= 900 ).

Não exatamente como neste exemplo simples, mas há pequenas alterações de desempenho no código complexo de loop. Suponho que isso tenha que fazer alguma coisa com o código da máquina gerada, caso isso seja verdade.

1459
Vinícius Magalhães Horta

Não, não será mais rápido na maioria das arquiteturas. Você não especificou, mas no x86, todas as comparações integrais serão tipicamente implementadas em duas instruções de máquina:

  • Uma instrução test ou cmp, que define EFLAGS
  • E uma instrução Jcc (jump) , dependendo do tipo de comparação (e layout de código):
    • jne - Salta se não for igual -> ZF = 0
    • jz - Salta se zero (igual) -> ZF = 1
    • jg - Salta se maior -> ZF = 0 and SF = OF
    • (etc ...)

Exemplo (Editado para breve) Compilado com $ gcc -m32 -S -masm=intel test.c

    if (a < b) {
        // Do something 1
    }

Compila para:

    mov     eax, DWORD PTR [esp+24]      ; a
    cmp     eax, DWORD PTR [esp+28]      ; b
    jge     .L2                          ; jump if a is >= b
    ; Do something 1
.L2:

E

    if (a <= b) {
        // Do something 2
    }

Compila para:

    mov     eax, DWORD PTR [esp+24]      ; a
    cmp     eax, DWORD PTR [esp+28]      ; b
    jg      .L5                          ; jump if a is > b
    ; Do something 2
.L5:

Portanto, a única diferença entre as duas é uma instrução jg versus uma jge. Os dois terão o mesmo tempo.


Gostaria de abordar o comentário de que nada indica que as diferentes instruções de salto levam a mesma quantidade de tempo. Este é um pouco difícil de responder, mas eis o que posso dar: Na Intel Instruction Set Reference , todos eles são agrupados sob uma instrução comum, Jcc (Saltar se a condição for atendida). O mesmo agrupamento é feito sob o Optimization Reference Manual , no Apêndice C. Latency and Throughput.

Latência - O número de ciclos de clock que são necessários para o núcleo de execução concluir a execução de todos os μops que formam uma instrução.

Throughput - O número de ciclos de clock necessários para aguardar antes que as portas de problemas estejam livres para aceitar a mesma instrução novamente. Para muitas instruções, a taxa de transferência de uma instrução pode ser significativamente menor que sua latência

Os valores para Jcc são:

      Latency   Throughput
Jcc     N/A        0.5

com a seguinte nota de rodapé em Jcc:

7) A seleção de instruções de saltos condicionais deve ser baseada na recomendação da seção 3.4.1, “Otimização de previsão de ramificação”, para melhorar a previsibilidade das ramificações. Quando as ramificações são previstas com sucesso, a latência de jcc é efetivamente zero.

Portanto, nada nos documentos da Intel sempre trata uma instrução Jcc de forma diferente das outras.

Se alguém pensar sobre o circuito real usado para implementar as instruções, pode-se supor que haveria portas AND/OR simples nos diferentes bits em EFLAGS, para determinar se as condições são atendidas. Não há, então, nenhuma razão para que uma instrução testando dois bits leve mais ou menos tempo do que um teste apenas um (Ignorando o atraso de propagação do gate, que é muito menor que o período do clock).


Editar: ponto flutuante

Isso vale também para o ponto flutuante x87: (praticamente o mesmo código acima, mas com double em vez de int.)

        fld     QWORD PTR [esp+32]
        fld     QWORD PTR [esp+40]
        fucomip st, st(1)              ; Compare ST(0) and ST(1), and set CF, PF, ZF in EFLAGS
        fstp    st(0)
        seta    al                     ; Set al if above (CF=0 and ZF=0).
        test    al, al
        je      .L2
        ; Do something 1
.L2:

        fld     QWORD PTR [esp+32]
        fld     QWORD PTR [esp+40]
        fucomip st, st(1)              ; (same thing as above)
        fstp    st(0)
        setae   al                     ; Set al if above or equal (CF=0).
        test    al, al
        je      .L5
        ; Do something 2
.L5:
        leave
        ret
1627
Jonathon Reinhart

Historicamente (estamos falando dos anos 80 e início dos anos 90), havia algumas arquiteturas em que isso era verdade. O problema da raiz é que a comparação de inteiros é inerentemente implementada via subtrações de inteiros. Isso dá origem aos seguintes casos.

Comparison     Subtraction
----------     -----------
A < B      --> A - B < 0
A = B      --> A - B = 0
A > B      --> A - B > 0

Agora, quando A < B a subtração tem que emprestar um bit alto para a subtração ser correta, assim como você carrega e pede emprestado ao adicionar e subtrair manualmente. Esse bit "emprestado" era geralmente chamado de carry bit e poderia ser testado por uma instrução de ramificação. Um segundo bit chamado bit zero seria definido se a subtração fosse identicamente igual a zero, o que implicava igualdade.

Normalmente havia pelo menos duas instruções de ramificação condicional, uma para ramificar no carry e outra no bit zero.

Agora, para chegar ao cerne da questão, vamos expandir a tabela anterior para incluir os resultados carry e zero bit.

Comparison     Subtraction  Carry Bit  Zero Bit
----------     -----------  ---------  --------
A < B      --> A - B < 0    0          0
A = B      --> A - B = 0    1          1
A > B      --> A - B > 0    1          0

Então, implementar um branch para A < B pode ser feito em uma instrução, porque o carry é claro somente neste caso, isto é,

;; Implementation of "if (A < B) goto address;"
cmp  A, B          ;; compare A to B
bcz  address       ;; Branch if Carry is Zero to the new address

Mas, se quisermos fazer uma comparação menor do que ou igual, precisamos fazer uma verificação adicional do sinalizador zero para capturar o caso da igualdade.

;; Implementation of "if (A <= B) goto address;"
cmp A, B           ;; compare A to B
bcz address        ;; branch if A < B
bzs address        ;; also, Branch if the Zero bit is Set

Assim, em algumas máquinas, usando uma comparação "menor que" pode salvar uma instrução da máquina . Isso foi relevante na era da velocidade do processador sub-megahertz e das taxas de velocidade de CPU para memória de 1: 1, mas hoje é quase totalmente irrelevante.

583
Lucas

Assumindo que estamos falando de tipos inteiros internos, não há maneira possível de um ser mais rápido que o outro. Eles são obviamente semanticamente idênticos. Ambos pedem ao compilador para fazer exatamente a mesma coisa. Somente um compilador terrivelmente corrompido geraria um código inferior para um desses.

Se houvesse alguma plataforma onde < fosse mais rápido que <= para tipos inteiros simples, o compilador deveria sempre converter <= para < para constantes. Qualquer compilador que não seria apenas um compilador ruim (para essa plataforma).

88
David Schwartz

Eu vejo que nem é mais rápido. O compilador gera o mesmo código de máquina em cada condição com um valor diferente.

if(a < 901)
cmpl  $900, -4(%rbp)
jg .L2

if(a <=901)
cmpl  $901, -4(%rbp)
jg .L3

Meu exemplo if é do GCC na plataforma x86_64 no Linux.

Os redatores de compiladores são pessoas muito inteligentes, e eles pensam nessas coisas e em muitas outras que a maioria de nós toma como garantidas.

Notei que, se não é uma constante, o mesmo código de máquina é gerado em ambos os casos.

int b;
if(a < b)
cmpl  -4(%rbp), %eax
jge   .L2

if(a <=b)
cmpl  -4(%rbp), %eax
jg .L3
66
Adrian Cornish

Para o código de ponto flutuante, a comparação <= pode de fato ser mais lenta (por uma instrução) mesmo em arquiteturas modernas. Aqui está a primeira função:

int compare_strict(double a, double b) { return a < b; }

No PowerPC, primeiro ele executa uma comparação de ponto flutuante (que atualiza cr, o registrador de condição), move o registro de condição para um GPR, desloca o bit "comparado a menos do que" e retorna. São necessárias quatro instruções.

Agora, considere esta função:

int compare_loose(double a, double b) { return a <= b; }

Isso requer o mesmo trabalho que compare_strict acima, mas agora há dois bits de interesse: "era menor que" e "era igual a". Isso requer uma instrução extra (cror - registro de condição bit a bit OU) para combinar esses dois bits em um. Então compare_loose requer cinco instruções, enquanto compare_strict requer quatro.

Você pode pensar que o compilador poderia otimizar a segunda função da seguinte forma:

int compare_loose(double a, double b) { return ! (a > b); }

No entanto, isso manipulará incorretamente NaNs. NaN1 <= NaN2 e NaN1 > NaN2 precisam ser ambos avaliados como false.

50
ridiculous_fish

Talvez o autor desse livro sem nome tenha lido que a > 0 corre mais rápido que a >= 1 e acha que isso é verdade universalmente.

Mas é porque um 0 está envolvido (porque CMP pode, dependendo da arquitetura, substituído por exemplo com OR) e não por causa do <.

34
glglgl

No mínimo, se isso fosse verdade, um compilador poderia otimizar trivialmente um <= b para! (A> b), e mesmo se a comparação em si fosse realmente mais lenta, com todos menos o compilador mais ingênuo você não notaria uma diferença .

31
Eliot Ball

Eles têm a mesma velocidade. Talvez em alguma arquitetura especial o que ele disse esteja certo, mas na família x86 pelo menos eu sei que eles são iguais. Porque para isso a CPU fará uma subtração (a - b) e então verificará os flags do registrador de bandeira. Dois bits desse registro são chamados ZF (zero Flag) e SF (flag de sinalização), e é feito em um ciclo, porque ele é feito com uma operação de máscara.

15
Masoud

Isso seria altamente dependente da arquitetura subjacente à qual o C é compilado. Alguns processadores e arquiteturas podem ter instruções explícitas para igual a, ou menor que e igual a, que executam em diferentes números de ciclos.

Isso seria bem incomum, já que o compilador poderia contorná-lo, tornando-o irrelevante.

14
Telgin

Você não deve ser capaz de notar a diferença, mesmo que haja alguma. Além disso, na prática, você terá que fazer um a + 1 ou a - 1 adicional para manter a condição a menos que você use algumas constantes mágicas, o que é uma prática muito ruim por todos os meios.

6
shinkou

Você poderia dizer que a linha está correta na maioria das linguagens de script, já que o caractere extra resulta em um processamento de código um pouco mais lento. No entanto, como a resposta principal apontou, ela não deve ter efeito em C++, e qualquer coisa que esteja sendo feita com uma linguagem de script provavelmente não está tão preocupada com a otimização.

4
Ecksters

Quando escrevi esta resposta, eu estava apenas olhando para a questão do título sobre <vs. <= em geral, não o exemplo específico de uma constante a < 901 vs. a <= 900. Muitos compiladores sempre reduzem a magnitude das constantes convertendo entre < e <=, por ex. porque o operando imediato x86 tem uma codificação mais curta de 1 byte para -128..127.

Para ARM e especialmente AArch64, poder codificar como um imediato depende de poder girar um campo estreito em qualquer posição em um Word. Então cmp w0, #0x00f000 seria codificável, enquanto cmp w0, #0x00effff pode não ser. Portanto, a regra menor para comparação versus uma constante de tempo de compilação nem sempre se aplica a AArch64.


<vs. <= em geral, incluindo para condições variáveis ​​de tempo de execução

Na linguagem Assembly na maioria das máquinas, uma comparação para <= tem o mesmo custo que uma comparação para <. Isso se aplica se você está ramificando nele, booleanizando-o para criar um inteiro 0/1 ou usando-o como um predicado para uma operação de seleção sem agência (como x86 CMOV). As outras respostas abordaram apenas essa parte da questão.

Mas esta questão é sobre os operadores C++, o input para o otimizador. Normalmente ambos são igualmente eficientes; o conselho do livro soa totalmente falso porque os compiladores sempre podem transformar a comparação que eles implementam em conjunto. Mas há pelo menos uma exceção em que usar <= pode criar acidentalmente algo que o compilador não pode otimizar.

Como uma condição de loop, há casos em que <= é qualitativamente diferente de <, quando ele pára o compilador de provar que um loop não é infinito. Isso pode fazer uma grande diferença, desabilitando a autovetorização.

O estouro não assinado é bem definido como contorno de base 2, diferente do UB (signed overflow). Os contadores de loop assinado são geralmente seguros disso com compiladores que otimizam com base no UB de estouro de sinal não acontecendo: ++i <= size sempre se tornará falso. ( O que cada programador C deve saber sobre comportamento indefinido )

void foo(unsigned size) {
    unsigned upper_bound = size - 1;  // or any calculation that could produce UINT_MAX
    for(unsigned i=0 ; i <= upper_bound ; i++)
        ...

Compiladores só podem otimizar de maneiras que preservam o comportamento (definido e legalmente observável) da fonte C++ para todos valores de entrada possíveis , exceto aqueles que levam a um comportamento indefinido.

(Um simples i <= size criaria o problema também, mas achei que calcular um limite superior era um exemplo mais realista de introduzir acidentalmente a possibilidade de um loop infinito para uma entrada com a qual você não se importa, mas que o compilador deve considerar.)

Nesse caso, size=0 leva a upper_bound=UINT_MAX e i <= UINT_MAX é sempre verdadeiro. Portanto, esse loop é infinito para size=0, e o compilador deve respeitar isso mesmo que você, como programador, provavelmente nunca pretenda passar size = 0. Se o compilador pode inline esta função em um chamador onde pode provar que size = 0 é impossível, então ótimo, ele pode otimizar como poderia para i < size.

Asm como if(!size) skip the loop;do{...}while(--size); é uma maneira normalmente eficiente de otimizar um loop for( i<size ), se o valor real de i não for necessário dentro do loop ( Por que os loops são sempre compilados no estilo "do ... while" (tail jump) ? ).

Mas isso não {} enquanto não pode ser infinito: se inserido com size==0, obtemos 2 ^ n iterações. ( Iterando todos os inteiros não assinados em um loop for C torna possível expressar um loop sobre todos os inteiros sem sinal, incluindo zero, mas não é fácil sem um flag carry do jeito que está em asm.)

Com a volta do contador de loops sendo uma possibilidade, os compiladores modernos muitas vezes simplesmente "desistem", e não otimizam quase tão agressivamente.

Exemplo: soma de inteiros de 1 a n

Usando unsigned i <= n derrota o reconhecimento idiomático do clang que otimiza sum(1 .. n) loops com um formulário fechado baseado na fórmula n * (n+1) / 2 de Gauss.

unsigned sum_1_to_n_finite(unsigned n) {
    unsigned total = 0;
    for (unsigned i = 0 ; i < n+1 ; ++i)
        total += i;
    return total;
}

x86-64 asm do clang7.0 e gcc8.2 no compilador Godbolt Explorer

 # clang7.0 -O3 closed-form
    cmp     edi, -1       # n passed in EDI: x86-64 System V calling convention
    je      .LBB1_1       # if (n == UINT_MAX) return 0;  // C++ loop runs 0 times
          # else fall through into the closed-form calc
    mov     ecx, edi         # zero-extend n into RCX
    lea     eax, [rdi - 1]   # n-1
    imul    rax, rcx         # n * (n-1)             # 64-bit
    shr     rax              # n * (n-1) / 2
    add     eax, edi         # n + (stuff / 2) = n * (n+1) / 2   # truncated to 32-bit
    ret          # computed without possible overflow of the product before right shifting
.LBB1_1:
    xor     eax, eax
    ret

Mas para a versão ingênua, acabamos de obter um loop idiota do clang.

unsigned sum_1_to_n_naive(unsigned n) {
    unsigned total = 0;
    for (unsigned i = 0 ; i<=n ; ++i)
        total += i;
    return total;
}
# clang7.0 -O3
sum_1_to_n(unsigned int):
    xor     ecx, ecx           # i = 0
    xor     eax, eax           # retval = 0
.LBB0_1:                       # do {
    add     eax, ecx             # retval += i
    add     ecx, 1               # ++1
    cmp     ecx, edi
    jbe     .LBB0_1            # } while( i<n );
    ret

O GCC não usa uma forma fechada de qualquer forma, então a escolha da condição de loop não a atrapalha ; auto-vetoriza com adição de inteiro SIMD, executando 4 valores i em paralelo nos elementos de um registrador XMM.

# "naive" inner loop
.L3:
    add     eax, 1       # do {
    paddd   xmm0, xmm1    # vect_total_4.6, vect_vec_iv_.5
    paddd   xmm1, xmm2    # vect_vec_iv_.5, tmp114
    cmp     edx, eax      # bnd.1, ivtmp.14     # bound and induction-variable tmp, I think.
    ja      .L3 #,       # }while( n > i )

 "finite" inner loop
  # before the loop:
  # xmm0 = 0 = totals
  # xmm1 = {0,1,2,3} = i
  # xmm2 = set1_epi32(4)
 .L13:                # do {
    add     eax, 1       # i++
    paddd   xmm0, xmm1    # total[0..3] += i[0..3]
    paddd   xmm1, xmm2    # i[0..3] += 4
    cmp     eax, edx
    jne     .L13      # }while( i != upper_limit );

     then horizontal sum xmm0
     and peeled cleanup for the last n%3 iterations, or something.

Ele também tem um loop escalar simples que eu acho que ele usa para n muito pequeno, e/ou para o caso de loop infinito.

BTW, ambos os loops desperdiçam uma instrução (e um uop em CPUs da família Sandybridge) na sobrecarga de loop. sub eax,1/jnz em vez de add eax,1/cmp/jcc seria mais eficiente. 1 em vez de 2 (após a macro-fusão de sub/jcc ou cmp/jcc). O código após os dois loops grava o EAX incondicionalmente, portanto, não está usando o valor final do contador de loops.

3
Peter Cordes