Salta al contingut principal
OpenAI

30 de juny del 2026

Enginyeria

Epidemiologia dels bolcats de memòria: corregir un error de 18 anys

Ús de l’anàlisi poblacional per depurar fallades difícils a la nostra infraestructura de dades.

S'està carregant…

Els models i agents d’OpenAI depenen cada cop més d’una infraestructura de dades escalable per cercar dades rellevants en inferència: quan pensen en la teva pregunta. Alguns serveis estan escrits en C++, que dona control de baix nivell per maximitzar el rendiment i reduir l’ús de memòria. Aquests guanys d’eficiència són importants en escalar, però la manca de seguretat de memòria de C++ fa que els errors puguin causar fallades escrivint en adreces errònies o inexistents.

Fa uns mesos vam veure fallades dins del servei Rockset, una peça a mida de la nostra infraestructura de dades de ChatGPT, clau per a molts connectors de dades i per cercar converses. En cada cas, una funció normal de C++ semblava acabar i retornar a una adreça falsa; el kernel aturava el programa perquè el punter d’instrucció ja no apuntava a codi. De vegades, la ranura de l’adreça de retorn al marc de pila era NULL. De vegades, el registre de CPU del punter de pila semblava desplaçat 8 bytes, com si %rsp s’hagués decrementat en plena execució normal. En tots dos casos, la fallada passava en retornar.

No són modes de fallada normals en codi d’aplicació. Una escriptura accidental que només toqui una adreça de retorn desada és possible, però molt improbable. Un error que desalineï %rsp 8 bytes sense assemblador en línia, setcontext ni longjmp (no en fem servir cap) és encara més estrany: el codi compilat només ajusta aquest registre al pròleg i l’epíleg de la funció. Totes les hipòtesis que se’ns acudien a nosaltres (o a ChatGPT) tenien proves sòlides en contra; l’error semblava impossible.

El que crèiem un sol problema va resultar ser dos errors no relacionats, descoberts alhora per casualitat. Primer, corrupció silenciosa de maquinari en un host d’Azure, on la CPU senzillament no calculava bé. Segon, una condició de cursa de 18 anys a GNU libunwind, un error inadvertit en una biblioteca de codi obert molt usada.

Aquesta publicació explica com vam identificar i corregir fallades aparentment inexplicables pensant com epidemiòlegs i creant un bon conjunt de dades sobre tota la població de fallades.

Primer intent: examinar bé uns quants bolcats de memòria

Primer, aprofundim en Rockset. És un sistema de dades natiu del núvol per a cerca i analítica en temps real que fem servir internament a OpenAI, per exemple en connectors de sincronització (OpenAI va adquirir Rockset el 2024). Les actualitzacions en flux mantenen al dia l’índex de la base de coneixement d’un espai de treball perquè ChatGPT trobi informació rellevant en respondre preguntes o fer accions.

La capa d’execució de Rockset està escrita en C++. C++ dona accés de baix nivell a la CPU, bo per al rendiment i l’eficiència, però fa que errors d’aplicació puguin causar accessos de memòria no vàlids i segfaults. Per localitzar-los, usem el gestor de senyals fatals de folly per registrar una traça de pila quan hi ha una fallada, i pugem els bolcats de memòria (instantànies de l’estat del programa) a l’emmagatzematge de blobs d’Azure per analitzar-los després. Totes les fulles de processament de consultes de Rockset estan replicades, cosa que redueix l’impacte d’una fallada en el client. Tot i així, cada segfault és un error que cal corregir per assolir els nostres objectius de fiabilitat i qualitat.

D’entrada vam tractar aquests bolcats com una depuració convencional: inspeccionar-ne uns quants a fons, formular hipòtesis i descartar-les una a una.

La majoria de fallades passaven en un mètode anomenat DocumentTree::updateDocument. Semblava que updateDocument havia cridat una funció desconeguda X, la pila s’havia corromput mentre X era activa i després X havia retornat a una adreça no executable. En alguns casos, el marc de X acabat de treure de la pila semblava vàlid, excepte per l’adreça de retorn desada, que era NULL. En d’altres, el punter de pila semblava erroni, però el següent marc vàlid encara semblava ser updateDocument.

No sabíem quan es corrompia la pila, i això deixava un espai de cerca enorme. updateDocument és un mètode gran amb molt inlining, així que els candidats a X eren massa nombrosos.

