Gå til hovedindhold
OpenAI

30. juni 2026

Ingeniørarbejde

Core dump-epidemiologi: rettelse af 18 år gammel fejl

Brug af analyse på populationsniveau til at fejlfinde vanskelige nedbrud i vores datainfrastruktur.

Indlæser ...

OpenAIs modeller og agenter bygger i stigende grad på skalerbar datainfrastruktur for at kunne søge efter relevante data ved inferenstid: når modellerne tænker over dit spørgsmål. Nogle af disse tjenester er skrevet i C++, hvis kontrol på lavt niveau over systemet gør det muligt at maksimere ydeevnen og minimere hukommelsesforbruget. De effektivitetsgevinster er vigtige, når vi skalerer, men C++’s manglende hukommelsessikkerhed betyder, at fejl kan give nedbrud ved at skrive til forkerte eller ikke-eksisterende hukommelsesadresser.

For nogle måneder siden så vi nedbrud inde fra Rockset-tjenesten, en specialbygget del af vores ChatGPT‑datainfrastruktur, som er central for mange dataplugins og for søgning i samtaler. I hvert af disse nedbrud så en normal C++-funktion ud til at afslutte og derefter returnere til en ugyldig adresse, så kernen stoppede programmet, fordi instruktionsmarkøren ikke længere pegede på kode. Nogle gange var returadressefeltet i stack-framen NULL. Nogle gange så CPU-registeret for stack-pointeren selv ud til at være 8 byte forskudt, som om %rsp på en eller anden måde var blevet dekrementeret midt i normal kørsel. I begge tilfælde skete nedbruddet ved returnering.

Det er ikke normale fejltilstande for applikationskode. En vildfaren skrivning, der kun rammer en gemt returadresse, er mulig, men ekstremt usandsynlig. En fejl, der fejljusterer %rsp med 8 uden inline assembly, setcontext eller longjmp (ingen af dem bruger vi), er endnu mærkeligere, fordi kompileret kode kun justerer det register direkte i funktionens prolog og epilog. Alle hypoteser, vi (eller ChatGPT) kunne komme på, havde stærke beviser imod sig, så fejlen virkede umulig.

Det, vi antog var ét problem, viste sig til sidst at være to uafhængige fejl, som tilfældigvis blev opdaget samtidig. For det første tavs hardwarekorruption på én Azure-host, hvor CPU’en simpelthen ikke regnede korrekt. For det andet en 18 år gammel race condition i GNU libunwind, en overset fejl i et udbredt open source-bibliotek.

Dette indlæg fortæller, hvordan vi identificerede og rettede tilsyneladende uforklarlige nedbrud ved at tænke som epidemiologer og bygge et datasæt af høj kvalitet om hele populationen af nedbrud.

Første fejlfindingsforsøg: nøje gennemgang af nogle få core dumps

Lad os først se nærmere på Rockset. Det er et cloud-native datasystem til søgning og realtidsanalyse, som vi bruger til mange interne formål hos OpenAI, f.eks. synkroniseringsconnectors (Rockset blev opkøbt af OpenAI i 2024). Streamingopdateringer bruges til at holde et aktuelt indeks over et arbejdsområdes vidensbase, så ChatGPT kan søge efter relevante oplysninger, når det besvarer spørgsmål eller udfører handlinger.

Rocksets eksekveringslag er skrevet i C++. C++-sproget giver adgang på lavt niveau til CPU’en, hvilket er godt for ydeevne og effektivitet, men det betyder, at applikationsfejl kan føre til ugyldige hukommelsesadgange og segfaults. For at finde dem bruger vi folly’s fatal signal handler til at logge en stack trace, når et nedbrud sker, og vi uploader de tilhørende core dumps (et snapshot af programmets tilstand ved nedbruddet) til Azure Blob Storage til senere analyse. Alle Rocksets query processing leaves er replikeret, hvilket minimerer klientpåvirkningen af et nedbrud. Hver segfault svarer dog til en fejl, der skal rettes for at nå vores mål for pålidelighed og kvalitet.

