Pasar al contenido principal
OpenAI

30 de junio de 2026

Ingeniería

Epidemiología de core dumps: corregir un error de 18 años

Usar análisis a nivel poblacional para depurar fallas difíciles en nuestra infraestructura de datos.

Cargando...

Los modelos y agentes de OpenAI dependen cada vez más de infraestructura de datos escalable para buscar datos relevantes en tiempo de inferencia: cuando procesan tu pregunta. Algunos servicios están escritos en C++, cuyo control de bajo nivel permite maximizar rendimiento y reducir memoria. Esa eficiencia importa al escalar, pero C++ no es seguro en memoria: un error puede causar fallas al escribir en direcciones inválidas o inexistentes.

Hace unos meses vimos fallas dentro de Rockset, parte a medida de la infraestructura de datos de ChatGPT, clave para muchos plugins de datos y la búsqueda en conversaciones. En cada caso, una función normal de C++ parecía terminar y volver a una dirección falsa; el kernel detenía el programa porque el puntero de instrucción ya no apuntaba a código. A veces, la ranura de dirección de retorno en el marco de pila era NULL. A veces, el registro de CPU del puntero de pila parecía corrido 8 bytes, como si %rsp se hubiera decrementado durante la ejecución normal. En ambos casos, la falla ocurría al retornar.

No son modos de falla normales en código de aplicación. Una escritura perdida que caiga justo en una dirección de retorno guardada es posible, pero muy improbable. Un error que desalineara %rsp por 8 sin ensamblador en línea, setcontext ni longjmp (no usamos ninguno) sería aún más raro: el código compilado solo ajusta ese registro en el prólogo y epílogo de la función. Toda hipótesis que se nos ocurrió (a nosotros o a ChatGPT) tenía evidencia fuerte en contra; el error parecía imposible.

Lo que creímos un solo problema terminó siendo dos errores no relacionados, descubiertos a la vez por coincidencia. Primero, corrupción silenciosa de hardware en un host de Azure, cuya CPU simplemente calculaba mal. Segundo, una condición de carrera de 18 años en GNU libunwind, un error desapercibido en una biblioteca de código abierto muy usada.

Esta publicación cuenta cómo identificamos y corregimos fallas aparentemente inexplicables pensando como epidemiólogos y creando un buen conjunto de datos sobre toda la población de fallas.

Primer intento de depuración: examinar algunos core dumps con cuidado

Primero, veamos Rockset con más detalle. Es un sistema de datos nativo de la nube para búsqueda y analítica en tiempo real, usado en muchos casos internos de OpenAI, como conectores de sincronización (OpenAI adquirió Rockset en 2024). Las actualizaciones en streaming mantienen al día el índice de la base de conocimiento de un Espacio de trabajo, para que ChatGPT busque información relevante al responder o actuar.

La capa de ejecución de Rockset está escrita en C++. C++ da acceso de bajo nivel a la CPU, útil para rendimiento y eficiencia, pero los errores de aplicación pueden provocar accesos inválidos a memoria y segfaults. Para rastrearlos usamos el manejador de señales fatales de folly, que registra una traza de pila al fallar, y subimos los core dumps correspondientes (instantáneas del programa al fallar) a Azure Blob Storage para analizarlos después. Todas las hojas de procesamiento de consultas de Rockset están replicadas, lo que reduce el impacto de una falla en el cliente. Aun así, cada segfault es un error que debemos corregir para cumplir nuestras metas de confiabilidad y calidad.

Nuestro enfoque inicial fue tratar estos cores como depuración convencional: revisar de cerca algunos core dumps, formular hipótesis y descartarlas una a una.

La mayoría de las fallas ocurrían en un método llamado DocumentTree::updateDocument. Parecía que updateDocument había llamado a una función X desconocida, la pila se corrompió mientras X estaba activa y luego X retornó a una dirección no ejecutable. En algunos casos, el marco recién desapilado de X parecía válido, salvo por una dirección de retorno NULL. En otros, el puntero de pila parecía incorrecto, pero el siguiente marco válido aún parecía ser updateDocument.

No sabíamos cuándo se corrompía la pila, lo que dejaba un espacio de búsqueda enorme. updateDocument es un método grande con mucho inlining, así que había demasiados candidatos para X.

¿Era un error en nuestro código C++? ¿Un problema de compilador o enlazado? ¿Un problema en una biblioteca de runtime? ¿Un error del kernel de Linux en entrega de señales o cambios de contexto? ¿Algo aún más raro? Si era una escritura perdida, ¿por qué no la detectó nuestro entorno de staging con ASAN?

