APX: la nuova architettura di Intel – 5 – Densità di codice

Con l’analisi dei vantaggi e delle pecche si è chiuso l’approfondimento delle caratteristiche di APX, per cui adesso passiamo ad una serie di analisi e riflessioni, cominciando dall’arcinota densità di codice, la quale è un fattore estremamente importante.

Questo perché lo spazio occupato in memoria dalle istruzioni comporta implicazioni su tutta la gerarchia della memoria e, quindi, influenza direttamente le prestazioni. Il discorso è complesso (e tiene banco, a livello accademico e industriale, da tantissimo tempo), ed è sufficiente una banale ricerca per rendersi conto di quanto materiale sia stato scritto sull’argomento, ma riporto di seguito la sintesi della tesi di uno dei progettisti di RISC-V per far comprendere quanta importanza abbia quest’aspetto:

Waterman shows that RVC fetches 25%-30% fewer instruction bits, which reduces instruction
cache misses by 20%-25%, or roughly the same performance impact as doubling the instruction
cache size
.

Le parti evidenziate (in particolare l’ultima) dovrebbero essere abbastanza eloquenti e, sebbene siano relative soltanto a RISC-V, risultati simili si possono riscontrare in tutte le architetture, in quanto il concetto e le problematiche sono generali. Non sono, comunque, direttamente pertinenti a questa serie di articoli, per cui mi limiterò a riportarne le mie osservazioni riguardo ad APX.

Intel afferma che, abilitando APX, la densità del codice risulti “similare” rispetto a x64 (la quale già di per sé non è che brilli!), basandosi su risultati preliminari compilando la già citata test suite SPEC2017. Se ciò fosse realmente confermato sarebbe sicuramente un bel colpo (vorrebbe dire che tutte le innovazioni introdotte hanno compensato il notevole aumento della dimensione delle istruzioni).

Al momento, però, nutro i miei dubbi in merito, anche perché Intel non s’è sbilanciata: non ha affermato che sia uguale, leggermente peggio o leggermente meglio, ma soltanto “similare”. Questo clima d’incertezza merita, quindi, almeno alcune considerazioni portando alcuni numeri, almeno finché non arriveranno quelli “ufficiali”.

PUSH2 e POP2 (usando EVEX)

Partiamo subito da un elemento di cui si sa certamente che peggiori la densità di codice: le nuove istruzioni PUSH2 e POP2. La loro codifica richiede l’uso del prefisso EVEX, per cui sono necessari 4 byte soltanto per averlo usato, più uno per l’opcode e infine un altro per ModR/M che risulta necessario per referenziare la memoria (in realtà si utilizza soltanto la configurazione che specifica un registro e non una locazione). Totale: sei byte (minimo).

Per confronto, un’istruzione PUSH o POP richiede uno o due byte (a seconda che si utilizzi un registro x86 o un nuovo registro x64). Quindi un paio di queste richiedono da due a quattro byte, ma in ogni caso sempre molto meno rispetto ai sei byte. Il peggioramento che si verifica con le nuove istruzioni risulta, in questo caso, evidentissimo (inoltre e come già detto, queste istruzioni sono molto usate).

I nuovi registri (usando REX2)

Utilizzare almeno uno dei nuovi registri richiede l’uso del prefisso REX2, che da solo occupa due byte, e che dovrebbe essere utilizzato ogni volta che questo registro venga referenziato in un’istruzione. Per contro, emularne in qualche modo il funzionamento con x64 richiederebbe più byte, a seconda degli scenari.

Ad esempio se servisse temporaneamente un registro per alcune operazioni, si dovrebbe prima conservarlo da qualche parte e lo stack è il luogo più comodo nonché indicato. Quindi si dovrebbe effettuare un PUSH per conservarlo e un POP per ripristinarlo dopo che le operazioni sono state concluse. Sappiamo che il costo in questo caso varierebbe da uno a due byte, per cui in totale servirebbero da due a quattro byte, quindi come minimo saremmo in pari con l’uso di REX2, ma nel caso peggiore avremmo un costo raddoppiato.

