Core dump-epidemiologi: fixa en 18 år gammal bugg
Populationsanalys för att felsöka knepiga krascher i vår datainfrastruktur.
OpenAI:s modeller och agenter förlitar sig allt mer på skalbar datainfrastruktur för att hitta relevant data vid inferenstid: när modellerna tänker kring din fråga. Vissa av dessa tjänster är skrivna i C++, vars kontroll på låg nivå över systemet gör att vi kan maximera prestanda och minimera minnesanvändning. De effektivitetsvinsterna är viktiga när vi skalar upp, men C++ saknar minnessäkerhet, vilket gör att buggar kan orsaka krascher genom skrivningar till felaktiga eller obefintliga minnesadresser.
För några månader sedan såg vi krascher inifrån Rockset-tjänsten, en specialbyggd del av vår ChatGPT‑datainfrastruktur som är central för många datapluginer och för sökning i konversationer. I varje sådan krasch verkade en vanlig C++-funktion avslutas och sedan återvända till en ogiltig adress, vilket fick kärnan att stoppa programmet eftersom instruktionspekaren inte längre pekade på kod. Ibland var returadressens plats i stackramen NULL. Ibland verkade CPU-registret för stackpekaren själv ligga 8 byte fel, som om %rsp på något sätt hade dekrementerats mitt under normal körning. I båda fallen skedde kraschen vid retur.
Detta är inte normala fellägen för applikationskod. En vilsen skrivning som bara träffar en sparad returadress är möjlig, men extremt osannolik. En bugg som feljusterar %rsp med 8 utan inline-assembler, setcontext eller longjmp (inget av vilket vi använder) är ännu märkligare, eftersom kompilerad kod bara justerar registret direkt i funktionens prolog och epilog. Varje hypotes vi (eller ChatGPT) kunde komma på hade starka bevis emot sig, så buggen verkade omöjlig.
Det vi antog var ett problem visade sig till slut vara två orelaterade buggar som råkade upptäckas samtidigt. Först tyst hårdvarukorruption på en Azure-värd, där CPU:n helt enkelt inte räknade rätt. Sedan ett 18 år gammalt race condition i GNU libunwind, en oupptäckt bugg i ett brett använt bibliotek med öppen källkod.
Det här inlägget berättar hur vi identifierade och fixade till synes oförklarliga krascher genom att tänka som epidemiologer och bygga en högkvalitativ datamängd över hela kraschpopulationen.
Låt oss först gå djupare in på Rockset. Det är ett molnbaserat datasystem för sökning och realtidsanalys som vi använder för många interna användningsfall på OpenAI, till exempel synkroniseringskopplingar (Rockset förvärvades av OpenAI 2024). Strömmande uppdateringar används för att hålla ett aktuellt index över en Arbetsytas kunskapsbas, så att ChatGPT kan söka relevant information när det besvarar frågor eller utför åtgärder.
Rocksets exekveringslager är skrivet i C++. C++ ger låg-nivååtkomst till CPU:n, vilket är bra för prestanda och effektivitet, men innebär att applikationsbuggar kan leda till ogiltiga minnesåtkomster och segfaults. För att spåra dem använder vi follys hanterare för fatala signaler för att logga en stacktrace vid krasch, och laddar upp motsvarande core dumps (en ögonblicksbild av programmets tillstånd vid kraschen) till Azure Blob Storage för senare analys. Alla Rocksets löv för frågebehandling är replikerade, vilket minimerar klientpåverkan av en krasch. Men varje segfault motsvarar en bugg som måste åtgärdas för att uppfylla våra mål för tillförlitlighet och kvalitet.
Vår första metod var att behandla dessa cores som ett vanligt felsökningsproblem: granska några core dumps mycket noga, formulera hypoteser och utesluta dem en i taget.
De flesta krascher inträffade i en metod som heter DocumentTree::updateDocument. I dessa krascher verkade updateDocument ha anropat någon okänd funktion X, stacken hade skadats medan X var aktiv, och sedan hade X returnerat till en adress som inte var körbar kod. I vissa fall såg X:s nyss bortplockade ram giltig ut förutom att dess sparade returadress var NULL. I andra fall såg själva stackpekaren fel ut, men nästa giltiga ram verkade fortfarande vara updateDocument.
Vi visste inte när stacken skadades, vilket gav ett enormt sökutrymme. updateDocument är en stor metod som inlinas mycket, så antalet kandidater för X var överväldigande.
Var det en bugg i vår C++-kod? Ett kompilator- eller länkningsproblem? Ett problem i något av våra runtime-bibliotek? En Linux-kärnbugg kring signalleverans eller kontextväxling? Något ännu mer sällsynt? Om detta var en vilsen skrivning, varför fångades den inte i vår ASAN-stagingmiljö?
Vi försökte använda våra loggar på applikationsnivå för att hitta alla förekomster, men stackkorruptionsbuggar är svåra att klassificera enbart från loggar eftersom de loggade stacktraces själva är skadade eller saknas. Vi kunde inte skapa en loggfråga som saknade både falska positiva och falska negativa träffar. Vi granskade fler cores manuellt och hittade några ytterligare exempel, men processen var för arbetskrävande för att ge oss en pålitlig datamängd.
I detta skede uteslöt vi (felaktigt) en hårdvarubugg, eftersom vi såg krascher i flera regioner och på flera hårdvarutyper, så vi letade fortfarande efter rena mjukvaruorsaker. Under några dagar gick vi mycket djupt i en enda krasch med feljusterat %rsp och rekonstruerade historiken före kraschen med hjälp av stack- och registerinnehåll. Det gav några möjliga ledtrådar, men eftersom vi höll fast vid vår första slutsats att alla buggar hade samma orsak kom vi inte vidare.
Innan vi kommer till vändpunkten i utredningen är det viktigt att förklara vilken information vi hämtade ur core-filerna.
Rockset kompileras med -fno-omit-frame-pointer, så den aktiva stackramen nås alltid via %rbp, och anropare bildar en länkad lista av rampekare.
På Linux x86_64 reserverar AMD64 System V ABI också 128 byte under %rsp som red zone. Det området är tillgängligt för userspace-kod och, viktigt nog, lovar kärnan att inte skriva över det när den levererar en signal, som del av ABI-kontraktet.
Red zone var central i vår felsökning av en krasch efter retur, eftersom den bevarar viss information från före returen. När en SIGSEGV utlöses körs follys fatala signalhanterare på den kraschande trådens stack. Stackramar som inte längre är aktiva (eftersom deras funktion har returnerat) skrivs över av signalhanteraren, utom de sista 128 byten. Därför kan vi säga saker som ”X:s nyss bortplockade stackram såg giltig ut, förutom en NULL-returadress.” Red zone bevarar delar av inaktiva ramar, eller ibland bara slutet av en inaktiv ram.
Vi hittade en krasch med feljusterad stack där alla inblandade funktioner var mycket små. Det lät oss se att %rsp hade blivit feljusterat under körning av en relativt enkel funktion, och att fler anrop hade lyckats efteråt. Programmet kraschade först när den aktiva funktionen till slut försökte returnera. Ingen av kodvägarna använde undantag, inline-assembler, setcontext eller longjmp, så om stackpekaren verkligen ändrades så som core-filen antydde fanns ingen rimlig bugg i userspace-kod som förklarade problemet.
Det drev oss mot kärnan.
Rockset använder signaler mer aggressivt än de flesta program. Frågeexekvering delas upp i många lätta uppgifter som utbyter data. Det är viktigt för att hantera hög-QPS-arbetslaster effektivt, men gör CPU-redovisning per fråga besvärlig när arbete för många frågor multiplexas till samma trådpool.
Vår lösning är något vi kallar coarse_thread_cputime_clock, som approximerar clock_gettime(CLOCK_THREAD_CPUTIME_ID, ...) billigt nog för att samplas vid varje uppgiftsgräns. API:t timer_create kan användas för att schemalägga periodisk signalleverans utifrån flera mått på tidens gång, inklusive ackumulerad CPU-tid. Vi schemalägger en signal (SIGUSR2) som levereras efter några millisekunders CPU-tid, varpå signalhanteraren uppdaterar ett trådlokalt värde. Även om många uppgifter inte ser den grova klockan gå framåt medan de körs ger summan av alla deltan en opartisk uppskattning av faktisk CPU-tid för en fråga.
Eftersom vi levererar signaler så ofta verkade en sällsynt kärnbugg kring kontextväxling eller signalleverans rimlig. Vi lade tid på att läsa felrapporter, kärnans källkod och Azure-specifika kärnpatchar. Vi provade stresstester. Vi kunde inte hitta något som verkade relaterat.
Då bestämde vi oss för att ta ett steg tillbaka och prova en annan metod.
Det finns två breda sätt att felsöka ett sådant problem.
Det ena är att agera som en sorts läkare: fokusera på en patient, göra många tester och försöka diagnostisera ett enskilt fall från detaljerade belägg.
Det andra är att agera mer som en epidemiolog: se på hela populationen och fråga om det finns mönster som ett enskilt fall inte kan visa. Började buggen vid en viss release? Korrelerar den med en hårdvaru-SKU (den specifika CPU- och servermodellen), en region eller en kärnversion? Finns flera skilda kluster gömda i det som ser ut som ett syndrom?
Vi hade mest varit i läkarläge. Det avgörande skiftet var beslutet att samla in högkvalitativa populationsdata.
Våra tidigare försök att automatiskt hitta alla instanser av problemet misslyckades eftersom vi försökte använda textsökningar i loggarna. Själva core dumps innehåller mycket mer information, men manuell granskning skalade inte. Vi bestämde oss för att lägga arbetet på att bygga en pipeline som automatiskt kunde analysera core dumps.
Vi lät ChatGPT skriva ett skript som laddade ned ett prefix av varje core-fil, extraherade registren, filtrerade kända falska positiva med loggarna och automatiskt etiketterade kraschen som return-to-null, misaligned-stack eller annat. Sedan körde vi skriptet parallellt över varje Rockset-core dump från produktion under föregående år.
Det var vändpunkten.
När vi väl hade en ren datamängd syntes korrelationerna direkt. Det vi hade behandlat som en konstig bugg var i själva verket två separata kraschpopulationer.
Return-to-null-cores var spridda över många kluster och geografiska regioner. Frekvensen hade ökat nyligen, men det fanns inget tydligt startdatum och ingen ren infrastrukturgräns.
Krascherna med feljusterad stack såg helt annorlunda ut. Alla kom från en region, hade ett tydligt startdatum och inträffade aldrig på noder som hade körts länge. Även om de gällde flera Azure-VM:ar (virtuella maskiner i molnet) såg mönstret ut som att en fysisk maskin med dålig hårdvara orsakade problem för den VM som råkade hamna på den.
Då insåg vi att vi mentalt hade blandat ihop två buggar. Eftersom vi hade blandat motexempel från båda buggarna kunde vi inte hitta en enda sammanhängande förklaring.
Med en ren lista över Kubernetes-noder och tidsstämplar kunde vi spåra krascherna med feljusterad stack till en enda fysisk värd, som var enkel att blocklista.
Vi kunde inte reproducera registerkorruptionen på den värden i en kontrollerad miljö, trots flera veckors stresstestning. Men när den problematiska värden togs ur drift försvann krascherna med feljusterad stack.
Att ta bort den dåliga värden är ingen permanent lösning i den meningen att det inte hindrar samma problem från att uppstå igen. Däremot kan vi ändra mjukvaran så att ett liknande problem lätt upptäcks och hanteras om det återkommer. Vi förbättrade vår hanterare för fatala signaler så att den inkluderar registertillstånd, vilket gör att vi kan upptäcka återfall enbart från loggarna (ingen core dump behövs). Vi ändrade kontrollplanet så att VM:ar oftast återanvänds i stället för att återvinnas, vilket gör det mycket enklare att hitta dåliga noder på vår nivå i infrastrukturlagret. Vi uppdaterade också våra runbooks (och teamets mentala modeller) så att de omfattar denna möjlighet.
När krascherna från den dåliga värden separerats blev de återstående return-to-null-cores mycket lättare att resonera kring. Tidigare hade vi uteslutit exception unwinding eftersom vi trodde att vi hade motexempel: krascher i kodvägar där undantag definitivt inte användes. Men alla dessa motexempel kom från klustret med hårdvarukorruption.
När vi gick igenom återstående cores igen med detta i åtanke såg vi att slutsatsen var precis bakvänd: alla krascher skedde under exception unwinding.
När C++ kastar ett undantag måste runtime avgöra vilket catch-block som ska ta emot det och vilka destruktorer eller rensningshanterare som ska köras på vägen. Kompilatorn genererar denna metadata, men den faktiska matchningen sker dynamiskt vid körning.
Exception unwinding utförs egentligen inte av funktionen som anropar throw, utan av hjälpfunktioner som anropas av den resulterande kompilerade koden. Dessa runtime-rutiner undersöker stacken, hämtar metadata om funktionerna på stacken, letar dynamiskt efter rensningshanterare och catch-block och för sedan över kontrollen till en av dessa platser. Att föra över kontrollen innebär att alla mellanliggande stackramar rullas tillbaka (inklusive hjälpfunktionernas).
Operativt liknar detta mycket mer en longjmp eller ett fiberbyte än ett normalt anrop och retur. Callee-save-register måste återställas, liksom stackramregistren %rbp och %rsp.
Vår binär länkar mot två bibliotek som innehåller implementationer av funktionerna som utför C++ exception unwinding: libgcc och GNU libunwind. GNU libunwinds definitioner var de som den dynamiska länkaren valde. Det överraskade oss; vi hade väntat oss att libgcc-implementationen skulle vinna på grund av reglerna för symbolversionering, men granskning av körande binärer visade att så inte var fallet.
Vid det här laget ändrades vår arbetshypotes, när vi lättade på ännu ett antagande vi hade gjort när vi trodde att det bara fanns en bugg.
Kanske såg vi inte en vanlig funktionsretur till NULL. Kanske såg vi en unwind-överföring – i praktiken en registeråterställning i setcontext-stil – där målinstruktionspekaren hade blivit NULL innan kontrollen fördes över. Med andra ord felaktig data från unwind-biblioteket, snarare än en felaktig returadressplats på stacken.
Det begränsade problemet dramatiskt. Antingen beräknade GNU libunwind fel måltillstånd, eller så beräknade det rätt tillstånd och något skadade det innan det kunde tillämpas.
Vi läste GNU libunwinds källkod och såg att den syntetiserar en ucontext_t på stacken, fyller i önskat registertillstånd för rensningshanterarens ram och sedan lämnar en pekare till den strukturen till en intern assemblerrutin: _Ux86_64_setcontext.
Nu hade vi alla bitar.
Den syntetiserade ucontext_t ligger i en av stackramarna som rullas tillbaka av _Ux86_64_setcontext under den funktionens körning. Läste _Ux86_64_setcontext från strukturen efter att den ändrat %rsp, när strukturen inte längre var del av den aktiva stacken? Det skulle göra den sårbar för att skrivas över vid signalleverans, som vår frekventa SIGUSR2.
Svaret var ja.
Här är de sista sex instruktionerna i _Ux86_64_setcontext i den version av GNU libunwind vi använde, mestadels mov-instruktioner som laddar från minne till ett målregister:
(%rdi pekar på stackallokerade ucontext_t, och makrona UC_MCONTEXT_* expanderar bara till den fasta offset där ett visst register lagras.)
Den första instruktionen är början på race-fönstret. Den uppdaterar %rsp så att det pekar på den aktiva stackens nya botten. Så snart det sker är strukturen som %rdi pekar på inte längre del av den aktiva stacken (eller red zone), och den är inte längre förbjudet område för kärnan.
Vanligen orsakar det inga problem, men om en signal kommer i exakt rätt (fel?) ögonblick bygger kärnan signalramen vid %rsp-128. Det kan skriva över minnet som %rdi pekar på.
Om det sker innan nästa instruktion läser UC_MCONTEXT_GREGS_RIP(%rdi) kan den återställda instruktionspekaren skadas. I våra krascher blev den NULL.
Det är buggen.
Den här assemblerkoden förklarar också en observation som förvirrade oss: varför funktion X hade NULL på returadressens plats i föregående stackram.
setcontext skrevs för att återställa alla register, inklusive %rdi, så den kan inte använda det registret för att läsa UC_MCONTEXT_GREGS_RIP(%rdi) i kontrollöverföringens sista ögonblick. I stället läser den värdet tidigare, sparar det på stacken, återställer några fler register och använder sedan retq för att läsa det sparade värdet och föra över kontrollen.
Det som i core-filerna såg ut som ”en funktion returnerade till NULL” var egentligen ”unwindern syntetiserade en målreturadress på stacken, men målet hade skadats innan överföringen slutfördes”. Vi hade antagit att returadressplatsen måste skadas på plats, eftersom vi inte kände till några ställen där (korruptibel) data avsiktligt skrevs till returadressplatsen.
Det som får buggen att verka absurd är hur smalt race-fönstret är. I den här typen av race condition måste den externa händelsen (signalen) inträffa mellan två steg som tas av en annan tråd. Ju närmare varandra stegen ligger, desto mindre sannolikt är det att race condition uppstår.
I det här fallet är det sårbara fönstret bokstavligen en instruktion brett! En signal måste levereras efter att %rsp har ändrats, men innan nästa instruktion laddar %rip. Flera enkla instruktioner som denna kan köras per cykel på en modern superskalär out-of-order-CPU, så race-fönstret är ungefär hundra pikosekunder.
När vi hittade racet var vår första reaktion att det måste vara för sällsynt för att förklara den observerade kraschfrekvensen. Vi såg mer än ett dussin return-to-null-krascher per dag i hela flottan. Kunde ett race på en instruktion under undantagsrensning verkligen förklara det?
Vi tog till Fermat-estimering. Om det sårbara fönstret är i storleksordningen sekunder och SIGUSR2 anländer var sekund CPU-tid, har varje handler för undantagsrensning eller catch-block ungefär sannolikhet att förlora racet.
Rockset använder undantag som del av sin interna backpressure-mekanism för inläsning. En enda överbelastad värd kan kasta i storleksordningen undantag per sekund. Det innebär att medeltiden mellan fel för en värd som använder backpressure är sekunder, eller en krasch med några timmars mellanrum. På flottans skala är det mer än nog för att förklara den observerade kraschfrekvensen.
GNU libunwind-buggen är gammal – mer än 18 år, och fanns i den första x86_64-versionen som stödde C++-exception unwinding.
Så varför visade den sig nu?
Kraschfrekvensen är ungefär proportionell mot hur många undantag som kastas och hur många signaler som levereras. Den beror också på hur mycket stack signalhanteraren använder.
Rockset är ovanligt på alla tre axlarna. Vi kastar undantag ofta som del av normal överlastkontroll; vi levererar SIGUSR2 ovanligt ofta på grund av coarse_thread_cputime_clock; och tidigare i år fick vi SIGUSR2-hanteraren att använda mer stack genom att lägga till ett anrop till timer_getoverrun, så att vi kunde redovisa sammanslagna signaler.
Den sista ändringen verkar ha varit viktig. Om hanteraren använder tillräckligt lite stack kanske den inte når och skriver över det gamla ucontext_t-minnet. Före den ändringen ser vi inte dessa krascher alls. Efter ändringen förblev frekvensen låg tills vi ökade lasten för några användningsfall som belastade backpressure-mekanismen.
Med andra ord har libunwind-buggen alltid funnits där, men produkten av vår undantagsfrekvens, signalfrekvens och hanterarens stackanvändning passerade först nyligen tröskeln där den blev operativt synlig.
Mekanismen förklarar också slumpen att både hårdvarubuggen och libunwind-buggen mest kraschade inne i DocumentTree::updateDocument. Krascher från libunwind var kraftigt viktade mot denna metod, eftersom den alltid är aktiv när vi kastar ett undantag för att tillämpa backpressure vid inläsning. Den valdes också starkt ut för krascherna med feljusterat %rsp, eftersom den dåliga hårdvarunoden var av en SKU som vi använder för bulkinläsning, där merparten av CPU-tiden läggs i den metoden.
Vår omedelbara åtgärd var att byta från GNU libunwind till libgcc:s unwinder. Det var ett bra byte i sig: libgcc:s implementation har dragit nytta av mycket arbete för att minska låskonkurrens, vilket är viktigt vid skalning till stora VM:ar.
Vi upstreamade också en fristående reproducerare och en fix(öppnas i ett nytt fönster) till GNU libunwind, och verifierade att de andra unwinders inte har ett liknande problem.
Den här felsökningsresan lärde oss mycket om detaljerna i dynamisk länkning, DWARF-unwind-metadata, Linux signalleverans, System V ABI och C++ undantagsmaskineri. Men huvudlärdomen var enklare än allt detta.
Det viktigaste steget var inte smart assemblerläsning eller djup detaljkunskap. Det var att bygga en högkvalitativ datamängd. Utan den datamängden blandade vi ihop två skilda fenomen till en berättelse och försökte resonera oss ur förvirringen. När vi hade korrekta och kompletta populationsdata blev problemets struktur uppenbar: en kraschpopulation hörde till en dålig värd, den andra till ett race i libunwind. När datan blev bättre blev felsökningen enklare.
För infrastruktursystem som Rockset spelar det stor roll. Utredningen stärkte vårt engagemang för djup instrumentering, automatiserade utredningar och ständiga förbättringar av våra driftverktyg. Tillförlitlighet handlar inte bara om att fixa buggar efter att de inträffat – det handlar om att bygga data, arbetsflöden och färdigheter som gör omöjliga problem diagnostiserbara och lösbara.
Författare
By Nathan Bronson, Member of Technical Staff


