DIEGO CECATO / NOTE DAL CAMPO

Wild 0.11: 32 thread, un mutex e il prezzo della compatibilità

diego cecato//
Copertina editoriale Wild 0.11: 32 thread, mutex Linux e compatibilità COMDAT

Un software che nasce per essere veloce pubblica una nuova versione e ammette due cose poco adatte alle slide commerciali: su certe macchine usare più thread lo rende più lento, e una correzione necessaria alla compatibilità gli costa prestazioni. Se ci si ferma ai numeri, sembra una contraddizione. Se invece si progetta software che deve funzionare davvero, è probabilmente la parte più interessante della notizia.

Il rilascio 0.11.0 di Wild, linker scritto in Rust e orientato soprattutto all’ecosistema Linux, porta anche WebAssembly abilitato per impostazione predefinita, progressi sul formato Mach-O e un supporto più ampio ai linker script GNU. Ma non è l’elenco delle feature a meritare attenzione: è il modo in cui queste novità rendono esplicito il prezzo di due promesse che ogni componente infrastrutturale deve mantenere, essere corretto ed essere prevedibile sotto carico.

Il linker è l’ultima parte della build che nessuno vuole vedere

Un compilatore produce file oggetto; il linker risolve simboli e riferimenti, applica relocation, dispone sezioni e genera l’eseguibile o la libreria finale. È una fase che può sembrare un dettaglio finché la build è piccola. Quando entrano in gioco migliaia di oggetti, librerie statiche, debug info e build incrementali, anche una differenza modesta nel tempo di linking diventa un costo ripetuto molte volte al giorno.

Da qui l’interesse per linker come Wild o mold: ridurre la latenza di un passaggio frequente. Ma un linker non è un compressore che può sacrificare un po’ di qualità per risparmiare tempo. Il suo output deve rispettare le regole dell’ABI, dei simboli, dei formati binari e delle opzioni che il resto della toolchain si aspetta. Un eseguibile generato più rapidamente ma semanticamente sbagliato non è una build ottimizzata. È un bug che ha imparato a correre.

Questa distinzione era già centrale nell’analisi della riscrittura di mold 3.0 in Rust: il valore di un componente sostituibile non sta soltanto nell’implementazione interna, ma nel contratto che riesce a preservare. Wild 0.11 aggiunge una domanda meno comoda: quanto siamo disposti a pagare per rispettare quel contratto?

COMDAT: diventare più corretti può significare rallentare

La release introduce il supporto appropriato ai gruppi ELF COMDAT. Semplificando, questi gruppi permettono a più file oggetto di contenere definizioni alternative dello stesso insieme di sezioni, lasciando al linker il compito di conservarne una coerente e scartare le altre secondo le regole del formato. È un meccanismo importante quando codice generico, template e compilazioni separate producono definizioni che devono convivere senza duplicazioni scorrette.

Il punto non è che Wild non riuscisse mai a produrre un binario senza questa funzione. È che la compatibilità con altri linker richiede di trattare correttamente anche questi casi. Nella discussione della modifica COMDAT, il maintainer spiega di aver rimandato il lavoro perché costoso e parzialmente sovrapposto ad altri meccanismi, ma di considerarlo necessario per una compatibilità e per confronti prestazionali più equi.

I benchmark pubblicati con quella modifica mostrano regressioni nei tempi di esecuzione del linker, da circa +1,18% a +5,43% nei workload elencati. Sono misure del progetto, su un insieme selezionato di casi, non un verdetto universale. Proprio per questo vanno lette bene: la release non promette un miglioramento su ogni asse, e il lavoro di correttezza ha un costo misurabile.

Se un’ottimizzazione funziona soltanto perché il software non sta rispettando tutte le regole, il benchmark sta misurando un vantaggio che non possiamo permetterci.

Per confrontare due linker occorre chiedersi se stiano davvero svolgendo lo stesso lavoro. Opzioni differenti di garbage collection delle sezioni, gestione dei simboli o controlli di compatibilità possono rendere una classifica formalmente precisa e tecnicamente ingannevole. Un grafico non sa se una delle due implementazioni ha semplicemente evitato una parte del contratto.