Era un error del nostre C++? Un problema de compilador o d’enllaç? Un problema en alguna biblioteca de temps d’execució? Un error del kernel de Linux en el lliurament de senyals o el canvi de context? Alguna cosa encara més rara? Si era una escriptura accidental, per què no la captava el nostre entorn de preproducció amb ASAN?

Vam provar d’usar els registres d’aplicació per trobar tots els casos, però els errors de corrupció de pila són difícils de classificar només amb registres: les traces registrades també estan corrompudes o falten. No vam aconseguir cap consulta de registres sense falsos positius ni falsos negatius. Vam inspeccionar més bolcats a mà i vam trobar-ne més exemples, però el procés era massa laboriós per obtenir un conjunt de dades fiable.

En aquesta fase vam descartar erròniament un error de maquinari, perquè vèiem fallades en diverses regions i tipus de maquinari, i seguíem buscant causes només de programari. Durant uns dies vam aprofundir en una sola fallada amb %rsp desalineat, reconstruint-ne la història prèvia a partir de la pila i els registres. Això va donar pistes, però com que ens aferràvem a la idea inicial que tots els errors tenien la mateixa causa, no ens va desencallar.

Pistes de la pila

Abans d’arribar al punt d’inflexió, cal explicar quina informació extreien dels fitxers de bolcat.

Rockset es compila amb -fno-omit-frame-pointer, de manera que el marc de pila actiu sempre és accessible per %rbp, i els cridadors formen una llista enllaçada de punters de marc.

A Linux x86_64, l’ABI System V d’AMD64 també reserva 128 bytes sota %rsp com a zona vermella. Aquesta regió és disponible per al codi d’espai d’usuari i, sobretot, el kernel promet no trepitjar-la en lliurar un senyal, com exigeix l’ABI.

La zona vermella va ser clau per depurar una fallada posterior al retorn, perquè conserva informació d’abans del retorn. Quan s’activa un SIGSEGV, el gestor de senyals fatals de folly s’executa a la pila del fil que falla. Els marcs de pila ja inactius (perquè la funció ha retornat) són trepitjats pel gestor de senyals, excepte els últims 128 bytes. Per això podem dir coses com «el marc de pila de X acabat de treure semblava vàlid, tret d’una adreça de retorn NULL». La zona vermella conserva part dels marcs inactius, o només la cua d’un.

Diagrama de pila que mostra marcs de pila corromputs que poden sobreescriure adreces de retorn i provocar fallades.

Vam trobar una fallada de pila desalineada en què totes les funcions implicades eren molt petites. Això va mostrar que %rsp s’havia desalineat durant una funció força simple, i que després havien reeixit més crides. El programa només va fallar quan la funció activa va intentar retornar. Cap d’aquests camins de codi usava excepcions, assemblador en línia, setcontext ni longjmp; si el punter de pila havia canviat com suggeria el bolcat, cap error plausible en espai d’usuari no ho explicava.

Això ens va portar cap al kernel.

Rockset usa senyals més intensament que la majoria de programes. L’execució de consultes es divideix en moltes tasques lleugeres que intercanvien dades. Això ajuda a gestionar eficientment càrregues d’alt QPS, però complica la comptabilitat de CPU per consulta perquè el treball de moltes consultes es multiplexa al mateix conjunt de fils.

La nostra solució és coarse_thread_cputime_clock, que aproxima clock_gettime(CLOCK_THREAD_CPUTIME_ID, ...) prou barat per mostrejar a cada límit de tasca. L’API timer_create permet programar el lliurament periòdic d’un senyal segons diverses nocions del pas del temps, inclosa l’acumulació de temps de CPU. Programem un senyal (SIGUSR2) cada pocs mil·lisegons de temps de CPU; llavors el gestor actualitza un valor local al fil. Tot i que moltes tasques no veuen avançar el rellotge aproximat mentre s’executen, sumar els deltes dona una estimació no esbiaixada del temps de CPU real d’una consulta.

Com que lliurem senyals tan sovint, semblava plausible un error rar del kernel en el canvi de context o el lliurament de senyals. Vam llegir informes d’errors, codi font del kernel i pedaços del kernel específics d’Azure. Vam fer proves d’estrès. No vam trobar res que semblés relacionat.

Aleshores vam fer un pas enrere i vam provar un altre enfocament.

