Il legacy di x86 & x64 – parte 8 (SIMD: MMX, 3DNow!, e… SSE)

Ci sono voluti 7 articoli per trattare di una problematica che nasce col capostipite della famiglia, l’8086, e che si è evoluta coi successori, principalmente con l’80286, ma anche con l’80386 (che ha duplicato, per i 32 bit, quanto introdotto dal precedente, per i 16 bit).

Il dubbio, sicuramente lecito, è che ci siano elementi di legacy anche nelle unità SIMD che sono state introdotte nell’architettura x86, e che quindi si trascinano anche nelle successive implementazioni.

MMX è stata la prima, e si espone a delle critiche, anche se, come abbiamo visto, bisogna sempre contestualizzare, poiché parliamo di un’estensione dell’ISA che è arrivata per i Pentium nel lontano 1996 quando il concetto di SIMD, già diffuso nelle workstation, era ormai maturo per essere portato anche nel mercato consumer.

Infatti la più grossa pecca di questa prima implementazione è rappresentata dall’aver utilizzato gli 8 registri dell’FPU per memorizzare i dati su cui lavorare. Ciò ha posto un limite sia al numero (8, appunto; rimasto sempre invariato fino ai giorni nostri) che alla dimensione (degli 80 bit a disposizione, ne sono sempre stati usati 64, quelli della mantissa).

Rispetto all’FPU i miglioramenti sono stati notevoli dal punto di vista della programmazione, perché è possibile indirizzare ogni registro direttamente, senza far ricorso al meccanismo dello stack che abbiamo già avuto modo di analizzare.

In realtà lo stack è sempre presente, ma la cima dello stack risulta bloccata all’indice zero, a prescindere da com’era lo stato dell’FPU. Infatti qualunque istruzione MMX provvede ad azzerare l’indice, la tag word di x87 (che indica lo stato di validità di un determinato registro), e impostare a 1 tutti i bit dell’esponente (il risultato vero e proprio risiede nella mantissa, come già detto).

Purtroppo la scelta fatta comporta un prezzo da pagare: non è possibile utilizzare contemporaneamente le istruzioni per l’FPU e quelle SIMD. Se si vuole mischiare le due tipologie di codice, è necessario effettuare una transizione (ripulire i registri dell’FPU tramite un’apposita istruzione EMMS), che richiede un certo numero di cicli di clock per rendere utilizzabile nuovamente l’FPU.

Comunque tolte le operazioni di “pulizia” effettuate a ogni esecuzione di un’istruzione MMX, che possiamo senz’altro annoverare come eredità di x86 (x87, per la precisione), non possiamo considerare anche come legacy l’utilizzo dei registri dell’FPU, sebbene ciò abbia, di fatto, limitato fortemente l’espansione dell’unità SIMD, che è rimasta vincolata agli 8 registri a 64 bit a disposizione.

Non si può considerare come legacy, perché non si tratta di una progettazione “scellerata” o figlia di vecchie scelte dell’architettura, poiché all’epoca ciò rappresentava un ottimo compromesso, e non soltanto per x86, ma anche per tutte le altre famiglie di microprocessori che hanno aggiunto il supporto alle istruzioni SIMD nella loro ISA, potendo risparmiare parecchi transistor rispetto all’aggiunta di un’intera nuova unità nel core.

Così fece Sun con SPARC un anno prima, che introdusse le use istruzioni VIS che sfruttavano i registri dell’FPU allo scopo, e idem MIPS con la sua estensione MDMX nello stesso periodo in cui intel introdusse le MMX. Peggio ancora per HP coi suoi PA-RISC, che nel 1994 avevano introdotto la prima implementazione delle SIMD, MAX, che però operavano sui registri general purpose; approccio copiato anche da ARM con le sue prime estensioni SIMD, salvo poi ripiegare sui registri dell’FPU con la sua nuova unità NEON.

Soltanto Apple, Motorola e IBM, unite nel consorzio PowerPC, produssero un’unità SIMD totalmente indipendente, Altivec, che poteva contare con un proprio insieme di registri, 32 a 128 bit, separati da quelli general purpose e dell’FPU. Era, però, il 1998, ed era già possibile impaccare molti più transistor in un core.

