Sari la conținutul principal
OpenAI

30 iunie 2026

Inginerie

Core dump epidemiology: fixing an 18-year-old bug

Using population-level analysis to debug tricky crashes in our data infrastructure.

Se încarcă…

By Nathan Bronson, Member of Technical Staff

OpenAI’s models and agents increasingly rely on scalable data infrastructure in order to search for relevant data at inference time: when the models are thinking about your question. Some of these services are written in C++, whose low-level control of the system lets us maximize performance and minimize memory usage. Those efficiency benefits are important as we scale, but C++’s lack of memory safety means that bugs can cause crashes by writing to incorrect or non-existent memory addresses.

A few months ago we observed some crashes from inside the Rockset service, a bespoke part of our ChatGPT data infrastructure which is key to many data plugins and to searching over conversations. In each of these crashes, a normal C++ function seemed to finish and then return to a bogus address, causing the kernel to stop the program because the instruction pointer no longer pointed at code. Sometimes the return address slot in the stack frame was NULL. Sometimes the stack pointer CPU register itself seemed to be off by 8 bytes, as if %rsp had somehow been decremented in the middle of normal execution. In both cases the crash happened on return.

These are not normal failure modes for application code. A stray write that lands only on a saved return address is possible, but extremely unlikely. A bug that misaligns %rsp by 8 without involving inline assembly, setcontext, or longjmp (none of which we use) is even stranger, because compiled code only adjusts that register directly in the function prologue and epilogue. Every hypothesis we (or ChatGPT) could think of had strong evidence against it, so the bug seemed impossible.

What we assumed was one problem eventually turned out to be two unrelated bugs, coincidentally discovered at the same time. First, silent hardware corruption on one Azure host, where the CPU just didn’t do math correctly. Second, an 18-year-old race condition in GNU libunwind, an unnoticed bug in a widely used open source library.

This post is the story of how we identified and fixed seemingly inexplicable crashes by thinking like an epidemiologist and building a high-quality data set about the entire population of crashes.

First debugging attempt: carefully examining a few core dumps

First, let’s go deeper on Rockset. It’s a cloud-native data system for search and real-time analytics that we use for many internal use cases at OpenAI, such as sync connectors (Rockset was acquired by OpenAI in 2024). Streaming updates are used to maintain an up-to-date index of a workspace’s knowledge base so that ChatGPT can search for relevant information when answering questions or performing actions.

Rockset’s execution layer is written in C++. The C++ language provides low-level access to the CPU, which is good for performance and efficiency, but it means that application bugs can lead to invalid memory accesses and segfaults. To help track these down we use folly’s fatal signal handler to log a stack trace when a crash happens, and we upload the corresponding core dumps (a snapshot of the state of the program when it crashed) to Azure blob storage for later analysis. All of Rockset’s query processing leaves are replicated, which minimizes the client impact of a crash. However, each segfault corresponds to a bug that needs to be fixed to meet our reliability and quality goals.

Our initial approach was to treat these cores like a conventional debugging problem: inspect a few core dumps very closely, form hypotheses, and rule them out one by one.

Most of the crashes occurred in a method called DocumentTree::updateDocument. In these crashes it appeared that updateDocument had called some unknown function X, the stack had become corrupted while X was active, then X had returned to an address that wasn’t executable code. In some cases X’s just-popped frame looked valid except that its saved return address was NULL. In other cases the stack pointer itself looked wrong, but the next valid frame still seemed to be updateDocument.

We didn’t know when the stack was getting corrupted, which left a huge search space. updateDocument is a large method that undergoes a lot of inlining, so the number of candidates for X was overwhelming.

Was this a bug in our C++ code? A compiler or linkage issue? A problem in one of our runtime libraries? A Linux kernel bug around signal delivery or context switching? Something even rarer? If this was a stray write, why wasn’t it caught by our ASAN staging environment?