Vores første tilgang var at behandle disse cores som et klassisk fejlfindingsproblem: gennemgå nogle få core dumps meget nøje, formulere hypoteser og udelukke dem én efter én.

De fleste nedbrud skete i en metode kaldet DocumentTree::updateDocument. I disse nedbrud så det ud til, at updateDocument havde kaldt en ukendt funktion X, at stacken var blevet korrupt, mens X var aktiv, og at X derefter var returneret til en adresse, der ikke var eksekverbar kode. I nogle tilfælde så X’s netop poppede frame gyldig ud, bortset fra at dens gemte returadresse var NULL. I andre tilfælde så selve stack-pointeren forkert ud, men den næste gyldige frame syntes stadig at være updateDocument.

Vi vidste ikke, hvornår stacken blev korrupt, hvilket efterlod et enormt søgeområde. updateDocument er en stor metode med meget inlining, så antallet af kandidater til X var overvældende.

Var det en fejl i vores C++-kode? Et compiler- eller linkage-problem? Et problem i et af vores runtime-biblioteker? En Linux-kernefejl omkring signallevering eller context switching? Noget endnu sjældnere? Hvis det var en vildfaren skrivning, hvorfor blev den så ikke fanget af vores ASAN-stagingmiljø?

Vi forsøgte at bruge vores applikationslogs til at finde alle forekomster af problemet, men stack-korruptionsfejl er svære at klassificere ud fra logs alene, fordi de loggede stack traces selv er korrupte eller mangler. Vi kunne ikke lave en logforespørgsel uden både falske positiver og falske negativer. Vi gennemgik flere cores manuelt og fandt nogle ekstra eksempler, men processen var for arbejdskrævende til at give os et troværdigt datasæt.

På dette trin i undersøgelsen udelukkede vi (fejlagtigt) en hardwarefejl, fordi vi så nedbrud på tværs af flere regioner og hardwaretyper, så vi ledte stadig efter rene softwareårsager. I nogle dage gik vi meget dybt ned i ét nedbrud med fejljusteret %rsp og rekonstruerede historikken før nedbruddet ud fra stack- og registerindhold. Det gav nogle mulige spor, men fordi vi ikke slap vores første konklusion om, at alle fejlene havde samme årsag, kom vi ikke videre.

Spor fra stacken

Før vi kommer til vendepunktet i undersøgelsen, er det vigtigt at forklare, hvilken information vi trak ud af core-filerne.

Rockset kompileres med -fno-omit-frame-pointer, så den aktive stack-frame altid kan nås via %rbp, og kalderne danner en linket liste af frame pointers.

På Linux x86_64 reserverer AMD64 System V ABI også 128 byte under %rsp som red zone. Det område er tilgængeligt for userspace-kode, og vigtigst lover kernen som del af ABI-kontrakten ikke at overskrive det, når den leverer et signal.

Red zone var central for vores fejlfindingsarbejde på et nedbrud efter returnering, fordi den bevarer noget information fra før returneringen. Når et SIGSEGV udløses, kører folly’s fatal signal handler på den nedbrudte tråds stack. Stack-frames, der ikke længere er aktive (fordi deres funktion er returneret), bliver overskrevet af signalhandleren, bortset fra de sidste 128 byte. Derfor kan vi sige ting som “X’s netop poppede stack-frame så gyldig ud, bortset fra en NULL-returadresse.” Red zone bevarer noget af de inaktive frames, eller nogle gange kun halen af én inaktiv frame.

Stackdiagram, der viser korrupte stack-frames, som kan overskrive returadresser og forårsage nedbrud.

Vi fandt ét nedbrud med fejljusteret stack, hvor alle involverede funktioner var meget små. Det lod os se, at %rsp var blevet fejljusteret under kørsel af en relativt simpel funktion, og at flere kald var lykkedes bagefter. Programmet crashede først, da den aktive funktion til sidst forsøgte at returnere. Ingen af de kodeveje brugte exceptions, inline assembly, setcontext eller longjmp, så hvis stack-pointeren virkelig ændrede sig, som coren antydede, kunne ingen plausibel fejl i userspace-kode forklare problemet.

