Преминаване към основното съдържание
OpenAI

30 юни 2026 г.

Инженерство

Епидемиология на core dump: поправка на 18-годишен бъг

Използване на анализ на ниво популация за дебъгване на трудни сривове в нашата инфраструктура за данни.

Зареждане…

Моделите и агентите на OpenAI все повече разчитат на мащабируема инфраструктура за данни, за да търсят релевантни данни по време на инференция: когато моделите обмислят въпроса ви. Някои от тези услуги са написани на C++, чийто контрол на ниско ниво върху системата ни позволява да максимизираме производителността и да минимизираме използването на памет. Тези ползи от ефективността са важни при мащабиране, но липсата на безопасност на паметта в C++ означава, че бъгове могат да причинят сривове, като пишат на грешни или несъществуващи адреси в паметта.

Преди няколко месеца наблюдавахме сривове вътре в услугата Rockset — специално разработена част от инфраструктурата ни за данни в ChatGPT, ключова за много плъгини за данни и за търсене в разговори. При всеки от тези сривове изглеждаше, че нормална C++ функция приключва и после се връща към фиктивен адрес, което кара ядрото да спре програмата, защото указателят на инструкцията вече не сочи към код. Понякога слотът за адреса за връщане в стековия кадър беше NULL. Понякога самият CPU регистър на указателя към стека изглеждаше изместен с 8 байта, сякаш %rsp някак е бил декрементиран по време на нормално изпълнение. И в двата случая сривът настъпваше при връщане.

Това не са нормални режими на отказ за приложен код. Случаен запис, който попада само върху запазен адрес за връщане, е възможен, но изключително малко вероятен. Бъг, който размества %rsp с 8 без inline assembly, setcontext или longjmp (никое от които не използваме), е още по-странен, защото компилираният код коригира този регистър директно само в пролога и епилога на функцията. Срещу всяка хипотеза, за която ние (или ChatGPT) успявахме да се сетим, имаше силни доказателства, така че бъгът изглеждаше невъзможен.

Това, което смятахме за един проблем, в крайна сметка се оказа два несвързани бъга, случайно открити по едно и също време. Първо, тиха хардуерна повреда на един Azure хост, при която CPU просто не смяташе правилно. Второ, 18-годишно състезателно състояние в GNU libunwind — незабелязан бъг в широко използвана библиотека с отворен код.

Тази публикация разказва как идентифицирахме и поправихме на пръв поглед необясними сривове, като мислехме като епидемиолози и изградихме качествен набор от данни за цялата популация от сривове.

Първи опит за дебъгване: внимателно разглеждане на няколко core dump-а

Първо, нека навлезем по-дълбоко в Rockset. Това е cloud-native система за данни за търсене и анализи в реално време, която използваме за много вътрешни случаи в OpenAI, например sync connectors (Rockset беше придобита от OpenAI през 2024 г.). Стрийминг актуализациите се използват за поддържане на актуален индекс на базата знания на работното пространство, така че ChatGPT да може да търси релевантна информация, когато отговаря на въпроси или извършва действия.

Слоят за изпълнение на Rockset е написан на C++. Езикът C++ осигурява достъп на ниско ниво до CPU, което е добро за производителността и ефективността, но означава, че бъгове в приложението могат да доведат до невалидни достъпи до памет и segfault-и. За да ги проследяваме, използваме обработчика на фатални сигнали на folly, за да записваме stack trace при срив, и качваме съответните core dump-ове (моментна снимка на състоянието на програмата при срива) в Azure blob storage за по-късен анализ. Всички листа за обработка на заявки в Rockset са репликирани, което минимизира въздействието на срив върху клиента. Всеки segfault обаче съответства на бъг, който трябва да бъде поправен, за да изпълним целите си за надеждност и качество.

Първоначалният ни подход беше да третираме тези core файлове като обичаен проблем за дебъгване: да прегледаме отблизо няколко core dump-а, да формулираме хипотези и да ги отхвърляме една по една.

