Perché una modifica ricompila tutto ciò che sta sopra
Ogni libreria che rustc compila registra un hash del crate, lo SVH (strict version hash). I dipendenti registrano lo SVH di ciascun crate rispetto al quale sono stati compilati, e rustc si rifiuta di caricare una dipendenza il cui hash differisce da quello registrato. Lo SVH di rustc copre l’intero crate, corpi delle funzioni compresi, quindi qualsiasi modifica lo cambia. Cargo, dal canto suo, calcola il fingerprint di un’unità a partire dagli output delle sue dipendenze. In entrambi i casi, una libreria modificata significa ricompilare tutto ciò che ne dipende, anche quando la modifica era un letterale stringa nel profondo di una funzione privata.
È un approccio sicuro e semplice, e in un workspace grande occupa la maggior parte del tempo speso dopo una modifica. La modifica al crate core di ripgrep, nei benchmark di QuickRust, richiedeva a Cargo circa 640 ms; quasi tutti spesi a compilare crate su cui la modifica non poteva influire.
Calcolare l’hash di ciò che i dipendenti possono osservare
Il driver del compilatore di QuickRust è rustc stesso: la librustc_driver della toolchain, con il suo LLVM e la sua release, eseguita tramite un piccolo programma che installa le callback di QuickRust. Senza callback attive, i suoi output sono identici byte per byte a quelli di rustc, e un test lo verifica. Con l’interface hash attivo cambia una sola cosa: lo SVH che una libreria registra viene calcolato a partire da ciò che i suoi dipendenti possono osservare, non dall’intero crate.
Ciò che i dipendenti possono osservare è più dell’API pubblica. L’hash copre l’HIR di ogni elemento, escludendo solo i corpi delle funzioni il cui codice resta nel proprio crate: non generiche, non inlinabili tra crate, non const, non async, e che non restituiscono impl Trait (il cui tipo nascosto lascia trapelare layout e auto trait). Copre ogni definizione in ordine e ogni espansione di macro per numero, perché i dipendenti si riferiscono alle definizioni e alle espansioni di un crate per posizione, quindi un corpo che acquisisce una closure o una chiamata di macro le sposta e cambia l’hash. Copre le posizioni (file, riga e colonna) di tutto ciò che i dipendenti possono vedere, perché le loro informazioni di debug e i loro messaggi di panic citano quelle posizioni. Copre i simboli a cui i dipendenti possono collegarsi invece di istanziare i propri, e tutto il resto che l’hash di rustc copre oltre all’HIR. Le macro procedurali mantengono l’hash di rustc, poiché i loro corpi vengono eseguiti dentro le compilazioni dei loro dipendenti.
Quindi una modifica che cambia solo un corpo escluso lascia invariato lo SVH della libreria. La libreria stessa viene comunque compilata. I suoi dipendenti, indicizzati nella cache degli artefatti in base a quell’interface hash anziché ai byte della libreria, trovano le loro voci e vengono ripristinati invece che compilati. La ricompilazione si ferma al crate modificato, fino alle unità che eseguono il link.
Quali modifiche vengono fermate
La regola discende dall’hash. Le modifiche dentro il corpo di una funzione ordinaria, non generica e non inline, che ne mantengono le righe e non aggiungono closure, chiamate di macro o nuove istanziazioni generiche, vengono fermate: cambiare un letterale, una condizione, un’espressione aritmetica, una chiamata a una funzione esistente. Le modifiche che i dipendenti possono osservare non vengono fermate, e non devono esserlo: una firma, il corpo di una funzione generica o #[inline], una const fn, i campi di una struct, un nuovo elemento. Spesso non vengono fermate nemmeno le modifiche che aggiungono o rimuovono righe, perché spostano le posizioni delle definizioni osservabili che le seguono nello stesso file, posizioni che le informazioni di debug dei dipendenti registrano. È un costo reale del mantenere veritiere le informazioni di debug, non un incidente.
Ripetere il link invece di ricompilare
Il cut-off si ferma alle unità che eseguono il link. Un binario o un eseguibile di test dipende dai byte di ogni libreria con cui viene collegato, quindi quando il codice della libreria modificata cambia, il binario deve essere collegato di nuovo. Non deve però essere compilato di nuovo: il suo codice non è cambiato, e nemmeno le interfacce rispetto alle quali è stato compilato.
Così, quando il driver compila un binario o un eseguibile di test, conserva i file oggetto: scrive il file .rlink che descrive il link, e crea hard link degli oggetti in <stem>.qrust-objects/ prima che il link li cancelli, con un record <stem>.qrust-relink di ciò da cui sono stati compilati. Quando una build successiva trova quell’unità compilata dagli stessi sorgenti rispetto alle stesse interfacce delle dipendenze, e soltanto ciò con cui viene collegata è cambiato, esegue -Zlink-only di rustc sugli oggetti conservati invece di compilare. Per un binario grande è una frazione del lavoro: il binario di ripgrep ripete il link in circa 45 ms, contro i circa 350 ms che richiede compilarlo. Le unità che la cache ha visto compilare rapidamente non ripetono il link, e nemmeno le build ottimizzate e non incrementali, il cui inlining MIR potrebbe copiare codice tra crate; queste sono indicizzate per contenuto come prima.
Insieme, questi accorgimenti hanno portato la modifica al crate core di ripgrep da circa 640 ms con Cargo a circa 140 ms con QuickRust al checkpoint 2 (mediane in benchmarks/speed-index.jsonl; riepilogo in benchmarks/PROGRESS.md). Non tutti i carichi di lavoro ne traggono ancora altrettanto vantaggio: la modifica core del servizio axum richiedeva circa 980 ms con QuickRust contro circa 1,03 s con Cargo, un divario che il registro dei progressi elenca come aperto.
Prima gli output, poi la contabilità
Il driver riordina anche un passaggio del lavoro di rustc. rustc salva il suo stato incrementale (il grafo delle dipendenze e i risultati delle query, circa 100 ms per un crate grande) quando termina il suo contesto dei tipi, e solo allora esegue il link degli output. Cargo, e ogni unità in attesa di questa, aspettano quel salvataggio. Il driver esegue il link all’interno della generazione del codice, mentre il contesto dei tipi è ancora attivo, poi comunica al wrapper di QuickRust che gli output sono scritti (un byte su una pipe) e smette di scrivere su stdout e stderr. rustc salva quindi il suo stato incrementale esattamente come avrebbe fatto, mentre la build prosegue. Questo vale su Unix, e non per i crate con #![feature], i cui warning sulle feature inutilizzate rustc emette dopo la generazione del codice.
Perché il driver viene compilato per ogni toolchain
L’interfaccia interna di rustc cambia da una release all’altra, quindi un driver compilato per un compilatore non può caricare la librustc_driver di un altro. QuickRust compila quindi il driver per ogni toolchain, a partire da sorgenti incorporati in QuickRust, rispetto al componente rustc-dev di quella toolchain (che installa quando serve), e lo usa solo quando riporta esattamente lo stesso rustc -vV del compilatore. Risiede in ~/.qrust/cache/drivers/, indicizzato in base al commit e all’host del compilatore e al sorgente del driver stesso.
Il driver fa parte di ogni chiave della cache degli artefatti per le unità che compila, quindi ciò che una build ha conservato senza di esso, le build che lo usano non lo troverebbero mai. QuickRust lo compila quindi prima del primo utilizzo di una toolchain, dove può: quando qrust setup installa una toolchain, e quando un canale passa a una nuova release. Altrimenti, la prima build che lo trova mancante avvia la sua compilazione in background e lo segnala; le build successive lo usano non appena è disponibile. qrust driver status dice a che punto sono le cose, e qrust driver build lo compila subito.
Quando il driver fallisce
Il driver è un’ottimizzazione, e viene trattato come tale. Se il driver non può essere compilato per una toolchain, quella toolchain compila con il rustc standard, e il motivo viene registrato accanto a dove si troverebbe il driver; le build in background non continuano a riprovare. Se il driver fallisce su un crate, quel crate viene invece compilato da rustc, e da rustc d’ora in poi; il report del driver viene conservato sotto failed/ per la diagnosi. I programmi che Cargo esegue al posto di rustc, come clippy-driver o un wrapper che hai configurato, vengono eseguiti così come sono. fast.early-cutoff = "off" disattiva tutto questo.
Come viene verificato
Ripristinare i dipendenti di un crate invece di compilarli è accettabile solo se il risultato è esattamente quello che darebbe compilarli. Il gate del cut-off (qrust-bench cutoff) verifica proprio questo: per modifiche casuali dentro i corpi delle funzioni dei crate di ciascun carico di lavoro di benchmark, compila come farebbe uno sviluppatore (build, modifica, nuova build con lo stesso stato di QuickRust) e compila anche il sorgente modificato da zero, e ogni libreria, file di metadati ed eseguibile deve essere identico byte per byte. Parallelamente, un ciclo incrementale aggiunge righe a ogni file sorgente e compila di nuovo, il che costringe rustc a verificare, in una sessione incrementale, ciò che legge dai crate il cui hash non è cambiato. I risultati vengono registrati in benchmarks/cutoff-gate.jsonl.
Il confronto dei byte viene eseguito senza il frontend parallelo di rustc e senza il linker wild, perché nessuno dei due produce gli stessi byte due volte. Questo indica l’unico limite noto: con più thread rustc numera le static annidate di un crate in ordine variabile, e quei numeri fanno parte di ciò che i dipendenti possono osservare, quindi l’interface hash li conserva. Un crate con static di questo tipo può quindi mancare il cut-off una volta dopo una build a freddo (compilata con il frontend parallelo), e poi va a segno come di consueto.