Metge o epidemiòleg?

Hi ha dues grans maneres de depurar un problema així.

Una és actuar com un metge: centrar-se en un pacient, fer moltes proves i diagnosticar un cas amb proves detallades.

L’altra és actuar com un epidemiòleg: mirar tota la població i buscar patrons que un sol cas no revela. L’error va començar en una versió concreta? Es correlaciona amb un SKU de maquinari (CPU i model de servidor concrets), una regió o una versió del kernel? Hi ha clústers diferents amagats dins del que sembla una sola síndrome?

Havíem estat sobretot en mode metge. El canvi clau va ser decidir que calia recollir bones dades poblacionals.

Neteja de dades

Els intents anteriors de trobar automàticament tots els casos van fallar perquè cercàvem text als registres. Els bolcats de memòria contenen molta més informació, però revisar-los a mà no escalava. Vam decidir crear una canalització que analitzés automàticament els bolcats de memòria.

Vam fer que ChatGPT escrivís un script que baixava un prefix de cada bolcat, extreia els registres, filtrava falsos positius coneguts amb els registres i etiquetava la fallada com a retorn-a-null, pila-desalineada o altra. Després el vam executar en paral·lel sobre tots els bolcats de memòria de Rockset en producció de l’any anterior.

Aquest va ser el punt d’inflexió.

Un cop vam tenir un conjunt de dades net, les correlacions van aparèixer de seguida. Allò que tractàvem com un sol error estrany eren en realitat dues poblacions de fallades separades.

Els bolcats de retorn-a-null estaven repartits per molts clústers i regions. La freqüència havia augmentat feia poc, però no hi havia una data d’inici clara ni un límit d’infraestructura net.

Les fallades de pila desalineada eren del tot diferents. Totes venien d’una sola regió, tenien una data d’inici clara i mai passaven en nodes que feia molt que s’executaven. Tot i implicar diverses VM d’Azure (màquines virtuals al núvol), el patró semblava una màquina física amb maquinari defectuós afectant qualsevol VM que hi queia.

Diagrama de punts de les taxes de fallada per clúster al llarg del temps, que mostra la majoria de fallades concentrades als clústers 2, 3 i 6, amb un pic al clúster 1 cap al final del període.

Aleshores vam adonar-nos que estàvem barrejant mentalment dos errors. Com que barrejavem contraexemples de tots dos errors, no trobàvem cap explicació coherent.

Error núm. 1: l’host defectuós

Amb una llista neta de nodes de Kubernetes i marques de temps, vam poder rastrejar les fallades de pila desalineada fins a un únic host físic, que va ser fàcil de posar a la llista de denegació.

No vam poder reproduir la corrupció de registres en aquell host en un entorn controlat, ni tan sols després de diverses setmanes de proves d’estrès. Tanmateix, un cop l’host problemàtic es va retirar del servei, les fallades de pila desalineada van desaparèixer.

Retirar l’host defectuós no és una solució permanent, en el sentit que no impedeix que es repeteixi el mateix problema. Tanmateix, podem canviar el programari perquè, si es repeteix un problema similar, sigui fàcil de detectar i gestionar. Vam millorar el nostre gestor de senyals fatals per incloure l’estat dels registres, de manera que puguem detectar una recurrència només a partir dels registres (sense necessitat de bolcat de memòria). Vam canviar el pla de control perquè les VM normalment es reutilitzin en lloc de reciclar-se, cosa que fa molt més fàcil detectar nodes defectuosos al nostre nivell de la pila d’infraestructura. També vam actualitzar els nostres runbooks (i els models mentals del nostre equip) per incloure aquesta possibilitat.

Un cop separades les fallades de l’host defectuós, els bolcats restants de retorn-a-null van ser molt més fàcils de raonar. Abans havíem descartat el desenrotllament d’excepcions perquè pensàvem que teníem contraexemples: fallades en camins de codi on definitivament no s’usaven excepcions. Però aquells contraexemples eren tots del clúster de corrupció de maquinari.

Quan vam revisar els bolcats restants amb això al cap, vam trobar que aquesta conclusió era exactament al revés: les fallades passaven totes durant el desenrotllament d’excepcions.

La gestió d’excepcions és una transferència de control dinàmica

