Statistiche su x86 & x64 – parte 8 (istruzioni / mnemonici)

Finora abbiamo visto parecchi aspetti delle istruzioni, ma non ci siamo mai soffermati sull’effettivo tipo di lavoro che eseguissero. Come anticipato nel precedente articolo, arriviamo finalmente a visionare le statistiche delle istruzioni x86 e x64 dal punto vista degli mnemonici.

Prima di procedere preciso che tutte le istruzioni condizionali sono state accorpate, eliminando la condizione di controllo, in modo da ottenere delle statistiche sul “macro-tipo” di operazione svolta, piuttosto che sulla specifica condizione. Non saranno, quindi, visibili istruzioni come JC, ma al suo posto sarà presente soltanto J, che racchiude anche le statistiche di JC, tanto per fare un esempio concreto.

Si tratta di una scelta arbitraria decisa per semplificare la trattazione, in modo da non rendere dispersiva l’analisi di certe famiglie di istruzioni. D’altra parte è quanto viene effettuato anche dalle case produttrici di processori, che nei loro manuali non elencano separatamente tutte le istruzioni, ma le accorpano secondo certi criteri; altrimenti si assisterebbe a un’esplosione del numero di istruzioni, racchiuse in tomi di considerevoli dimensioni.

Un altro motivo è che possiamo pensare alla condizione come a un parametro che viene utilizzato dall’istruzione per svolgere correttamente il proprio lavoro. Nella fattispecie il parametro rappresenta la condizione da controllare, ma in altre istruzioni potrebbe essere il segmento da utilizzare per referenziare la memoria, ad esempio, mentre in processori che supportano l’aggiornamento dell’indirizzo base fra le modalità d’indirizzamento della memoria il parametro potrebbe essere rappresentato dal flag che impone oppure no l’aggiornamento del registro base usato.

Raggruppare le istruzioni è, dunque, una comodità, ma a volte è veramente indispensabile per chi ha a che fare con tabelle degli opcode e la definizione della struttura dei singoli opcode di un’ISA, dove cercare di far collimare tutti i desiderata col poco spazio a disposizione è un’impresa già abbastanza ardua di per sé, pur ricorrendo a questi accorgimenti.

E’ proprio in quest’ottica di semplificazione dell’analisi che le prime tabelle statistiche di mnemonici sono state preparate: ponendo l’attenzione esclusivamente sullo mnemonico, ignorando tutto il resto (parametri e argomenti vari).

Come di consueto, la distribuzione degli mnemonici per x86 fa uso della beta pubblica di Adobe Photoshop CS6 a 32 bit (PS32) per ricavare i dati:

Mnemonic              Count      % Avg sz
MOV                  602130  34.48    3.9
PUSH                 257768  14.76    1.7
CALL                 126675   7.25    4.9
LEA                  121033   6.93    4.2
J                    110954   6.35    2.9
POP                   78536   4.50    1.0
CMP                   68943   3.95    3.4
ADD                   59819   3.42    3.0
TEST                  38756   2.22    2.2
FLD                   34660   1.98    3.7
FSTP                  29664   1.70    3.5
RET                   26552   1.52    1.9
XOR                   25881   1.48    2.0
JMP                   25434   1.46    3.2
SUB                   23560   1.35    2.9
INC                   11675   0.67    1.3
FXCH                   8327   0.48    2.0
FMUL                   7660   0.44    2.9
SHL                    7382   0.42    3.0

Senza grosse sorprese, notiamo subito che i primi dieci mnemonici sono sufficienti a coprire la stragrande maggioranza, pari a circa l’85% del totale, delle istruzioni che sono state trovate e disassemblate. Conteggiando i successivi dieci mnemonici, si arriva al 95% circa, mentre il rimanente 5% si trova spalmato su poco più di un centinaio di mnemonici diversi.

Ovviamente la parte del leone la fa l’istruzione MOV, che da sempre è quella più usata, credo di qualunque architettura si parli (ma bisogna considerare che molte hanno istruzioni dedicate di load / store, con mnemonici e opcode specifici). Ho parlato di istruzione anziché di mnemonico, ma va precisato che i due concetti non sono equivalenti. Infatti lo stesso mnemonico viene usato anche per istruzioni che hanno opcode diversi, o che si comportano anche in maniera diversa.

