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.