Articoli approfonditi sulla tecnologia che plasma il futuro.

Come i compilatori JIT velocizzano i linguaggi dinamici

Come i compilatori JIT ottimizzano Ruby, Python e JavaScript eliminando operazioni ridondanti, con esempi reali da YJIT e ZJIT.

Una gemma Ruby che entra in una macchina luminosa e ne esce come una cometa di luce veloce

Ruby ha la reputazione di essere lento. Lo stesso vale per Python. Lo stesso valeva per JavaScript, almeno finché V8 non lo ha reso abbastanza veloce da reggere carichi di lavoro lato server. La storia di come i linguaggi dinamici diventano veloci è la storia della compilazione JIT (Just-In-Time), e rappresenta una delle aree più affascinanti dell'informatica applicata. L'ultimo capitolo: ZJIT di Ruby sta eliminando caricamenti e salvataggi di oggetti ridondanti a livello di rappresentazione intermedia, la stessa classe di ottimizzazioni che ha reso così efficace TurboFan di V8.

Se ti sei mai chiesto perché il tuo codice Ruby giri a una frazione della velocità del C, o come JavaScript sia diventato abbastanza veloce da alimentare VS Code, la risposta sta nel capire cosa fanno davvero i compilatori JIT e cosa rende l'ottimizzazione dei linguaggi dinamici intrinsecamente più difficile di quella dei linguaggi statici.

Il problema fondamentale dei linguaggi dinamici

Quando un compilatore C vede a + b, conosce i tipi di a e b già in fase di compilazione. Se sono entrambi interi, emette una singola istruzione ADD. Se sono float, emette un'addizione in virgola mobile. La CPU esegue quell'istruzione in un ciclo. Non c'è ambiguità, né decisioni da prendere a runtime.

Quando un interprete Ruby vede a + b, non sa quasi nulla. a potrebbe essere un intero, un float, una stringa, un array o qualsiasi oggetto che definisca un metodo +. L'interprete deve: controllare il tipo di a, trovare il metodo + per quel tipo, controllare il tipo di b, eventualmente effettuare conversioni di tipo, gestire i casi limite (overflow, oggetti congelati) e infine eseguire l'operazione. Quel singolo + può richiedere decine di istruzioni, accessi alla memoria e decisioni di branch.

# What the programmer writes:
def sum(a, b)
a + b
end
# What the interpreter actually has to do (pseudocode):
def sum(a, b)
method = lookup_method(a.class, :+)      # Hash lookup
raise NoMethodError unless method         # Branch
if a.is_a?(Integer) && b.is_a?(Integer)   # Type checks
result = integer_add(a.value, b.value)  # Actual math
if overflow?(result)                     # Overflow check
result = promote_to_bignum(result)    # Bignum promotion
end
elsif a.is_a?(Float) || b.is_a?(Float)
result = float_add(to_float(a), to_float(b))
elsif a.is_a?(String)
result = string_concat(a, b.to_s)
else
result = call_method(method, a, [b])    # Generic dispatch
end
result
end

Questo overhead (controllo dei tipi, ricerca dei metodi, dispatch) è il prezzo da pagare per la dinamicità. La flessibilità di scrivere a + b e far sì che funzioni con interi, float, stringhe e oggetti personalizzati ha un costo elevato a runtime.

Come la compilazione JIT reagisce

Il compito di un compilatore JIT è osservare cosa fa davvero il programma a runtime e generare codice macchina ottimizzato in base a queste osservazioni. L'intuizione chiave è questa: il codice Ruby potrebbe operare su qualsiasi tipo, ma in pratica ogni specifico call site vede quasi sempre gli stessi tipi.

Se sum(a, b) è stato chiamato 10.000 volte e a e b sono sempre stati interi, il JIT può generare codice macchina specializzato che presuppone che continueranno a essere interi. Emette una singola istruzione di addizione intera con una 'guardia', cioè un rapido controllo del tipo che ripiega sul percorso lento se l'ipotesi viene mai violata.

Interpreter execution of sum(a, b) where a and b are Integers:
1. Load a from stack
2. Check if a is an object reference
3. Load a's class pointer
4. Look up :+ in method table (hash lookup)
5. Check method visibility
6. Load b from stack
7. Check if b is an object reference
8. Check if b is compatible type
9. Unbox a's integer value
10. Unbox b's integer value
11. Perform addition
12. Check for overflow
13. Box result as new Integer object
14. Return result
JIT-compiled version (after profiling shows a,b are always Integers):
1. Guard: check a is Integer (bail if not)
2. Guard: check b is Integer (bail if not)
3. ADD instruction
4. Guard: check no overflow (bail if overflow)
5. Return result