Però il vantaggio di x64 è che le operazioni eseguite fra il PUSH e il POP non richiederebbero l’uso di REX2, ma al massimo il REX (per referenziare i nuovi registri di x64), che occupa un solo byte, quindi eseguendo due o più istruzioni verrebbe assorbito il costo di PUSH e POP e a un certo punto si andrebbe in vantaggio in termini di spazio utilizzato da queste istruzioni. Mentre e come già detto, tutte le istruzioni che usano i nuovi registri di APX richiederebbe sempre l’uso di REX2, pagando costantemente due byte ogni volta.

Un altro scenario per emulare il funzionamento dei nuovi registri APX sarebbe quello dell’uso dello stack come una sorta di banco di registri addizionali, referenziandone direttamente precise locazioni (ad esempio [SP+16] per emulare il nuovo registro R16, [SP+24] per R17, [SP+32] per R18, e così via).

In questo caso i costi sarebbero differenti, in base all’uso che se ne farebbe. Ad esempio copiare da/a lo stack richiede l’impiego di un’istruzione MOV, che occupa tre byte (opcode + ModR/M + offset a 8 bit) se si usano soltanto registri x86 e 4 byte (è necessario premettere il prefisso REX) se si usa almeno un registro x64.

Mentre una MOV equivalente usando uno dei nuovi registri messi a disposizione da APX avrà sempre bisogno di REX2, ma non dell’offset a 8 bit, quindi richiederà sempre 4 byte. In questo caso la soluzione con lo stack (x64) risulterebbe più vantaggiosa (si potrebbe risparmiare un byte, mentre nel caso peggiore occuperebbe lo stesso spazio).

Discorso diverso se il nuovo registro fosse impiegato in istruzioni che usino soltanto registri. In questo caso le istruzioni facenti uso di ModR/M dovrebbero sempre specificare l’offset a 8 bit, mentre le equivalenti APX sarebbero costrette a impiegare REX2. Il vantaggio sarebbe chiaramente della soluzione con stack, perché si risparmierebbe sempre un byte.

Se, invece, il nuovo registro fosse impiegato in istruzioni che referenzino anche una locazione di memoria, allora la soluzione con lo stack diverrebbe meno efficiente e occuperebbe molto più spazio, poiché bisognerebbe trovare un registro libero (di cui conservare il valore) dove caricare il valore dallo stack, poi eseguire l’operazione richiesta, e infine ripristinare il registro che è stato utilizzato.

Servirebbero, quindi, 2-4 byte per “liberare” il registro necessario e 3-4 byte per copiarvi il valore presente nello stack. Mentre usare il nuovo registro con APX richiede soltanto due byte in più dovuti a REX2. Inoltre, se il valore finale dovesse essere anche conservato nello stack, allora servirebbe un’altra istruzione (3-4 byte) allo scopo. Il prezzo pagare, in questi casi, risulterebbe molto salato!

Una soluzione ibrida fra le due (nonché quella preferibile) sarebbe quella di eseguire il PUSH del registro da utilizzare nello stack, per poi usarlo tenendo conto della posizione in cui si trova adesso al suo interno. Alla fine, quando non più utile, una POP ripristinerebbe il contenuto del registro temporaneamente utilizzato. Si tratta di una tecnica che ho utilizzato nella prima metà degli anni ’90, quando mi sono cimentato nella scrittura di un emulatore 80186 per sistemi Amiga dotati di processore 68020 (o superiore), e che ha il pregio di unire i due precedenti scenari, prendendone il meglio (minimizzando i costi dovuti alla conservazione e ripristino del valore precedente nel/dallo stack).

Come si può vedere, a seconda degli specifici scenari risulta avvantaggiato x64 o APX, a seconda di come e quanto vengano utilizzati i nuovi registri.

NF (No Flags. Usando EVEX)

