A livello di codice di programmazione, una singola funzione può raccontare molto: sulle scelte progettuali del passato, sui limiti dei compilatori e perfino sul lavoro degli sviluppatori che realizzano emulatori e sistemi di compatibilità. Una curiosa vicenda ricordata da Raymond Chen, storico ingegnere Microsoft e autore del blog The Old New Thing, riporta l’attenzione sugli anni in cui Windows doveva eseguire software x86 a 32 bit su processori completamente differenti. In quel periodo Microsoft sviluppò più volte tecnologie di emulazione e traduzione binaria per garantire la compatibilità applicativa, una necessità che ha accompagnato l’evoluzione di Windows dai processori MIPS e Alpha fino alle piattaforme ARM più recenti.
La compatibilità con il software esistente rappresenta da decenni uno dei principali fattori di successo di Windows. Per raggiungere l’obiettivo, però, non basta interpretare le istruzioni della CPU originale: servono prestazioni accettabili. Così nacquero sofisticati sistemi di traduzione dinamica del codice macchina, capaci di convertire istruzioni x86 in codice nativo eseguibile dalla piattaforma ospite. Una tecnica che oggi appare normale, ma che già negli anni ’90 richiedeva soluzioni ingegneristiche notevoli.
Quando l’emulazione diventa una compilazione JIT
L’episodio raccontato da Chen riguarda un team impegnato nello sviluppo di un emulatore x86-32 basato sulla binary translation. Invece di interpretare un’istruzione alla volta, il sistema analizzava blocchi di codice x86 e li trasformava in sequenze equivalenti per il processore di destinazione.
Il meccanismo somigliava molto a un moderno JIT compiler: il codice originale diventava una sorta di bytecode che il traduttore convertiva in istruzioni native più efficienti.
L’approccio riduceva drasticamente il numero di operazioni necessarie durante l’esecuzione e consentiva prestazioni sensibilmente superiori rispetto agli emulatori puramente interpretativi.
Una tecnologia simile è stata utilizzata in diverse generazioni di Windows tanto che il concetto rimane attuale ancora oggi nei livelli di compatibilità destinati all’esecuzione di software x86 su sistemi ARM, anche se le implementazioni moderne sfruttano algoritmi molto più sofisticati e cache persistenti del codice tradotto.
Un buffer da 64 KB e una scelta difficile da giustificare
Durante l’analisi di un’applicazione, gli sviluppatori notarono un comportamento decisamente insolito. Il programma doveva allocare circa 64 KB sullo stack e inizializzare l’area appena riservata.
In genere un compilatore gestisce una situazione di questo tipo in modo piuttosto lineare. Dopo lo stack probe, una procedura che verifica la disponibilità delle pagine di memoria e consente l’eventuale espansione automatica dello stack, il codice diminuisce il valore dello stack pointer, cioè il registro che indica la posizione corrente dello stack, e utilizza un piccolo ciclo per inizializzare il buffer.
Il meccanismo è molto semplice: un numero ridotto di istruzioni viene eseguito ripetutamente migliaia di volte. In questo modo il codice occupa poco spazio in memoria e sfrutta in modo efficiente la cache della CPU, la memoria ad alta velocità utilizzata dal processore per accelerare l’esecuzione. Il software analizzato dal team adottava invece un approccio completamente diverso: il compilatore impiegato aveva scelto di eliminare il ciclo e di sostituirlo con tutte le operazioni già espanse nel codice. In pratica, invece di poche istruzioni ripetute più volte, il programma conteneva oltre 65.000 singole operazioni di scrittura in memoria.
Nel caso descritto da Chen, ogni istruzione occupava quattro byte; il risultato finale era un blocco di codice grande circa 256 KB dedicato esclusivamente all’azzeramento di un buffer da 64 KB.
Dal punto di vista delle prestazioni il danno era evidente. Un frammento così esteso aumenta la pressione sulla instruction cache, peggiora la località del codice e costringe il traduttore binario a elaborare una quantità enorme di istruzioni praticamente identiche.
Come intervenne il team dell’emulatore
Fu proprio a questo punto che la vicenda assunse una piega inaspettata. Gli sviluppatori dell’emulatore non si limitarono a osservare l’anomalia: decisero di gestirla direttamente durante la traduzione del codice.
Il sistema, infatti, non convertiva le istruzioni x86 una alla volta ma analizzava interi blocchi prima di generare il codice per il processore di destinazione. Quando il traduttore individuava quella lunghissima sequenza di operazioni ripetitive, ne riconosceva il vero scopo: inizializzare una regione di memoria. Invece di convertire fedelmente oltre 65.000 istruzioni quasi identiche, l’emulatore produceva una rappresentazione molto più compatta ed efficiente, ottenendo lo stesso risultato finale con una quantità di codice nettamente inferiore.
La particolarità della storia raccontata da Chen sta proprio qui. Un sistema di traduzione binaria dovrebbe limitarsi a preservare il comportamento del programma originale, non a correggere le decisioni prese dal compilatore che ha generato l’eseguibile.
In questo caso, però, la situazione appariva talmente estrema da spingere il team a introdurre un’ottimizzazione dedicata. Il comportamento dell’applicazione rimaneva identico, ma l’emulatore evitava di sprecare tempo e risorse nella conversione di migliaia di istruzioni ridondanti.
Perché l’episodio è rimasto memorabile
Proprio per questo Chen ricorda ancora l’episodio: il codice prodotto dal compilatore era così inefficiente da convincere gli autori dell’emulatore a implementare una regola speciale per aggirarne automaticamente gli effetti.
Oggi compilatori moderni come MSVC, GCC e Clang utilizzano analisi molto più sofisticate per decidere quando applicare il loop unrolling (sostituire un ciclo con una sequenza di istruzioni ripetute, una tecnica che riduce il numero di iterazioni da gestire e può migliorare le prestazioni), valutando dimensione del codice, caratteristiche della CPU, predizione dei salti e utilizzo delle cache.
Nonostante ciò il compromesso tra velocità e dimensione del codice continua a essere un tema centrale. Le moderne opzioni di compilazione consentono spesso di privilegiare la riduzione dell’occupazione in memoria oppure la massima velocità di esecuzione, proprio perché non esiste una scelta universalmente corretta.
La storia raccontata da Chen mostra un aspetto poco visibile dell’informatica: a volte il software di compatibilità non si limita soltanto a eseguire applicazioni progettate per un’altra architettura. Quando un team al lavoro su un emulatore decide di correggere un programma durante l’esecuzione, significa che qualcuno, in precedenza, aveva davvero esagerato con le ottimizzazioni.