Intentamos usar logs de aplicación para identificar todos los casos, pero la corrupción de pila es difícil de clasificar solo con logs: las trazas registradas están corruptas o faltan. No logramos crear una consulta de logs sin falsos positivos ni falsos negativos. Revisamos más cores a mano y hallamos otros ejemplos, pero el proceso era demasiado laborioso para producir un conjunto confiable.

En esa etapa descartamos (mal) un error de hardware: veíamos fallas en varias regiones y tipos de hardware, así que seguíamos buscando causas solo de software. Durante unos días profundizamos en una falla con %rsp desalineado, reconstruyendo la historia previa con el contenido de la pila y los registros. Eso dio algunas pistas, pero como seguimos aferrados a la idea de una causa única, no nos destrabó.

Pistas en la pila

Antes del punto de inflexión, conviene explicar qué información extraíamos de los archivos core.

Rockset se compila con -fno-omit-frame-pointer, por lo que el marco activo siempre es accesible por %rbp, y los llamadores forman una lista enlazada de punteros de marco.

En Linux x86_64, la ABI AMD64 System V también reserva 128 bytes bajo %rsp como zona roja. Esa región está disponible para código de espacio de usuario y, como parte del contrato ABI, el kernel promete no sobrescribirla al entregar una señal.

La zona roja fue clave para depurar una falla posterior al retorno, porque conserva información previa al retorno. Cuando se dispara un SIGSEGV, el manejador de señales fatales de folly corre en la pila del hilo que falla. Los marcos ya inactivos (porque su función retornó) son sobrescritos por el manejador, salvo los últimos 128 bytes. Por eso podemos decir: “el marco recién desapilado de X parecía válido, salvo por una dirección de retorno NULL”. La zona roja conserva parte de los marcos inactivos o, a veces, solo la cola de uno.

Diagrama de pila que muestra marcos de pila corruptos que pueden sobrescribir direcciones de retorno y causar fallas.

Encontramos una falla de pila desalineada donde todas las funciones involucradas eran muy pequeñas. Eso mostró que %rsp se desalineó durante una función relativamente simple y que después otras llamadas tuvieron éxito. El programa solo falló cuando la función activa intentó retornar. Ninguna de esas rutas usaba excepciones, ensamblador en línea, setcontext ni longjmp; si el puntero de pila cambió como sugería el core, ningún error plausible de espacio de usuario lo explicaba.

Eso nos llevó a mirar el kernel.

Rockset usa señales más agresivamente que la mayoría de los programas. La ejecución de consultas se divide en muchas tareas ligeras que intercambian datos. Esto ayuda a manejar cargas de alto QPS con eficiencia, pero complica contabilizar CPU por consulta porque muchas consultas comparten el mismo pool de hilos.

Nuestra solución, coarse_thread_cputime_clock, aproxima clock_gettime(CLOCK_THREAD_CPUTIME_ID, ...) con costo tan bajo que permite muestrear en cada límite de tarea. La API timer_create permite programar señales periódicas según varias nociones del paso del tiempo, incluida la acumulación de tiempo de CPU. Programamos una señal (SIGUSR2) cada pocos milisegundos de tiempo de CPU; entonces el manejador actualiza un valor local del hilo. Aunque muchas tareas no ven avanzar el reloj aproximado mientras corren, sumar los deltas da una estimación no sesgada del tiempo real de CPU de una consulta.

Como entregamos señales tan seguido, parecía plausible un error raro del kernel en cambios de contexto o entrega de señales. Leímos reportes de errores, código fuente del kernel y parches de kernel específicos de Azure. Probamos tests de estrés. No encontramos nada que pareciera relacionado.

Entonces decidimos tomar distancia y probar otro enfoque.

Hay dos formas generales de depurar un problema así.

Una es actuar como médico: concentrarse en un paciente, hacer muchas pruebas y diagnosticar un caso con evidencia detallada.

La otra es actuar como epidemiólogo: mirar a toda la población y buscar patrones que un solo caso no revela. ¿El error empezó en una versión específica? ¿Se correlaciona con un SKU de hardware (CPU y modelo de servidor), una región o una versión del kernel? ¿Hay varios clústeres ocultos dentro de lo que parece un solo síndrome?

Habíamos estado casi siempre en modo médico. El cambio clave fue decidir que necesitábamos datos poblacionales de alta calidad.

Limpieza de datos

Nuestros intentos previos de encontrar automáticamente todos los casos fallaron porque buscábamos texto en los logs. Los core dumps tienen mucha más información, pero revisarlos a mano no escalaba. Decidimos invertir en un pipeline que analizara automáticamente los core dumps.

