Vai al contenuto principale
OpenAI

30 giugno 2026

Ingegneria

Epidemiologia dei core dump: risolvere un bug di 18 anni

Usare l’analisi dell’insieme dei crash per eseguire il debug di crash complessi nella nostra infrastruttura dati.

Caricamento in corso...

I modelli e gli agenti di OpenAI usano sempre più infrastrutture dati scalabili per cercare dati pertinenti in fase di inferenza, cioè mentre ragionano sulla tua domanda. Alcuni servizi sono scritti in C++, che offre controllo a basso livello del sistema e ci aiuta a massimizzare le prestazioni e a ridurre l'uso della memoria. Questa efficienza conta quando scaliamo, ma in C++ l’assenza di sicurezza della memoria fa sì che i bug possano causare crash scrivendo a indirizzi errati o inesistenti.

Qualche mese fa abbiamo visto crash nel servizio Rockset, un componente realizzato su misura dell’infrastruttura dati di ChatGPT, essenziale per molti plugin dati e per la ricerca nelle conversazioni. In ciascun caso una normale funzione C++ sembrava terminare e poi tornare a un indirizzo non valido; il kernel fermava il programma perché l’instruction pointer non puntava più a codice. A volte lo slot dell’indirizzo di ritorno nello stack frame era NULL. A volte il registro CPU dello stack pointer sembrava disallineato di 8 byte, come se %rsp fosse stato decrementato durante la normale esecuzione. In entrambi i casi il crash avveniva al ritorno.

Non sono modalità di errore normali per il codice applicativo. Una scrittura fuori posto che colpisce solo un indirizzo di ritorno salvato è possibile, ma molto improbabile. Un bug che disallinea %rsp di 8 senza assembly inline, setcontext o longjmp (che non usiamo) è ancora più strano: il codice compilato modifica quel registro direttamente solo nel prologo e nell’epilogo della funzione. Ogni ipotesi pensata da noi (o da ChatGPT) aveva forti prove contrarie, quindi il bug sembrava impossibile.

Quello che credevamo un solo problema si è rivelato essere due bug indipendenti, scoperti insieme per caso. Primo: corruzione hardware silenziosa su un host Azure, dove la CPU sbagliava i calcoli. Secondo: una race condition di 18 anni in GNU libunwind, un bug passato inosservato in una libreria open source molto usata.

Questo post racconta come abbiamo identificato e corretto crash apparentemente inspiegabili ragionando da epidemiologi e creando un set di dati di qualità su tutto l'insieme dei crash.

Primo tentativo di debug: esaminare alcuni core dump

Partiamo da Rockset. È un sistema dati cloud-native per ricerca e analisi in tempo reale, usato in molti casi interni a OpenAI, come i connettori di sincronizzazione (Rockset è stata acquisita da OpenAI nel 2024). Gli aggiornamenti in streaming mantengono aggiornato l’indice della knowledge base di un’area di lavoro, così ChatGPT può cercare informazioni pertinenti quando risponde o agisce.

Il livello di esecuzione di Rockset è scritto in C++. C++ dà accesso a basso livello alla CPU, utile per prestazioni ed efficienza, ma consente ai bug applicativi di causare accessi di memoria non validi e segfault. Per individuarli usiamo il gestore di segnali fatali di folly, che registra uno stack trace al crash, e carichiamo i relativi core dump (istantanee dello stato del programma) in Azure Blob Storage per analizzarli dopo. Tutti i nodi leaf di elaborazione delle query di Rockset sono replicati, riducendo al minimo l’impatto di un crash sul client. Ogni segfault però indica un bug da correggere per raggiungere i nostri obiettivi di affidabilità e qualità.

All’inizio abbiamo trattato questi core come un normale problema di debug, esaminando alcuni core dump, formulando ipotesi ed escludendole una a una.

La maggior parte dei crash avveniva in un metodo chiamato DocumentTree::updateDocument. Sembrava che updateDocument avesse chiamato una funzione ignota X, che lo stack si fosse corrotto mentre X era attiva e che X fosse poi tornata a un indirizzo non eseguibile. In alcuni casi il frame di X appena tolto dallo stack sembrava valido, salvo l’indirizzo di ritorno salvato, che era NULL. In altri, lo stack pointer sembrava errato, ma il frame valido successivo pareva ancora updateDocument.