We tried to use our application-level logs to identify all occurrences of the problem, but stack-corruption bugs are hard to classify from logs alone because the logged stack traces are themselves corrupted or missing. We weren’t able to construct a log query that didn’t have both false positives and false negatives. We manually inspected more cores and found some additional examples, but that process was too labor-intensive to give us a trustworthy data set.

At this stage of the investigation, we (incorrectly) ruled out a hardware bug, because we saw crashes across multiple regions and multiple hardware types, so we were still looking for software-only causes. For a few days, we went super-deep on a single misaligned-%rsp crash, reconstructing the pre-crash history using stack and register contents. This produced some possible clues, but because we didn’t let go of our initial conclusions that all of the bugs had the same cause, this didn’t get us unstuck.

Clues from the stack

Before getting to the turning point of our investigation, it’s important to explain what kind of information we were extracting from the core files.

Rockset is compiled with -fno-omit-frame-pointer, so the active stack frame is always reachable through %rbp, and callers form a linked list of frame pointers.

On Linux x86_64, the AMD64 System V ABI also reserves 128 bytes below %rsp as the red zone. That region is available to userspace code and, importantly, the kernel promises not to clobber it when it delivers a signal, as part of the ABI contract.

The red zone was central to our debugging of a post-return crash, because it preserves some information from before the return. When a SIGSEGV is triggered, folly’s fatal signal handler runs on the crashing thread’s stack. Stack frames that are no longer active (because their function has returned) will get clobbered by the signal handler, except for the last 128 bytes. That’s why we can say things like “X’s just-popped stack frame looked valid, except for a NULL return address.” The red zone preserves some of the inactive frames, or sometimes just the tail of one inactive frame.

Diagramă a stivei care arată cadre de stivă corupte ce pot suprascrie adresele de retur și pot provoca blocări.

We found one misaligned-stack crash in which all of the functions involved were very small. That let us see that %rsp had become misaligned during execution of a relatively simple function, and that more calls had succeeded afterward. The program only crashed when the active function finally tried to return. None of those code paths used exceptions, inline assembly, setcontext, or longjmp, so if the stack pointer truly changed in the way the core suggested, no plausible bug in userspace code explained the issue.

That pushed us toward the kernel.

Rockset uses signals more aggressively than most programs. Query execution is broken into many lightweight tasks that exchange data. This is important for handling high-QPS workloads efficiently, but it makes per-query CPU accounting awkward as work for many queries is multiplexed onto the same thread pool.

Our solution is something we call coarse_thread_cputime_clock, which approximates clock_gettime(CLOCK_THREAD_CPUTIME_ID, ...) cheaply enough to sample at every task boundary. The timer_create API can be used to schedule a periodic signal delivery based on several notions of the passage of time, including the accumulation of CPU time. We schedule a signal (SIGUSR2) to be delivered every few milliseconds of CPU time, at which point the signal handler updates a thread-local value. Even though many tasks don’t see the coarse clock advance while they are executing, summing all of the deltas produces an unbiased estimate of the actual CPU time for a query.

Because we deliver signals so often, a rare kernel bug around context switching or signal delivery seemed plausible. We spent time reading bug reports, kernel source code, and the Azure-specific kernel patches. We tried stress tests. We weren’t able to find anything that seemed related.

At that point we decided to step back and try a different approach.

Doctor or epidemiologist?

There are two broad ways to debug a problem like this.

One is to act like a doctor of sorts: focus on one patient, run lots of tests, and try to diagnose a single case from detailed evidence.

The other is to act more like an epidemiologist: look at the entire population and ask whether there are patterns that a single case cannot reveal. Did the bug start at a specific release? Does it correlate with one hardware SKU (the specific CPU and server model), one region, or one kernel version? Are there multiple distinct clusters hiding inside what looks like one syndrome?

We had mostly been in doctor mode. The key shift was deciding that we needed to gather high-quality population data.

Cleaning the data