128 CPU logiche, ma il limite diventa 32 thread

Il secondo caso è ancora più istruttivo perché riguarda un riflesso quasi automatico dello sviluppo ad alte prestazioni: se ci sono più core, facciamo lavorare più thread. Nella PR che introduce il limite, l’autore racconta di aver provato Wild su un AMD EPYC 8534P con 64 core e 128 thread hardware. Nei suoi test, oltre circa 32 thread non emergeva più un miglioramento; superata la zona dei 40, le prestazioni peggioravano sensibilmente.

La spiegazione proposta è la contesa su uno spinlock della page cache associato all’inode nel kernel Linux. È un’osservazione su una specifica configurazione, con kernel 7.0, non una legge matematica valida per ogni filesystem e ogni macchina. La risposta ingegneristica della release è comunque concreta: Wild usa al massimo 32 thread per impostazione predefinita, salvo override esplicito con --threads.

Qui sta il paradosso apparente. I thread aggiuntivi non eliminano il lavoro che richiede coordinamento; possono aumentare il numero di concorrenti che cercano di attraversare lo stesso punto. Se il percorso critico passa da un lock condiviso, aggiungere esecutori non aumenta automaticamente il lavoro utile. Aumenta, talvolta, il traffico davanti alla porta.

La dinamica ricorda quella discussa nell’articolo sulla contesa sul reference counting dei folio Linux, ma non è lo stesso bug né lo stesso meccanismo: là il costo nasceva da un contatore condiviso, qui la diagnosi del maintainer riguarda uno spinlock per inode. Il principio architetturale è comune, non la soluzione tecnica.

Il numero giusto di thread non è un parametro da indovinare

Un limite predefinito di 32 non significa che 32 sia il numero ottimo in assoluto. Significa che, nei test che hanno motivato la decisione, era più ragionevole fermarsi prima di entrare nella zona di regressione. Su una macchina più piccola, su un filesystem diverso o con una diversa versione del kernel, il punto di equilibrio potrebbe cambiare.

Per misurarlo seriamente bisogna separare il tempo totale della build dal solo linking, ripetere l’invocazione su input identici e variare il numero di thread mantenendo costanti le altre condizioni. Poi occorre guardare dove si consuma il tempo: lavoro utile dei core, attesa su lock, page fault, scritture e memoria. Un profiler può mostrare una contesa che il semplice cronometro non sa spiegare.

Il filesystem fa parte del benchmark, anche quando nessuno lo cita

La release aggiunge interventi mirati su ext4 e XFS con fallocate e hugepages, e una modalità mmap per Btrfs con kernel sufficientemente recente. Sono modifiche che ricordano un fatto elementare: il linker legge molti file oggetto e scrive un output consistente. Non opera in un vuoto astratto fatto soltanto di istruzioni CPU.

La guida ai benchmark del progetto mette in guardia dall’effetto della cache del filesystem, dai backlog di scrittura e dal thermal throttling. Spiega anche che, per confrontare linker diversi, conviene ripetere solo la fase di linking e allineare le opzioni che cambiano il lavoro effettivo. Se i file di input sono già in page cache, il test misura un regime diverso rispetto a una lettura a freddo. Se il dispositivo sta smaltendo scritture precedenti, una run può pagare il conto di un’altra.

Persino le metriche apparentemente semplici possono ingannare. Wild e mold possono avviare un processo figlio per il linking: misurare la memoria del solo processo padre senza l’opzione --no-fork rischia di produrre numeri irrilevanti. Non serve un benchmark spettacolare. Serve un benchmark che misuri l’oggetto giusto.

WebAssembly e macOS: il supporto non è un interruttore

Nel frattempo Wild 0.11 abilita per default la funzionalità WebAssembly. È un passo reale, ma le note dichiarano che le informazioni di debug Wasm non sono ancora supportate. Per chi deve usare debugger e strumenti di diagnosi, la differenza tra “produce il binario” e “copre l’intero flusso di sviluppo” è sostanziale.