Det pegede os mod kernen.

Rockset bruger signaler mere aggressivt end de fleste programmer. Query execution opdeles i mange letvægtsopgaver, der udveksler data. Det er vigtigt for effektiv håndtering af workloads med høj QPS, men det gør CPU-regnskab pr. query besværligt, fordi arbejde for mange queries multiplexes på samme thread pool.

Vores løsning er noget, vi kalder coarse_thread_cputime_clock, som tilnærmer clock_gettime(CLOCK_THREAD_CPUTIME_ID, ...) billigt nok til at sample ved hver opgavegrænse. API’en timer_create kan bruges til at planlægge periodisk signallevering ud fra flere måder at måle tidens gang på, herunder akkumuleret CPU-tid. Vi planlægger, at et signal (SIGUSR2) leveres for hver få millisekunders CPU-tid, hvorefter signalhandleren opdaterer en trådlokal værdi. Selv om mange opgaver ikke ser det grove ur rykke frem, mens de kører, giver summen af alle deltaer et unbiased estimat af den faktiske CPU-tid for en query.

Fordi vi leverer signaler så ofte, virkede en sjælden kernefejl omkring context switching eller signallevering plausibel. Vi brugte tid på at læse fejlrapporter, kernekildekode og de Azure-specifikke kernepatches. Vi prøvede stresstests. Vi kunne ikke finde noget, der virkede relateret.

På det tidspunkt besluttede vi at træde et skridt tilbage og prøve en anden tilgang.

Læge eller epidemiolog?

Der er to overordnede måder at fejlfinde et problem som dette på.

Den ene er at opføre sig som en slags læge: fokusere på én patient, køre mange tests og forsøge at diagnosticere ét tilfælde ud fra detaljerede beviser.

Den anden er at opføre sig mere som en epidemiolog: se på hele populationen og spørge, om der er mønstre, som ét enkelt tilfælde ikke kan afsløre. Startede fejlen ved en bestemt release? Korrelerer den med én hardware-SKU (den specifikke CPU- og servermodel), én region eller én kerneversion? Gemmer der sig flere forskellige klynger inde i det, der ligner ét syndrom?

Vi havde mest været i lægetilstand. Det afgørende skift var beslutningen om, at vi måtte indsamle populationsdata af høj kvalitet.

Rensning af dataene

Vores tidligere forsøg på automatisk at finde alle forekomster af problemet mislykkedes, fordi vi prøvede at bruge tekstsøgninger i loggene. Selve core dumps indeholder langt mere information, men manuel gennemgang skalerede ikke. Vi besluttede at investere arbejdet i at bygge en pipeline, der automatisk kunne analysere core dumps.

Vi fik ChatGPT til at skrive et script, der downloadede et præfiks af hver core-fil, udtrak registrene, filtrerede kendte falske positiver ved hjælp af loggene og automatisk mærkede nedbruddet som return-to-null, misaligned-stack eller andet. Derefter kørte vi scriptet parallelt over alle produktions-core dumps fra Rockset fra det foregående år.

Det var vendepunktet.

Da vi havde et rent datasæt, dukkede korrelationerne straks op. Det, vi havde behandlet som én mærkelig fejl, var faktisk to separate nedbrudspopulationer.

Return-to-null-cores var spredt over mange clusters og geografiske regioner. Deres frekvens var steget for nylig, men der var ingen klar startdato og ingen tydelig infrastrukturgrænse.

Nedbruddene med fejljusteret stack så helt anderledes ud. De kom alle fra én region, havde en klar startdato og skete aldrig på knuder, der havde kørt længe. Selv om de involverede flere Azure-VM’er (virtuelle maskiner hostet i skyen), lignede mønstret én fysisk maskine med defekt hardware, der skabte problemer for den VM, der tilfældigvis landede på den.

Punktdiagram over nedbrudsrater efter cluster over tid, der viser de fleste nedbrud koncentreret i cluster 2, 3 og 6, med en stigning i cluster 1 nær periodens slutning.

Det var dér, vi indså, at vi mentalt havde blandet to fejl sammen. Fordi vi havde blandet modeksempler fra begge fejl, kunne vi ikke finde én sammenhængende forklaring.