Our previous attempts to automatically find all of the instances of the problem failed because we were trying to use text searches over the logs. The core dumps themselves have a lot more information, but looking at them manually didn’t scale. We decided to invest the effort to build a pipeline that could automatically analyze the core dumps.

We had ChatGPT write a script that downloaded a prefix of each core file, extracted the registers, filtered known false positives using the logs, and automatically labeled the crash as return-to-null, misaligned-stack, or other. Then we ran that script in parallel over every production Rockset core dump from the previous year.

This was the turning point.

Once we had a clean data set, correlations appeared immediately. What we had been treating as one weird bug was actually two separate crash populations.

The return-to-null cores were spread across many clusters and geographic regions. Their frequency had increased recently, but there was no crisp start date and no clean infrastructure boundary.

The misaligned-stack crashes looked completely different. They all came from one region, had a clear start date, and never happened on nodes that had been running for a long time. Even though they involved multiple Azure VMs (virtual machines hosted in the cloud), the pattern looked like one physical machine with bad hardware causing problems for whichever VM happened to land on it.

Diagramă cu puncte a ratelor de blocare pe grup în timp, arătând că majoritatea blocărilor sunt concentrate în grupurile 2, 3 și 6, cu un vârf în grupul 1 aproape de sfârșitul perioadei.

That was the moment we realized we had been mentally conflating two bugs. Because we had been mixing counterexamples from both bugs, we couldn’t find a single coherent explanation.

Bugul #1: gazda defectă

Având la dispoziție o listă fiabilă cu nodurile Kubernetes și marcajele temporale, am putut urmări blocările cauzate de dezalinierea stivei până la o singură mașină gazdă fizică, pe care am putut-o adăuga cu ușurință pe lista de blocare.

Nu am reușit să reproducem coruperea registrelor pe acea mașină gazdă într-un mediu controlat, nici măcar după câteva săptămâni de teste de stres. Totuși, după ce mașina gazdă problematică a fost scoasă din funcțiune, blocările cauzate de dezalinierea stivei au dispărut.

Eliminarea mașinii gazdă defecte nu este o soluție permanentă, în sensul că nu împiedică reapariția aceleiași probleme. Putem însă modifica software-ul astfel încât, dacă apare din nou o problemă similară, aceasta să fie ușor de detectat și gestionat. Am îmbunătățit gestionarul de semnale fatale pentru a include starea registrelor, astfel încât să putem detecta reapariția problemei doar din jurnale, fără a mai avea nevoie de un fișier core dump. Am modificat planul de control astfel încât mașinile virtuale să fie, de regulă, reutilizate în loc să fie recreate, ceea ce facilitează mult identificarea nodurilor defecte la nivelul nostru din stiva de infrastructură. De asemenea, ne-am actualizat procedurile operaționale și modelele mentale ale echipei pentru a include și această posibilitate.

După separarea blocărilor cauzate de mașina gazdă defectă, fișierele core dump rămase, asociate revenirii la NULL, au devenit mult mai ușor de analizat. Anterior eliminaserăm ipoteza derulării stivei în timpul tratării excepțiilor, deoarece credeam că aveam contraexemple: blocări produse pe căi de execuție în care cu siguranță nu erau utilizate excepții. Însă toate aceste contraexemple proveneau din grupul de cazuri cauzate de coruperea hardware.

După ce am reanalizat fișierele core dump rămase din această perspectivă, am constatat că ajunseserăm la concluzia exact opusă realității: toate blocările se produceau în timpul derulării stivei pentru tratarea excepțiilor.

Gestionarea excepțiilor este un transfer dinamic de control

Când C++ aruncă o excepție, runtime-ul trebuie să descopere ce bloc catch ar trebui să o primească și ce destructori sau gestionari de curățare ar trebui să ruleze pe parcurs. Compilatorul emite aceste metadate, dar potrivirea efectivă are loc dinamic, în timpul execuției.