Le pedimos a ChatGPT un script que descargara un prefijo de cada core, extrajera registros, filtrara falsos positivos conocidos con logs y etiquetara la falla como retorno a null, pila desalineada u otra. Luego lo ejecutamos en paralelo sobre todos los core dumps de producción de Rockset del año anterior.

Ese fue el punto de inflexión.

Con un conjunto de datos limpio, las correlaciones aparecieron de inmediato. Lo que tratábamos como un solo error raro eran en realidad dos poblaciones de fallas separadas.

Los cores de retorno a null estaban distribuidos en muchos clústeres y regiones. Su frecuencia había aumentado, pero sin fecha de inicio clara ni límite de infraestructura nítido.

Las fallas de pila desalineada se veían muy distintas. Todas venían de una región, tenían una fecha de inicio clara y nunca ocurrían en nodos con mucho tiempo en ejecución. Aunque involucraban varias VM de Azure (máquinas virtuales en la nube), el patrón parecía una máquina física con hardware defectuoso que afectaba a cualquier VM alojada allí.

Diagrama de puntos de tasas de fallas por clúster a lo largo del tiempo, que muestra la mayoría de las fallas concentradas en los clústeres 2, 3 y 6, con un pico en el clúster 1 cerca del final del período.

Ahí entendimos que habíamos mezclado mentalmente dos errores. Al mezclar contraejemplos de ambos errores, no podíamos llegar a una explicación coherente.

Error n.º 1: el host defectuoso

Con una lista limpia de nodos de Kubernetes y marcas de tiempo, pudimos rastrear las fallas de pila desalineada hasta un solo host físico, que fue fácil de poner en una lista de bloqueo.

No pudimos reproducir la corrupción de registros en ese host en un entorno controlado, ni siquiera después de varias semanas de pruebas de estrés. Sin embargo, una vez que el host problemático se retiró de servicio, las fallas de pila desalineada desaparecieron.

Quitar el host defectuoso no es una solución permanente, en el sentido de que no impide una nueva aparición del mismo problema. Sin embargo, podemos cambiar el software para que, si se repite un problema similar, sea fácil detectarlo y manejarlo. Mejoramos nuestro manejador de señales fatales para incluir el estado de los registros, de modo que podamos detectar una recurrencia solo a partir de los logs (sin necesitar core dump). Cambiamos el plano de control para que las VM normalmente se reutilicen en lugar de reciclarse, lo que facilita mucho la detección de nodos defectuosos en nuestro nivel de la pila de infraestructura. También actualizamos nuestros runbooks (y los modelos mentales de nuestro equipo) para incluir esta posibilidad.

Con las fallas del host defectuoso separadas, los cores restantes de retorno a null se volvieron mucho más fáciles de razonar. Antes habíamos descartado el desenrollado de excepciones porque creíamos tener contraejemplos: fallas en rutas de código donde definitivamente no se usaban excepciones. Pero esos contraejemplos eran todos del clúster de corrupción de hardware.

Cuando revisamos los cores restantes con eso en mente, descubrimos que esta conclusión estaba exactamente al revés: todas las fallas ocurrían durante el desenrollado de excepciones.

El manejo de excepciones es una transferencia dinámica de control

Cuando C++ lanza una excepción, el runtime debe descubrir qué bloque catch debe recibirla y qué destructores o manejadores de limpieza deben ejecutarse en el camino. El compilador emite estos metadatos, pero el emparejamiento real ocurre dinámicamente en tiempo de ejecución.

El desenrollado de excepciones no lo realiza en realidad la función que invoca throw, sino funciones auxiliares llamadas por el código compilado resultante. Esas rutinas de runtime examinan la pila, obtienen metadatos sobre las funciones encontradas en ella, buscan dinámicamente manejadores de limpieza y bloques catch, y luego transfieren el control a una de esas ubicaciones. Transferir el control incluye desenrollar todos los marcos de pila intermedios (incluidos los de las funciones auxiliares).

En términos operativos, esto se parece mucho más a un longjmp o a un cambio de fibra que a una llamada y retorno normales. Deben restaurarse los registros que guarda el llamado, así como los registros de marco de pila %rbp y %rsp.

Nuestro binario enlaza con dos bibliotecas que contienen implementaciones de las funciones que realizan el desenrollado de excepciones de C++: libgcc y GNU libunwind. Las definiciones de GNU libunwind fueron las elegidas por el enlazador dinámico. Eso nos sorprendió; esperábamos que ganara la implementación de libgcc por las reglas de versionado de símbolos; sin embargo, al inspeccionar binarios en ejecución vimos que no era así.

