di  -  giovedì 4 aprile 2013

Dopo l’analisi e i freddi numeri riportati nei precedenti articoli, passiamo a verificare il codice x86 e x64, dando un’occhiata ad alcuni spezzoni di codice e mostrando come si comportano le due architetture quando c’è da effettuare lo stesse elaborazioni, cercando riscontro a quanto detto finora.

La scelta del codice deve, però, soddisfare alcuni criteri. Dovrebbe offrire uno spaccato abbastanza comune, senza perdersi in listati chilometrici che disperdono l’attenzione e l’interesse, ma soprattutto è necessario recuperare spezzoni “uguali”, che elaborino sostanzialmente la stessa cosa, in modo da rendere semplice il confronto fra le due ISA.

Cercare le similitudini fra le istruzioni in modo da recuperare la stessa routine non è cosa semplice, poiché i due disassemblati, per x86 e x64, non le riportano fedelmente, ma hanno “ramificazioni” diverse o sono proprio pezzi di codice diversi. Il disassemblato, infatti, parte dall’entry point, e cerca di scovarli, ma è impossibile che tutto il codice venga coperto, a causa dell’approccio conservativo che s’è scelto (non vengono disassemblate porzioni che potrebbero contenere dati, ma soltanto riferimenti alle istruzioni di salto).

In ogni caso non siamo in presenza della medesima sequenza di blocchi di codice, per cui è necessario partire da qualcosa, trovare un modo per arrivare allo scopo, e in questo le statistiche accumulate hanno dato una mano. Infatti l’idea è stata quella di utilizzare le istruzioni più rare come “cavalli di Troia”, individuando nel mezzo del listato la routine che le contenessero, e verificando poi che esistesse la stessa nell’altro disassemblato (che ovviamente avrebbe contenuto la medesima istruzione).

Una di quelle che ha permesso tutto ciò è stata la PAUSE, che è presente soltanto tre volte nel codice x86 della beta pubblica di Adobe Photoshop CS6 (PS32, come già descritto nei precedenti articoli) e soltanto due volte in quello x64 (PS64). La ridottissima frequenza ha consentito di poter verificare abbastanza velocemente che, preso un blocco di codice x86, ci fosse il corrispondente x64, come si è poi verificato, e fosse utilizzabile per lo scopo prefisso.

Per PS32 abbiamo la seguente routine:

0x00de37c0 (1) 57                             PUSH EDI
0x00de37c1 (2) 8bf9                           MOV EDI, ECX
0x00de37c3 (5) b801000000                     MOV EAX, 0x1
0x00de37c8 (2) 8701                           XCHG [ECX], EAX
0x00de37ca (3) 83f801                         CMP EAX, 0x1
0x00de37cd (2) 7535                           JNZ 0xde3804
0x00de37cf (1) 53                             PUSH EBX
0x00de37d0 (6) 8b1d94131602                   MOV EBX, [0x2161394]
0x00de37d6 (1) 56                             PUSH ESI
0x00de37d7 (2) 8b17                           MOV EDX, [EDI]
0x00de37d9 (5) be01000000                     MOV ESI, 0x1
0x00de37de (2) 85d2                           TEST EDX, EDX
0x00de37e0 (2) 7412                           JZ 0xde37f4
0x00de37e2 (2) f390                           PAUSE
0x00de37e4 (2) 8bc6                           MOV EAX, ESI
0x00de37e6 (1) 46                             INC ESI
0x00de37e7 (3) 83f820                         CMP EAX, 0x20
0x00de37ea (2) 7e02                           JLE 0xde37ee
0x00de37ec (2) ffd3                           CALL EBX
0x00de37ee (2) 8b0f                           MOV ECX, [EDI]
0x00de37f0 (2) 85c9                           TEST ECX, ECX
0x00de37f2 (2) 75ee                           JNZ 0xde37e2
0x00de37f4 (5) ba01000000                     MOV EDX, 0x1
0x00de37f9 (2) 8bc7                           MOV EAX, EDI
0x00de37fb (2) 8710                           XCHG [EAX], EDX
0x00de37fd (3) 83fa01                         CMP EDX, 0x1
0x00de3800 (2) 74d5                           JZ 0xde37d7
0x00de3802 (1) 5e                             POP ESI
0x00de3803 (1) 5b                             POP EBX
0x00de3804 (1) 5f                             POP EDI
0x00de3805 (1) c3                             RET

Mentre per PS64:

0x0000000140b674d0 (2) 4057                           PUSH RDI
0x0000000140b674d2 (4) 4883ec20                       SUB RSP, 0x20
0x0000000140b674d6 (5) b801000000                     MOV EAX, 0x1
0x0000000140b674db (3) 488bf9                         MOV RDI, RCX
0x0000000140b674de (2) 8701                           XCHG [RCX], EAX
0x0000000140b674e0 (3) 83f801                         CMP EAX, 0x1
0x0000000140b674e3 (2) 7543                           JNZ 0x140b67528
0x0000000140b674e5 (5) 48895c2430                     MOV [RSP+0x30], RBX
0x0000000140b674ea (6) 660f1f440000                   NOP WORD [RAX+RAX+0x0]
0x0000000140b674f0 (2) 8b07                           MOV EAX, [RDI]
0x0000000140b674f2 (5) bb01000000                     MOV EBX, 0x1
0x0000000140b674f7 (2) 85c0                           TEST EAX, EAX
0x0000000140b674f9 (2) 741c                           JZ 0x140b67517
0x0000000140b674fb (5) 0f1f440000                     NOP DWORD [RAX+RAX+0x0]
0x0000000140b67500 (2) f390                           PAUSE
0x0000000140b67502 (2) 8bc3                           MOV EAX, EBX
0x0000000140b67504 (2) ffc3                           INC EBX
0x0000000140b67506 (3) 83f820                         CMP EAX, 0x20
0x0000000140b67509 (2) 7e06                           JLE 0x140b67511
0x0000000140b6750b (6) ff15ef74ec01                   CALL QWORD [RIP+0x1ec74ef]
;                                                          KERNEL32.SwitchToThread
0x0000000140b67511 (2) 8b07                           MOV EAX, [RDI]
0x0000000140b67513 (2) 85c0                           TEST EAX, EAX
0x0000000140b67515 (2) 75e9                           JNZ 0x140b67500
0x0000000140b67517 (5) b801000000                     MOV EAX, 0x1
0x0000000140b6751c (2) 8707                           XCHG [RDI], EAX
0x0000000140b6751e (3) 83f801                         CMP EAX, 0x1
0x0000000140b67521 (2) 74cd                           JZ 0x140b674f0
0x0000000140b67523 (5) 488b5c2430                     MOV RBX, [RSP+0x30]
0x0000000140b67528 (4) 4883c420                       ADD RSP, 0x20
0x0000000140b6752c (1) 5f                             POP RDI
0x0000000140b6752d (1) c3                             RET

Ogni riga riporta l’indirizzo (virtuale), tra parentesi il numero di byte (in esadecimale) occupati dall’istruzione, la codifica esadecimale dell’opcode, e infine lo mnemonico con gli eventuali argomenti a seguire. Tutte le costanti sono riportate sempre in esadecimale.

Senza soffermarci su cosa faccia il codice, già dalla prima istruzione è possibile individuare il contributo e le differenze dell’architettura x64 rispetto a quella x86. Infatti siamo in presenza dell’istruzione PUSH RDI anziché della classica PUSH EDI: sullo stack finisce l’intero contenuto del registro a 64 bit, anziché quello a 32 bit, com’è giusto che sia, visto che bisogna poi ripristinarlo all’uscita dalla routine.

Salta subito all’occhio la presenza del byte 40 all’inizio dell’opcode, il quale identifica immediatamente il prefisso REX (è un byte i cui valori ricoprono l’intervallo 40-4F, che in x86 era impiegato per codificare le istruzioni di INC e DEC sui registri), utilizzato per indirizzare gli 8 nuovi registri oppure per forzare l’uso dei 64 bit anziché i 32 bit che sono la dimensione di default anche su x64.

Sembrerebbe, quindi, tutto regolare: RDI è un registro a 64 bit, dunque richiede l’uso di REX per utilizzare anche i 32 bit superiori, senonché questo prefisso risulta del tutto inutile, poiché non ha alcune effetto, e questo per due motivi.

Il primo è che le istruzioni di PUSH e POP sono fra le poche che utilizzano operandi che di default sono a 64 bit anziché a 32, e dunque non necessitano del prefisso REX per forzare la dimensione a 64 bit, perché questa risulta già impostata.