È un processo di 14 passaggi ridotto a 5, dove i passaggi 1, 2 e 4 sono semplici istruzioni di confronto. Il calcolo vero e proprio, cioè l'ADD, richiede un ciclo di CPU. È così che JavaScript è passato da 'troppo lento per qualsiasi cosa seria' a 'abbastanza veloce da far girare un IDE completo'.

Il percorso JIT di Ruby: da MJIT a YJIT fino a ZJIT

La storia della compilazione JIT in Ruby è un caso di studio su quanto sia difficile questo problema. Ruby ha attraversato diverse implementazioni JIT, ognuna con un approccio diverso.

MJIT (Ruby 2.6, 2018) traduceva il bytecode Ruby in codice C, per poi invocare GCC o Clang per compilarlo. Il risultato era codice ben ottimizzato, ma con un warm-up pessimo: compilare C richiede secondi, non millisecondi. Quando il codice compilato dal JIT era pronto, il programma poteva essere già terminato.

YJIT (Ruby 3.1, 2022) è stato il contributo di Shopify, scritto inizialmente in C e poi riscritto in Rust. YJIT usa una tecnica chiamata 'lazy basic block versioning': compila il codice un basic block alla volta, solo quando viene effettivamente eseguito, e crea versioni specializzate in base ai tipi osservati. Questo garantisce un warm-up rapido (millisecondi, non secondi) con buone prestazioni a regime. YJIT migliora tipicamente le prestazioni di Ruby del 15-30% su carichi di lavoro reali, come le applicazioni Rails.

ZJIT è la prossima evoluzione, ed è qui che le cose si fanno davvero interessanti. ZJIT introduce una rappresentazione intermedia (IR), cioè una struttura del programma tra il bytecode e il codice macchina. Questa IR abilita le ottimizzazioni classiche dei compilatori, che l'approccio diretto da bytecode a codice macchina di YJIT non riusciva a fare facilmente.

Eliminare caricamenti e salvataggi ridondanti

L'ottimizzazione specifica che ZJIT ha introdotto di recente, cioè la rimozione di caricamenti e salvataggi di oggetti ridondanti, suona ermetica ma ha un impatto enorme. Ecco perché.

Gli oggetti Ruby memorizzano le loro variabili d'istanza in una tabella delle proprietà. Ogni volta che leggi @name, l'interprete carica il valore dalla tabella delle proprietà dell'oggetto in memoria. Ogni volta che scrivi @name = value, salva nella stessa tabella. In un metodo che accede più volte alla stessa variabile d'istanza, l'interprete la ricarica dalla memoria ogni volta, perché nel caso generale qualcosa potrebbe aver modificato il valore tra una lettura e l'altra.

class Rectangle
def area
@width * @height
end
def perimeter
# @width and @height are each loaded TWICE from memory
2 * (@width + @height)
end
def scale(factor)
@width = @width * factor    # Load @width, multiply, store @width
@height = @height * factor  # Load @height, multiply, store @height
self
end
end
# In a tight loop, these redundant loads add up:
rects.each { |r| r.scale(2).perimeter }

Grazie a una IR, ZJIT può eseguire la load elimination: se @width è già stato caricato e nessuno l'ha modificato da allora, riutilizza il valore da un registro invece di ricaricarlo dalla memoria. Può anche eseguire la store elimination: se scrivi due volte di seguito su @width senza che nessuno lo legga tra una scrittura e l'altra, la prima scrittura può essere eliminata.

Queste ottimizzazioni sono lo standard minimo nei compilatori per linguaggi statici: GCC e LLVM le fanno da decenni. Ma farle per un linguaggio dinamico è molto più difficile a causa dell'aliasing. In Ruby, chiamare un metodo qualsiasi potrebbe modificare le variabili d'istanza di qualunque oggetto (tramite instance_variable_set, method_missing o trace hook). Il JIT deve dimostrare che tra due letture di @width nulla avrebbe potuto cambiarlo, e questo richiede di analizzare cosa potrebbe fare ogni operazione intermedia.

Il vantaggio della IR

