Il compilatore JIT di Python finalmente è realtà
Python 3.15 introduce un JIT copy-and-patch frutto di anni di lavoro: come funziona, quanto velocizza e perché CPython ci ha messo tanto.

Python è stato «troppo lento» per tutta la sua esistenza. La risposta standard della comunità Python — «usa le estensioni C per i cicli critici» — è sempre stata una concessione al fatto che il modello di esecuzione predefinito del linguaggio sia intrinsecamente limitato. CPython interpreta il bytecode un'istruzione alla volta, con ogni istruzione inviata a un costrutto switch. È semplice, portabile e facile da debuggare. È anche circa 100 volte più lento del C compilato nei carichi di lavoro intensivi di calcolo.
Python 3.15 cambia le cose. Dopo anni di lavoro sperimentale, il compilatore JIT copy-and-patch arriva come funzionalità abilitata di default. Non renderà Python veloce quanto il C — nulla lo farà, a meno di una compilazione statica — ma i primi benchmark mostrano accelerazioni del 15-30% su codice reale, con alcuni pattern che migliorano molto di più. Per un linguaggio la cui storia delle prestazioni si riassume da 30 anni in «riscrivi il percorso critico in C», un JIT che accelera in modo concreto il codice Python puro è una vera pietra miliare.
Perché CPython non ha mai avuto un JIT
Non perché nessuno ci abbia provato. PyPy ha un JIT da oltre dieci anni e spesso esegue codice Python 5-10 volte più velocemente di CPython. Ma PyPy è un'implementazione separata con un proprio runtime, e non ha mai raggiunto la quota di mercato di CPython, perché l'ecosistema delle estensioni C — NumPy, pandas, scikit-learn, tutto ciò che rende Python il linguaggio della data science — è legato all'API C di CPython.
Integrare un JIT direttamente in CPython è stato discusso e tentato più volte. Le difficoltà sono ben documentate. L'architettura di CPython rende la compilazione JIT complicata: il bytecode è tipizzato dinamicamente (il JIT ha bisogno di informazioni sui tipi per generare codice efficiente, e Python non le fornisce staticamente), l'API C permette al codice C di manipolare direttamente gli oggetti Python in modi che violano le assunzioni del JIT, e il garbage collector a conteggio dei riferimenti dell'interprete introduce un overhead di contabilità che un JIT non riesce facilmente a eliminare.
I tentativi precedenti — Unladen Swallow (Google, 2009), Pyston (Dropbox, 2014) — hanno cercato di innestare una compilazione JIT basata su LLVM su CPython. Entrambi hanno constatato che il sovraccarico di compilazione di LLVM era troppo alto per i carichi tipici di Python. LLVM è progettato per la compilazione anticipata (AOT) di codebase grandi; usarlo per compilare al volo funzioni Python brevi aggiunge millisecondi di compilazione per microsecondi di esecuzione. La compilazione costava più di quanto la velocizzazione facesse risparmiare.
Copy-and-Patch: un JIT di tipo diverso
La tecnica copy-and-patch, presentata in un articolo di ricerca del 2021, adotta un approccio radicalmente diverso alla compilazione JIT. Invece di tradurre il bytecode in una rappresentazione intermedia ed eseguire passaggi di ottimizzazione (l'approccio di LLVM), copy-and-patch lavora con template di codice precompilati.
L'idea è questa: per ogni istruzione di bytecode (LOAD_FAST, BINARY_ADD, CALL_FUNCTION, ecc.), il compilatore precompila un'implementazione C in codice macchina, lasciando dei «buchi» segnaposto per i dati specifici della variabile — allocazioni di registri, valori costanti, offset di memoria. A runtime, la compilazione JIT consiste solo in questo: copiare il template precompilato e riempire i buchi con i valori specifici di quella funzione. Niente passaggi di ottimizzazione, niente algoritmi di allocazione dei registri, niente selezione delle istruzioni. Solo memcpy e patch.
Traditional JIT pipeline:
Python bytecode
→ Parse into IR
→ Type inference
→ Optimization passes (CSE, DCE, loop unrolling, inlining...)
→ Register allocation
→ Instruction selection
→ Machine code
Total: 1-100ms per function
Copy-and-patch pipeline:
Python bytecode
→ For each instruction, copy pre-compiled template
→ Fill in holes (constants, offsets)
→ Done — machine code ready
Total: 1-100μs per function (1000x faster compilation)
Il compromesso riguarda la qualità del codice. LLVM produce codice macchina altamente ottimizzato. Copy-and-patch produce codice macchina che è essenzialmente una versione compilata del loop dell'interprete — ogni istruzione di bytecode è ancora un template separato, con un'ottimizzazione minima tra un'istruzione e l'altra. Il codice generato è migliore dell'interpretazione (niente overhead di dispatch, niente costrutto switch, una migliore predizione dei salti), ma peggiore di quello che produrrebbe un compilatore ottimizzante completo.
Per Python, questo compromesso è ottimo. Le funzioni Python sono di solito brevi, chiamate moltissime volte e richiedono singolarmente microsecondi. Un JIT che compila in microsecondi e accelera l'esecuzione del 20-30% vale più di uno che compila in millisecondi e accelera del 200% — perché il costo di compilazione del primo approccio viene ammortizzato quasi subito.
Cosa diventa più veloce
Il JIT non accelera magicamente tutto il codice Python allo stesso modo. Capire cosa ne trae più beneficio richiede di capire su cosa l'interprete spende il suo tempo.
Overhead di dispatch del bytecode. Nell'interprete, ogni istruzione di bytecode richiede: leggere il prossimo opcode, decodificarlo, saltare al gestore tramite un costrutto switch. Questo overhead di dispatch può arrivare al 30-50% del tempo totale nei loop stretti. Il JIT lo elimina del tutto: le istruzioni vengono compilate in codice macchina sequenziale con salti diretti.
Operazioni specializzate per tipo. Python 3.11 ha introdotto l'interprete adattivo con specializzazione, che sostituisce le operazioni generiche con versioni specifiche per tipo dopo aver osservato i tipi effettivamente usati. BINARY_ADD diventa BINARY_ADD_INT quando vede due interi. Il JIT compila queste istruzioni specializzate in codice macchina efficiente: una somma di interi diventa una singola istruzione add invece di una chiamata di funzione.
Predizione dei salti. Il ciclo di dispatch centrale dell'interprete — uno switch con centinaia di casi — è un incubo per il branch predictor della CPU. Il JIT lo sostituisce con un flusso di controllo diretto che la CPU prevede con precisione. Sulle CPU moderne, dove un salto sbagliato costa 15-20 cicli, questo da solo spiega una parte significativa dell'accelerazione.
Cosa non diventa più veloce: le chiamate alle estensioni C (NumPy, pandas), le operazioni di I/O (rete, disco), le operazioni dominate dall'allocazione di memoria (creare milioni di piccoli oggetti). Se il tuo programma Python passa il 95% del tempo nelle estensioni C e solo il 5% in Python puro, il JIT accelera quel 5% — misurabile, ma non trasformativo.
La pipeline di specializzazione
Il JIT non lavora da solo. È l'ultimo stadio di una pipeline di prestazioni iniziata con l'interprete specializzante di Python 3.11 e proseguita con i miglioramenti incrementali delle versioni 3.12-3.14.
- Tier 0: interprete. Tutto il codice parte da qui. Interpretazione standard del bytecode con specializzazione adattiva. Dopo alcune chiamate di una funzione, le istruzioni più frequenti vengono sostituite con versioni specializzate per tipo.
- Tier 1: bytecode compilato JIT. Il JIT copy-and-patch compila il bytecode specializzato in codice macchina. Questo elimina l'overhead di dispatch e abilita ottimizzazioni di base come il constant folding e l'eliminazione del codice morto all'interno dei template compilati.
- Tier 2 (futuro): ottimizzazione basata su tracce. Registrare le tracce di esecuzione lungo i percorsi di codice caldi e compilare intere tracce — oltre i confini delle funzioni — in codice macchina ottimizzato. È pianificato ma non ancora disponibile.
Questo approccio a livelli è simile a quello usato da YJIT di Ruby e da altri runtime di linguaggi moderni. Si parte da un'interpretazione veloce, si passa a una compilazione rapida quando il codice diventa caldo, e si riserva l'ottimizzazione costosa ai percorsi più caldi. È la stessa intuizione di fondo: la maggior parte del codice non vale la pena di essere ottimizzata, quindi conviene spendere il budget di compilazione sul codice che gira più spesso.
Impatto su memoria e avvio
I compilatori JIT consumano memoria per il codice compilato. L'overhead di memoria del JIT copy-and-patch è contenuto: il codice compilato è più grande del bytecode ma più piccolo di quello prodotto dai JIT basati su LLVM (perché non c'è il gonfiore dovuto all'ottimizzazione). L'implementazione attuale usa circa 1,5-3 volte la memoria del bytecode che sostituisce, e compila solo le funzioni chiamate abbastanza spesso da trarne beneficio.
Il tempo di avvio è un problema per gli script Python di breve durata. Il JIT aggiunge un overhead per caricare la libreria di template e preparare l'infrastruttura di compilazione. Per gli script che girano per meno di un secondo, l'overhead del JIT potrebbe superare l'accelerazione. CPython risolve questo problema compilando le funzioni solo dopo un certo numero di chiamate: gli script di breve durata restano nell'interprete e non pagano il costo del JIT.
È configurabile. Il flag -X jit controlla il comportamento del JIT, e le variabili d'ambiente regolano la soglia di compilazione. Per funzioni serverless e strumenti da riga di comando, dove l'avvio conta, puoi alzare la soglia o disattivare del tutto il JIT. Per i server di lunga durata e gli script di elaborazione dati, dove conta la prestazione a regime, i valori predefiniti vanno bene.
Cosa significa per l'ecosistema Python
Il JIT non cambia la posizione di Python nella gerarchia delle prestazioni: C, Rust, Go e Java restano molto più veloci nei carichi intensivi di calcolo. Cambia invece la soglia oltre la quale gli sviluppatori Python devono ricorrere a queste alternative.
Un'accelerazione del 20-30% sul codice Python puro significa che alcuni carichi di lavoro che prima richiedevano estensioni C o una riscrittura ora girano abbastanza velocemente in Python puro. Script di elaborazione dati che impiegavano 10 minuti ne impiegano 7. Server web che gestivano 1000 richieste al secondo ne gestiscono 1300. Non sono numeri rivoluzionari, ma fanno la differenza tra «Python è abbastanza veloce» e «dobbiamo riscrivere tutto in Go».
Ancora più importante, l'infrastruttura JIT crea le basi per ottimizzazioni future. L'approccio copy-and-patch può essere esteso con template migliori, più specializzazione e, alla fine, compilazione basata su tracce. Ogni versione di Python potrà distribuire template migliori senza cambiare l'architettura di fondo del JIT. Il 20-30% di accelerazione in 3.15 è un pavimento, non un soffitto.
Dopo tre decenni come uno dei linguaggi più popolari e, insieme, più lenti al mondo, CPython investe finalmente seriamente nelle prestazioni. Il JIT non soddisferà i fan del «Python è troppo lento» — nulla lo farà, perché per alcuni carichi Python è davvero troppo lento, con o senza JIT. Ma per la stragrande maggioranza del codice Python, dove la velocità era «sufficiente ma non ottima», il JIT lo porta più vicino a «davvero buona». È una questione più grande di quanto sembri.