Non sapevamo quando lo stack si corrompesse, quindi lo spazio di ricerca era enorme. updateDocument è un metodo grande e molto inline, quindi i candidati per X erano troppi.

Era un bug nel nostro codice C++? Un problema di compilatore o linkage? Un problema in una libreria runtime? Un bug del kernel Linux nella consegna dei segnali o nel cambio di contesto? Qualcosa di ancora più raro? Se era una scrittura fuori posto, perché il nostro ambiente di staging con ASAN non la intercettava?

Abbiamo provato a usare i log applicativi per trovare tutte le occorrenze, ma i bug di corruzione dello stack sono difficili da classificare solo dai log: gli stack trace registrati sono corrotti o mancanti. Non riuscivamo a creare una query sui log senza falsi positivi e falsi negativi. Abbiamo ispezionato altri core a mano e trovato nuovi esempi, ma era un processo troppo oneroso per ottenere un set di dati affidabile.

A quel punto abbiamo escluso, sbagliando, un bug hardware: vedevamo crash in più regioni e su hardware diversi, quindi cercavamo ancora cause solo software. Per qualche giorno ci siamo concentrati su un singolo crash con %rsp disallineato, ricostruendo la storia pre-crash da stack e registri. Ne sono usciti alcuni indizi, ma non ci hanno sbloccato perché restavamo ancorati all’idea che tutti i bug avessero la stessa causa.

Indizi dallo stack

Prima della svolta, vale la pena spiegare quali informazioni estraevamo dai file core.

Rockset è compilato con -fno-omit-frame-pointer, quindi lo stack frame attivo è sempre raggiungibile tramite %rbp e i chiamanti formano una lista collegata di frame pointer.

Su Linux x86_64, l’ABI AMD64 System V riserva anche 128 byte sotto %rsp come red zone. Quella regione è disponibile al codice userspace e, per contratto ABI, il kernel promette di non sovrascriverla quando consegna un segnale.

La red zone è stata centrale nel debug di un crash post-ritorno, perché conserva alcune informazioni precedenti al ritorno. Quando scatta un SIGSEGV, il gestore di segnali fatali di folly gira sullo stack del thread in crash. Gli stack frame non più attivi, perché la funzione è tornata, vengono sovrascritti dal gestore del segnale, tranne gli ultimi 128 byte. Per questo possiamo dire cose come: «lo stack frame di X appena tolto sembrava valido, tranne un indirizzo di ritorno NULL». La red zone conserva parte dei frame inattivi, o talvolta solo la coda di uno.

Diagramma dello stack che mostra stack frame corrotti che possono sovrascrivere indirizzi di ritorno e causare crash anomali.

Abbiamo trovato un crash con stack disallineato in cui tutte le funzioni coinvolte erano molto piccole. Così abbiamo visto che %rsp si era disallineato durante l'esecuzione di una funzione abbastanza semplice e che altre chiamate erano poi riuscite. Il programma è andato in crash solo quando la funzione attiva ha provato a tornare. Nessuno di quei percorsi usava eccezioni, assembly inline, setcontext o longjmp; quindi, se lo stack pointer era davvero cambiato come indicava il core, nessun bug plausibile in userspace spiegava il problema.

Questo ci ha spinti verso il kernel.

Rockset usa i segnali più intensamente della maggior parte dei programmi. L’esecuzione delle query è divisa in molte attività leggere che si scambiano dati. È importante per gestire efficientemente carichi ad alto QPS, ma complica la contabilizzazione della CPU per query, perché il lavoro di molte query è multiplexato sullo stesso pool di thread.

La nostra soluzione è coarse_thread_cputime_clock, che approssima clock_gettime(CLOCK_THREAD_CPUTIME_ID, ...) con costi abbastanza bassi da campionare a ogni passaggio tra attività. L’API timer_create può pianificare la consegna periodica di un segnale in base a varie misure del tempo, incluso il tempo CPU accumulato. Pianifichiamo un segnale (SIGUSR2) ogni pochi millisecondi di tempo CPU; il gestore aggiorna poi un valore locale al thread. Anche se molte attività non vedono avanzare l’orologio grossolano mentre girano, sommare i delta produce una stima non distorta del tempo CPU reale di una query.

