Il concetto di condizionali introduce jcc, la famiglia di istruzioni per eseguire salti in base a una condizione.
Un jcc trasferisce l'esecuzione a un altro punto del programma solo se la sua condizione è soddisfatta.
Tuttavia, le CPU moderne sono veloci e capaci di eseguire molte istruzioni in parallelo. Sarebbe troppo costoso bloccare tutto il lavoro mentre la CPU aspetta di verificare una condizione a runtime.
Ecco perché, al suo interno, la CPU non aspetta. Usa un predittore di salti per indovinare l'esito di una condizione e inizia a eseguire speculativamente il percorso previsto.
Se la previsione è corretta, l'esecuzione procede come se il salto non ci fosse. Se invece la previsione è sbagliata, la CPU deve scartare il lavoro svolto sul percorso previsto e ricominciare da quello corretto.
Questo fenomeno si chiama previsione errata del salto ed è costoso, perché introduce un ritardo.
Quando un salto è difficile da prevedere, queste previsioni errate si accumulano.
In questi casi, a volte è preferibile evitare del tutto il salto, così che vengano eseguite le stesse istruzioni indipendentemente dalla condizione.
Il codice che sceglie tra valori senza un jcc si dice senza salti.
Ci sono diversi modi per strutturare il codice in modo che esegua meno salti, o che i salti che esegue siano più prevedibili. In particolare, x86-64 fornisce due famiglie di istruzioni comunemente usate per scrivere codice senza salti.
La famiglia di istruzioni cmovcc copia l'operando sorgente nella destinazione solo se una condizione specifica è soddisfatta. Altrimenti, la destinazione resta invariata.
Il suffisso cc segue la stessa nomenclatura di jcc, con lo stesso significato:
| istruzione | sposta se |
|---|---|
cmove |
A == B dopo cmp A, B
|
cmovne |
A != B dopo cmp A, B
|
cmovl |
A < B (con segno) dopo cmp A, B
|
cmovb |
A < B (senza segno) dopo cmp A, B
|
cmovg |
A > B (con segno) dopo cmp A, B
|
cmova |
A > B (senza segno) dopo cmp A, B
|
Le regole sono simili a quelle di jcc:
l e g si usano per i confronti con segno; b e a per quelli senza segno.e a un suffisso include l'uguaglianza.n nega la condizione.cmovz e cmovc.Consideriamo il valore assoluto di un intero con segno in rdi, restituito in rax.
Scritta con jcc, la funzione sceglie uno di due percorsi:
abs_branch:
mov rax, rdi
cmp rax, 0
jge .done
neg rax
.done:
ret
Scritta con cmovcc, entrambi i valori candidati vengono calcolati incondizionatamente e lo spostamento condizionale ne sceglie uno:
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
La versione senza salti esegue sempre le stesse istruzioni.
Non c'è alcun jcc che il predittore possa indovinare.
La destinazione di un cmovcc deve essere un registro.
La sorgente può essere un registro o una locazione di memoria, ma non un valore immediato.
Nel codice qui sopra, l'istruzione neg imposta diversi flag in base al risultato, incluso il flag di segno (SF).
Questo significa che il cmp rdi, 0 può essere eliminato del tutto:
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
Per rdi positivo, neg produce un risultato negativo e SF = 1, quindi cmovs ripristina rdi.
Per rdi uguale a zero o negativo, neg produce un risultato non negativo e SF = 0, quindi rax mantiene il valore negato (che è il valore assoluto corretto).
Anche se cmp è il modo principale per confrontare valori nel codice assembly, molte istruzioni impostano i flag in base al loro risultato.
L'elenco completo dei flag che un'istruzione influisce è di solito riportato nella sua documentazione di riferimento.
Per neg in particolare, sono SF, ZF, CF, OF e PF.
La famiglia di istruzioni setcc imposta l'operando di destinazione a 1 se una condizione specifica è soddisfatta, e a 0 altrimenti.
La destinazione è sempre un operando a 8 bit.
Il suffisso cc segue la stessa nomenclatura di jcc e cmovcc.
Per esempio, setz imposta la destinazione a 1 se ZF == 1 e a 0 altrimenti.
Dato che la destinazione è a 8 bit, è comune far seguire setcc da un movzx quando serve un valore più ampio:
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
È un modo di scrivere comune per trasformare il risultato di un confronto in un intero 0 o 1.
Il codice senza salti non è sempre più veloce di quello con salti.
Quando un salto è altamente prevedibile, il predittore ha ragione quasi sempre e un jcc non costa praticamente nulla.
Il codice senza salti che sostituisce un salto di questo tipo è più lento, perché esegue sempre entrambi i calcoli candidati.
Un contatore di ciclo è l'esempio canonico.
Consideriamo una funzione che somma rsi interi a 64 bit a partire da 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
Questo è esattamente il tipo di schema che i predittori di salti gestiscono bene, quindi il salto non costa praticamente nulla, indipendentemente da quanti elementi elabora sum_array.
Il codice senza salti conviene quando il salto è difficile da prevedere, o quando serve una temporizzazione costante indipendentemente dagli input.
Un riferimento approfondito sulla previsione dei salti per le CPU x86-64 si trova nel terzo capitolo di questo libro di riferimento.