Derularea stivei pentru tratarea excepțiilor nu este efectuată propriu-zis de funcția care invocă throw, ci de funcții auxiliare apelate de codul compilat rezultat. Aceste rutine de execuție examinează stiva, preiau metadatele despre funcțiile identificate în aceasta, caută dinamic gestionarii de curățare și blocurile catch, apoi transferă controlul către una dintre aceste locații. Transferul controlului presupune derularea tuturor cadrelor de stivă intermediare, inclusiv a celor aparținând funcțiilor auxiliare.

Din punct de vedere operațional, acest proces seamănă mult mai mult cu un longjmp sau cu o comutare între fibre decât cu un apel și o revenire obișnuite. Trebuie restaurate atât registrele salvate de funcția apelată, cât și registrele cadrului de stivă %rbp și %rsp.

Binarul nostru este legat cu două biblioteci care conțin implementări ale funcțiilor responsabile de derularea stivei pentru tratarea excepțiilor C++: libgcc și GNU libunwind. Definițiile din GNU libunwind erau cele selectate de editorul de legături dinamic. Acest lucru ne-a surprins; ne așteptaserăm ca implementarea din libgcc să aibă prioritate datorită regulilor de versionare a simbolurilor, însă examinarea binarelor aflate în execuție a arătat că nu era așa.

Renunțarea la o ultimă presupunere

În acest moment, ipoteza noastră de lucru s-a schimbat, pe măsură ce am renunțat la încă o presupunere făcută atunci când credeam că exista un singur bug.

Poate că nu asistam la revenirea obișnuită a unei funcții la adresa NULL. Poate că asistam la un transfer de control în timpul derulării stivei — practic, la o restaurare a registrelor de tip setcontext — în care indicatorul de instrucțiuni al destinației devenise NULL înainte de transferul controlului. Cu alte cuvinte, problema putea consta în date incorecte furnizate de biblioteca de derulare a stivei, nu într-o adresă de retur incorectă stocată pe stivă.

Asta a restrâns dramatic problema. Fie GNU libunwind calcula starea destinației greșit, fie calcula starea corectă și ceva o corupea înainte să poată fi aplicată.

Am analizat codul sursă al GNU libunwind și am constatat că acesta generează o structură ucontext_t pe stivă, completează în ea starea dorită a registrelor pentru cadrul gestionarului de curățare, apoi transmite un indicator către structura respectivă unei rutine interne scrise în limbaj de asamblare: _Ux86_64_setcontext.

În acest moment, aveam toate piesele puzzle-ului.

Structura ucontext_t generată se află într-unul dintre cadrele de stivă derulate de _Ux86_64_setcontext în timpul executării acestei funcții. Oare _Ux86_64_setcontext continua să citească din structură după modificarea lui %rsp, moment în care structura nu mai făcea parte din stiva activă? În acest caz, ea ar fi devenit vulnerabilă la suprascriere în timpul transmiterii unui semnal, precum semnalul nostru frecvent SIGUSR2.

Bugul #2: bugul din libunwind

Răspunsul a fost da.

Iată ultimele șase instrucțiuni din _Ux86_64_setcontext în versiunea GNU libunwind pe care o foloseam, constând în mare parte din instrucțiuni mov care încarcă din memorie într-un registru destinație:

Text simplu

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 indică spre structura ucontext_t alocată pe stivă, iar macrocomenzile UC_MCONTEXT_* se extind pur și simplu la decalajul fix la care este stocat un anumit registru.)

Prima instrucțiune este începutul ferestrei condiției de concurență. Actualizează %rsp ca să indice spre noua bază a stivei active. Imediat ce se întâmplă asta, structura indicată de %rdi nu mai face parte din stiva activă (sau din zona roșie) și nu mai este interzisă kernelului.

De obicei, asta nu provoacă probleme, dar dacă un semnal sosește exact în momentul potrivit (greșit?), kernelul va construi cadrul de semnal la %rsp-128. Asta poate suprascrie memoria indicată de %rdi.