Повечето сривове възникваха в метод, наречен DocumentTree::updateDocument. При тези сривове изглеждаше, че updateDocument е извикал някаква неизвестна функция X, стекът се е повредил, докато X е била активна, а после X се е върнала към адрес, който не е изпълним код. В някои случаи току-що премахнатият кадър на X изглеждаше валиден, с изключение на това, че запазеният му адрес за връщане беше NULL. В други случаи самият указател към стека изглеждаше грешен, но следващият валиден кадър все още изглеждаше updateDocument.

Не знаехме кога стекът се поврежда, което оставяше огромно пространство за търсене. updateDocument е голям метод с много inlining, така че броят кандидати за X беше огромен.

Бъг в нашия C++ код ли беше това? Проблем с компилатора или линкването? Проблем в някоя от runtime библиотеките ни? Бъг в Linux ядрото около доставяне на сигнали или превключване на контекст? Нещо още по-рядко? Ако това беше случаен запис, защо не беше уловен от нашата ASAN staging среда?

Опитахме да използваме логовете на ниво приложение, за да идентифицираме всички прояви на проблема, но бъговете с повреда на стека са трудни за класифициране само по логове, защото самите записани stack trace-и са повредени или липсват. Не успяхме да създадем заявка към логовете, която да няма едновременно фалшиви положителни и фалшиви отрицателни резултати. Ръчно прегледахме още core файлове и намерихме допълнителни примери, но този процес беше твърде трудоемък, за да ни даде надежден набор от данни.

На този етап от разследването (погрешно) изключихме хардуерен бъг, защото виждахме сривове в множество региони и върху множество типове хардуер, така че все още търсехме изцяло софтуерни причини. Няколко дни се заровихме изключително дълбоко в един срив с неправилно подравнен %rsp, като възстановявахме историята преди срива чрез съдържанието на стека и регистрите. Това даде някои възможни следи, но понеже не се отказахме от първоначалното си заключение, че всички бъгове имат една и съща причина, не успяхме да излезем от задънената улица.

Следи от стека

Преди да стигнем до повратния момент в разследването, е важно да обясним каква информация извличахме от core файловете.

Rockset се компилира с -fno-omit-frame-pointer, така че активният стеков кадър винаги е достижим чрез %rbp, а извикващите функции образуват свързан списък от указатели към кадри.

В Linux x86_64 AMD64 System V ABI също запазва 128 байта под %rsp като red zone. Тази област е достъпна за потребителски код и, важното е, ядрото обещава да не я презаписва при доставяне на сигнал като част от ABI договора.

Red zone беше централна за дебъгването ни на срив след връщане, защото запазва част от информацията отпреди връщането. Когато се задейства SIGSEGV, обработчикът на фатални сигнали на folly се изпълнява върху стека на сриващата се нишка. Стековите кадри, които вече не са активни (защото функцията им се е върнала), ще бъдат презаписани от обработчика на сигнала, с изключение на последните 128 байта. Затова можем да казваме неща като „току-що премахнатият стеков кадър на X изглеждаше валиден, с изключение на NULL адрес за връщане“. Red zone запазва част от неактивните кадри или понякога само края на един неактивен кадър.

Диаграма на стек, показваща повредени стекови кадри, които могат да презапишат адреси за връщане и да причинят сривове.

Открихме един срив с неправилно подравнен стек, при който всички участващи функции бяха много малки. Това ни позволи да видим, че %rsp е станал неправилно подравнен по време на изпълнение на сравнително проста функция и че след това са успели още извиквания. Програмата се срина едва когато активната функция най-накрая се опита да се върне. Нито един от тези пътища в кода не използваше изключения, inline assembly, setcontext или longjmp, така че ако указателят към стека наистина се беше променил по начина, който core файлът подсказваше, нямаше правдоподобен бъг в потребителския код, който да обясни проблема.

Това ни насочи към ядрото.