Lo stesso fece Intel l’anno successivo, con la sua nuova unità SSE, che però metteva a disposizione soltanto 8 registri a 128 bit, a causa delle limitazioni dell’ISA x86, la cui struttura degli opcode e, in particolare, dei byte di estensione per l’indirizzamento della memoria, dedicavano soltanto 3 bit per specificare un registro.

Prima di passare alle SSE, per chiudere con le MMX bisogna dire che aggiungevano 47 istruzioni, e queste sono state mappate nello spazio dei 256 opcode che è stato “aperto” con l’80286, sfruttando come prefisso l’opcode 0F (esadecimale), che corrispondeva all’istruzione POP CS negli 8086, divenuta poi illegale (non si può impostare soltanto il registro CS, ma è necessario specificare contemporaneamente l’offset IP/EIP/RIP).

Ad esempio, la summenzionata EMMS è codificata con l’opcode 0F 77, mentre PADDB (che somma tutti i singoli byte del registro destinazione con quelli della sorgente, la quale può essere un altro registro o una locazione di memoria, sempre a 64 bit) con 0F FC (a sui seguono tutti i byte che rappresentano l’estensione per specificare sia il registro destinazione che l’operando sorgente).

In questo caso possiamo parlare di legacy per MMX nella misura in cui viene utilizzato il famigerato meccanismo dei prefissi, 0F in questo caso, per estendere la tabella degli opcode, e per la consueta disposizione casuale (non pseudo, questa volta) degli opcode all’interno di questa “sotto-tabella”.

3DNow! è l’estensione SIMD introdotta da AMD dopo l’MMX di Intel, che con essa condivide lo stesso principio, cioè utilizzare i registri dell’FPU per mappare le coppie di valori in virgola mobile a 32 bit su cui lavora, divenendo quindi complementare a MMX che, invece, manipola esclusivamente valori interi (a 8, 16, e 32 bit; in rari casi a 64 bit).

Di conseguenza ne condivide anche tutti i limiti già menzionati, ma peggiora notevolmente la situazione per quanto riguarda la mappatura delle istruzioni, poiché fa uso di un doppio prefisso, 0F 0F, che segnala la presenza di un opcode 3DNow!, a cui seguono i byte per l’estensione che definisce i due operandi, e infine un ultimo byte che specifica l’effettivo tipo di istruzione. Soltanto per FEMMS, introdotta per velocizzare il cambio di contesto (da MMX/3DNow! a FPU/x87), l’opcode è codificato come 0F 0E.

Probabilmente sono stati da un parte il successo delle 3DNow!, che mettevano a disposizione su x86 parecchia potenza di calcolo utilizzabile per i calcoli in virgola mobile a 32 bit, e dall’altra le Altivec dei PowerPC (all’epoca ancora antagonisti, sebbene non a livello di AMD), a mettere pressione a Intel, che ha rilasciato di corsa le SSE l’anno successivo.

Le SSE hanno rappresentato da una parte la risposta di Intel al mercato, che richiedeva ormai operazioni SIMD anche su valori in virgola mobile a 32 bit, e dall’altra la necessità di poter elaborare più dati rispetto a quanti potevano essere contenuti nei 64 bit fino ad allora utilizzati, svincolandosi al contempo dall’FPU, che poteva continuare a essere utilizzata in maniera indipendente rispetto alla nuova unità SIMD.

Si è trattato di una decisione che inizialmente è costata cara a Intel, in primo luogo perché è arrivata in ritardo rispetto alla concorrenza (che, quindi, ha potuto spingere i propri prodotti, a danno del colosso di Santa Clara), e secondo perché l’aggiunta di un’unità nuova di pacca richiedeva non soltanto la scrittura di nuovo codice applicativo, ma il ben più importante cambiamento dei kernel dei sistemi operativi, i quali dovevano tenere conto dello stato delle SSE nelle fasi di context-switch di un processo/thread.

