NEx64T – 5: modalità d’indirizzamento e valori immediati

Come abbiamo avuto modo di vedere nel precedente articolo, uno dei punti di forza di NEx64T è quello di mettere a disposizione una ricca combinazione e varietà di configurazioni in cui le istruzioni “base” possono operare, e in particolare potendo indirizzare direttamente la memoria per uno o (al massimo) due degli operandi.

In questo senso la nuova architettura s’inserisce pienamente nel solco di buona parte della macrofamiglia dei processori CISC, ponendosi, quindi, in maniera diametralmente opposta a quella delle architetture L/S (Load/Store. Gli ex-RISC).

Avere la possibilità di poter indirizzare la memoria da parte delle normali istruzioni è un vantaggio enorme, come abbiamo già avuto modo di discutere diverse volte, ma se le modalità d’indirizzamento a disposizione sono poche allora questo vantaggio si può ridurre considerevolmente.

L’eredità di x86 e x64…

Fortunatamente NEx64T nasce come profonda ispirazione & riscrittura di x86 e x64, che contano già diverse utili nonché comuni modalità messe a disposizione per poter referenziare dati direttamente in memoria, com’è riportato nella seguente tabella:

NEx64T deve necessariamente supportare tutte queste modalità d’indirizzamento per poter essere pienamente compatibile con entrambe le architetture a livello di codice sorgente assembly, ed è effettivamente ciò che si verifica, ma con alcuni distinguo.

Infatti, e come avevo già anticipato nel precedente articolo, le modalità d’indirizzamento con indice scalato (le prime due riportate nella tabella) sono supportate soltanto se lo scaling è della stessa dimensione del dato letto o scritto, altrimenti è necessaria un’istruzione aggiuntiva (da eseguire prima dell’istruzione vera e propria).

Si tratta di una precisa scelta architetturale, anche perché lo spazio a disposizione per poter specificare il valore dello scaling avrebbe comportato sacrifici che non avrebbero giustificato i minori benefici che ne sarebbero derivati.

D’altra parte è bene sottolineare come l’obiettivo di essere pienamente compatibili a livello sorgente è ugualmente raggiunto, e ciò non implica necessariamente che si debbano effettuare le stesse scelte di x86 e x64. Quest’aspetto verrà trattato meglio nel prossimo articolo, che si occuperà specificamente dell’aspetto legacy relativamente a queste due vecchie architetture.

Ciò rappresenta l’unica differenza (puramente formale, visto che è possibile emularla tranquillamente), poiché il resto delle modalità è supportato senza problemi o anche meglio. Infatti x86 e x64 hanno dei problemi con alcune modalità, perché non possono utilizzare tutti i registri, ma in alcune configurazioni non è possibile specificarne qualcuno, mentre con NEx64T si può usare qualunque registro sia come base sia come indice.

Un’altra dissonanza rilevante è rappresentata dalla modalità d’indirizzamento relativa al PC (RIP) che è stata introdotta da ed è disponibile soltanto con x64, mentre x86 ha soltanto quella assoluta. E viceversa, visto che quest’ultima manca in x64.

NEx64T consente, invece, di usarle entrambe sia in modalità a 32 bit sia in quella a 64 bit. Unica accortezza: per quella assoluta si può utilizzare soltanto un indirizzo a 32-bit (quindi indirizzare soltanto i primi 4GB di memoria).

Nel caso della modalità a 64 bit e se si volesse leggere o scrivere un valore in uno qualunque degli indirizzi a 64 bit rimane sempre la possibilità, come per x64, di utilizzare una particolare istruzione MOV che consente di farlo (più aventi se ne parlerà meglio).

…e le novità di NEx64T

Sebbene NEx64T si dimostri già sostanzialmente un sovrainsieme proprio (superset) rispetto alle altre due architetture, quella presentata è soltanto la punta dell’iceberg, in quanto parecchie altre modalità d’indirizzamento sono state introdotte, com’è possibile vedere da questa nuova tabella:

La prima è stata “presa in prestito” dalla gloriosa famiglia Motorola 68000 (ancora una volta grande ispiratrice per NEx64T), la quale consente di utilizzare un indice scalato usando, però, il PC (RIP, nel gergo x64) come registro base.

Quindi permette di indirizzare strutture più complesse (vettori, ad esempio) che si trovino a una certa distanza dal codice che le sta referenziando, senza richiedere che l’indirizzo base sia prima caricato su un registro (come avverrebbe con x64. Con x86, invece, è possibile specificare un offset a 32 bit che, quindi, copre l’intero spazio d’indirizzamento).

In questo modo è possibile realizzare le utilissime tabelle di puntatori a funzione (costrutti switch/case complessi, emulatori, macchine virtuali, …) in maniera molto semplice, diretta, veloce e senza nemmeno sporcare un registro (introducendo, tra l’altro, una dipendenza nella pipeline).

Le altre quattro modalità che seguono sono quelle di pre-incremento, pre-decremento, post-incremento, e post-decremento. Tutte e quattro fanno uso di un registro base, al quale viene aggiunto o sottratto (nei primi due casi) un valore costante (si tratta, in genere, della dimensione dell’elemento) prima di leggere o scrivere il dato alla locazione di memoria referenziata, oppure tale valore viene aggiunto o sottratto soltanto dopo la lettura o scrittura (quindi negli ultimi due casi).

Un caso d’uso frequente per un processore è quella di eseguire operazioni su blocchi di memoria (vettori di interi, di valori in virgola mobile, di puntatori, di strutture dati aventi dimensione fissa), i quali vengono scansionati dall’inizio (primo elemento) alla fine (ultimo elemento), o viceversa.

Spesso tale codice viene implementato facendo uso della suddetta modalità d’indirizzamento indicizzata e scalata, ma soltanto per le architetture che ne hanno una a disposizione. In mancanza si deve far ricorso ai puntatori: si calcola l’indirizzo del primo elemento (o dell’ultimo, se si deve scorrere in maniera inversa), si processa, poi aggiorna l’indirizzo per farlo puntare al successivo elemento, e così via.

A volte si preferisce utilizzare i puntatori anche nel caso in cui un’architettura abbia a disposizione la modalità indicizzata scalata, perché magari il codice che viene generato è più compatto e/o più veloce.

Quale che sia la ragione, ciò comporta l’esecuzione di apposite istruzioni per l’aggiornamento del puntatore, ogni volta che un elemento è stato processato. Inoltre se si manipolano più strutture dati come queste allo stesso tempo, servono più puntatori (idealmente e pensando al caso generale, uno per ogni struttura che dev’essere letta, e uno per ogni struttura che dev’essere scritta) e, quindi, più istruzioni per il loro aggiornamento.

Le summenzionate quattro nuove modalità aiutano enormemente in questi scenari piuttosto comuni, in quanto consentono di eseguire le operazioni di aggiornamento dei puntatori in maniera automatica a seguito della lettura o scrittura dello specifico elemento, ottenendo notevoli benefici sia in termini di densità di codice (con la stessa istruzione si fa tutto) sia a livello prestazionale (meno istruzioni eseguite).

Alcuni processori, come il già menzionato 68000, supportano tutte o alcune di tali modalità, ma sono limitati soltanto all’incremento o decremento della sola dimensione del dato letto o scritto (chiamato unit stride in gergo). Altre, come ARM ad esempio, consentono di specificare un piccolo valore immediato (chiamato data stride in gergo), in modo da essere un po’ più generali.

NEx64T si pone molto più avanti da questo punto di vista, perché offre molta più flessibilità: oltre allo unit stride, consente di specificare un data stride con valori immediati di una certa dimensione (fino a 32 bit), quindi coprendo molti più casi e consentendo di rimpiazzare più istruzioni di aggiornamento dei puntatori.