Poiché consegniamo segnali così spesso, un raro bug del kernel nel cambio di contesto o nella consegna dei segnali sembrava plausibile. Abbiamo letto segnalazioni di bug, sorgenti del kernel e patch del kernel specifiche di Azure. Abbiamo provato stress test. Non abbiamo trovato nulla che sembrasse collegato.

A quel punto abbiamo fatto un passo indietro e cambiato approccio.

Ci sono due grandi modi per fare debug di un problema così.

Uno è comportarsi da medico: concentrarsi su un paziente, fare molti test e diagnosticare un singolo caso da prove dettagliate.

L’altro è agire da epidemiologo: considerare tutti i casi e cercare schemi che un singolo caso non può rivelare. Il bug è iniziato con una release specifica? È correlato a una SKU hardware (lo specifico modello di CPU e server), a una regione o a una versione del kernel? Dentro quella che sembra un’unica sindrome si nascondono cluster distinti?

Eravamo rimasti quasi sempre in modalità medico. La svolta è stata decidere di raccogliere dati completi e affidabili sull'insieme dei casi.

Pulizia dei dati

I tentativi precedenti di trovare automaticamente tutte le istanze del problema erano falliti perché eseguivamo ricerche testuali nei log. I core dump contengono molte più informazioni, ma esaminarli a mano non era sostenibile. Abbiamo quindi costruito una pipeline per analizzare automaticamente i core dump.

Abbiamo chiesto a ChatGPT di scrivere uno script che scaricasse un prefisso di ogni file core, estraesse i registri, filtrasse i falsi positivi noti tramite i log ed etichettasse il crash come ritorno-a-null, stack-disallineato o altro. Poi lo abbiamo eseguito in parallelo su tutti i core dump Rockset di produzione dell’anno precedente.

È stata la svolta.

Con un set di dati pulito, le correlazioni sono emerse subito. Quello che trattavamo come un unico strano bug era in realtà formato da due gruppi distinti di crash.

I core da ritorno-a-null erano distribuiti su molti cluster e regioni. La frequenza era salita di recente, ma senza una data di inizio netta né un confine infrastrutturale chiaro.

I crash con stack disallineato erano del tutto diversi. Venivano tutti da una sola regione, avevano una data di inizio chiara e non comparivano mai su nodi attivi da molto tempo. Anche se coinvolgevano più VM Azure (macchine virtuali nel cloud), lo schema indicava una macchina fisica con hardware guasto che colpiva qualunque VM vi finisse sopra.

Grafico a punti dei tassi di crash per cluster nel tempo, che mostra la maggior parte dei crash concentrata nei cluster 2, 3 e 6, con un picco nel cluster 1 verso la fine del periodo.

In quel momento abbiamo capito di aver confuso due bug. Mescolando controesempi di entrambi, non potevamo trovare una sola spiegazione coerente.

Bug n. 1: l’host difettoso

Con un elenco pulito di nodi Kubernetes e timestamp, siamo riusciti a ricondurre i crash con stack disallineato a un singolo host fisico, che è stato facile inserire in denylist.

Non siamo riusciti a riprodurre la corruzione dei registri su quell’host in un ambiente controllato, nemmeno dopo diverse settimane di stress test. Tuttavia, una volta messo fuori servizio l’host problematico, i crash con stack disallineato sono scomparsi.

Rimuovere l’host difettoso non è una soluzione permanente, nel senso che non impedisce una nuova occorrenza dello stesso problema. Possiamo però modificare il software in modo che, se un problema simile si ripresenta, venga rilevato e gestito facilmente. Abbiamo migliorato il nostro gestore di segnali fatali includendo lo stato dei registri, così possiamo rilevare una ricorrenza solo dai log (senza bisogno di core dump). Abbiamo modificato il control plane in modo che le VM vengano di solito riutilizzate invece che riciclate, il che rende molto più facile rilevare nodi difettosi al nostro livello dello stack infrastrutturale. Abbiamo anche aggiornato i nostri runbook (e i modelli mentali del team) per includere questa possibilità.

