O conceito de condicionais apresenta o jcc, a família de instruções para desviar com base em uma condição.
Uma jcc transfere a execução para outro ponto do programa apenas se a condição dela for satisfeita.
No entanto, as CPUs modernas são rápidas e capazes de executar muitas instruções em paralelo. Seria custoso demais parar todo o trabalho enquanto a CPU espera por uma verificação de condição em tempo de execução.
É por isso que, internamente, a CPU não espera. Ela usa um preditor de desvio para adivinhar o resultado de uma condição e começa a executar especulativamente o caminho previsto.
Se a predição estiver correta, a execução segue como se não houvesse desvio. No entanto, se a predição estiver errada, a CPU precisa descartar o trabalho feito no caminho previsto e recomeçar no caminho correto.
Isso se chama erro de predição de desvio e é caro, causando um atraso.
Quando um desvio é difícil de prever, esses erros de predição se acumulam.
Nesses casos, às vezes é preferível evitar o desvio por completo, para que as mesmas instruções sejam executadas independentemente da condição.
O código que escolhe entre valores sem usar uma jcc é chamado de código sem desvios.
Existem várias maneiras de estruturar o código para que ele desvie com menos frequência, ou para que os desvios que ele tem sejam mais previsíveis. Em particular, o x86-64 oferece duas famílias de instruções usadas com frequência para escrever código sem desvios.
A família de instruções cmovcc copia o operando de origem para o destino apenas se uma condição específica for satisfeita. Caso contrário, o destino permanece inalterado.
O sufixo cc segue a mesma nomenclatura de jcc, com o mesmo significado:
| instrução | move se |
|---|---|
cmove |
A == B depois de cmp A, B
|
cmovne |
A != B depois de cmp A, B
|
cmovl |
A < B (com sinal) depois de cmp A, B
|
cmovb |
A < B (sem sinal) depois de cmp A, B
|
cmovg |
A > B (com sinal) depois de cmp A, B
|
cmova |
A > B (sem sinal) depois de cmp A, B
|
As regras são parecidas com as de jcc:
l e g são usados para comparações com sinal; b e a são usados para comparações sem sinal.e a um sufixo inclui a igualdade.n nega a condição.cmovz e cmovc.Considere o valor absoluto de um inteiro com sinal em rdi, retornado em rax.
Escrita com jcc, a função escolhe um de dois caminhos:
abs_branch:
mov rax, rdi
cmp rax, 0
jge .done
neg rax
.done:
ret
Escrita com cmovcc, os dois valores candidatos são calculados incondicionalmente e o movimento condicional escolhe um:
abs_branchless:
mov rax, rdi
neg rax ; rax = -rdi
cmp rdi, 0
cmovge rax, rdi ; if rdi >= 0, rax = rdi
; otherwise rax stays = -rdi
; now rax = abs(rdi)
ret
A versão sem desvios sempre executa as mesmas instruções.
Não há nenhuma jcc para o preditor adivinhar.
O destino de uma cmovcc precisa ser um registrador.
A origem pode ser um registrador ou um endereço de memória, mas não um valor imediato.
No código acima, a instrução neg define várias flags de acordo com o resultado, incluindo a flag de sinal (SF).
Isso significa que o cmp rdi, 0 pode ser removido por completo:
abs_branchless:
mov rax, rdi
neg rax ; rax = -rdi. `neg` sets SF = 1 if the result is negative
cmovs rax, rdi ; if SF = 1 (i.e., rax < 0), replace rax with rdi
; otherwise rax stays = -rdi, which is non-negative
; now rax = abs(rdi)
ret
Para rdi positivo, neg produz um resultado negativo e SF = 1, então cmovs restaura rdi.
Para rdi zero ou negativo, neg produz um resultado não negativo e SF = 0, então rax mantém o valor negado (que é o valor absoluto correto).
Embora cmp seja a principal forma de comparar valores em código assembly, muitas instruções também definem flags de acordo com o resultado.
A lista completa de flags que uma instrução afeta costuma aparecer na referência dela.
No caso específico de neg, são SF, ZF, CF, OF e PF.
A família de instruções setcc define o operando de destino como 1 se uma condição específica for satisfeita, e como 0 caso contrário.
O destino é sempre um operando de 8 bits.
O sufixo cc segue a mesma nomenclatura de jcc e cmovcc.
Por exemplo, setz define o destino como 1 se ZF == 1 e como 0 caso contrário.
Como o destino tem 8 bits, é comum usar movzx depois de setcc quando um valor mais largo é necessário:
cmp rdi, rsi
setg al ; al = 1 if rdi > rsi (signed), 0 otherwise
movzx eax, al ; eax (and rax) = 1 or 0, with the upper bits cleared
Esse é um idioma comum para transformar o resultado de uma comparação no inteiro 0 ou 1.
Código sem desvios nem sempre é mais rápido do que código com desvios.
Quando um desvio é altamente previsível, o preditor acerta quase sempre e a jcc praticamente não custa nada.
O código sem desvios que substitui esse tipo de desvio é mais lento, porque sempre executa os dois cálculos candidatos.
Um contador de laço é o exemplo canônico.
Considere uma função que soma rsi inteiros de 64 bits começando em rdi:
sum_array:
xor rax, rax
.loop:
add rax, [rdi]
add rdi, 8
dec rsi
jnz .loop ; taken on every iteration except the last
ret
Esse é exatamente o tipo de padrão que os preditores de desvio lidam bem, então o desvio praticamente não tem custo, não importa quantos elementos o sum_array processe.
O código sem desvios compensa quando o desvio é difícil de prever, ou quando é preciso um tempo de execução consistente independentemente das entradas.
Uma referência completa sobre predição de desvio para CPUs x86-64 pode ser encontrada no terceiro capítulo deste livro de referência.