La modalità d’indirizzamento etichettata come “Embedded & more” non ha un’utilità così generale. Serve per mercati / casi d’uso più di nicchia, come quello embedded, per l’appunto, o per lavorare su alcuni aspetti di sistema; c’è anche qualche idea per sfruttarla a livello più generale. In ogni caso al momento preferisco non rilasciare ulteriori dettagli, per non scoprire troppe carte: basti sapere che c’è qualcosa in più rispetto a quanto normalmente si trova quando si parla di modalità d’indirizzamento per un processore.

Modalità d’indirizzamento “compresse”

Infine quella definita come “Compressed TLS” rappresenta una semplificazione / velocizzazione del meccanismo di funzionamento del cosiddetto Thread-Local Storage (TLS, per l’appunto) che su x86 e x64 viene implementato coi famigerati prefissi (viene usato FS nel codice a 32 bit e GS in quello a 64 bit).

In questo caso il funzionamento di un’istruzione che referenzia un dato in memoria viene alterato grazie all’apposito prefisso, il quale istruisce il processore ad aggiungere un ulteriore valore all’indirizzo della locazione di memoria, che corrisponde alla “base” per il particolare segmento selezionato.

Nulla di trascendentale, quindi, a parte l’uso dell’arcinoto prefisso, il quale fa ovviamente allungare l’istruzione che ne fa uso. A questo scopo, e siccome nel codice di applicazioni reali se ne trova non raramente l’uso, ho pensato che fosse più efficace definire un’apposita modalità che lo rendesse immediatamente utilizzabile e senza richiedere alcun prefisso e istruzioni più lunghe.

La differenza, rispetto a x86 e x64, è che risulta adesso possibile utilizzare il TLS anche con istruzioni che referenzino più operandi in memoria (come abbiamo già visto in un precedente articolo). Dunque più libertà e flessibilità, a cui si aggiungono anche istruzioni più corte (e meno complesse da decodificare).

Questa nuova modalità, però, funziona esclusivamente quando si referenzia un indirizzo assoluto in memoria (che in realtà sarà soltanto un offset a partire dall’indirizzo base del segmento specificato). Diversamente (quindi per tutte le altre modalità d’indirizzamento) è necessario utilizzare una codifica più lunga (che, però, finora non ho mai riscontrato nel codice disassemblato).

Sempre a beneficio della pluricitata densità di codice c’è da aggiungere che NEx64T utilizza diverse versioni “compresse” di alcune delle modalità d’indirizzamento più utilizzate (ad esempio la celeberrima base + offset, con base rappresentata da un qualunque registro) con l’esplicito obiettivo di ridurre la lunghezza delle istruzioni.

x86 e x64, ad esempio, consentono di utilizzare offset di soli 8 bit, oltre quelli di 32-bit, oppure di sopprimere del tutto l’offset. Altre architetture hanno strutture simili nelle istruzioni che consentono di accedere alla memoria.

E’ per tale motivo che NEx64T ne integra una certa varietà, che ha consentito di compattare ancora di più il codice e ottenere i risultati (preliminari) che sono già stati presentati e ampiamente discussi nei passati articoli.

Modalità d’indirizzamento “larghe”

Potrà sembrare paradossale, ma a fare da contraltare a tali versioni corte c’è anche la possibilità di utilizzare alcune modalità per poter indirizzare locazioni di memoria che vanno ben oltre i canonici 32-bit / 4GB di memoria indirizzabile, e con ciò mi riferisco esplicitamente alla modalità d’esecuzione a 64 bit.

Infatti se le architetture a 64 bit sono nate è stato principalmente per poter indirizzare molta più memoria di quella che un sistema a 32 bit consentisse (pur con pastrocchi come la PAE, che è stata aggiunta appositamente per superare i soliti 4GB di memoria fisicamente indirizzabile).