Deshacer una última suposición

En este punto cambió nuestra hipótesis de trabajo, al relajar otra suposición que habíamos hecho cuando creíamos que solo había un error.

Quizá no estábamos viendo un retorno normal de función a NULL. Quizá estábamos viendo una transferencia de desenrollado —en la práctica, una restauración de registros al estilo setcontext— donde el puntero de instrucción de destino se había vuelto NULL antes de transferir el control. En otras palabras, datos incorrectos de la biblioteca de desenrollado, no una ranura de dirección de retorno incorrecta en la pila.

Eso acotó drásticamente el problema. O GNU libunwind calculaba el estado de destino equivocado, o calculaba el correcto y algo lo corrompía antes de que pudiera aplicarse.

Leímos el código fuente de GNU libunwind y descubrimos que sintetiza un ucontext_t en la pila, completa el estado de registros deseado para el marco del manejador de limpieza y luego pasa un puntero a esa estructura a una rutina interna en ensamblador: _Ux86_64_setcontext.

En ese punto teníamos todas las piezas.

El ucontext_t sintetizado vive en uno de los marcos de pila que _Ux86_64_setcontext desenrolla durante la ejecución de esa función. ¿Estaba _Ux86_64_setcontext leyendo la estructura después de cambiar %rsp, momento en que la estructura ya no formaba parte de la pila activa? Eso la volvería vulnerable a ser sobrescrita por la entrega de una señal, como nuestro frecuente SIGUSR2.

Error n.º 2: el error de libunwind

La respuesta fue sí.

Estas son las últimas seis instrucciones de _Ux86_64_setcontext en la versión de GNU libunwind que usábamos, compuestas en su mayoría por instrucciones mov que cargan desde memoria a un registro de destino:

Texto plano

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 asignado en la pila, y las macros UC_MCONTEXT_* simplemente se expanden al desplazamiento fijo donde se almacena un registro específico).

La primera instrucción es el comienzo de la ventana de carrera. Actualiza %rsp para apuntar al nuevo fondo de la pila activa. Tan pronto como esto ocurre, la estructura a la que apunta %rdi ya no forma parte de la pila activa (ni de la zona roja) y deja de estar fuera del alcance del kernel.

Por lo general esto no causa problemas, pero si una señal llega exactamente en el momento correcto (¿incorrecto?), el kernel construirá el marco de señal en %rsp-128. Eso puede sobrescribir la memoria a la que apunta %rdi.

Si eso ocurre antes de que la siguiente instrucción lea UC_MCONTEXT_GREGS_RIP(%rdi), entonces el puntero de instrucción restaurado puede corromperse. En nuestras fallas, se volvió NULL.

Ese es el error.

Por qué los cores parecían retornos erróneos comunes

Este ensamblador también explica una de las observaciones que nos confundió: por qué la función X tenía un NULL en la ranura de dirección de retorno del marco de pila anterior.

setcontext fue escrito para restaurar todos los registros, incluido %rdi, así que no puede usar ese registro para leer UC_MCONTEXT_GREGS_RIP(%rdi) en el momento final de la transferencia de control. En cambio, lee el valor antes, lo guarda en la pila, restaura algunos registros más y luego usa retq para leer el valor guardado y transferir el control.

Lo que en los cores parecía “una función retornó a NULL” era en realidad “el unwinder sintetizó una dirección de retorno objetivo en la pila, pero ese objetivo se corrompió antes de que terminara la transferencia”. Habíamos asumido que la corrupción de la ranura de dirección de retorno debía ocurrir en el lugar, porque no conocíamos sitios donde se escribieran datos (corruptibles) en la ranura de dirección de retorno a propósito.

Una ventana de carrera de una sola instrucción

Lo que hace que este error parezca absurdo es lo estrecha que es esta ventana de carrera. En este tipo de condición de carrera, el evento externo (la señal) debe ocurrir entre dos pasos realizados por otro hilo. Cuanto más cerca están esos pasos entre sí, menos probable es que ocurra la condición de carrera.

En este caso, la ventana vulnerable mide literalmente una instrucción. Una señal debe entregarse después de que %rsp haya cambiado, pero antes de que la siguiente instrucción cargue %rip. Varias instrucciones simples como esta pueden ejecutarse por ciclo en una CPU superescalar moderna con ejecución fuera de orden, así que la ventana de carrera es de unos cien picosegundos.