Fejl nr. 1: den dårlige host

Med en ren liste over Kubernetes-knuder og tidsstempler kunne vi spore nedbruddene med fejljusteret stack tilbage til én fysisk host, som var let at denylist’e.

Vi kunne ikke reproducere registerkorruptionen på den host i et kontrolleret miljø, selv efter flere ugers stresstest. Da den problematiske host blev taget ud af drift, forsvandt nedbruddene med fejljusteret stack dog.

At fjerne den dårlige host er ikke en permanent løsning i den forstand, at det ikke forhindrer, at samme problem opstår igen. Vi kan dog ændre softwaren, så et lignende problem let opdages og håndteres, hvis det vender tilbage. Vi forbedrede vores fatal signal handler til at inkludere registertilstand, så vi kan opdage gentagelser alene fra loggene (ingen core dump nødvendig). Vi ændrede kontrolplanet, så VM’er normalt genbruges i stedet for at blive recyclet, hvilket gør det meget lettere at opdage dårlige knuder på vores niveau i infrastrukturlaget. Vi opdaterede også vores runbooks (og vores teams mentale modeller) til at omfatte denne mulighed.

Da nedbruddene fra den dårlige host var skilt fra, blev de resterende return-to-null-cores meget lettere at ræsonnere om. Tidligere havde vi udelukket exception unwinding, fordi vi troede, vi havde modeksempler: nedbrud i kodeveje, hvor exceptions bestemt ikke blev brugt. Men de modeksempler kom alle fra hardwarekorruptionsklyngen.

Da vi gennemgik de resterende cores igen med det i tankerne, fandt vi ud af, at konklusionen var præcis omvendt: nedbruddene skete alle under exception unwinding.

Exception handling er en dynamisk kontroloverførsel

Når C++ kaster en exception, skal runtimen finde ud af, hvilken catch-blok der skal modtage den, og hvilke destructors eller cleanup handlers der skal køre undervejs. Compileren udsender disse metadata, men selve matchningen sker dynamisk ved runtime.

Exception unwinding udføres faktisk ikke af den funktion, der kalder throw, men af hjælpefunktioner, som den resulterende kompilerede kode kalder. Disse runtime-rutiner undersøger stacken, henter metadata om funktionerne på stacken, leder dynamisk efter cleanup handlers og catch-blokke og overfører derefter kontrollen til et af de steder. At overføre kontrollen omfatter unwinding af alle mellemliggende stack-frames (inklusive hjælpefunktionernes).

Operationelt minder det langt mere om et longjmp eller et fiberskift end om et normalt kald og returnering. Callee-save-registre skal gendannes, ligesom stack-frame-registrene %rbp og %rsp.

Vores binary linker mod to biblioteker, der indeholder implementeringer af de funktioner, som udfører C++ exception unwinding: libgcc og GNU libunwind. GNU libunwinds definitioner var dem, den dynamiske linker valgte. Det overraskede os; vi havde forventet, at libgcc-implementeringen ville vinde på grund af regler for symbolversionering, men inspektion af kørende binaries viste, at det ikke var tilfældet.

At opgive en sidste antagelse

På dette tidspunkt ændrede vores arbejdshypotese sig, da vi slap endnu en antagelse, vi havde gjort, mens vi troede, der kun var én fejl.

Måske så vi ikke en almindelig funktion returnere til NULL. Måske så vi en unwind-overførsel — i praksis en setcontext-lignende registergendannelse — hvor destinations-instruktionsmarkøren var blevet NULL, før kontrollen blev overført. Med andre ord forkerte data fra unwind-biblioteket snarere end et forkert returadressefelt på stacken.

Det indsnævrede problemet dramatisk. Enten beregnede GNU libunwind den forkerte destinationstilstand, eller også beregnede det den rigtige tilstand, som noget derefter korrumperede, før den kunne anvendes.

Vi læste GNU libunwinds kildekode og fandt ud af, at det syntetiserer en ucontext_t på stacken, udfylder den ønskede registertilstand for cleanup-handlerens frame og derefter giver en pointer til den struct til en intern assembly-rutine: _Ux86_64_setcontext.