Rockset използва сигнали по-агресивно от повечето програми. Изпълнението на заявки е разбито на много леки задачи, които обменят данни. Това е важно за ефективната обработка на натоварвания с висок QPS, но прави отчитането на CPU по заявки неудобно, защото работата за много заявки се мултиплексира върху един и същ пул от нишки.

Нашето решение е нещо, което наричаме coarse_thread_cputime_clock, което апроксимира clock_gettime(CLOCK_THREAD_CPUTIME_ID, ...) достатъчно евтино, за да семплираме на всяка граница между задачи. API-то timer_create може да се използва за планиране на периодично доставяне на сигнал според няколко представи за протичането на времето, включително натрупването на CPU време. Планираме доставянето на сигнал (SIGUSR2) на всеки няколко милисекунди CPU време, при което обработчикът на сигнала обновява стойност, локална за нишката. Макар много задачи да не виждат грубия часовник да напредва, докато се изпълняват, сумирането на всички делти дава неизместена оценка на реалното CPU време за заявка.

Тъй като доставяме сигнали толкова често, рядък бъг в ядрото около превключването на контекст или доставянето на сигнали изглеждаше правдоподобен. Прекарахме време в четене на доклади за бъгове, изходен код на ядрото и специфичните за Azure пачове на ядрото. Опитахме стрес тестове. Не успяхме да намерим нищо, което да изглежда свързано.

В този момент решихме да се отдръпнем и да опитаме друг подход.

Лекар или епидемиолог?

Има два основни начина за дебъгване на подобен проблем.

Единият е да действаме донякъде като лекар: да се фокусираме върху един пациент, да направим много тестове и да се опитаме да диагностицираме единичен случай от подробни доказателства.

Другият е да действаме по-скоро като епидемиолог: да погледнем цялата популация и да попитаме дали има модели, които един отделен случай не може да разкрие. Започнал ли е бъгът с конкретна версия? Корелира ли с един хардуерен SKU (конкретния модел CPU и сървър), един регион или една версия на ядрото? Крият ли се няколко различни клъстера вътре в това, което изглежда като един синдром?

Дотогава основно бяхме в режим „лекар“. Ключовата промяна беше решението, че трябва да съберем качествени данни на ниво популация.

Почистване на данните

Предишните ни опити автоматично да намерим всички прояви на проблема се провалиха, защото се опитвахме да използваме текстови търсения в логовете. Самите core dump-ове съдържат много повече информация, но ръчното им разглеждане не се мащабираше. Решихме да вложим усилия в изграждането на pipeline, който да анализира core dump-овете автоматично.

Накарахме ChatGPT да напише скрипт, който изтегля префикс от всеки core файл, извлича регистрите, филтрира известни фалшиви положителни резултати чрез логовете и автоматично етикетира срива като return-to-null, misaligned-stack или друг. След това изпълнихме този скрипт паралелно върху всеки production core dump на Rockset от предходната година.

Това беше повратният момент.

След като имахме чист набор от данни, корелациите се появиха веднага. Това, което бяхме третирали като един странен бъг, всъщност бяха две отделни популации от сривове.

Core файловете с return-to-null бяха разпръснати из много клъстери и географски региони. Честотата им наскоро се беше увеличила, но нямаше ясна начална дата и чиста инфраструктурна граница.

Сривовете с неправилно подравнен стек изглеждаха съвсем различно. Всички идваха от един регион, имаха ясна начална дата и никога не се случваха на възли, които работеха отдавна. Макар да включваха множество Azure VM (виртуални машини, хоствани в облака), моделът приличаше на една физическа машина с дефектен хардуер, която създава проблеми за която VM случайно попадне върху нея.

Точкова диаграма на честотата на сривовете по клъстери във времето, показваща, че повечето сривове са концентрирани в клъстери 2, 3 и 6, със скок в клъстер 1 към края на периода.

Това беше моментът, в който осъзнахме, че мислено сме смесвали два бъга. Тъй като смесвахме контрапримери от двата бъга, не можехме да намерим едно съгласувано обяснение.