Il secondo è che il prefisso REX risulta errato, in quanto non è impostato il flag che indica la richiesta di utilizzare 64 bit anziché 32 per l’istruzione. Anziché 40 il prefisso sarebbe dovuto essere 48, com’è possibile notare nell’istruzione immediatamente successiva, SUB RSP, 0x20, che presenta correttamente tale valore.

Si tratta quasi sicuramente di un bug del compilatore, che genera un prefisso REX ridonante in questo caso. La conferma è data dalla duale POP RDI presente alla fine della routine che, com’è possibile vedere, non usa alcun prefisso REX, ed è, quindi, costituita da un solo byte.

Si potrebbe pensare che questo bug contribuisca a far diminuire la densità del codice rispetto a quello x86, e ciò è teoricamente vero, ma bisogna considerare che per x64 le PUSH sono di gran lunga meno frequenti, per cui, a meno di errori di arrotondamento, la situazione rimarrebbe sostanzialmente invariata.

A tal proposito sappiamo che il codice a 64 bit occupa mediamente il 34% di spazio in più rispetto a quello 32 bit (4,3 byte contro 3,2), situazione che si rispecchia in questo caso, dove i 94 byte del primo corrispondo al 34% in più di spazio rispetto ai 70 byte del secondo. Anche togliendo di mezzo il prefisso REX superfluo, avremmo in ogni caso un’occupazione maggiore del 33%.

Tornando al confronto fra le due versioni della routine, si nota una scarsa influenza del prefisso REX (e, quindi, dell’uso dei 64 bit e/o dei nuovi registri) nel codice. Tolto il caso anomalo del PUSH, viene utilizzato soltanto 5 volte: decisamente poco per giustificare la considerevole diminuzione della densità del codice.

La causa principale va ricercata nella differente ABI utilizzata. x86 predilige il PUSH dei registri da preservare sullo stack, per poi ripristinarli alla fine della routine, com’è possibile vedere dallo spezzone riportato.

x64, invece, usa poco i PUSH, mentre preferisce creare uno stack frame (per le variabili locali e/o temporanee), e salvare o ripristinare i registri in questo spazio. Lo si vede all’inizio e alla fine della routine, che presenta le istruzioni SUB RSP, 0x20 e ADD RSP, 0x20 per crearlo ed eliminarlo, mentre la MOV [RSP+0x30], RBX e la MOV RBX, [RSP+0x30] si occupano rispettivamente di conservare a recuperare il valore del registro RBX.

Si tratta di istruzioni molto costose in termini di spazio, e che francamente si potrebbero anche evitare, dato il tipo di codice eseguito in ultima analisi, che potrebbe essere organizzato diversamente, magari ricalcando in parte l’ABI x64 in alcuni casi.

Sarebbero possibili anche altre ottimizzazioni, e il codice lascia trasparire anche la possibilità, per Intel o AMD, di introdurre nuove istruzioni atte a coprire meglio alcuni casi d’uso frequenti (è strano che non sia stato fatto in tutto questo tempo), ma non è questo il momento per discuterne, visto che l’articolo è stato scritto per un argomento diverso.

Dall’analisi del codice x64 emerge anche l’uso del padding di cui avevamo parlato nel precedente articolo, che serve ad allineare il codice del target dei salti a multipli di 16 byte. Sono presenti, infatti, due istruzioni NOP che da sole occupano la bellezza di 11 byte, che su 94 (o 93, tolto il REX in più) hanno senz’altro un peso non indifferente nella maggiore occupazione dello spazio.

Sia chiaro che la situazione generale non è quella rappresentata da questa routine, che è stata presa soltanto come esempio per mostrare come anche praticamente si possa trovare riscontro di quanto analizzato nei due articoli già pubblicati. Le tipologie di codice dipendono dal tipo di applicazione, e all’interno della stessa sono presenti parti anche molto eterogenee, quindi quanto visto finora non si può applicare alla generalità, ma fornisce un quadro, un andamento, che ha trovato riscontro anche in altre applicazioni disassemblate.

Sarebbe stato interessante, per completare il pezzo, riportare anche il caso di chiamata a routine con passaggio dei parametri, in modo da mostrare l’uso massiccio dei PUSH nel caso di x86 e delle MOVE e LEA nel caso di x64, ma non è stato possibile, per i (numerosi) tentativi effettuati e il poco tempo a disposizione, trovare due spezzoni che riportassero esattamente la stessa situazione.

Il prossimo pezzo della serie si occuperà dell’analisi delle istruzioni per numero di argomenti.

2 Commenti »