Dacă se întâmplă înainte ca instrucțiunea următoare să citească UC_MCONTEXT_GREGS_RIP(%rdi), atunci indicatorul de instrucțiuni restaurat poate fi corupt. În blocările noastre, a devenit NULL.

Acesta este bugul.

De ce fișierele core dump păreau simple reveniri la adrese invalide

Acest cod de asamblare explică și una dintre observațiile care ne-au derutat: de ce funcția X avea NULL în slotul adresei de retur al cadrului de stivă precedent.

setcontext a fost scris pentru a restaura toate registrele, inclusiv %rdi, deci nu poate folosi acel registru pentru a citi UC_MCONTEXT_GREGS_RIP(%rdi) în momentul final al transferului de control. În schimb, citește valoarea mai devreme, o salvează pe stivă, restaurează încă vreo câteva registre, apoi folosește retq pentru a citi valoarea salvată și a transfera controlul.

Ceea ce părea în fișierele core dump că este „o funcție care a revenit la NULL” era, de fapt, o situație în care „mecanismul de derulare a stivei generase pe stivă o adresă de retur țintă, dar aceasta fusese coruptă înainte ca transferul să se încheie”. Presupuseserăm că deteriorarea locației în care era stocată adresa de retur trebuia să se producă direct acolo, deoarece nu cunoșteam niciun caz în care date susceptibile de corupere să fie scrise intenționat în acea locație.

O fereastră de concurență de o singură instrucțiune

Ceea ce face ca acest bug să pară absurd este cât de îngustă este această fereastră de concurență. În acest tip de condiție de concurență, evenimentul extern (semnalul) trebuie să se producă între doi pași făcuți de un alt fir. Cu cât acești pași sunt mai apropiați, cu atât este mai puțin probabil să apară condiția de concurență.

În acest caz, fereastra vulnerabilă are literalmente lățimea unei singure instrucțiuni! Semnalul trebuie transmis după modificarea lui %rsp, dar înainte ca instrucțiunea următoare să încarce %rip. Pe un procesor modern superscalar, cu execuție în afara ordinii, mai multe instrucțiuni simple de acest tip pot fi executate într-un singur ciclu, astfel încât fereastra de concurență este de ordinul a aproximativ o sută de picosecunde.

Când am găsit această concurență, prima noastră reacție a fost că trebuie să fie prea rară ca să explice rata de blocări observată. Vedeam peste o duzină de blocări cu revenire-la-null pe zi în întreaga flotă. Putea oare o concurență de o singură instrucțiune în timpul curățării excepțiilor să explice asta?

Am apelat la estimarea Fermi. Dacă fereastra vulnerabilă este de ordinul a 101010^{-10} secunde, iar SIGUSR2 sosește la fiecare 10210^{-2} secunde de timp CPU, atunci fiecare gestionar de curățare a excepțiilor sau bloc catch are o probabilitate de aproximativ 10810^{-8} ca această condiție de concurență să se producă.

Rockset folosește excepții ca parte a mecanismului intern de contrapresiune la ingestie. O singură gazdă supraîncărcată poate arunca de ordinul a 10410^{4} excepții pe secundă. Asta implică un timp mediu între eșecuri pentru o gazdă care folosește contrapresiune de 10410^{4} secunde, adică o blocare la câteva ore. La scara flotei, este mai mult decât suficient pentru a explica frecvența blocărilor observate.

De ce a apărut acum bugul din libunwind?

Bugul GNU libunwind este vechi — are peste 18 ani și era prezent în prima versiune x86_64 care suporta derularea excepțiilor C++.

Atunci de ce a apărut acum?

Rata de blocări este aproximativ proporțională cu numărul de excepții aruncate și cu numărul de semnale livrate. Depinde și de câtă stivă consumă gestionarul de semnal.