Sul fronte GNU linker script il progetto dichiara di poter collegare moduli del kernel Linux e di essere vicino al linking del kernel completo. Vicino non significa arrivato. La compatibilità con la sintassi storica e i suoi casi limite richiede lavoro poco visibile, proprio quello che decide se un tool possa diventare una sostituzione credibile.

Anche il port Mach-O per macOS progredisce: Wild riesce a linkare sé stesso e un maintainer lo usa già come linker predefinito per Rust sul proprio Mac. Ma il supporto non è abilitato di default. Sono traguardi diversi, e confonderli trasformerebbe una roadmap tecnica in una promessa di prodotto che il progetto non ha fatto.

Che cosa misurare prima di cambiare linker in un progetto vero

Il modo utile di leggere Wild 0.11 non è chiedersi se sia il linker più veloce del momento. È stabilire quali proprietà deve mantenere per diventare un componente della propria toolchain. Prima di sostituire il linker di una build reale, servono almeno una baseline ripetibile, una verifica dell’output e un controllo dei casi che la propria applicazione utilizza: script, relocation, librerie condivise, simboli, debug info e piattaforme target.

Una prova sensata confronta i tempi su più esecuzioni e più profili di carico, include sia build pulite sia iterative quando pertinenti, registra CPU, filesystem e versione del kernel, e considera il costo degli errori di compatibilità. Se una configurazione guadagna pochi millisecondi ma introduce un problema di debug che costa ore, il saldo è già negativo. Se un limite di thread riduce la contesa sul server CI, il guadagno potrebbe arrivare proprio dalla rinuncia a saturare tutti i core.

Vale anche la pena ricordare che la versione minima di Rust richiesta per compilare Wild 0.11 è la 1.99.0. Per chi usa pacchetti precompilati può essere un dettaglio; per chi mantiene build riproducibili o distribuzioni, è un vincolo operativo da mettere nel conto.

La velocità è una proprietà del sistema, non del titolo della release

Wild 0.11 è interessante perché rende visibili i compromessi invece di nasconderli: meno thread per evitare una contesa osservata, un po’ di prestazione ceduta per supportare correttamente COMDAT, portabilità che avanza per gradi e benchmark che includono condizioni spesso trascurate. Non sono difetti da coprire con un grafico più grande. Sono decisioni tecniche che permettono di capire che cosa il software sa fare e dove smette di prometterlo.

La domanda da portarsi a casa non è “quanti core posso occupare?”, ma “quale lavoro utile sto accelerando, con quali garanzie e a quale costo?”. Quando una release sa rispondere anche alla parte scomoda, racconta molto più di un altro record di velocità.

Domande frequenti

Perché Wild 0.11 limita i thread a 32?

Nei test del maintainer su un EPYC con 128 thread hardware, oltre circa 32 thread il linking non migliorava e oltre circa 40 peggiorava, probabilmente per contesa su uno spinlock della page cache per inode Linux. Il limite predefinito può essere modificato con –threads e non è un optimum universale.

Che cosa cambia con il supporto COMDAT in Wild 0.11?

Il linker gestisce correttamente i gruppi ELF COMDAT, necessari per selezionare in modo coerente sezioni duplicate tra file oggetto. La maggiore compatibilità comporta nei benchmark pubblicati dal progetto alcune regressioni dei tempi di linking.

Wild 0.11 può linkare l'intero kernel Linux?

Le note ufficiali indicano il supporto ai linker script necessario per linkare moduli del kernel Linux. Il linking dell’intero kernel è descritto come un obiettivo vicino, non come una funzionalità già completata.

Il supporto WebAssembly e macOS è completo?

No. In Wild 0.11 il backend WebAssembly è abilitato di default, ma mancano ancora le informazioni di debug Wasm. Il port Mach-O/macOS progredisce, ma non è ancora abilitato per impostazione predefinita.