På dette tidspunkt havde vi alle brikkerne.

Den syntetiserede ucontext_t ligger i en af de stack-frames, der unwindes af _Ux86_64_setcontext under den funktions kørsel. Læste _Ux86_64_setcontext fra structen, efter at den ændrede %rsp, hvor structen ikke længere var en del af den aktive stack? Det ville gøre den sårbar over for at blive overskrevet af en signallevering, f.eks. vores hyppige SIGUSR2.

Fejl nr. 2: libunwind-fejlen

Svaret var ja.

Her er de sidste seks instruktioner i _Ux86_64_setcontext i den version af GNU libunwind, vi brugte; de består mest af mov-instruktioner, der indlæser fra hukommelse til et destinationsregister:

Almindelig tekst

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 peger på den stack-allokerede ucontext_t, og UC_MCONTEXT_*-makroerne udvider blot til den faste offset, hvor et bestemt register er gemt.)

Den første instruktion er begyndelsen på race-vinduet. Den opdaterer %rsp, så det peger på den nye bund af den aktive stack. Så snart det sker, er den struct, som %rdi peger på, ikke længere en del af den aktive stack (eller red zone), og den er ikke længere fredet for kernen.

Normalt giver det ikke problemer, men hvis et signal ankommer i præcis det rigtige (forkerte?) øjeblik, bygger kernen signal-framen ved %rsp-128. Det kan overskrive den hukommelse, som %rdi peger på.

Hvis det sker, før den næste instruktion læser UC_MCONTEXT_GREGS_RIP(%rdi), kan den gendannede instruktionsmarkør blive korrupt. I vores nedbrud blev den NULL.

Det er fejlen.

Hvorfor cores lignede almindelige dårlige returneringer

Denne assembly forklarer også en af de observationer, der forvirrede os: hvorfor funktion X havde NULL i returadressefeltet i den foregående stack-frame.

setcontext blev skrevet til at gendanne alle registre, inklusive %rdi, så den kan ikke bruge det register til at læse UC_MCONTEXT_GREGS_RIP(%rdi) i det sidste øjeblik af kontroloverførslen. I stedet læser den værdien tidligere, gemmer den på stacken, gendanner nogle flere registre og bruger derefter retq til at læse den gemte værdi og overføre kontrollen.

Det, der i cores lignede “en funktion returnerede til NULL”, var faktisk “unwinderen syntetiserede en målreturadresse på stacken, men målet var blevet korrupt, før overførslen var færdig.” Vi havde antaget, at korruption af returadressefeltet måtte ske på stedet, fordi vi ikke kendte steder, hvor (korruptérbare) data med vilje blev skrevet til returadressefeltet.

Et race-vindue på én instruktion

Det, der får denne fejl til at virke absurd, er hvor snævert race-vinduet er. I denne type race condition skal den eksterne hændelse (signalet) ske mellem to trin, som en anden tråd udfører. Jo tættere de trin ligger på hinanden, desto mindre sandsynlig er race conditionen.

I dette tilfælde er det sårbare vindue bogstaveligt talt én instruktion bredt! Et signal skal leveres, efter at %rsp er ændret, men før den næste instruktion indlæser %rip. Flere simple instruktioner som denne kan køres pr. cyklus på en moderne superskalær out-of-order CPU, så race-vinduet er cirka hundrede picosekunder.

Da vi fandt dette race, var vores første reaktion, at det måtte være for sjældent til at forklare den observerede nedbrudsrate. Vi så mere end et dusin return-to-null-nedbrud om dagen på tværs af flåden. Kunne et race på én instruktion under exception cleanup virkelig forklare det?

Vi vendte os mod Fermat-estimering. Hvis det sårbare vindue er i størrelsesordenen 101010^{-10} sekunder, og SIGUSR2 ankommer for hver 10210^{-2} sekunders CPU-tid, har hver exception cleanup handler eller catch-blok cirka 10810^{-8} sandsynlighed for at tabe racet.