L’ISA x86/x64 è piena di casi che ricadono nell’uno o nell’altro. Il primo contempla, ad esempio, i cosiddetti “alias”: istruzioni duplicate, che fanno esattamente la stessa cosa, ma con l’unica differenza che risiede nella diversa lunghezza dell’opcode. Il motivo dovrebbe essere evidente, e ricade nella ricerca di una maggiore densità del codice da parte del progettista della CPU.

Ci sarebbe molto da dire sull’argomento, che sembra scontato e di minore importanza rispetto ai due parametri che vengono continuamente citati quando si parla di processori: le prestazioni e il consumo. A mio modesto avviso anche la densità di codice meriterebbe di ricevere la medesima attenzione, visto che ha ricadute sia sull’uno che sull’altro aspetto, e ovviamente sullo spazio occupato dal codice.

Cosa di certo non banale e troppo sottovalutata anche nell’era dei GB di memoria a poco prezzo, ma che è molta cara ai produttori di microprocessori, che altrimenti non tirerebbero fuori intere nuove ISA (come Thumb e Thumb-2 nel caso di ARM, di cui abbiamo già discusso; ma gli esempi in letteratura sono molti) solo per questo motivo. Non essendo oggetto della serie, comunque, la breve parentesi si chiude qui.

Un altro esempio del primo caso riguarda il fatto che per particolari modalità d’indirizzamento viene fatto uso di opcode specifici. Ad esempio per caricare valori immediati esistono opcode appositi, ben diversi dagli altri “più generali”, perché questa particolare modalità non viene considerata un indirizzamento (verso la memoria o un registro), come invece in altre architetture (i Motorola 68000). Anche qui esistono fenomeni di aliasing, con istruzioni duplicate per caricare valori immediati “corti” o per privilegiare i registri più usati (AL, AX, EAX), sempre con l’obiettivo di aumentare la densità del codice…

Nel secondo caso, invece, vanno annoverate le istruzioni di MOV verso registri speciali, come quelli di controllo o debug del processore, che non possono essere eseguite alacremente (essendo vincolate ai livelli di privilegio nell’esecuzione del codice) e che, quindi, possono sollevare eccezioni, oltre al fatto che alterano il comportamento del processore (ad esempio cambiando modalità d’esecuzione, oppure impostando un break point, ecc.).

Queste istruzioni, sebbene molto diverse dalle altre MOV, fanno uso del medesimo mnemonico e, dunque, il loro utilizzo andrà sicuramente a incidere sulle sue statistiche. Tuttavia, anche se dovessero essere presenti (ma non lo sono certamente in applicazioni utente; discorso diverso se disassemblassimo moduli del kernel), il loro uso è talmente raro da non inficiare i risultati generali che stiamo analizzando.

Tornando alle statistiche dopo questo doveroso preambolo, l’elevata frequenza di istruzioni PUSH e CALL è giustificata dall’ABI utilizzata per x86, che privilegia il push di valori sullo stack per il passaggio dei parametri alla routine da chiamare, come abbiamo ampiamente discusso.

Simmetricamente, il minor uso di POP, meno di un terzo rispetto alle PUSH, deriva dal fatto che i parametri passati sullo stack vengono poi eliminati con istruzioni RET dotate di parametro (che indica di “rimuovere” un certo numero di byte, ormai inutili, dallo stack) o da apposite istruzioni SUB (che rimettono a posto lo stack).

Le POP sono utilizzate generalmente per recuperare il valore di un registro che è stato precedentemente conservato temporaneamente sullo stack, tramite una PUSH, perché dev’essere impiegato per qualche altro scopo.

Tenendo conto di tutto ciò, potremmo anche azzardare un calcolo molto rozzo sul numero medio di argomenti passati a una routine. Togliendo dalle PUSH le “simmetriche” POP, e dividendo per il numero di CALL, otteniamo 1,41. Quindi a una routine vengono mediamente passati uno o due parametri. Un valore che, con tutti i limiti del calcolo effettuato, appare plausibile.

