Core dump epidemiology: fixing an 18-year-old bug
Using population-level analysis to debug tricky crashes in our data infrastructure.
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, 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.
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.
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.
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.
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.
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.
Вооружившись чистым списком узлов Kubernetes и временных меток, мы смогли проследить падения с нарушением выравнивания стека до одного конкретного физического сервера, который мы без труда занесли в чёрный список.
Нам так и не удалось воспроизвести повреждение регистров на этом хосте в контролируемой среде, даже после нескольких недель стресс-тестирования. Тем не менее, как только проблемный сервер был выведен из эксплуатации, падения из-за невыровненного стека полностью прекратились.
Удаление неисправного хоста не является окончательным решением в том смысле, что оно не предотвращает повторения аналогичной проблемы в будущем. Однако мы можем изменить программное обеспечение таким образом, чтобы при повторении подобного сбоя его можно было легко обнаружить и локализовать. Мы доработали наш обработчик критических сигналов, включив в него запись состояния регистров — теперь мы можем фиксировать подобные аномалии исключительно по логам (дампы памяти больше не требуются). Также мы изменили логику работы управляющего контура: теперь виртуальные машины, как правило, переиспользуются, а не уничтожаются и создаются заново. Это значительно упрощает обнаружение неисправных физических узлов на нашем уровне инфраструктурного стека. Наконец, мы обновили наши инструкции и скорректировали понимание команды на случай подобных ситуаций.
Когда падения из-за неисправного хоста были отсеяны, анализировать оставшиеся дампы памяти с адресом возврата NULL стало намного проще. Ранее мы полностью исключили из версий механизм раскрутки стека при исключениях, поскольку думали, что у нас есть контрпримеры — падения в тех путях выполнения кода, где исключения гарантированно не использовались. Но, как выяснилось, все эти контрпримеры относились как раз к кластеру аппаратных сбоев.
Как только мы пересмотрели оставшиеся дампы памяти с учётом этого фактора, мы поняли, что наш предыдущий вывод был в корне неверным: абсолютно все эти падения происходили именно во время раскрутки стека при обработке исключений.
Когда в C++ генерируется исключение (через throw), среда выполнения должна определить, какой блок catch должен его перехватить, и какие деструкторы или обработчики очистки необходимо выполнить по пути. Компилятор формирует эти метаданные заранее, но сам поиск и сопоставление происходят динамически во время выполнения программы.
Раскрутка стека выполняется вовсе не той функцией, которая вызывает throw, а вспомогательными функциями, к которым обращается скомпилированный код. Эти системные подпрограммы анализируют стек, извлекают метаданные о находящихся в нём функциях, динамически ищут обработчики очистки и блоки catch, после чего передают управление в одну из этих точек. Передача управления включает в себя сворачивание всех промежуточных кадров стека (включая кадры самих вспомогательных функций).
С технической точки зрения этот процесс гораздо ближе к механизму longjmp или переключению контекста файберов, нежели к обычному вызову функции и возврату из неё. В процессе должны быть восстановлены как сохраняемые вызываемой функцией регистры, так и регистры кадров стека — %rbp и %rsp.
Наш бинарный файл линкуется с двумя библиотеками, содержащими реализации функций для раскрутки стека исключений C++: libgcc и GNU libunwind. Динамический линкер почему-то выбрал определения из GNU libunwind. Это нас удивило: мы ожидали, что приоритет будет за реализацией из libgcc в силу правил версионирования символов. Однако инспекция запущенных бинарных файлов показала, что это не так.
К этому моменту рабочая гипотеза изменилась: мы ослабили ещё одно допущение, сделанное, когда считали, что баг один.
Возможно, мы видели не обычный возврат функции в NULL. Возможно, это была передача управления при раскрутке — фактически восстановление регистров в стиле setcontext, где целевой указатель инструкций стал NULL до передачи управления. Иными словами, дело было в неверных данных из библиотеки раскрутки, а не в неверном слоте адреса возврата в стеке.
Это позволило резко сузить круг поиска. Либо библиотека GNU libunwind вычисляла неверное целевое состояние регистров, либо состояние вычислялось правильно, но что-то повреждало эти данные еще до того, как они вступали в силу.
Мы изучили исходный код GNU libunwind и обнаружили, что она формирует структуру ucontext_t прямо на стеке, заполняет её нужными значениями регистров для кадра обработчика очистки (cleanup handler), а затем передает указатель на эту структуру во внутреннюю ассемблерную подпрограмму: _Ux86_64_setcontext.
В этот момент все элементы пазла наконец сложились воедино.
Сформированная структура ucontext_t находится в одном из тех самых кадров стека, которые функция _Ux86_64_setcontext и должна была свернуть в процессе своего выполнения. Не считывала ли _Ux86_64_setcontext данные из этой структуры уже после того, как изменила значение регистра %rsp? Ведь в этот момент структура переставала быть частью активного стека, что делало её уязвимой для затирания при доставке любого внешнего сигнала — например, нашего частого SIGUSR2.
Ответ был: да.
Вот последние шесть инструкций _Ux86_64_setcontext в версии GNU libunwind, которую мы использовали; в основном это инструкции mov, загружающие данные из памяти в целевой регистр:
(%rdi указывает на выделенный в стеке ucontext_t, а макросы UC_MCONTEXT_* просто разворачиваются в фиксированное смещение, где хранится конкретный регистр.)
Первая инструкция — начало окна гонки. Она обновляет %rsp, чтобы он указывал на новое дно активного стека. Как только это происходит, структура, на которую указывает %rdi, больше не часть активного стека или красная зона и уже не защищена от ядра.
Обычно это не вызывает проблем, но если сигнал приходит в точно правильный — или неправильный — момент, ядро строит сигнальный кадр по адресу %rsp-128. Это может перезаписать память, на которую указывает %rdi.
Если это произойдёт до того, как следующая инструкция прочитает UC_MCONTEXT_GREGS_RIP(%rdi), восстановленный указатель инструкций может быть повреждён. В наших сбоях он становился NULL.
Вот в этом и был баг.
Этот ассемблер также объясняет одно наблюдение, которое нас сбило с толку: почему у функции X в слоте адреса возврата предыдущего кадра стека был NULL.
setcontext написан так, чтобы восстановить все регистры, включая %rdi, поэтому в финальный момент передачи управления он не может использовать этот регистр для чтения UC_MCONTEXT_GREGS_RIP(%rdi). Вместо этого он читает значение заранее, сохраняет его в стек, восстанавливает ещё несколько регистров, а затем через retq считывает сохранённое значение и передаёт управление.
То, что в дампах памяти выглядело как «функция вернулась по адресу NULL», на самом деле означало следующее: «библиотека раскрутки стека сама сформировала целевой адрес возврата на стеке, но этот целевой адрес был повреждён ещё до завершения передачи управления». Мы ошибочно полагали, что повреждение слота адреса возврата происходит непосредственно на месте, поскольку не знали ни одного случая, когда в этот слот намеренно записывались бы (подверженные порче) данные.
Эта ошибка кажется абсурдной из-за того, насколько узкое у неё окно гонки. В такой гонке внешнее событие — сигнал — должно произойти между двумя шагами другого потока. Чем ближе эти шаги друг к другу, тем менее вероятна гонка.
В данном случае окно уязвимости составляет буквально одну инструкцию! Сигнал должен быть доставлен строго после изменения регистра %rsp, но до того, как следующая инструкция загрузит значение в %rip. Современный суперскалярный процессор с внеочередным исполнением команд (out-of-order CPU) способен выполнять несколько таких простых инструкций за один такт, так что временное окно этой «гонки» составляет примерно сто пикосекунд.
Когда мы обнаружили это состояние гонки, нашей первой реакцией было удивление: оно казалось слишком редким событием, чтобы объяснить наблюдаемую частоту сбоев. Мы фиксировали более десятка падений с возвратом по адресу NULL в день по всей нашей инфраструктуре. Неужели гонка длиной всего в одну инструкцию во время очистки стека при обработке исключений действительно могла приводить к таким масштабам?
Мы обратились к методу оценке Ферми. Если уязвимое окно порядка секунды, а SIGUSR2 приходит каждые секунды CPU-времени, то вероятность проигрыша в этой «гонке» для каждого отдельного обработчика очистки стека или блока catch составляет примерно .
В Rockset исключения используются как часть внутреннего механизма обратного давления (ingest backpressure). Один перегруженный хост может генерировать порядка исключений в секунду. Отсюда следует, что среднее время между сбоями (MTBF) для хоста, находящегося под действием обратного давления, составляет секунд, то есть один сбой за несколько часов. В масштабах всего парка серверов этого более чем достаточно, чтобы объяснить наблюдаемую частоту аварийных завершений.
Баг GNU libunwind старый — ему больше 18 лет; он был уже в первой версии x86_64, поддерживавшей раскрутку исключений C++.
Почему же он проявился только сейчас?
Частота таких падений примерно пропорциональна количеству генерируемых исключений и частоте доставки сигналов. Кроме того, она напрямую зависит от того, какой объем стека потребляет сам обработчик сигналов.
Rockset демонстрирует аномальные показатели по всем трем осям. Мы генерируем исключения с высокой частотой в рамках штатного контроля перегрузки; мы необычно часто доставляем сигнал SIGUSR2 из-за работы механизма coarse_thread_cputime_clock; и, наконец, ранее в этом году мы увеличили потребление стека обработчиком SIGUSR2, добавив туда вызов функции timer_getoverrun для учета объединенных сигналов.
Как оказалось, последнее изменение сыграло решающую роль. Если обработчик сигналов использует слишком мало стека, он физически не дотягивается до устаревших данных структуры ucontext_t в памяти и не затирает их. До этого изменения мы вообще не фиксировали подобных падений. После его внесения частота сбоев оставалась низкой — ровно до тех пор, пока мы не нарастили нагрузку в ряде сценариев, которые начали активно нагружать механизм обратного давления.
Иными словами, баг libunwind существовал всегда, но произведение нашей частоты исключений, частоты сигналов и расхода стека обработчиком лишь недавно превысило порог операционной заметности.
Этот же механизм объясняет и удивительное совпадение: почему и аппаратный сбой, и баг в libunwind приводили к падениям преимущественно внутри метода DocumentTree::updateDocument. Сбои в libunwind были сильно смещены в сторону этого метода, поскольку он всегда активен в тот момент, когда мы выбрасываем исключение для включения обратного давления при загрузке данных. В то же время этот метод лидировал и по количеству падений с нарушением выравнивания стека %rsp, поскольку неисправный сервер относился к конфигурации (SKU), которую мы используем для массовой загрузки данных, и большую часть процессорного времени он проводил именно в этом методе.
Нашей оперативной мерой защиты стал переход с GNU libunwind на встроенный механизм раскрутки стека из библиотеки libgcc. Само по себе это решение оказалось выгодным: реализация в libgcc прошла серьезную оптимизацию для снижения конкуренции за блокировки, что критически важно при масштабировании на крупных виртуальных машинах.
Кроме того, мы отправили в репозиторий GNU libunwind изолированный пример для воспроизведения бага (reproducer) вместе с патчем для его исправления(открывается в новом окне), а также убедились, что другие библиотеки раскрутки стека не подвержены аналогичной проблеме.
Это расследование многому научило нас в отношении тонкостей динамической линковки, метаданных раскрутки стека DWARF, механизмов доставки сигналов в Linux, соглашения System V ABI и внутренней кухни обработки исключений в C++. Однако главный урок оказался гораздо проще.
Самым важным шагом было не хитрое чтение ассемблера и не глубокое знание деталей. Им стало создание качественного набора данных. Без него мы смешивали два разных явления в одну историю и пытались рассуждениями выбраться из путаницы. Когда появились точные и полные данные по популяции, структура проблемы стала очевидной: одна популяция сбоев относилась к плохому хосту, другая — к гонке в libunwind. Когда данные стали лучше, отладка стала проще.
Для инфраструктурных систем уровня Rockset это имеет колоссальное значение. Данное расследование лишь укрепило нашу приверженность глубокому инструментированию кода, автоматизации расследований и непрерывному совершенствованию наших инструментов эксплуатации. Надежность системы заключается не только в исправлении уже случившихся багов — она строится на создании данных, рабочих процессов и навыков, которые превращают «невозможные» проблемы в диагностируемые и решаемые задачи.
Авторы
By Nathan Bronson, Member of Technical Staff