Rockset bruger exceptions som del af sin interne backpressure-mekanisme ved ingest. En enkelt overbelastet host kan kaste i størrelsesordenen 10410^{4} exceptions pr. sekund. Det betyder, at middel­tiden mellem fejl for en host, der bruger backpressure, er 10410^{4} sekunder, eller ét nedbrud med få timers mellemrum. På flådeskala er det mere end nok til at forklare den observerede nedbrudsfrekvens.

Hvorfor dukkede libunwind-fejlen op nu?

GNU libunwind-fejlen er gammel — mere end 18 år gammel og til stede i den første x86_64-version, der understøttede C++ exception unwinding.

Hvorfor viste den sig så nu?

Nedbrudsraten er omtrent proportional med, hvor mange exceptions der kastes, og hvor mange signaler der leveres. Den afhænger også af, hvor meget stack signalhandleren bruger.

Rockset er usædvanlig på alle tre akser. Vi kaster exceptions med høj frekvens som del af normal overload control; vi leverer SIGUSR2 usædvanligt ofte på grund af coarse_thread_cputime_clock; og tidligere i år fik vi SIGUSR2-handleren til at bruge mere stack ved at tilføje et kald til timer_getoverrun, så vi kunne medregne sammenlagte signaler.

Den sidste ændring ser ud til at have været vigtig. Hvis handleren bruger lidt nok stack, når den måske ikke frem til og overskriver den forældede ucontext_t-hukommelse. Før den ændring observerer vi slet ikke disse nedbrud. Efter ændringen forblev raten lav, indtil vi skruede op for belastningen i nogle use cases, der pressede backpressure-mekanismen.

Med andre ord har libunwind-fejlen altid været der, men produktet af vores exception-rate, signalrate og handlerens stackforbrug havde først for nylig krydset den tærskel, hvor den blev operationelt synlig.

Denne mekanisme forklarer også det sammentræf, at både hardwarefejlen og libunwind-fejlen crashede mest inde i DocumentTree::updateDocument. Nedbrud fra libunwind var stærkt biased mod denne metode, fordi den altid er aktiv, når vi kaster en exception for at anvende ingest-backpressure. Den blev også stærkt udvalgt for %rsp-fejljusteringsnedbruddene, fordi den dårlige hardwareknude var af en SKU, vi bruger til bulk ingest, som bruger det meste af sin CPU-tid i den metode.

Vores umiddelbare afbødning var at skifte fra GNU libunwind til libgcc’s unwinder. Det var i sig selv et godt tradeoff: libgcc’s implementering har nydt godt af meget arbejde med at reducere lock contention, hvilket betyder noget ved skalering til store VM’er.

Vi upstreamede også en selvstændig reproducer og et fix(åbner i et nyt vindue) til GNU libunwind og verificerede, at de andre unwinders ikke har et lignende problem.

Styrken ved en diagnose på populationsniveau

Denne fejlfindingsrejse lærte os meget om de specifikke detaljer i dynamisk linking, DWARF-unwind-metadata, Linux-signallevering, System V ABI og C++’s exception-maskineri. Men hovedlæren var enklere end alt det.

Det vigtigste skridt var ikke den kloge læsning af assembly eller dyb viden om detaljerne. Det var at bygge et datasæt af høj kvalitet. Uden dette datasæt blandede vi to forskellige fænomener sammen til én historie og prøvede at tænke os ud af forvirringen. Da vi havde præcise og komplette populationsdata, blev problemets struktur tydelig: én nedbrudspopulation hørte til en dårlig host, og den anden til et race i libunwind. Da dataene blev bedre, blev fejlfindingsarbejdet lettere.

For infrastruktursystemer som Rockset betyder det meget. Undersøgelsen styrkede vores fokus på dyb instrumentation, automatiserede undersøgelser og løbende forbedringer af vores operationelle værktøjer. Pålidelighed handler ikke kun om at rette fejl, efter de sker – det handler om at bygge de data, workflows og kompetencer, der gør umulige problemer diagnosticerbare og løselige.

Forfattere

By Nathan Bronson og Member of Technical Staff