Una volta separati i crash dovuti all’host difettoso, i core rimanenti da ritorno-a-null sono diventati molto più facili da comprendere. In precedenza avevamo escluso l’unwinding delle eccezioni perché pensavamo di avere controesempi: crash in percorsi di codice in cui le eccezioni sicuramente non erano usate. Ma quei controesempi provenivano tutti dal cluster di corruzione hardware.

Quando abbiamo riesaminato i core rimanenti tenendolo presente, abbiamo scoperto che quella conclusione era esattamente al contrario: i crash avvenivano tutti durante l’unwinding delle eccezioni.

La gestione delle eccezioni è un trasferimento dinamico di controllo

Quando C++ lancia un’eccezione, il runtime deve scoprire quale blocco catch debba riceverla e quali distruttori o handler di pulizia debbano essere eseguiti lungo il percorso. Il compilatore emette questi metadati, ma il matching effettivo avviene dinamicamente a runtime.

L’unwinding delle eccezioni non viene in realtà eseguito dalla funzione che invoca throw, ma da funzioni helper chiamate dal codice compilato risultante. Queste routine runtime esaminano lo stack, recuperano i metadati sulle funzioni trovate sullo stack, cercano dinamicamente handler di pulizia e blocchi catch, e poi trasferiscono il controllo a una di quelle posizioni. Il trasferimento del controllo include l’unwinding di tutti gli stack frame intermedi (compresi quelli delle funzioni helper).

Dal punto di vista operativo, è molto più simile a un longjmp o a uno switch di fiber che a una normale chiamata e ritorno. I registri callee-save devono essere ripristinati, così come i registri dello stack frame %rbp e %rsp.

Il nostro binario linka due librerie che contengono implementazioni delle funzioni che eseguono l’unwinding delle eccezioni C++: libgcc e GNU libunwind. Le definizioni di GNU libunwind erano quelle scelte dal linker dinamico. La cosa ci ha sorpresi: ci aspettavamo che vincesse l’implementazione di libgcc per via delle regole di versioning dei simboli; tuttavia, l’ispezione dei binari in esecuzione ha mostrato che non era così.

Abbandonare un’ultima convinzione

A questo punto la nostra ipotesi di lavoro è cambiata, mentre mettevamo in discussione un’altra convinzione maturata quando pensavamo ci fosse un solo bug.

Forse non stavamo vedendo una normale funzione ritornare a NULL. Forse stavamo vedendo un trasferimento di unwinding, di fatto un ripristino dei registri in stile setcontext, in cui l’instruction pointer di destinazione era diventato NULL prima del trasferimento del controllo. In altre parole, dati errati provenienti dalla libreria di unwinding, non uno slot dell’indirizzo di ritorno errato sullo stack.

Questo ha ristretto drasticamente il problema. O GNU libunwind calcolava lo stato di destinazione sbagliato, oppure calcolava lo stato corretto e qualcosa lo corrompeva prima che potesse essere applicato.

Abbiamo letto il sorgente di GNU libunwind e scoperto che sintetizza un ucontext_t sullo stack, inserisce lo stato dei registri desiderato per il frame dell’handler di pulizia e poi passa un puntatore a quella struct a una routine assembly interna: _Ux86_64_setcontext.

A questo punto avevamo tutti i pezzi.

Il ucontext_t sintetizzato vive in uno degli stack frame di cui viene fatto l’unwinding da _Ux86_64_setcontext durante l’esecuzione di quella funzione. _Ux86_64_setcontext stava leggendo dalla struct dopo aver modificato %rsp, quando ormai la struct non faceva più parte dello stack attivo? Questo la renderebbe vulnerabile alla sovrascrittura da parte della consegna di un segnale, come il nostro frequente SIGUSR2.

Bug n. 2: il bug di libunwind

La risposta era sì.

Ecco le ultime sei istruzioni di _Ux86_64_setcontext nella versione di GNU libunwind che stavamo usando, costituite per lo più da istruzioni mov che caricano dalla memoria a un registro di destinazione:

Testo semplice

1
74: mov UC_MCONTEXT_GREGS_RSP(%rdi),%rsp
2
75:
3
76: /* push the return address on the stack */
4
77: mov UC_MCONTEXT_GREGS_RIP(%rdi),%rcx
5
78: push %rcx
6
79:
7
80: mov UC_MCONTEXT_GREGS_RCX(%rdi),%rcx
8
81: mov UC_MCONTEXT_GREGS_RDI(%rdi),%rdi
9
82: retq

(%rdi punta al ucontext_t allocato sullo stack, e le macro UC_MCONTEXT_* si espandono semplicemente all’offset fisso in cui è memorizzato un particolare registro.)

La prima istruzione è l’inizio della finestra di race. Aggiorna %rsp in modo che punti al nuovo fondo dello stack attivo. Non appena questo accade, la struct puntata da %rdi non fa più parte dello stack attivo (né della red zone) e non è più off-limits per il kernel.

Di solito questo non causa problemi, ma se un segnale arriva esattamente nel momento giusto (sbagliato?), il kernel costruirà il frame del segnale a %rsp-128. Questo può sovrascrivere la memoria puntata da %rdi.

Se accade prima che l’istruzione successiva legga UC_MCONTEXT_GREGS_RIP(%rdi), allora l’instruction pointer ripristinato può essere corrotto. Nei nostri crash, diventava NULL.

Questo è il bug.

Perché i core sembravano normali ritorni errati

Questo assembly spiega anche una delle osservazioni che ci aveva confusi: perché la funzione X avesse un NULL nello slot dell’indirizzo di ritorno dello stack frame precedente.

setcontext era scritto per ripristinare tutti i registri, incluso %rdi, quindi non può usare quel registro per leggere UC_MCONTEXT_GREGS_RIP(%rdi) nel momento finale del trasferimento di controllo. Invece legge il valore in anticipo, lo salva sullo stack, ripristina qualche altro registro e poi usa retq per leggere il valore salvato e trasferire il controllo.

Quello che nei core sembrava "una funzione è ritornata a NULL" era in realtà "l’unwinder ha sintetizzato sullo stack un indirizzo di ritorno di destinazione, ma quella destinazione era stata corrotta prima che il trasferimento fosse completato". Avevamo dato per scontato che la corruzione dello slot dell’indirizzo di ritorno dovesse avvenire in loco, perché non conoscevamo punti in cui dati (corruttibili) venissero scritti intenzionalmente nello slot dell’indirizzo di ritorno.

Una finestra di race di una singola istruzione

Ciò che rende questo bug assurdo è quanto sia stretta questa finestra di race. In questo tipo di race condition, l’evento esterno (il segnale) deve verificarsi tra due passaggi eseguiti da un altro thread. Più questi passaggi sono vicini tra loro, meno è probabile che la race condition si verifichi.

In questo caso la finestra vulnerabile è letteralmente larga una sola istruzione! Un segnale deve essere consegnato dopo che %rsp è stato modificato, ma prima che l’istruzione successiva carichi %rip. Su una moderna CPU superscalare out-of-order possono essere eseguite diverse istruzioni semplici come questa per ciclo, quindi la finestra di race è di circa cento picosecondi.

Quando abbiamo trovato questa race, la nostra prima reazione è stata pensare che dovesse essere troppo rara per spiegare il tasso di crash osservato. Stavamo vedendo più di una dozzina di crash ritorno-a-null al giorno in tutta la flotta. Una race di una sola istruzione durante la pulizia delle eccezioni poteva davvero spiegarli?

Ci siamo affidati a una stima di Fermi. Se la finestra vulnerabile è dell’ordine di 101010^{-10} secondi e SIGUSR2 arriva ogni 10210^{-2} secondi di tempo CPU, allora ogni handler di pulizia delle eccezioni o blocco catch ha una probabilità di circa 10810^{-8} di perdere la race.

Rockset usa le eccezioni come parte del proprio meccanismo interno di backpressure durante l’ingestione. Un singolo host sovraccarico può generare circa 10410^{4} eccezioni al secondo. Ciò implica che il tempo medio tra guasti di un host che usa backpressure è 10410^{4} secondi, ossia un crash ogni poche ore. Su scala di flotta, è più che sufficiente per spiegare la frequenza dei crash osservata.