Бъг №1: дефектният хост

Въоръжени с чист списък от Kubernetes възли и времеви маркери, успяхме да проследим сривовете с неправилно подравнен стек до един физически хост, който лесно можеше да бъде добавен в denylist.

Не успяхме да възпроизведем повредата на регистрите на този хост в контролирана среда, дори след няколко седмици стрес тестове. След като проблемният хост беше изведен от експлоатация обаче, сривовете с неправилно подравнен стек изчезнаха.

Премахването на дефектния хост не е трайно решение в смисъл, че не предотвратява нова поява на същия проблем. Можем обаче да променим софтуера така, че ако подобен проблем се повтори, да бъде лесно открит и обработен. Подобрихме обработчика си на фатални сигнали, за да включва състоянието на регистрите, така че да можем да откриваме повторна поява само от логовете (без нужда от core dump). Променихме control plane така, че VM обикновено да се използват повторно, вместо да се рециклират, което прави откриването на дефектен възел много по-лесно на нашето ниво от инфраструктурния стек. Също така актуализирахме runbook-овете си (и менталните модели на екипа), за да включим тази възможност.

След като сривовете от дефектния хост бяха отделени, останалите return-to-null core файлове станаха много по-лесни за осмисляне. По-рано бяхме изключили exception unwinding, защото смятахме, че имаме контрапримери: сривове в пътища на кода, където изключения определено не се използват. Но всички тези контрапримери бяха от клъстера с хардуерна повреда.

Когато прегледахме останалите core файлове отново с това наум, открихме, че заключението е било точно обратното: всички сривове се случваха по време на exception unwinding.

Обработката на изключения е динамично прехвърляне на управление

Когато C++ хвърли изключение, runtime-ът трябва да открие кой catch блок трябва да го получи и кои деструктори или обработчици за почистване трябва да се изпълнят по пътя. Компилаторът генерира тези метаданни, но реалното съпоставяне се случва динамично по време на изпълнение.

Exception unwinding всъщност не се извършва от функцията, която извиква throw, а от помощни функции, извикани от получения компилиран код. Тези runtime рутини разглеждат стека, извличат метаданни за функциите, намерени в стека, динамично търсят обработчици за почистване и catch блокове, а после прехвърлят управлението към едно от тези места. Прехвърлянето на управление включва unwind на всички междинни стекови кадри (включително тези на помощните функции).

Оперативно това е много по-близо до longjmp или превключване на fiber, отколкото до нормално извикване и връщане. Callee save регистрите трябва да бъдат възстановени, както и регистрите на стековите кадри %rbp и %rsp.

Нашият binary се линква към две библиотеки, които съдържат реализации на функциите, извършващи C++ exception unwinding: libgcc и GNU libunwind. Дефинициите на GNU libunwind бяха избраните от динамичния linker. Това ни изненада; очаквахме реализацията на libgcc да спечели заради правилата за версиониране на символи, но проверката на работещите binary файлове показа, че не е така.

Отмяна на едно последно допускане

В този момент работната ни хипотеза се промени, след като отпуснахме още едно допускане, направено, когато мислехме, че има само един бъг.

Може би не виждахме обикновено връщане на функция към NULL. Може би виждахме unwind прехвърляне — на практика възстановяване на регистри в стил setcontext — при което целевият указател на инструкцията е станал NULL преди прехвърлянето на управлението. С други думи, грешни данни от unwind библиотеката, а не грешен слот за адрес за връщане в стека.

Това драматично стесни проблема. Или GNU libunwind изчисляваше грешно целево състояние, или изчисляваше правилното състояние и нещо го повреждаше, преди да може да бъде приложено.

Прочетохме изходния код на GNU libunwind и открихме, че той синтезира ucontext_t върху стека, попълва желаното състояние на регистрите за кадъра на обработчика за почистване и после подава указател към тази структура на вътрешна assembly рутина: _Ux86_64_setcontext.

