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.
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:
test
ou cmp
, que define EFLAGS
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
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
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.
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).
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
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.
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 <
.
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 .
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.
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.
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.
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.
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.
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.
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.