Nel lungo termine, però, ha pagato. AMD, infatti, è rimasta vittima della cristallizzazione dell’FPU, che dalla sua introduzione non ha cambiato né il numero di registri né tanto meno la loro dimensione, per cui le 3DNow! hanno dovuto cedere il passo alle maggiori prestazioni ottenibili con le SSE, che pur avendo lo stesso numero di registri (8) hanno potuto elaborare fino al doppio di operazioni, grazie alla raddoppiata capienza (128 bit anziché 64 bit).

Una scelta sicuramente vincente perché, svincolandosi completamente dall’FPU (o, peggio ancora, dai registri general purpose della CPU), ha aperto le porte a futuri ampliamenti, che si sono verificati una prima volta col passaggio alla versione a 64 bit dell’architettura x86 (x64 che, ironia della sorte, è stata introdotta proprio da AMD), e di recente con le estensioni AVX di Intel, che hanno portato i registri da 128 a 256 bit (raddoppiando, quindi, la capacità strettamente teorica di elaborare dati).

Chiusa questa parentesi, nelle SSE troviamo, però, pesanti tracce dell’eredità di x86, come pure delle precedenti MMX. Lasciamo un attimo da parte le SSE, che hanno introdotto quasi esclusivamente istruzioni che lavorano su valori in virgola mobile a singola precisione, e quindi non sono direttamente confrontabili con le MMX.

Prendiamo l’esempio precedente dell’istruzione PADDB, ma tenendo tenendo conto delle SSE2, che hanno introdotto nella nuova unità SIMD il supporto alle stesse istruzioni presenti nelle MMX, che però lavorano suoi nuovi registri e, quindi, beneficiano dell’estensione a 128 bit. La stessa istruzione è presente nelle SSE2, con lo stesso mnemonico, ma che lavora sui nuovi registri XMM anziché MMX.

L’opcode utilizzato è sostanzialmente lo stesso: 66 0F FC. La differenza rispetto alla versione MMX è rappresentata dall’uso del prefisso 66 (sempre esadecimale, lo ricordo) davanti ai rimanenti byte, che sono poi esattamente gli stessi (usando identici operandi, ovviamente).

Sono tornati, quindi, i famigerati prefissi; nello specifico, il 66 è quello introdotto da Intel sull’80386 per segnalare la presenza di un’operazione a 32 bit anziché a 16 bit, mentre qui viene usato per segnalare un’operazione a 128 bit anziché a 64 bit, con l’aggiunta di un set di registri diverso.

Questa soluzione presenta pregi e difetti. Complica il decoder, perché deve avere a che fare ancora una volta coi prefissi, ma semplifica l’implementazione della microarchitettura, poiché l’istruzione è sostanzialmente la stessa, e quindi alla fine potrebbe essere mappata sulla stessa micro-op RISC, con l’aggiunta di qualche flag che differenzia l’una o l’altra modalità.

Semplifica il backend, perché l’unità che si occupa dell’elaborazione vera e propria può essere “figlia unica”: può prendere sempre dati a 128 bit, e restituire un risultato a 128 bit. Sarà poi la logica rimanente a occuparsi di inviarle i dati dai registri MMX (espandendoli a 128 bit, magari azzerando i 64 bit alti) o SSE, e viceversa salvare poi il risultato (troncandolo a 64 bit per le MMX, prendendo soltanto i 64 bit bassi).

Più intelligentemente, si potrebbe segnalare all’unità di elaborazione di “spegnere” le parti che non sarebbero utilizzate, in modo da risparmiare energia, similmente a quanto ha fatto Intel con Knights Corner (ex Larrabee), tramite l’uso di apposite “maschere”; ma questo è un altro discorso.

Preciso che la semplificazione di microarchitettura e/o backend è una mia idea, che potrebbe non trovare riscontro poi nella realtà. Un esperto in materia mi correggerà, eventualmente, ma ho voluto soltanto esprimere una mia valutazione, da profano, sull’argomento.