Rockset este neobișnuit pe toate cele trei axe. Aruncăm excepții la rate ridicate ca parte a controlului normal al supraîncărcării; livrăm SIGUSR2 neobișnuit de des din cauza coarse_thread_cputime_clock; iar la începutul acestui an am făcut ca gestionarul SIGUSR2 să folosească mai multă stivă adăugând un apel la timer_getoverrun, ca să putem contabiliza semnalele comasate.

Această ultimă modificare pare să fi fost importantă. Dacă gestionarul folosește suficient de puțină memorie din stivă, este posibil să nu ajungă până la zona de memorie rămasă de la ucontext_t și să nu o suprascrie. Înainte de această modificare, nu observăm deloc astfel de blocări. După modificare, frecvența a rămas redusă până când am crescut volumul de lucru pentru anumite cazuri de utilizare care solicitau intens mecanismul de contrapresiune.

Cu alte cuvinte, bugul din libunwind a existat dintotdeauna, dar produsul dintre frecvența excepțiilor, frecvența semnalelor și utilizarea stivei de către gestionar a depășit abia recent pragul de la care problema a devenit vizibilă în exploatare.

Acest mecanism explică și coincidența că atât bugul hardware, cât și bugul din libunwind provocau blocări în principal în DocumentTree::updateDocument. Blocările cauzate de libunwind apăreau disproporționat de des în această metodă, deoarece ea este întotdeauna activă în momentul în care aruncăm o excepție pentru a aplica mecanismul de contrapresiune la ingestie. Metoda era, de asemenea, puternic suprareprezentată în blocările cauzate de dezalinierea lui %rsp, deoarece nodul hardware defect aparținea unei configurații pe care o folosim pentru ingestia în masă, iar aceasta își petrece cea mai mare parte a timpului de procesor în metoda respectivă.

Măsura noastră imediată de atenuare a fost să trecem de la GNU libunwind la mecanismul de derulare a stivei din libgcc. Aceasta a fost, în sine, o alegere bună: implementarea din libgcc a beneficiat de numeroase optimizări pentru reducerea concurenței la accesarea blocărilor, aspect important pentru scalarea pe VM-uri de mari dimensiuni.

De asemenea, am trimis proiectului GNU libunwind un exemplu autonom de reproducere a bugului și o remediere(se deschide într-o fereastră nouă), apoi am verificat că celelalte mecanisme de derulare a stivei nu prezintă o problemă similară.

Puterea unui diagnostic la nivel de populație

Această investigație ne-a învățat multe despre detaliile legării dinamice, metadatele DWARF pentru derularea stivei, transmiterea semnalelor în Linux, ABI-ul System V și mecanismul de gestionare a excepțiilor din C++. Dar lecția principală a fost mai simplă decât toate acestea.

Cel mai important pas nu a fost interpretarea ingenioasă a codului de asamblare și nici cunoașterea aprofundată a detaliilor tehnice. A fost construirea unui set de date de înaltă calitate. În lipsa acestuia, combinam două fenomene distincte într-o singură explicație și încercam să ieșim din confuzie doar prin raționament. După ce am obținut date exacte și complete la nivelul întregii populații, structura problemei a devenit evidentă: un grup de blocări provenea de la o mașină gazdă defectă, iar celălalt de la o condiție de concurență din libunwind. Pe măsură ce datele s-au îmbunătățit, depanarea a devenit mai ușoară.

Pentru sisteme de infrastructură precum Rockset, acest lucru este foarte important. Investigația ne-a consolidat angajamentul față de instrumentarea detaliată, investigațiile automatizate și îmbunătățirea continuă a instrumentelor noastre operaționale. Fiabilitatea nu înseamnă doar remedierea bugurilor după ce apar, ci și construirea datelor, fluxurilor de lucru și competențelor care transformă problemele aparent imposibile în probleme ce pot fi diagnosticate și rezolvate.

Autori

By Nathan Bronson, Member of Technical Staff