В този момент имахме всички части.

Синтезираният ucontext_t живее в един от стековите кадри, които се unwind-ват от _Ux86_64_setcontext по време на изпълнението на тази функция. Дали _Ux86_64_setcontext четеше от структурата, след като промени %rsp, в който момент структурата вече не беше част от активния стек? Това би я направило уязвима към презаписване при доставяне на сигнал, например нашия чест SIGUSR2.

Бъг №2: бъгът в libunwind

Отговорът беше да.

Ето последните шест инструкции на _Ux86_64_setcontext във версията на GNU libunwind, която използвахме; те се състоят основно от инструкции mov, които зареждат от паметта в целеви регистър:

Обикновен текст

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 сочи към заделения в стека ucontext_t, а макросите UC_MCONTEXT_* просто се разгръщат до фиксираното отместване, на което се съхранява конкретен регистър.)

Първата инструкция е началото на състезателния прозорец. Тя обновява %rsp да сочи към новото дъно на активния стек. Веднага щом това се случи, структурата, към която сочи %rdi, вече не е част от активния стек (или red zone) и вече не е забранена за ядрото.

Обикновено това не причинява проблеми, но ако сигнал пристигне точно в правилния (грешния?) момент, ядрото ще изгради сигналния кадър при %rsp-128. Това може да презапише паметта, към която сочи %rdi.

Ако това се случи, преди следващата инструкция да прочете UC_MCONTEXT_GREGS_RIP(%rdi), възстановеният указател на инструкцията може да бъде повреден. При нашите сривове той ставаше NULL.

Това е бъгът.

Защо core файловете се маскираха като обикновени лоши връщания

Този assembly обяснява и едно от наблюденията, които ни объркваха: защо функция X имаше NULL в слота за адрес за връщане на предходния стеков кадър.

setcontext е написан да възстановява всички регистри, включително %rdi, така че не може да използва този регистър, за да прочете UC_MCONTEXT_GREGS_RIP(%rdi) в последния момент от прехвърлянето на управлението. Вместо това прочита стойността по-рано, запазва я в стека, възстановява още няколко регистъра и после използва retq, за да прочете запазената стойност и да прехвърли управлението.

Това, което в core файловете изглеждаше като „функция се е върнала към NULL“, всъщност беше „unwinder-ът е синтезирал целеви адрес за връщане върху стека, но тази цел е била повредена, преди прехвърлянето да завърши“. Бяхме приели, че повредата на слота за адрес за връщане трябва да се случва на място, защото не знаехме за места, където (податливи на повреда) данни нарочно се записват в слота за адрес за връщане.

Състезателен прозорец с ширина една инструкция

Това, което прави този бъг абсурден на пръв поглед, е колко тесен е състезателният прозорец. При такъв вид състезателно състояние външното събитие (сигналът) трябва да се случи между две стъпки, извършвани от друга нишка. Колкото по-близо са тези стъпки една до друга, толкова по-малка е вероятността състезателното състояние да се прояви.

В този случай уязвимият прозорец буквално е широк една инструкция! Сигнал трябва да бъде доставен, след като %rsp е променен, но преди следващата инструкция да зареди %rip. Няколко прости инструкции като тази могат да се изпълнят за цикъл на модерен superscalar out-of-order CPU, така че състезателният прозорец е приблизително сто пикосекунди.

Когато открихме това състезание, първата ни реакция беше, че трябва да е твърде рядко, за да обясни наблюдаваната честота на сривовете. Виждахме повече от дузина return-to-null сривове на ден в целия флот. Може ли състезание с една инструкция по време на почистване след изключение наистина да обясни това?

Обърнахме се към оценяване по Ферми. Ако уязвимият прозорец е от порядъка на 101010^{-10} секунди и SIGUSR2 пристига на всеки 10210^{-2} секунди CPU време, тогава всеки обработчик за почистване след изключение или catch блок има приблизително 10810^{-8} вероятност да загуби състезанието.