Passando alla funzionalità NF (No Flags), utilizzata per sopprimere la generazione dei flag, essa richiede l’utilizzo del prefisso EVEX, per cui bisogna aggiungere sempre 4 byte alla lunghezza dell’istruzione, tranne quando questa risieda nella mappa 1 (quindi con prefisso 0F): in questo caso caso il prefisso 0F risulta già incorporato in EVEX, per cui i byte aggiuntivi si ridurrebbero a tre.

L’incremento della lunghezza delle istruzioni risulterebbe, quindi, decisamente consistente, se consideriamo che per emularne il funzionamento con x64 sarebbe sufficiente salvare i flag nello stack, tramite istruzione PUSHF, e ripristinarli alla fine delle operazioni (quindi quando serve controllarne o utilizzarne il valore), usando l’istruzione POPF. Costo totale: appena due byte.

Se consideriamo, inoltre, che il blocco delle istruzioni da eseguire potrebbe cambiare i flag più volte e, quindi, che sarebbe necessario utilizzare sempre NF, il costo per APX aumenterebbe ancora di più (mentre per x64 rimane sempre fisso a due byte).

Il vantaggio dell’attuale soluzione (PUSHF + POPF) con x64, quindi, è decisamente maggiore in termini di miglior densità di codice rispetto ad APX.

NDD (New Data Destination. Usando EVEX)

L’ultima funzionalità da prendere in esame e che influenza in maniera consistente la densità di codice è NDD, la quale, come abbiamo già visto, consente di far diventare ternare le istruzioni binarie, e binarie quelle unarie, dando la possibilità di utilizzare un registro come destinazione (con gli attuali due operandi in x64 che, a questo punto, fungono entrambi da sorgenti dati).

Un’istruzione del genere richiede sempre l’uso del prefisso EVEX, per cui valgono le stesse considerazioni fatte prima per NF: servono 4 byte in più, tranne per le istruzioni che stanno nella mappa 1 (che ne richiedono 3), a cui poi si aggiunge il byte dell’opcode e quello del ModR/M. Quindi in totale servirebbero sempre (almeno) 6 byte.

Per emularne il funzionamento con x64 servirebbe sempre un’istruzione in più: una MOV che copiasse il valore della prima sorgente nel (nuovo) registro di destinazione. Come abbiamo visto, ciò richiede 2-3 byte. Successivamente servirebbe l’istruzione che esegue la vera e propria operazione, la quale richiederebbe a sua volta 2-3 byte. Quindi in totale servirebbero 4-6 byte.

Da questi calcoli ho rimosso, per semplificare un po’ il discorso, i dati riguardanti eventuali offset e/o valori immediati, in quanto risultano invarianti rispetto a entrambe le soluzioni (occupano esattamente gli stessi byte ).

Ciò che rimane è la reale differenza fra APX e x64 quando c’è da eseguire un’istruzione ternaria o binaria (emulata, nel caso di quest’ultima), ed è possibile vedere come x64 risulterebbe più efficiente o, al massimo, avrebbe lo stesso costo.

Tirando le somme

Cercando di arrivare a delle conclusioni, se prendiamo le singole nuove funzionalità di APX, la mia opinione è che il loro utilizzo inciderà, in media, in maniera decisamente negativa sulla densità di codice, la quale diminuirà rispetto a x64 (che, come già detto, non è certamente messa bene da questo punto di vista, rispetto ad altre architetture — inclusa la x86 da cui deriva).

Avendo, invece, la possibilità di combinare / usare più di queste funzionalità nella stessa istruzione, ci sarebbero dei vantaggi (se ne cumulerebbero i risparmi). Ma non credo che scenari del genere siano, in ogni caso, così comuni da influenzare pesantemente la densità di codice. A mio avviso non lo sono abbastanza da portare APX, complessivamente, anche soltanto ad arrivare vicino a x64.

Vedremo concretamente in futuro, quando verranno rilasciati i primi processori con APX e i binari corrispondenti, poiché quelle che ho fornito al momento sono valutazioni personali dettate soltanto dalla mia esperienza in quest’ambito e dalla mia analisi dei costi d’impiego delle nuove funzionalità di questa estensione.

Il prossimo articolo affronterà il tema dei costi implementativi di APX.

Press ESC to close