Di più difficile interpretazione è l’istruzione LEA, perché viene utilizzata in contesti diversi. Nasce, come suggerisce anche lo mnemonico, per il calcolo di un indirizzo, che viene poi conservato in un registro; quindi entra in gioco nel discorso dell’ABI, sebbene il suo peso si faccia notevolmente sentire per x64.

Data la cronica carenza di istruzioni che operano con più di due argomenti nell’ISA x86 e x64, viene spesso utilizzata per simulare istruzioni ADD, SUB, o MUL/SHL che adoperano fino a 3 argomenti (due registri, più un offset che diventa sostanzialmente un valore immediato a 32 bit con segno) e che infine conservano il risultato in un altro argomento (registro), arrivando quindi a utilizzare 4 argomenti. Com’è facile intuire, si tratta di un’istruzione che consente una flessibilità così elevata da essere particolarmente apprezzata da programmatori e compilatori, da cui deriva, per l’appunto, la sua notevole frequenza (quasi il 7% delle istruzioni).

Subito a ridosso della LEA troviamo l’istruzione J che, come anticipato, racchiude tutte quelle di salto condizionale basate su flag (ne esistono altre, legacy, che operano su registri e/o flag). Quasi il 7% del totale è un valore in linea con quanto ci si aspetta poiché sappiamo che il controllo di condizioni è un’operazione frequente nel codice di tutti i giorni. Croce e delizia dei programmatori…

Non a caso le istruzioni CMP e TEST si trovano subito dopo nella classifica, poiché “preparano il terreno” alle J di cui sopra, che generalmente seguono immediatamente dopo nel flusso di codice (è l’istruzione successiva).

Sorprende il dato sulle ADD, che addirittura segue quello sulle CMP, poiché sappiamo bene che la somma di interi è una delle operazioni più frequenti utilizzate nel codice. Se, però, teniamo conto del fatto che la suddetta LEA viene spesso usata come sostituta della ADD, allora i conti tornano, e lo stesso vale per l’istruzione INC che, incrementando di uno l’argomento, è sostanzialmente a essa equivalente (ma in realtà non lo è: c’è una piccola, ma a volte significativa, differenza che riguarda i flag settati dalle due istruzioni). Un discorso analogo si può dare per la SUB, con la sua duale DEC, e a volte la LEA.

Una veloce panoramica degli ultimi dieci mnemonici mostra come le istruzioni che lavorano con l’FPU (FLD, FSTP, FXCH, FMUL) abbiano un peso consistente su questo gruppetto. Poiché l’FPU lavora sostanzialmente come macchina stack-based (per maggiori informazioni vi rimando al precedente articolo sul legacy di x86 legato all’FPU), le load e store dominano, come pure quelle di scambio di due registri. Soltanto alla fine troviamo la moltiplicazione, che stranamente scalza la somma. Ciò mostra, inoltre, come il codice x86 privilegi l’uso dell’FPU anziché dell’unità SSE; situazione che, come vedremo, si ribalterà con x64.

Singolare risulta, invece, la presenza così massiccia dell’istruzione XOR. E’ un dato che non ci aspetterebbe, poiché l’esperienza ci porta a pensare che le istruzioni AND e OR siano, invece, decisamente più frequenti. La spiegazione di ciò la si trova in mezzo al disassemblato del codice, che è ricco di istruzioni come XOR EAX,EAX, tanto per fare un esempio, le quali, logica di Boole alla mano, non fa che distruggere (azzerare) il contenuto del registro, ma con l’effetto collaterale (rispetto a una MOV) di alterare i flag.

Si tratta, ancora una volta, di una sorta di aliasing: un mezzo molto compatto per azzerare un registro. Il tutto tenendo, però, presente che  l’operazione potrebbe non essere molto veloce, poiché richiede l’utilizzo dell’ALU e in più modifica anche i flag, creando una dipendenza nella pipeline; ma le prestazioni, in ultima analisi, dipendono anche dalla microarchitettura . Comunque l’operazione di azzeramento è abbastanza comune, e dunque l’uso dello XOR risulta gradito ai compilatori e (a volte meno) ai programmatori, da cui la sua frequenza nella tabella.