Tornando alle SSE, c’è da dire che il meccanismo di “espansione” e utilizzo dei prefissi è stato utilizzato ampiamente. Nelle SSE con l’introduzione del supporto ai valori in virgola mobile a 32 bit in versione packed (4 alla volta) e scalare (uno solo alla volta). Nelle SSE2 con l’aggiunta del supporto ai valori in virgola mobile a 64 bit, sempre in versione packed (2 alla volta, però) e scalare; oltre, ovviamente, all’aggiunta delle “duali” rispetto alle MMX per le elaborazioni intere, come abbiamo visto sopra.

Ciò si traduce nell’uso dell’opcode “nudo e crudo” per le istruzioni che operano con valori in virgola mobile a 32 bit packed, con l’aggiunta del prefisso F3 per specificare che l’operazione dev’essere scalare, col (solo) prefisso 66 per segnalare l’uso di valori in virgola mobile a 64 bit, e infine col prefisso F2 per la medesima, ma che opera però con un solo valore.

Un esempio pratico per ognuno dei quattro casi è dato dall’istruzione “ADD” che somma valori in virgola mobile tramite SSE/2. E’ presente con opcode 0F 58 nel primo caso (ADDPS), come F3 0F 58 nel secondo (ADDSS), 66 0F 58 nel terzo (ADDPD), e infine come F2 0F 58 (ADDPS). Quattro istruzioni con lo stesso opcode “base”, che si differenziano per il solo uso di uno dei vari prefissi (o dell’assenza).

Da notare che i prefissi F3 ed F2 non sono stati introdotti con le SSE ed SSE2, ma erano già presenti. Sono, infatti, quelli utilizzati dalle istruzioni di ripetizione sulle stringhe, di cui abbiamo discusso nel primo articolo di questa lunga serie.

Possiamo notare da questa breve analisi, concludendo, che sono pochi gli elementi di legacy che possono incidere nelle scelte fatte da Intel (e, in parte, da AMD) per le unità SIMD dell’architettura x86. Tolta l’implementazione delle mere operazioni da effettuare, che possiamo banalmente considerare come eguali per tutte le unità SIMD (non è realmente così, sia chiaro; semplifico a scopo puramente divulgativo), il più grosso peso sta sicuramente nella notevole complessità del decoder.

Questi, infatti, doveva già tener conto dell’uso dei prefissi per le normali istruzioni, e adesso dovrà farlo anche per quelle SIMD. Essendo queste ultime parecchie (nell’ordine delle centinaia), si può immaginare come le unità di decodifica siano diventate più complesse, e di conseguenza anche più affamate di corrente, per svolgere il loro compito.

A maggior ragione se consideriamo che l’introduzione di così tante istruzioni ha portato rapidamente all’esaurimento dello spazio disponibile per gli opcode, che era stato “aperto” col citato prefisso. Per ovviare a questo problema, Intel ha dovuto introdurre due ulteriori prefissi, 0F 38 e 0F 3A, che hanno consentito di espandere ulteriormente la opcode table con altre 512 (256 + 256) possibili istruzioni. Prefissi che, questa volta, occupano 2 byte anziché uno (prima c’era soltanto 0F).

Ci troviamo, alla fine, con un decoder nettamente più complesso, che consuma di più, probabilmente richiede pipeline più lunghe per svolgere il proprio compito, e con la densità di codice che risulta diminuita per le nuove istruzioni.

Infatti le istruzioni sono mediamente più lunghe a causa dei prefissi 66/F2/F3, per i quali ovviamente si aggiunge un byte. Le nuove istruzioni che usano gli spazi 0F 38/3A richiedono un byte in più rispetto a tutte le altre istruzioni SIMD. Inoltre, sempre per le nuove istruzioni, facendo uso di uno degli altri prefissi, i byte aggiunti diventano due. Senza considerare l’uso del prefisso 0F, che di per sé richiede il suo byte…

Per porre rimedio a questa situazione Intel ha pensato bene di cambiare le carte in tavola, introducendo le istruzioni AVX, che offrono uno schema di decodifica molto più semplificato, ampio spazio per l’aggiunta di nuove istruzioni (senza allungare ulteriormente gli opcode), e altri miglioramenti.

Continueremo a parlarne brevemente nel prossimo articolo, che conclude anche la serie, con una sintesi e delle riflessioni di ciò di cui abbiamo trattato finora.

Press ESC to close