Si pone, però, il problema di come fare a raggiungere porzioni di codice o dati così lontani. Una soluzione immediata è quella di poter definire una modalità d’indirizzamento assoluta a 64 bit, in modo da poter coprire tutto il nuovo spazio d’indirizzamento. Si capisce facilmente, però, che le istruzioni diventeranno particolarmente lunghe, a detrimento della densità di codice e, quindi, delle prestazioni.

Un’altra soluzione è quella di delegare soltanto ad alcune speciali istruzioni di load/store tale possibilità, ed è ciò che fa x64, dove esiste una versione dell’istruzione MOV che consente di leggere o scrivere dati da/su qualunque indirizzo di memoria, utilizzando l’accumulatore (il registro AL/AX/EAX/RAX).

NEx64T supporta anche quest’istruzione, generalizzandola con la possibilità di poter poter specificare un qualunque registro anziché limitarsi soltanto all’accumulatore Rimane, in ogni caso, il fatto che tali istruzioni siano particolarmente lunghe (9 o 10 byte per x64; 11 per la nuova estensione/architettura APX di Intel. 10 per NEx64T).

Per cercare di mitigare la necessità di ricorrere a tali istruzioni, NEx64T consente di superare i canonici 32-bit di offset per alcune precise utilizzare modalità d’indirizzamento, ma senza utilizzare spazio aggiuntivo negli opcode delle istruzioni. Il che permette di indirizzare memoria fino a circa 1TB di “distanza” in alcuni casi molto comuni, inclusi pezzi di codice.

Aggiungo, a completamento, che le istruzioni di salto e chiamata a sottoprogramma sono disponibili in alcune versioni più compatte, ma le più lunghe delle quali consentono in ogni caso raggiungere 1TB di “distanza”, non impattando sostanzialmente sulla densità di codice.

A tal proposito sottolineo come NEx64T non abbia alcuna istruzione di salto “lungo”, in grado di raggiungere una qualunque locazione di memoria dello spazio d’indirizzamento a 64 bit, come invece Intel ha introdotto con la già citata APX. La ragione è alquanto semplice: potendo già eseguire salti e chiamate a routine con indirizzi assoluti nell’ordine del TB, tale esigenza viene meno.

Il perché sia così importante poter accedere a codice o dati molto lontani da dove vengono referenziati è, infine, ben illustrato in un interessantissimo articolo che copre tutti i “modelli” di codice/dati che sono utilizzabili su x64 (ben sei: small, medium, large. Tutti e tre disponibili in versione assoluta o relativa al PC).

Molto probabilmente i modelli small e medium potrebbero essere raggruppati, grazie alle innovazioni introdotte da NEx64T e che sono state spiegate qui sopra, a tutto vantaggio sia degli sviluppatori sia delle prestazioni.

Valori immediati

Ultima, ma assolutamente non meno importante, nuova funzionalità aggiunta da questa nuova architettura è quella di poter specificare valore immediati di una certa grandezza (che per semplicità chiamerò tiny, small e medium, senza indicare in maniera precisa quanta informazione siano in grado di trasportare / codificare).

Sappiamo che x86 e x64 hanno già delle istruzioni che sono in grado di specificare valori immediati di certe dimensioni, e anche NEx64T, dovendone essere pienamente compatibile a livello di sorgente assembly, è in grado di farlo (ma consentendo di usare anche immediati di ben 64 bit in questi casi, mentre x64 si ferma ai soli 32. Ecco, quindi, un ulteriore vantaggio!).

La novità di cui parlo, però, è di tutt’altro profilo e attiene alla capacità di poter specificare valori immediati in qualunque istruzione che sia in grado di referenziare locazioni di memoria e con le modalità d’indirizzamento discusse già all’apertura dell’articolo.