Quan C++ llança una excepció, el temps d’execució ha de descobrir quin bloc catch l’ha de rebre i quins destructors o gestors de neteja s’han d’executar pel camí. El compilador emet aquestes metadades, però l’aparellament real passa dinàmicament en temps d’execució.

El desenrotllament d’excepcions no el fa realment la funció que invoca throw, sinó funcions auxiliars cridades pel codi compilat resultant. Aquestes rutines de temps d’execució examinen la pila, obtenen metadades sobre les funcions que hi troben, busquen dinàmicament gestors de neteja i blocs catch, i després transfereixen el control a una d’aquestes ubicacions. Transferir el control inclou desenrotllar tots els marcs de pila intermedis (incloent-hi els de les funcions auxiliars).

Operativament, això s’assembla molt més a un longjmp o a un canvi de fibra que a una crida i retorn normals. Cal restaurar els registres que desa el cridat, així com els registres de marc de pila %rbp i %rsp.

El nostre binari enllaça amb dues biblioteques que contenen implementacions de les funcions que fan el desenrotllament d’excepcions de C++: libgcc i GNU libunwind. Les definicions de GNU libunwind van ser les triades per l’enllaçador dinàmic. Això ens va sorprendre; esperàvem que guanyés la implementació de libgcc per les regles de versionat de símbols; tanmateix, inspeccionar binaris en execució va mostrar que no era així.

Desfer una última suposició

En aquest punt, la nostra hipòtesi de treball va canviar, quan vam relaxar una altra suposició que havíem fet pensant que només hi havia un error.

Potser no estàvem veient un retorn de funció ordinari a NULL. Potser estàvem veient una transferència de desenrotllament —en la pràctica, una restauració de registres a l’estil de setcontext— en què el punter d’instrucció de destinació s’havia convertit en NULL abans de transferir el control. En altres paraules, dades incorrectes de la biblioteca de desenrotllament en lloc d’una ranura d’adreça de retorn incorrecta a la pila.

Això va acotar el problema de manera dràstica. O bé GNU libunwind calculava un estat de destinació equivocat, o bé calculava l’estat correcte i alguna cosa el corrompia abans que es pogués aplicar.

Vam llegir el codi font de GNU libunwind i vam trobar que sintetitza un ucontext_t a la pila, omple l’estat de registres desitjat per al marc del gestor de neteja i després passa un punter a aquesta estructura a una rutina interna d’assemblador: _Ux86_64_setcontext.

En aquest punt ja teníem totes les peces.

El ucontext_t sintetitzat viu en un dels marcs de pila que _Ux86_64_setcontext desenrotlla durant l’execució d’aquesta funció. Llegia _Ux86_64_setcontext l’estructura després de canviar %rsp, quan l’estructura ja no formava part de la pila activa? Això la faria vulnerable a ser trepitjada pel lliurament d’un senyal, com ara el nostre SIGUSR2 freqüent.

Error núm. 2: l’error de libunwind

La resposta era sí.

Aquestes són les últimes sis instruccions de _Ux86_64_setcontext en la versió de GNU libunwind que fèiem servir, que consisteixen sobretot en instruccions mov que carreguen de memòria a un registre de destinació:

Text sense format

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 apunta al ucontext_t assignat a la pila, i les macros UC_MCONTEXT_* simplement s’expandeixen al desplaçament fix on s’emmagatzema un registre concret.)

La primera instrucció és l’inici de la finestra de cursa. Actualitza %rsp perquè apunti al nou fons de la pila activa. Tan aviat com passa això, l’estructura apuntada per %rdi ja no forma part de la pila activa (ni de la zona vermella), i el kernel ja no té prohibit tocar-la.

Normalment això no causa problemes, però si arriba un senyal en el moment exactament oportú (inoportú?), el kernel construirà el marc de senyal a %rsp-128. Això pot sobreescriure la memòria apuntada per %rdi.

Si això passa abans que la instrucció següent llegeixi UC_MCONTEXT_GREGS_RIP(%rdi), el punter d’instrucció restaurat es pot corrompre. En les nostres fallades, es convertia en NULL.

Aquest és l’error.

Why the cores masked as ordinary bad returns

This assembly also explains one of the observations that confused us: why function X had a NULL in the return address slot of the preceding stack frame.

setcontext was written to restore all registers, including %rdi, so it can’t use that register to read UC_MCONTEXT_GREGS_RIP(%rdi) at the final moment of the control transfer. Instead, it reads the value earlier, saves it to the stack, restores a few more registers, then uses retq to read the saved value and transfer control.