Non c’è molto altro da dire sulle RET e i JMP (salti assoluti), mentre l’SHL trova un impiego così frequente perché spesso viene utilizzata per rimpiazzare le più costose MUL (ma anche qui dipende anche molto dalla microarchitettura).

Tocca finalmente passare alle statistiche di x64, i cui numeri sono stati ricavati sempre con la beta pubblica di Adobe Photoshop CS6 a 64 bit (PS64):

Mnemonic              Count      % Avg sz
MOV                  642687  36.99    5.0
LEA                  186105  10.71    5.8
J                    132638   7.63    3.0
CALL                 131855   7.59    5.0
CMP                   77335   4.45    4.0
ADD                   53417   3.07    4.1
TEST                  49506   2.85    2.7
POP                   47358   2.73    1.4
MOVSXD                36507   2.10    4.5
XOR                   33694   1.94    2.5
SUB                   32572   1.87    3.8
JMP                   32299   1.86    3.2
NOP                   30316   1.74    1.9
PUSH                  25639   1.48    1.5
RET                   23106   1.33    1.0
INC                   19014   1.09    2.8
MOVZX                 18906   1.09    4.6
MOVAPS                18143   1.04    4.9
MOVSS                 15353   0.88    6.2

Molto è stato detto, e in particolare sul ruolo della diversa ABI nei precedenti articoli. Sfruttando i registri per il passaggio dei parametri alle routine si comprende l’aumento delle MOV, ma soprattutto delle LEA, e il drastico crollo delle PUSH. Da notare che la presenza dell’istruzione MOVSXD (che carica un valore a 32 bit dalla memoria, estendendolo poi col segno a 64 bit) e MOVZX (che estende con zero a 32 bit un valore a 8 o 16 bit preso dalla memoria; in realtà l’operazione coinvolge tutto il registro a 64 bit, i cui bit superiori sono tutti azzerati), riconducibili proprio all’uso delle MOV per il caricamento dei registri. Tutto come da programma, insomma.

Salta all’occhio anche un leggero aumento delle J, a cui corrisponde un altrettanto aumento delle CMP e TEST che, com’è stato sottolineato, fanno il paio con le istruzioni di salto condizionale. Non è chiaro il motivo per cui ci sia stato quest’aumento, che fra l’altro coinvolge in misura simile anche la ADD; è possibile che con x64 il compilatore privilegi altri pattern di generazione del codice.

Avendo una più ampia disponibilità di registri (il doppio rispetto a x86), risultano di conseguenza molto meno frequenti anche le POP, perché non è necessario conservare temporaneamente il valore di un registro, per poi recuperarlo subito dopo l’utilizzo.

Un altro aspetto curioso che emerge subito è il notevole impiego di istruzioni NOP, ma di cui ormai conosciamo molto bene la causa: il padding del codice, necessario per allineare a 16 byte gli indirizzi di destinazione dei salti. In questo modo è più facile che nei 16 byte letti dalla cache si trovino più istruzioni, potendone decodificare molte di più se il codice fosse disallineato, e aiutando a riempire molto velocemente la pipeline, che è l’operazione “interna” più importante a cui deve lavorare il processore a fronte di un salto che s’è verificato.

Infine le istruzioni MOVAPS e MOVSS denotano l’uso dell’unità SIMD al posto dell’FPU, che su x64 risulta, invece, scarsamente impiegata.

Volendo aggiungere un altro po’ di carne al fuoco riporto altre statistiche interessanti, dove gli mnemonici vengono messi in relazione agli operandi da loro utilizzati (che abbiamo visto nel precedente articolo).

Per x86 (PS32):