Da questo punto di vista è equiparabile a una nuova modalità d’indirizzamento, solo che anziché referenziare una locazione di memoria per poterne leggere il contenuto e fornirlo poi all’istruzione, tale valore risulta già immediatamente disponibile e utilizzabile, similmente a quanto consentono di fare da decine di anni i favolosi 68000 di Motorola (che non mi stancherò mai di citare).

Tale funzionalità risulta estremamente importante, poiché le sue benefiche ricadute sono molteplici, ricadendo in alcune aree particolarmente strategiche e fondamentali per un processore:

  • minor lunghezza delle istruzioni (quindi vantaggi nella stracitata densità di codice);
  • riduzione del numero di dati utilizzati (perché vengono rimossi dalla sezione dati, essendo già direttamente presenti nelle istruzioni che li usano. In sostanza gli offset usati per poter caricare i dati da tale sezione vengono rimpiazzati coi dati stessi);
  • riduzione del numero di accessi in memoria (per leggere quei dati).

L’impatto di tutto ciò ricade, quindi, sia nella cache codice sia in quella per i dati e, di conseguenza, sull’intera gerarchia della memoria, sulle prestazioni, e sui consumi del processore.

A corollario serve precisare che tali valori immediati codificano le informazioni in maniera diversa, a seconda della tipologia di istruzione che li usa. Saranno:

  • valori interi (con o senza segno) per le istruzioni general-purpose / “intere” / “scalari”, oppure per quelle dell’FPU x87 o dell’unità SIMD / vettoriale che elaborano valori interi;
  • valori in virgola mobile per le istruzioni di FPU/SIMD/vettoriali che elaborano valori di questo tipo;
  • costanti predefinite per alcuni particolarissimi numeri interi (256, ad esempio) o in virgola mobile (pi greco, ad esempio).

In tutti questi casi si possono utilizzare valori valori “più corti” a prescindere dalla dimensione dei dati che l’istruzione deve elaborare. Se, ad esempio, si sta effettuando la somma di dati a 64 bit ma il valore immediato che serve richiede meno bit per essere specificato, allora se ne può utilizzare una versione più breve fra le tre disponibili (tiny, small o medium).

Allo stesso modo, per i valori in virgola mobile si può benissimo utilizzare un valore immediato fra quei tre formati disponibili e scegliendo quello che riesce a esprimere lo stesso numero (o ci si avvicina molto) anche se l’istruzione in oggetto sta effettuando una somma a 128 bit (FP128).

Dulcis in fundo, il valore immediato (intero o in virgola mobile) viene automaticamente duplicato su tutti gli elementi da elaborare quando si usano istruzioni SIMD di tipo “packed” o quelle vettoriali, non richiedendo pertanto l’esecuzione di apposite istruzioni (chiamate di broadcast o splat, in gergo) e, quindi, l’uso di un registro intermedio da utilizzare per l’operazione (che in genere comporta l’introduzione di una dipendenza nella pipeline del processore).

Conclusioni

E’ arrivato il momento di fermarci, perché la carne al fuoco è stata davvero tanta, ma era anche molto importante poter illustrare le numerose nonché utilissime innovazioni di questa nuova architettura.

Purtroppo della maggior parte di queste non è stato possibile trarre vantaggio (in particolare per i valori immediati), poiché servono compilatori appositi che siano in grado di sfruttarle all’occorrenza.

Sono, però, decisamente convinto che grazie a esse il trend, già illustrato brevemente nei grafici riportati nei precedenti articoli, sarà destinato a migliorare nettamente i risultati in termini di densità di codice, numero di istruzioni eseguite, e riduzione delle sezioni dei dati.

Visto che se n’è già parlato in diverse occasioni, il prossimo articolo verterà sugli aspetti “legacy” di NEx64T, cioè di come sia stato possibile introdurre una struttura completamente diversa degli opcode rispetto a x86 e x64, rimanendo totalmente compatibile a livello di sorgenti assembly e, quindi, dovendo supportare un bel po’ di zavorra che queste due vetuste architetture si portano dietro.

Press ESC to close