I commenti inseriti dai lettori di AppuntiDigitali non sono oggetto di moderazione preventiva, ma solo di eventuale filtro antispam. Qualora si ravvisi un contenuto non consono (offensivo o diffamatorio) si prega di contattare l'amministrazione di Appunti Digitali all'indirizzo info@appuntidigitali.it, specificando quale sia il commento in oggetto.

  • # 1
    Xeus32
     scrive: 

    Come al solito un ottimo articolo.
    Mi chiedo solamente se ci sarebbero miglioramenti in una architettura x86 con un nomero doppio di registri.
    Ho ricordi di alcune architetture embedded dove ci sono 32 registri per evitare i push and pop.

  • # 2
    Cesare Di Mauro (Autore del post)
     scrive: 

    Grazie. I miglioramenti ci sarebbero, perché sarebbe possibile ridurre gli accessi alla memoria, quindi diminuendo le load e/o store. Non è un caso che ARM, con la sua nuova ISA a 64 bit (ARM64 aka ARMv8), abbia provveduto a estendere i registri dai 16 (in realtà 15, perché uno era il PC) a 30/31 (ci sono alcuni registri che hanno un utilizzo speciale).
    Ma non credo che assisteremmo a risultati eccezionali. Un po’ come AMD, che introducendo x64 ha raddoppiato i registri e migliorato le prestazioni mediamente del 10-15%. Al solito, dipende dal codice, ma penso che ne gioverebbero di più i server, ed è soprattutto per questo che ARM ha tirato fuori ARM64.

    Comunque raddoppiare il numero di registri di x64 (x86 è stato già esteso da questa) portandoli a 32 introdurrebbe altri problemi non di poco conto.
    Il primo è che, ovviamente, la nuova architettura (perché tale diventa alla fine, sebbene le istruzioni vengano sostanzialmente riciclate) romperebbe la compatibilità col passato, richiedendo codice appositamente generato, compilatori e kernel dei s.o. ad hoc per supportarla correttamente.
    Il secondo riguarda la densità del codice, che diminuirebbe ulteriormente. Abbiamo visto con x64 che abbiamo mediamente il 34% di spazio in più occupato. Nella migliore delle ipotesi dovremmo introdurre un prefisso lungo due byte (il secondo byte conterrebbe il bit di size a 64 bit, un bit per il quarto registro delle estensioni SIMD AVX, e poi 3 coppie di bit per ognuno dei 3 possibili registri utilizzabili) per accedere ai nuovi 16 registri (fino ai primi 16 registri si potrebbe continuare a usare il prefisso REX, mentre fino ai primi 8 non servirebbe nemmeno questo), e ciò risulterebbe abbastanza penalizzante. Già adesso abbiamo una lunghezza media di 4,3 byte per istruzione rispetto a x86 (che vanta 3,2 byte di media), per cui il panorama è destinato a peggiorare; anche supponendo che, ad esempio, mediamente si aggiunga soltanto 1/4 di byte in media, avremmo 4,55 byte di media per le istruzioni, cioè il 42% in più rispetto a x86.
    Aumentare la dimensione del codice significa mettere più pressione alla memoria, alla cache, e anche alle entry della TLB, oltre al fatto che i decoder troverebbero meno istruzioni da decodifica.
    Quindi tutto ciò porta ad avere prestazioni leggermente minori, anche se controbilanciate e in saldo positivo per il fatto di poter disporre del doppio di registri (in particolare per l’unità SIMD, che è quella che ne gioverebbe di più).

    E’ una situazione abbastanza complessa, come vedi, e il problema sta tutto nel fatto che parliamo sempre dell’architettura x86 (e x64), che si porta uno schema di codifica decisamente contorto.Con una codifica (e quindi ISA) nuova di pacca, tanti di questi problemi si potrebbero, invece, risolvere, pur ottenendo codice con densità molto più elevata rispetto a x64 (e comparabile a x86). Ma di questo magari ne riparleremo in futuro, se si presenterà l’occasione.

Scrivi un commento!

Aggiungi il commento, oppure trackback dal tuo sito.

I commenti inseriti dai lettori di AppuntiDigitali non sono oggetto di moderazione preventiva, ma solo di eventuale filtro antispam. Qualora si ravvisi un contenuto non consono (offensivo o diffamatorio) si prega di contattare l'amministrazione di Appunti Digitali all'indirizzo info@appuntidigitali.it, specificando quale sia il commento in oggetto.