Mnemonic         Addressing mode               Count      % Avg sz
PUSH             REG                          187508  10.74    1.0
CALL             PC                           116761   6.69    5.0
MOV              REG,[REG+DISP]               112738   6.45    3.5
J                PC                           110954   6.35    2.9
MOV              REG,REG                       89188   5.11    2.0
POP              REG                           78535   4.50    1.0
PUSH             IMM                           69388   3.97    3.6
LEA              REG,[EBP-DISP*8]              62624   3.59    4.2
MOV              REG,[ESP+DISP*8]              58744   3.36    5.7
MOV              REG,[EBP-DISP*8]              51989   2.98    3.8
MOV              [EBP-DISP*8],REG              46840   2.68    3.8
ADD              REG,IMM                       43279   2.48    3.1
MOV              [EBP-DISP*8],IMM              39384   2.25    5.7
MOV              [REG+DISP],REG                38857   2.22    3.6
MOV              [ESP+DISP*8],REG              37833   2.17    5.0
TEST             REG,REG                       32649   1.87    2.0
MOV              REG,[REG]                     29994   1.72    2.0
XOR              REG,REG                       25696   1.47    2.0
JMP              PC                            24636   1.41    3.1

Per x64 (PS64):

Mnemonic         Addressing mode               Count      % Avg sz
MOV              REG,REG                      136358   7.85    2.9
J                PC                           132638   7.63    3.0
CALL             PC                           121294   6.98    5.0
MOV              REG,[RSP+DISP*8]             117040   6.74    7.0
MOV              [RSP+DISP*8],REG             108514   6.25    5.8
MOV              REG,[REG+DISP]                71937   4.14    4.7
MOV              [REG+DISP],REG                56648   3.26    4.7
LEA              REG,[RSP+DISP*8]              56141   3.23    6.4
LEA              REG,[REG+DISP]                55826   3.21    5.4
POP              REG                           47358   2.73    1.4
TEST             REG,REG                       46419   2.67    2.6
LEA              REG,[RIP+DISP]                36053   2.08    7.0
MOV              REG,IMM                       34042   1.96    4.9
XOR              REG,REG                       33555   1.93    2.5
ADD              REG,IMM                       31705   1.82    4.4
JMP              PC                            31502   1.81    3.2
CMP              REG,REG                       30056   1.73    2.8
MOV              REG,[REG]                     28566   1.64    2.9
NOP                                            26002   1.50    1.0

Se per istruzioni come J, CALL, POP, XOR, CMP il quadro si mantiene abbastanza stabile perché riflette all’incirca l’effettivo utilizzo, l’introduzione degli argomenti stravolge il resto, poiché provoca una notevole frammentazione dovuta alla certosina specializzazione dei singoli mnemonici.

D’altra parte una distribuzione più granulare e dettagliata ci consente di scoprire ancora meglio le dinamiche che occorrono nel codice, dove risultano esplicitate.

Ad esempio per x86 salta all’occhio l’utilizzo dello stack frame per accedere ai parametri e alle variabili locali di una routine, spesso tramite l’uso del registro EBP quale base per l’indirizzamento della memoria.

Viceversa, su x64 non viene usato uno stack frame col registro RBP (a 64 bit questa volta), ma si accede direttamente allo stack quando necessario, cioè per variabili locali che non possono essere mappate su dei registri, oppure nel caso di parametri che non posso essere mappati sui registri (perché la routine ne prevede troppi rispetto a quanto ne mette a disposizione l’ABI).

Sempre nel caso di x64, fa capolino la nuova modalità d’indirizzamento relativa al PC (RIP), che consente sia di accorciare le distanze (usando un offset con segno a 32 bit anziché un valore assoluto a 64 bit) che di eliminare la necessità della rilocazione del codice da parte del loader del sistema operativo.

Nelle statistiche è stata anche riportata la dimensione media delle istruzioni. Ci sono poche parole da spendere in merito, perché appare chiaro come il peso, in termini di frequenza, di ogni particolare tipologia di mnemonici contribuisce a determinare in ultima analisi la dimensione media globale, come già discusso all’inizio della serie.

Col prossimo, che tratterà dell’aspetto legacy e le conclusioni, si chiuderà questa serie.

Press ESC to close