Cuando encontramos esta carrera, nuestra primera reacción fue pensar que debía ser demasiado rara para explicar la tasa de fallas observada. Veíamos más de una docena de fallas de retorno a null por día en toda la flota. ¿Podía una carrera de una sola instrucción durante la limpieza de excepciones explicar eso de verdad?

Recurrimos a una estimación de Fermat. Si la ventana vulnerable es del orden de 101010^{-10} segundos y SIGUSR2 llega cada 10210^{-2} segundos de tiempo de CPU, entonces cada manejador de limpieza de excepciones o bloque catch tiene una probabilidad aproximada de 10810^{-8} de perder la carrera.

Rockset usa excepciones como parte de su mecanismo interno de contrapresión de ingesta. Un solo host sobrecargado puede lanzar del orden de 10410^{4} excepciones por segundo. Eso implica que el tiempo medio entre fallas de un host que usa contrapresión es de 10410^{4} segundos, o una falla cada pocas horas. A escala de flota, eso es más que suficiente para explicar la frecuencia de fallas observada.

¿Por qué apareció ahora el error de libunwind?

El error de GNU libunwind es viejo: tiene más de 18 años y está presente en la primera versión x86_64 que admitía el desenrollado de excepciones de C++.

Entonces, ¿por qué apareció ahora?

La tasa de fallas es aproximadamente proporcional a cuántas excepciones se lanzan y cuántas señales se entregan. También depende de cuánta pila consume el manejador de señales.

Rockset es inusual en los tres ejes. Lanzamos excepciones a tasas altas como parte del control normal de sobrecarga; entregamos SIGUSR2 con una frecuencia inusual debido a coarse_thread_cputime_clock; y a principios de este año hicimos que el manejador de SIGUSR2 usara más pila al agregar una llamada a timer_getoverrun, para poder contabilizar señales fusionadas.

Ese último cambio parece haber sido importante. Si el manejador usa poca pila, quizá no alcance a llegar a la memoria obsoleta de ucontext_t ni a sobrescribirla. Antes de ese cambio, no observamos estas fallas en absoluto. Después del cambio, la tasa siguió baja hasta que aumentamos la carga en algunos casos de uso que exigían el mecanismo de contrapresión.

En otras palabras, el error de libunwind siempre estuvo ahí, pero el producto de nuestra tasa de excepciones, tasa de señales y uso de pila del manejador recién había cruzado el umbral donde se volvió visible operativamente.

Este mecanismo también explica la coincidencia de que tanto el error de hardware como el error de libunwind fallaran principalmente dentro de DocumentTree::updateDocument. Las fallas de libunwind estaban muy sesgadas hacia este método, porque siempre está activo cuando lanzamos una excepción para aplicar contrapresión de ingesta. También estuvo muy sobrerrepresentado en las fallas por desalineación de %rsp, porque el nodo de hardware defectuoso era de un SKU que usamos para ingesta masiva, que pasa la mayor parte de su tiempo de CPU en ese método.

Nuestra mitigación inmediata fue cambiar de GNU libunwind al unwinder de libgcc. Fue una buena decisión por sí misma: la implementación de libgcc se ha beneficiado de mucho trabajo para reducir la contención de locks, algo importante al escalar a VM grandes.

También enviamos upstream un reproductor autocontenido y una corrección(se abre en una nueva ventana) a GNU libunwind, y verificamos que los otros unwinders no tuvieran un problema similar.

El poder de un diagnóstico a nivel poblacional

Este recorrido de depuración nos enseñó mucho sobre detalles específicos de enlazado dinámico, metadatos de desenrollado DWARF, entrega de señales en Linux, la ABI System V y la maquinaria de excepciones de C++. Pero la lección principal fue más simple que todo eso.

El paso más importante no fue leer ensamblador con astucia ni conocer profundamente los detalles. Fue construir un conjunto de datos de alta calidad. Sin ese conjunto de datos, estábamos mezclando dos fenómenos distintos en una sola historia e intentando razonar para salir de la confusión. Una vez que tuvimos datos poblacionales precisos y completos, la estructura del problema se volvió obvia: una población de fallas pertenecía a un host defectuoso y la otra a una carrera en libunwind. Cuando los datos mejoraron, la depuración se volvió más fácil.

Para sistemas de infraestructura como Rockset, eso importa mucho. Esta investigación reforzó nuestro compromiso con la instrumentación profunda, las investigaciones automatizadas y la mejora continua de nuestras herramientas operativas. La confiabilidad no consiste solo en corregir errores después de que ocurren: consiste en construir los datos, flujos de trabajo y habilidades que convierten problemas imposibles en problemas diagnosticables y solucionables.

Autores

By Nathan Bronson y Member of Technical Staff