La ragione per cui ZJIT ha introdotto una IR (e per cui V8 con TurboFan, DFG/FTL di JavaScriptCore e C2 di HotSpot usano tutti delle IR) è che questo rende le ottimizzazioni componibili. Una IR è essenzialmente un grafo di operazioni su cui le ottimizzazioni si applicano come trasformazioni del grafo.

  • Constant folding: se entrambi gli operandi di un'addizione sono costanti note, l'operazione viene sostituita con il suo risultato. 2 + 3 diventa 5 già in fase di compilazione.
  • Dead code elimination: se il risultato di un'operazione non viene mai usato, l'operazione viene rimossa del tutto.
  • Common subexpression elimination: se la stessa computazione compare due volte, viene eseguita una sola volta e il risultato viene riutilizzato.
  • Load/store elimination: rimozione delle operazioni di memoria ridondanti, come descritto sopra.
  • Escape analysis: se un oggetto viene creato e non esce dal metodo corrente, viene allocato sullo stack invece che sull'heap (oppure l'allocazione viene eliminata del tutto).
  • Inlining: una chiamata di metodo viene sostituita dal corpo del metodo, esponendo più opportunità per le altre ottimizzazioni.

Queste ottimizzazioni si sommano. L'inlining di un metodo espone le sue operazioni al contesto del chiamante, il che può rivelare valori costanti, che abilitano il constant folding, che rende morto del codice, che viene eliminato. Una singola decisione di inlining può far scattare la rimozione di decine di operazioni.

La rete di sicurezza della deottimizzazione

Tutto ciò che fa un compilatore JIT è speculativo. Presume che i tipi non cambieranno, che i metodi non verranno ridefiniti e che il monkey-patching non invaliderà il codice ottimizzato. Quando queste ipotesi cadono, il JIT deve 'deottimizzare': scartare il codice ottimizzato e tornare all'interprete.

La deottimizzazione è una delle parti più difficili del design di un JIT. Il codice ottimizzato potrebbe aver eliminato variabili locali, riordinato operazioni o inlinato chiamate profondamente annidate. Per tornare all'interprete, il JIT deve ricostruire lo stato dell'interprete (tutte le variabili locali, lo stack delle chiamate, il program counter) a partire da ciò che il codice ottimizzato ha a disposizione. Questo richiede di mantenere dei metadati (chiamati 'on-stack replacement' o mappe OSR) che collegano gli stati del codice ottimizzato agli stati dell'interprete.

Quando la deottimizzazione avviene di frequente, una condizione chiamata 'deopt thrashing', le prestazioni possono essere peggiori di un'interpretazione pura. Il JIT passa il tempo a compilare codice ottimizzato, eseguirlo per un attimo, deottimizzare e ripetere. V8 gestisce il problema tracciando il numero di deottimizzazioni e, alla fine, rinunciando a ottimizzare una certa funzione. YJIT adotta un approccio più semplice: genera più versioni di ogni percorso di codice per diverse combinazioni di tipi, riducendo la necessità di deottimizzare al costo di generare più codice.

Perché questo conta anche oltre Ruby

Il percorso JIT di Ruby rispecchia ciò che sta accadendo in tutti i linguaggi dinamici. Il JIT copy-and-patch di Python (introdotto in CPython 3.13) è il primo passo verso una vera compilazione JIT per Python. LuaJIT è stato sorprendentemente veloce per anni grazie a un JIT di tipo tracing molto aggressivo. Il JIT di PHP 8.0+ usa LLVM per le sue ottimizzazioni basate su IR.

Lo schema è coerente: si parte da un interprete, si aggiunge il profiling per capire il comportamento a runtime, si compilano i percorsi caldi con specializzazione sui tipi, si introduce una IR per le ottimizzazioni classiche e si raffina. Ogni linguaggio affronta le stesse sfide (dispatch dinamico, oggetti mutabili, eval, monkey-patching) e arriva a soluzioni simili.

Per chi usa questi linguaggi, la conclusione pratica è che il divario di prestazioni tra linguaggi dinamici e statici si sta riducendo. Non si chiuderà mai del tutto: i controlli sui tipi e le guardie hanno comunque un costo, e la deottimizzazione è un overhead intrinseco. Ma un JIT ben ottimizzato può arrivare entro 2-5 volte il codice C equivalente per carichi di lavoro computazionali, che è 'abbastanza veloce' per la grande maggioranza delle applicazioni.

La lezione più sottile: scrivi codice lineare. I compilatori JIT ottimizzano i pattern prevedibili. I call site monomorfici (dove un metodo riceve sempre gli stessi tipi) si ottimizzano bene. Quelli polimorfici (dove i tipi variano) sono più difficili. Quelli megamorfici (decine di tipi) potrebbero non essere mai ottimizzati. Il codice semplice da capire per un essere umano è di solito semplice da ottimizzare anche per un JIT, e questo è un bell'allineamento di incentivi.