Rockset използва изключения като част от вътрешния си механизъм за backpressure при ingest. Един претоварен хост може да хвърля от порядъка на 10410^{4} изключения в секунда. Това предполага средно време между отказите за хост, използващ backpressure, от 10410^{4} секунди, или по един срив на всеки няколко часа. В мащаба на целия флот това е повече от достатъчно, за да обясни наблюдаваната честота на сривовете.

Защо бъгът в libunwind се появи сега?

Бъгът в GNU libunwind е стар — на повече от 18 години, наличен още в първата версия за x86_64, която поддържа C++ exception unwinding.

Тогава защо се прояви сега?

Честотата на сривовете е приблизително пропорционална на броя хвърлени изключения и броя доставени сигнали. Тя зависи и от това колко стек използва обработчикът на сигнала.

Rockset е необичаен и по трите оси. Хвърляме изключения с висока честота като част от нормалния контрол на претоварването; доставяме SIGUSR2 необичайно често заради coarse_thread_cputime_clock; а по-рано тази година накарахме обработчика на SIGUSR2 да използва повече стек, като добавихме извикване към timer_getoverrun, за да отчитаме слети сигнали.

Изглежда, че последната промяна е била важна. Ако обработчикът използва достатъчно малко стек, може да не достигне и презапише остарялата памет на ucontext_t. Преди тази промяна изобщо не наблюдаваме тези сривове. След промяната честотата остана ниска, докато не увеличихме натоварването за някои случаи на употреба, които натоварваха механизма за backpressure.

С други думи, бъгът в libunwind винаги е бил там, но произведението от честотата на изключенията, честотата на сигналите и използването на стек от обработчика едва наскоро беше преминало прага, при който става оперативно видимо.

Този механизъм обяснява и съвпадението, че както хардуерният бъг, така и бъгът в libunwind сриваха програмата най-често вътре в DocumentTree::updateDocument. Сривовете от libunwind бяха силно изместени към този метод, защото той винаги е активен в момента, когато хвърляме изключение, за да приложим backpressure при ingest. Той беше силно избран и при сривовете с неправилно подравнен %rsp, защото дефектният хардуерен възел беше от SKU, който използваме за bulk ingest, а той прекарва по-голямата част от CPU времето си в този метод.

Незабавното ни смекчаващо действие беше да преминем от GNU libunwind към unwinder-а на libgcc. Това само по себе си беше добър компромис: реализацията на libgcc се е възползвала от много работа за намаляване на lock contention, което е важно при мащабиране към големи VM.

Също така изпратихме upstream самостоятелен reproducer и поправка(отваря се в нов прозорец) към GNU libunwind и проверихме, че другите unwinder-и нямат подобен проблем.

Силата на диагнозата на ниво популация

Това пътешествие в дебъгването ни научи на много за конкретните детайли на динамичното линкване, DWARF unwind метаданните, доставянето на сигнали в Linux, System V ABI и механизма за C++ изключения. Но основният урок беше по-прост от всичко това.

Най-важната стъпка не беше хитрото четене на assembly или дълбокото познаване на детайлите. Беше изграждането на качествен набор от данни. Без този набор от данни смесвахме две различни явления в една история и се опитвахме с разсъждения да излезем от объркването. След като имахме точни и пълни данни за популацията, структурата на проблема стана очевидна: една популация от сривове принадлежеше на дефектен хост, а другата — на състезание в libunwind. Когато данните станаха по-добри, дебъгването стана по-лесно.

За инфраструктурни системи като Rockset това има голямо значение. Това разследване затвърди ангажимента ни към задълбочена инструментализация, автоматизирани разследвания и постоянни подобрения в оперативните ни инструменти. Надеждността не е само да поправяме бъгове, след като се случат — тя е да изграждаме данните, работните процеси и уменията, които превръщат невъзможните проблеми в диагностируеми и решими.

Автори

By Nathan Bronson, Member of Technical Staff