Perché il bug di libunwind è emerso ora?

Il bug di GNU libunwind è vecchio: ha più di 18 anni ed è presente nella prima versione x86_64 che supportava l’unwinding delle eccezioni C++.

Allora perché è emerso proprio ora?

Il tasso di crash è approssimativamente proporzionale al numero di eccezioni lanciate e al numero di segnali consegnati. Dipende anche da quanto stack consuma il gestore del segnale.

Rockset è insolito su tutti e tre gli assi. Lanciamo eccezioni ad alta frequenza come parte del normale controllo del sovraccarico; consegniamo SIGUSR2 con frequenza insolita a causa di coarse_thread_cputime_clock; e all’inizio di quest’anno abbiamo fatto sì che il gestore di SIGUSR2 usasse più stack aggiungendo una chiamata a timer_getoverrun, così da contabilizzare i segnali accorpati.

Quest’ultima modifica sembra essere stata importante. Se il gestore usa poco stack, potrebbe non raggiungere e sovrascrivere la memoria ucontext_t obsoleta. Prima di quella modifica, non osservavamo affatto questi crash. Dopo la modifica, il tasso è rimasto basso finché non abbiamo aumentato il carico per alcuni casi d’uso che stressavano il meccanismo di backpressure.

In altre parole, il bug di libunwind era sempre stato lì, ma il prodotto tra il nostro tasso di eccezioni, il tasso di segnali e l’uso dello stack da parte del gestore aveva superato solo di recente la soglia in cui diventava visibile operativamente.

Questo meccanismo spiega anche la coincidenza per cui sia il bug hardware sia il bug di libunwind andavano in crash per lo più dentro DocumentTree::updateDocument. I crash causati da libunwind erano fortemente sbilanciati verso questo metodo, perché è sempre attivo nel punto in cui lanciamo un’eccezione per applicare la backpressure all’ingestione. Era selezionato in modo forte anche per gli crash da disallineamento di %rsp, perché il nodo hardware difettoso apparteneva a una SKU che usiamo per l’ingestione bulk, la quale passa la maggior parte del tempo CPU in quel metodo.

La nostra mitigazione immediata è stata passare da GNU libunwind all’unwinder di libgcc. È stata una buona scelta anche di per sé: l’implementazione di libgcc ha beneficiato di molto lavoro per ridurre la contesa sui lock, un aspetto importante quando si scala su VM di grandi dimensioni.

Abbiamo anche inviato upstream a GNU libunwind un riproduttore autonomo e una correzione(si apre in una nuova finestra), e verificato che gli altri unwinder non abbiano un problema simile.

La forza di una diagnosi basata sull'insieme dei casi

Questo percorso di debug ci ha insegnato molto sui dettagli specifici del linking dinamico, dei metadati di unwinding DWARF, della consegna dei segnali in Linux, dell’ABI System V e del meccanismo delle eccezioni C++. Ma la lezione principale era più semplice di tutto questo.

Il passaggio più importante non è stato leggere l’assembly in modo brillante o conoscere a fondo i dettagli. È stato costruire un set di dati di alta qualità. In assenza di questo set di dati, stavamo fondendo due fenomeni distinti in un’unica storia e cercando di capire cosa stesse succedendo. Una volta ottenuti dati completi e accurati sull'insieme dei crash, la struttura del problema è diventata ovvia: un gruppo di crash apparteneva a un host difettoso, l’altro a una race in libunwind. Quando i dati sono migliorati, il debug è diventato più semplice.

Per sistemi infrastrutturali come Rockset, questo conta moltissimo. Questa indagine ha rafforzato il nostro impegno verso una strumentazione avanzata, indagini automatizzate e miglioramenti continui dei nostri strumenti operativi. L’affidabilità non consiste solo nel correggere i bug dopo che si verificano: significa costruire dati, flussi di lavoro e competenze che trasformino problemi impossibili in problemi diagnosticabili e risolvibili.

Autori

By Nathan Bronson e Member of Technical Staff