What looked in the cores like “a function returned to NULL” was actually “the unwinder synthesized a target return address on the stack, but that target had been corrupted before the transfer completed.” We had assumed that corruption of the return address slot must happen in-place, because we didn’t know of any places where (corruptible) data was written to the return address slot on purpose.

A single-instruction race window

What makes this bug seem absurd is how narrow this race window is. In this kind of race condition, the external event (the signal) needs to happen in between two steps taken by another thread. The closer those steps are to each other, the less likely the race condition is to happen.

In this case the vulnerable window is literally one instruction wide! A signal must be delivered after %rsp has been changed, but before the next instruction loads %rip. Several simple instructions like this can be run per cycle on a modern super-scalar out-of-order CPU, so the race window is roughly a hundred picoseconds.

When we found this race, our first reaction was that it must be too rare to explain the observed crash rate. We were seeing more than a dozen return-to-null crashes per day across the fleet. Could a one-instruction race during exception cleanup really account for that?

We turned to Fermi estimation. If the vulnerable window is on the order of 101010^{-10} seconds and SIGUSR2 arrives every 10210^{-2} seconds of CPU time, then each exception cleanup handler or catch block has a roughly 10810^{-8} probability of losing the race.

Rockset uses exceptions as part of its internal ingest backpressure mechanism. A single overloaded host can throw on the order of 10410^{4} exceptions per second. That implies the mean time between failures of a host using backpressure is 10410^{4} seconds, or one crash every few hours. At fleet scale, that is more than enough to explain the observed crash frequency.

Why did the libunwind bug appear now?

The GNU libunwind bug is old—more than 18 years old, present in the first x86_64 version that supported C++ exception unwinding.

So why did it show up now?

The crash rate is roughly proportional to how many exceptions are thrown and how many signals are delivered. It’s also dependent on how much stack the signal handler consumes.

Rockset is unusual on all three axes. We throw exceptions at high rates as part of normal overload control; we deliver SIGUSR2 unusually often because of coarse_thread_cputime_clock; and earlier this year we made the SIGUSR2 handler use more stack by adding a call to timer_getoverrun, so we could account for merged signals.

That last change seems to have been important. If the handler uses little enough stack, it may not reach and overwrite the stale ucontext_t memory. Before that change, we do not observe these crashes at all. After the change the rate remained low until we ramped up load for some use cases that stressed the backpressure mechanism.

In other words, the libunwind bug has always been there, but the product of our exception rate, signal rate, and handler stack usage had only recently crossed the threshold where it became operationally visible.

This mechanism also explains the coincidence that both the hardware bug and the libunwind bug crashed mostly inside DocumentTree::updateDocument. Crashes from libunwind were heavily biased toward this method, because it’s always active at the point we throw an exception to apply ingest backpressure. It was also heavily selected for the %rsp-misalignment crashes because the bad hardware node was of a SKU that we use for bulk ingest, which spends the majority of its CPU time in that method.

Our immediate mitigation was to switch from GNU libunwind to libgcc’s unwinder. That was a good trade on its own: libgcc’s implementation has benefited from a lot of work to reduce lock contention, which matters when scaling to large VMs.

We also upstreamed a self-contained reproducer and a fix(s'obre en una finestra nova) to GNU libunwind, and verified that the other unwinders don’t have a similar issue.

The power of a population-level diagnosis

This debugging journey taught us a lot about the specific details of dynamic linking, DWARF unwind metadata, Linux signal delivery, the System V ABI, and C++ exception machinery. But the main lesson was simpler than any of that.

The most important step was not the clever assembly reading or deep knowledge of the details. It was building a high-quality data set. In the absence of this data set, we were mixing two distinct phenomena into one story and trying to reason our way out of the confusion. Once we had accurate and complete population data, the structure of the problem became obvious: one crash population belonged to a bad host, and the other belonged to a race in libunwind. Once the data got better, the debugging got easier.

For infrastructure systems like Rockset, that matters a lot. This investigation reinforced our commitment to deep instrumentation, automated investigations, and continual improvements in our operational tooling. Reliability is not just about fixing bugs after they happen—it’s about building the data, workflows, and skills that turn impossible problems into diagnosable and solvable ones.

Autors

By Nathan Bronson i Member of Technical Staff