Dopo il primo articolo introduttivo su APX
, che ha esposto anche il formato del nuovo prefisso REX2
e, soprattutto, le modifiche a quello EVEX
, analizziamo quanto offerto da quest’ultimo poiché, oltre all’accesso dei nuovi 16 registri general-purpose (unica innovazione messa a disposizione da REX2
), consente di poter estendere / migliorare alcune istruzioni esistenti (ma non tutte: soltanto un certo numero che hanno il privilegio di essere “promosse” allo scopo) abilitando opportune funzionalità.
La base, quindi, rimane quella dell’istruzione promossa, ma ne viene alterato il comportamento impostando alcuni bit (NF
e ND
) che adesso EVEX
mette a disposizione, i quali possono essere utilizzati anche contemporaneamente (abilitando entrambe le funzionalità).
NF
: No Flags
Penso siano molto rari i processori CISC a cui manchi il registro dei flag o, comunque, che non abbiano alcuni bit da qualche parte riservati per conservare i flag derivanti dal risultato dell’ultima operazione eseguita (usualmente aritmetica o logica), e ovviamente x64
non è da meno.
Questa scelta architetturale ricade sulla necessità di poter ottenere più “lavoro utile” dalle istruzioni eseguite, in modo da risparmiarne l’esecuzione di apposite per controllare esplicitamente il risultato di ciò che sia stato elaborato, in quanto potrebbe, magari, servire immediatamente per decidere il da farsi.
Ma è altrettanto vero che non sempre sarà necessario effettuare questi controlli, per cui la maggior parte delle volte capita che i flag generati da un’istruzione non siano mai utilizzati e, quindi, il processore abbia soltanto “perso tempo” (impiegato inutilmente risorse) per la loro elaborazione. Infatti le successive istruzioni che vengono eseguite sequenzialmente provvederanno a scartare i vecchi flag rimpiazzandoli, poi, coi nuovi.
A volte, però, capita che i flag risultanti dall’esecuzione di un’istruzione siano importanti successivamente, ma nel frattempo ne saranno eseguite anche altre che li distruggeranno (sostituendoli).
In questi casi l’unica possibilità rimane quella di conservarli da qualche parte, per poi recuperali nuovamente proprio nel momento in cui serviranno. Il che comporta l’esecuzione di più istruzioni per il salvataggio & ripristino, oltre a un posto dove collocarli temporaneamente. Il tutto, quindi, impattando sulle prestazioni e lo spazio (o i registri) utilizzato.
Proprio per venire in contro a quest’ultimo scenario Intel ha introdotto, con APX
, la funzionalità NF
, che consente di evitare la generazione dei flag per le istruzioni che, invece, normalmente lo farebbero. Quindi sarà sufficiente “estendere” (usando il prefisso EVEX
) le istruzioni eseguite dopo quella di cui a noi interessi conservarne i flag, impostando per tutte questo bit, in modo da preservare i flag che sono stati generati prima della loro esecuzione.
Possono sembrare scenari rari, ma non lo sono affatto, specialmente considerato che spesso le istruzioni dei processori sono “riordinate” opportunamente in modo che se ne eseguano contemporaneamente il maggior numero possibile. Basti pensare, ad esempio, ai diffusissimi cicli, la cui condizione di uscita (o, viceversa, quella di ripetizione del ciclo) dev’essere controllata (test) per poi decidere quale delle due strade imboccare (salto).
E’ abbastanza diffuso il caso in cui fra l’istruzione che produce la condizione da controllare e la relativa istruzione di salto (che controlla la condizione) siano inserite altre istruzioni in modo da “ritardare” la dipendenza fra queste due istruzioni (test e poi salto) e cercare di massimizzare il numero di istruzioni eseguite. Si capisce subito che se le istruzioni inserite alterassero i flag a loro volta, avremmo un grosso problema (a valle).
Un esempio pratico (da una mia implementazione della famosissima funzione daxpy della libreria BLAS, utilizzatissima in applicazioni HPC – High Performance Computing):
xor eax,eax ; Sets offset of first vector.
sub rcx,8 ; Checks if we have a full vector to process.
js .check_tail ; No, we have none.
l’istruzione SUB
viene utilizzata per controllare se ci siano abbastanza elementi da processare per poter riempire un vettore (di un registro AVX-512) e in caso negativo saltare (con l’istruzione JS
) alla routine che si occupa di processare gli elementi rimasti (che non riempiono un intero vettore, ovviamente).
JS
viene eseguita immediatamente dopo SUB
, per cui deve aspettare che quest’ultima istruzione sia stata processata prima di poter valutare (grazie ai flag aggiornati da SUB
) se saltare o meno. Nel frattempo la pipeline del processore è bloccata (c’è uno stallo) e ciò è particolarmente vero se ci troviamo di fronte a un processore in-order anziché out-of-order.
Potremmo sfruttare meglio le risorse del processore se fra SUB
e JS
potessimo, invece, piazzare l’istruzione XOR
, in modo da eseguire qualche altro calcolo nel frattempo, impegnando la pipeline:
sub rcx,8 ; Checks if we have a full vector to process.
xor eax,eax ; Sets offset of first vector.
js .check_tail ; No, we have none.
Il problema è che la XOR
altera, a sua volta, i flag, per cui quelli generati dalla SUB
sarebbero distrutti e la JS
sbaglierebbe nel decidere se saltare o meno.
Problema che, con APX
, viene brillantemente risolto evitando che queste istruzioni addizionali modifichino i flag, grazie a NF
(basterebbe impostare questo flag nella XOR
).
Il prezzo da pagare, però, risulta molto alto se l’unico intento è quello di evitare che i flag vengano modificati, in quanto l’impiego di EVEX
per le istruzioni che usano NF
allunga le istruzioni di 3 o 4 byte (a seconda che l’istruzione si trovi nella mappa 1
o 0
), quindi impattando (negativamente) sulla densità di codice (argomento che sarà affrontato meglio in un apposito articolo).
Per questi motivi non si può pensare di utilizzare liberamente NF
, ma bisogna impiegarlo in maniera accorta soltanto dove realmente serva, facendo in modo di eseguire il più possibile le istruzioni senza di esso.
NDD
: New Data Destination
Con NDD
possiamo dire di trovarci di fronte a un cambiamento memorabile per quest’architettura, che rende x64
con APX
non soltanto un’ISA più moderna, ma anche ben più competitiva rispetto ad altre architetture. Posso senz’altro affermare, senza mezzi termini, che si tratti della funzionalità in assoluto più importante che APX
abbia introdotto. Ed è anche quella che, a mio modesto avviso, contribuisce di più al miglioramento delle prestazioni.
Infatti impostando l’apposito bit ND
le istruzioni (non tutte, ma soltanto quelle che sono state “promosse” da APX
) possono (finalmente!) far uso di un registro addizionale (definito dal campo v̅4..v̅0
di EVEX
) nel quale confluirà il risultato dell’operazione eseguita.
Come anticipato, se un’istruzione è binaria (fa uso di due argomenti per effettuare i suoi calcoli), con ND
diventerà ternaria. I due argomenti che sono presenti nelle normali istruzioni x64
diventano, quindi, entrambi delle sorgenti di dati, mentre il registro addizionale messo a disposizione con APX
(tramite il prefisso EVEX
) sarà usato per contenere il risultato dell’operazione (mentre con le normali istruzioni binarie uno dei due argomenti funge contemporaneamente da sorgente e destinazione).
Similmente, un’istruzione unaria (con un solo argomento che fa sia da sorgente sia da destinazione) viene trasformata in binaria grazie a ND
, con l’unico argomento che farà esclusivamente da sorgente.
Gli esempi riportati nel documento di Intel lo spiegano meglio di mille parole:
Existing x86 form | Existing x86 semantics | NDD extension | NDD semantics |
INC r/m | r/m := r/m + 1 | INC ndd, r/m | ndd := r/m + 1 |
SUB r/m, imm | r/m := r/m – imm | SUB ndd, r/m, imm | ndd(v) := r/m – imm |
SUB r/m, reg | r/m := r/m – reg | SUB ndd, r/m, reg | ndd(v) := r/m – reg |
SUB reg, r/m | reg := reg – r/m | SUB ndd, reg, r/m | ndd(v) := reg – r/m |
La flessibilità della soluzione di Intel è decisamente superiore rispetto alle equivalenti istruzioni ternarie e binarie fornite da altre architetture, per un duplice motivo:
- consente di attingere dalla memoria per una delle sorgenti (mentre nelle altre ISA generalmente si utilizzano esclusivamente registri. I RISC, infatti, sono processori solitamente “load/store“: l’accesso alla memoria si effettua esclusivamente con questi due tipi di istruzione);
- l’eventuale operando in memoria per le istruzioni ternarie può essere utilizzato in una qualunque delle due sorgenti (anziché solo nella prima o solo nella seconda, cioè con uno schema fisso).
Inutile dire che emularne il funzionamento da parte degli altri processori richieda l’esecuzione di più istruzioni. Il che si traduce in una maggior efficienza, con ovvie nonché benefiche ricadute prestazionali da parte di APX
.
Un’importante differenza con le normali istruzioni va, però, sottolineata. Quando quelle “promosse” e che usano ND
hanno una dimensione di 8 o 16 bit, i bit non direttamente interessati (quelli successivi agli 8 o 16) vengono azzerati (similmente a quanto succede con tutte le operazioni a 32 bit con x64
. Questo comportamento si chiama clear in gergo), mentre normalmente mantengono il loro valore (in questi casi il processore “unisce” i nuovi 8 / 16 bit con gli altri bit non coinvolti dall’operazione. In gergo viene chiamato merge).
Tale decisione è stata presa per poter evitare stalli nella pipeline del processore, dovuti ad accessi ai registri quando le istruzioni ne abbiano cambiato solamente una parte (ad esempio con un’istruzione si cambiano soltanto i primi 8 bit di un registro, ma quella successiva accede a tutti i 64 bit), con relative ricadute (negative) prestazionali.
Uniche eccezioni a questo nuovo comportamento sono alcune istruzioni IMUL
(moltiplicazione con segno) e SETcc
(imposta un byte a 1
se la condizione specificata da cc
venga soddisfatta, oppure a 0
altrimenti), che non supportano NDD
(non fanno uso del registro addizionale per la destinazione). In quest caso si comportano come al solito (eseguendo il merge) se il bit ND
è 0
, mentre viene applicato il nuovo meccanismo di clear se ND
è 1
.
Per il momento è tutto. Nel prossimo articolo tratteremo le nuove istruzioni che sono state introdotte da APX
.