Dopo aver discusso delle funzionalità innovative di APX
, passiamo alle nuove istruzioni che sono state aggiunte da questa estensione.
Convenzione di chiamata (a routine)
L’avere raddoppiato i registri general-purpose comporta la necessità di doverli salvare e poi recuperare nello/dallo stack quando vengono utilizzati nelle chiamate a routine (funzioni o metodi che siano), a seconda della convenzione di chiamata adottata dalla specifica piattaforma (che fa parte della cosiddetta ABI).
Intel ha proposto di definire i nuovi registri come volatile, ossia sono liberamente utilizzabili dalla routine che è stata chiamata (chiamata callee in gergo). Sarà, quindi, compito della routine chiamante (chiamata caller) conservarne i valori prima di invocare la routine, per poi ripristinarli subito dopo (questa convenzione è chiamata caller-saved).
Ci sono pro e contro in ogni scelta del genere. In questo caso possiamo dire che, essendo il salvataggio e ripristino di questi nuovi registri totalmente a carico dei chiamante, influirà non poco sulla densità di codice, poiché si dovranno effettuare queste operazioni ogni singola volta che si dovrà chiamare la routine che li usi (quindi se ci sono 100 parti nel programma che la chiamano, ci saranno 100 volte le operazioni di salvataggio e ripristino dei nuovi registri utilizzati).
Se, invece, fosse stata adottata la convenzione opposta (callee-saved), la densità di codice ne avrebbe giovato notevolmente (perché ci sarebbe stato un solo punto nel programma in cui si effettuano queste operazioni: a inizio e fine della routine chiamata), ma ne avrebbero sofferto le prestazioni della routine (perché si sarebbero dovuti salvare i nuovi registri prima di poterli utilizzare e, viceversa, si sarebbero dovuti ripristinare prima di restituire l’eventuale risultato o, in ogni caso, tornare il controllo al chiamante).
Non è facile né si può stabilire a priori quale possa essere la miglior convenzione da adottare, in quanto risulta piuttosto ovvio ed evidente che ciò dipenda strettamente dal tipo di codice che dev’essere eseguito. Ma un’ABI
ha bisogno di fissare a priori una convenzione, perché dev’essere valida e utilizzata da tutte le applicazioni che gireranno nel sistema, dunque una scelta andava fatta.
A mio avviso forse sarebbe stato meglio scegliere una via di mezzo: una soluzione ibrida in cui i primi otto nuovi registri sarebbero potuti essere usati liberamente dal chiamante (e, quindi, salvati e ripristinati dal chiamato, se avesse avuto bisogno di utilizzarli a sua volta), mentre gli altri otto sarebbero stati a disposizione del chiamato (e, quindi, il chiamante ne avrebbe dovuto conservare il valore).
Questo perché raramente una routine utilizza tutti i registri a disposizione, per cui spesso si sarebbe usata una parte dei registri, ma senza alcun bisogno di conservarne i valori sia da parte del chiamante sia da parte del chiamato, con evidenti vantaggi da ambo le parti (inclusa la famigerata densità di codice).
Nuove istruzioni
Tornando alle nuove istruzioni, avendo a che fare con 32 registri significa potenzialmente dover eseguire parecchie istruzioni di PUSH
e POP
ogni volta che si ricada in una delle precedenti situazioni. Il che dovrebbe essere anche abbastanza frequente: se i 16 nuovi registri sono stati aggiunti, è proprio perché li si vuole usare, e pure spesso (anche se non sempre tutti)! Altrimenti non avrebbe avuto senso comportare tutti questi cambiamenti.
Ciò mi suona abbastanza strano, in quanto ricordo ancora benissimo come AMD avesse affermato, introducendo x86-64
AKA x64
, di aver valutato l’estensione di x86
a 32 anziché 16 registri, ma di aver lasciato perdere perché i vantaggi non s’erano mostrati significativi (contrariamente al passaggio da 8 a 16 registri, dove le differenze, invece, sono state parecchio rilevanti, come abbiamo poi potuto toccare con mano) e non giustificavano la maggior complessità implementativa di tale soluzione.
In ogni caso, e tornando sui binari, Intel ha pensato di mitigare un po’ la situazione aggiungendo un paio di nuove istruzioni, PUSH2
e POP2
, le quali, com’è chiaramente intuibile dai loro mnemonici, consentono di eseguire il push o il pop su/dallo stack di due registri alla volta, anziché soltanto uno (come avviene con le normali PUSH
e POP
). In questo modo si può all’incirca dimezzare il numero di istruzioni corrispondenti che normalmente sarebbero necessarie, con evidenti vantaggi in termini prestazionali (un’istruzione eseguita ogni volta, anziché due).
Un esempio, tratto da una vecchia versione di FFMPEG
(per x64
):
PUSH R12
PUSH RDI
PUSH RSI
PUSH RBX
SUB RSP, 0x68
LEA RBP, [RSP+0x80]
MOV ESI, [RIP+0x20f79f2]
TEST ESI, ESI
JZ 0x140d55203
LEA RSP, [RBP-0x18]
POP RBX
POP RSI
POP RDI
POP R12
POP R13
POP R14
POP R15
POP RBP
RET
mostra facilmente come si potrebbero dimezzare le istruzioni di PUSH
e POP
ricorrendo alle nuove PUSH2
e POP2
.
Sempre sull’argomento, anche se non è una nuova istruzione vera e propria, è l’introduzione di un cosiddetto “hint” per le istruzioni PUSH
e POP
(esclusivamente quelle che operano sui registri e che usano la classica nonché più diffusa codifica), il quale indicherebbe al processore che tali istruzioni (eseguite nell’opportuna sequenza) sarebbero “bilanciate”. In questo caso il processore non salverebbe e ripristinerebbe i loro valori in/dalla memoria, ma li conserverebbe internamente, in modo da migliorare le prestazioni di queste due operazioni.
Infine un’altra nuova istruzione che è stata aggiunta è la JMPABS
, la quale, come suggerisce già il nome, consente di saltare a un indirizzo assoluto a 64 bit. Evidentemente Intel ha riscontrato dei casi non rari in cui ciò risulti necessario (d’altra parte le classiche istruzioni di CALL
e JMP
consentono, in modalità a 64 bit, di potersi spostare soltanto di + o – 2GB al massimo) e ha deciso di sopperirvi, anche se personalmente non ho incontrato occasioni in cui una simile operazione fosse necessaria.
Nuove istruzioni condizionali
Altre nuove istruzioni introdotte da APX
sono quelle cosiddette condizionali, per le quali cambia il formato del prefisso EVEX
secondo l’ultima tabella mostrata nel primo articolo (la quale vede l’introduzione dei campi OF
, SF
, ZF
e CF
e SC3..SC0
) e che, ovviamente, controllano se una determinata condizione (specificata in SC3..SC0
) sia vera per decidere in che modo procedere (a seconda del particolare tipo di istruzione).
In realtà le uniche due (nuove, ovviamente) istruzioni che utilizzano questo speciale formato di EVEX
sono CCMPscc
e CTESTscc
, le cui differenze risiedono soltanto nel tipo di controllo che, eventualmente, viene effettuato (come per le istruzioni CMP
e TEST
, rispettivamente) se la condizione in SC3..SC0
risulti verificata.
La loro logica di funzionamento si sintetizza brevemente in questo modo: se SC3..SC0
risultasse soddisfatta, allora i flag del processore sarebbero aggiornati effettuando il confronto dei due operandi, esattamente come per CMP
e TEST
. Se, invece, non lo fosse, non verrebbe effettuato alcun confronto, ma i flag OF
, SF
, ZF
e CF
verrebbero impostati copiandone il valore dagli equivalenti campi che si trovano in EVEX
; inoltre il flag AF
verrebbe sempre azzerato.
E’ bene precisare che non tutte le condizioni normalmente possibili con x86
/x64
possono essere utilizzate: non lo sono, infatti, quelle di controllo del flag di parità (P
). In questo caso le due codifiche sono state riutilizzate rispettivamente per forzare la valutazione (e, quindi, eseguire il controllo degli operandi) o saltarla (evitando il controllo e, quindi, copiando i campi OF
, SF
, ZF
e CF
nei rispettivi flag).
Una cosa importante da sottolineare è che queste istruzioni possono sempre generare un’eccezione se uno degli elementi è in memoria e non risulti accessibile (o, in generale, che generi un qualunque tipo fault). Ciò si verifica a prescindere, anche se la condizione in SC3..SC0
sia insoddisfatta e leggere l’operando in memoria risulti, quindi, del tutto inutile. In questo caso il comportamento è identico a un’altra istruzione condizionale già presente dai tempi del Pentium Pro, la famosa CMOVcc
.
Proprio quest’ultima è, tra l’altro, anche alla base delle ulteriori quattro nuove istruzioni condizionali che APX
mette a disposizione. La prima è la stessa CMOVcc
, che risulta estesa usando l’NDD
e, quindi, guadagna un registro di destinazione per conservare il risultato dell’operazione (si tratta di copiare la seconda sorgente se la condizione cc
risulti soddisfatta, altrimenti si copia la prima sorgente).
Le altre tre istruzioni si chiamano CFCMOVcc
, perché sono tutte accomunate dalla medesima cosa: non sollevano alcuna eccezione se l’operando in memoria non risulta accessibile e la condizione risulti falsa (ovviamente l’eccezione viene sollevata se la condizione sarà vera). La prima di queste è, quindi, identica alla CMOVcc
di cui sopra, ma con la soppressione delle eccezioni (se la condizione non è soddisfatta).
Le altre due CFCMOVcc
non usano l’NDD
e, quindi, hanno soltanto due operandi: il primo fungerà sempre sia da prima sorgente sia da destinazione. La differenza fra le due è che gli operandi sono invertiti: per la prima il primo argomento è un registro e il secondo è un operando che può stare in memoria (o in un registro), mentre per la seconda istruzione è l’esatto opposto (il primo operando può stare in memoria e il secondo è sempre un registro).
La peculiarità di queste quattro nuove istruzioni è che non usano affatto il formato particolare di EVEX
(che, come avevo già anticipato, viene sfruttato esclusivamente per le nuove CCMP
e CTEST
), ma la condizione da controllare è inclusa direttamente nell’opcode (come nell’istruzione originale dalla quale sono nate).
Infine il funzionamento dell’istruzione SETcc
(di cui avevo accennato nel precedente articolo) è stato esteso, dando la possibilità (sfruttando il bit ND
) di applicare la logica di clear (anziché di merge, che è quella di default) quando l’operando (che rappresenta la destinazione del risultato) è un registro anziché una locazione di memoria (in questo caso non v’è alcuna modifica). Decisamente utile, perché evita di aggiungere un’istruzione prima della SETcc
per azzerare il contenuto del registro (cosa che succede tipicamente nel codice reale, dove si fa spesso utilizzo dell’intero registro e non soltanto dei primi 8 bit).
In esempio, preso sempre da FFMPEG
(x64
):
XOR EAX, EAX
CMP WORD [RCX+0x18], 0x20b
SETZ AL
dove si può vedere come il registro EAX
venga azzerato con l’istruzione XOR
, e soltanto successivamente l’istruzione SETZ
imposti a 1
il valore del byte meno significativo (rappresentato dal registro AL
) nel caso in cui la locazione di memoria della CMP
contenesse il valore 0x20
(altrimenti AL
rimarrebbe a 0
).
Un simile pattern ricorrente che si trova spesso è anche il seguente:
CMP [RDI], EAX
SETZ AL
MOVZX EAX, AL
dove, in questo caso, prima viene effettuato il confronto per aggiornare opportunamente i flag, poi si esegue l’istruzione SETZ
per impostare, in accordo ai flag, il valore del byte meno significativo (sempre AL
), e subito dopo tutti gli altri byte di EAX
vengono azzerati con l’istruzione MOVZX
.
Con questo è tutto per il momento. Il prossimo articolo si concentrerà sull’analisi dei vantaggi e delle pecche di APX
.