Ir 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 de población para depurar fallos difíciles en nuestra infraestructura de datos.

Cargando…

Los modelos y agentes de OpenAI dependen cada vez más de una infraestructura de datos escalable para buscar datos relevantes en inferencia: cuando razonan sobre tu pregunta. Algunos servicios están escritos en C++, cuyo control de bajo nivel permite maximizar el rendimiento y reducir la memoria. Esa eficiencia importa al escalar, pero la falta de seguridad de memoria de C++ permite que errores provoquen fallos al escribir en direcciones incorrectas o inexistentes.

Hace unos meses vimos fallos en Rockset, parte a medida de nuestra infraestructura de datos de ChatGPT, clave para muchos plugins de datos y para buscar en conversaciones. En cada fallo, 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 dirección de retorno guardada en la pila era NULL. A veces, el registro de CPU del puntero de pila parecía desplazado 8 bytes, como si %rsp se hubiera decrementado durante la ejecución normal. En ambos casos, el fallo ocurría al retornar.

No son fallos normales en código de aplicación. Una escritura errante que caiga justo en una dirección de retorno guardada es posible, pero muy improbable. Un error que desalinee %rsp en 8 sin ensamblador en línea, setcontext ni longjmp (no usamos ninguno) es aún más raro: el código compilado solo ajusta ese registro en el prólogo y el epílogo de la función. Todas las hipótesis que se nos ocurrieron, a nosotros o a ChatGPT, tenían pruebas sólidas en contra; el error parecía imposible.

Lo que creíamos un solo problema resultó ser dos errores independientes, descubiertos a la vez por casualidad. Primero, corrupción silenciosa de hardware en un host de Azure, cuya CPU no calculaba bien. Segundo, una condición de carrera de 18 años en GNU libunwind, una biblioteca de código abierto muy usada.

Esta publicación cuenta cómo identificamos y corregimos fallos inexplicables pensando como epidemiólogos y creando un buen conjunto de datos sobre todos los fallos.

Primer intento de depuración: examinar unos core dumps

Primero, profundicemos en Rockset. Es un sistema de datos nativo de la nube para búsqueda y analítica en tiempo real que usamos 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 del área de trabajo para que ChatGPT busque información 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 pueden causar accesos de memoria no válidos y segfaults. Para localizarlos usamos el manejador de señales fatales de folly, que registra una traza de pila al fallar, y subimos los core dumps (instantáneas del programa al fallar) a Azure Blob Storage para analizarlos. Todas las hojas de procesamiento de consultas de Rockset están replicadas, lo que reduce el impacto para el cliente. Aun así, cada segfault es un error que debemos corregir para cumplir nuestros objetivos de fiabilidad y calidad.

Al principio tratamos estos cores como una depuración convencional: revisar algunos core dumps, formular hipótesis y descartarlas.

La mayoría de fallos ocurrían en un método llamado DocumentTree::updateDocument. Parecía que updateDocument había llamado a una función X, la pila se corrompía mientras X estaba activa y luego X retornaba a una dirección no ejecutable. A veces, el marco recién retirado de X parecía válido salvo por su dirección de retorno NULL. Otras veces, el puntero de pila parecía mal, pero el siguiente marco válido seguía pareciendo updateDocument.

No sabíamos cuándo se corrompía la pila, así que el espacio de búsqueda era 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 la entrega de señales o el cambio de contexto? ¿Algo aún más raro? Si era una escritura errante, ¿por qué no la detectó nuestro entorno de staging con ASAN?

Intentamos usar logs de aplicación para hallar todos los casos, pero la corrupción de pila es difícil de clasificar solo con logs: las trazas registradas también están corruptas o faltan. No logramos crear una consulta de logs sin falsos positivos y falsos negativos. Revisamos más cores a mano y hallamos algunos ejemplos, pero era demasiado trabajo para obtener datos fiables.

En esa fase descartamos (por error) el hardware, porque veíamos fallos en varias regiones y tipos de hardware, y seguimos buscando solo causas de software. Durante unos días analizamos a fondo un fallo con %rsp desalineado, reconstruyendo lo anterior con la pila y los registros. Aparecieron posibles pistas, pero como seguíamos pensando que todos los errores tenían la misma causa, no salimos del atasco.

Pistas de 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, así que el marco activo siempre es accesible vía %rbp, y los llamadores forman una lista enlazada de punteros de marco.

En Linux x86_64, la ABI System V de AMD64 reserva también 128 bytes bajo %rsp como red zone. Esa región está disponible para el código de usuario y, según la ABI, el kernel no debe sobrescribirla al entregar una señal.

La red zone fue clave para depurar un fallo posterior al retorno porque conserva datos previos 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ó, quedan sobrescritos por el manejador de señales salvo los últimos 128 bytes. Por eso podemos decir cosas como «el marco de pila de X recién retirado parecía válido, salvo por una dirección de retorno NULL». La red zone conserva parte de los marcos inactivos, o solo la cola de uno.

Diagrama de pila que muestra marcos de pila corruptos que pueden sobrescribir direcciones de retorno y provocar fallos.

Hallamos un fallo de pila desalineada donde todas las funciones implicadas eran muy pequeñas. Eso mostró que %rsp se había desalineado durante una función bastante simple, y que luego otras llamadas habían funcionado. El programa solo falló cuando la función activa intentó retornar. Ninguna ruta usaba excepciones, ensamblador en línea, setcontext ni longjmp; si el puntero de pila cambió como indicaba el core, ningún error plausible de espacio de usuario lo explicaba.

Eso nos llevó al kernel.

Rockset usa señales más que la mayoría de programas. La ejecución de consultas se divide en muchas tareas ligeras que intercambian datos. Esto ayuda a gestionar cargas con muchos QPS, pero complica medir la 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 coste suficiente para muestrear en cada límite de tarea. La API timer_create permite programar señales periódicas según varias nociones de tiempo, incluida la acumulación de tiempo de CPU. Programamos una señal (SIGUSR2) cada pocos milisegundos de CPU; entonces el manejador actualiza un valor local al hilo. Aunque muchas tareas no ven avanzar el reloj grueso mientras se ejecutan, sumar los deltas da una estimación insesgada del tiempo de CPU real de la consulta.

Como enviamos señales tan a menudo, parecía plausible un error raro del kernel en el cambio de contexto o la entrega de señales. Leímos informes de errores, código del kernel y parches 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: centrarse en un paciente, hacer muchas pruebas y diagnosticar un caso con evidencias detalladas.

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

Hasta entonces habíamos estado sobre todo en modo médico. El cambio clave fue decidir que necesitábamos datos de población de alta calidad.

Limpiar los datos

Nuestros intentos previos de hallar 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 crear una canalización que analizara automáticamente los core dumps.

Pedimos a ChatGPT un script que descargara un prefijo de cada core, extrajera registros, filtrara falsos positivos conocidos con los logs y etiquetara el fallo como retorno a NULL, pila desalineada u otro. 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 enseguida. Lo que tratábamos como un único error extraño eran en realidad dos poblaciones de fallos distintas.

Los cores de retorno a NULL estaban repartidos por muchos clústeres y regiones. Su frecuencia había subido, pero no había una fecha de inicio nítida ni un límite de infraestructura claro.

Los fallos de pila desalineada eran totalmente distintos. Todos venían de una región, tenían fecha de inicio clara y nunca ocurrían en nodos con mucho tiempo en ejecución. Aunque afectaban a varias VM de Azure (máquinas virtuales en la nube), el patrón parecía una máquina física con hardware defectuoso que dañaba cualquier VM alojada en ella.

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

Entonces entendimos que habíamos mezclado mentalmente dos errores. Al mezclar contraejemplos de ambos, no podíamos hallar una explicación única y coherente.

Error n.º 1: el host defectuoso

Con una lista limpia de nodos de Kubernetes y marcas de tiempo, pudimos rastrear los fallos de pila desalineada hasta un único host físico, que fue fácil de incluir en una lista de bloqueo.

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

Eliminar el host defectuoso no es una solución permanente, en el sentido de que no evita que el mismo problema vuelva a ocurrir. Sin embargo, podemos cambiar el software para que, si un problema similar se repite, sea fácil de detectar y gestionar. 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 necesidad de core dump). Cambiamos el plano de control para que las VM se reutilicen normalmente en lugar de reciclarse, lo que hace mucho más fácil detectar 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.

Una vez separados los fallos del host defectuoso, los cores restantes de retorno a NULL fueron mucho más fáciles de razonar. Antes habíamos descartado el desenrollado de excepciones porque creíamos tener contraejemplos: fallos en rutas de código donde definitivamente no se usaban excepciones. Pero esos contraejemplos procedían todos del clúster de corrupción de hardware.

Cuando revisamos de nuevo los cores restantes con eso en mente, descubrimos que aquella conclusión era exactamente la contraria: todos los fallos se producían durante el desenrollado de excepciones.

La gestión de excepciones es una transferencia de control dinámica

Cuando C++ lanza una excepción, el runtime tiene que descubrir qué bloque catch debe recibirla y qué destructores o manejadores de limpieza deben ejecutarse por el camino. El compilador emite estos metadatos, pero la correspondencia real se produce 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).

Operativamente, 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 guardados por 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, nuestra hipótesis de trabajo cambió al relajar otra suposición que habíamos hecho cuando pensábamos que solo había un error.

Quizá no estábamos viendo el retorno ordinario de una función a NULL. Quizá estábamos viendo una transferencia de desenrollado —en la práctica, una restauración de registros al estilo de 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ó el problema de forma drástica. O bien GNU libunwind estaba calculando el estado de destino equivocado, o calculaba el estado 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, rellena el estado de registros deseado para el marco del manejador de limpieza y luego pasa un puntero a esa estructura a una rutina interna de ensamblador: _Ux86_64_setcontext.

En ese momento 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 de la estructura después de cambiar %rsp, momento en el que la estructura ya no formaba parte de la pila activa? Eso la harí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 era sí.

Estas son las últimas seis instrucciones de _Ux86_64_setcontext en la versión de GNU libunwind que usábamos, compuestas sobre todo 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 concreto).

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

Normalmente esto no causa problemas, pero si llega una señal exactamente en el momento adecuado (¿o inadecuado?), 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), el puntero de instrucción restaurado puede corromperse. En nuestros fallos, se volvió NULL.

Ese es el error.

Por qué los cores se disfrazaban de retornos erróneos ordinarios

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

setcontext se escribió para restaurar todos los registros, incluido %rdi, así que no puede usar ese registro para leer UC_MCONTEXT_GREGS_RIP(%rdi) en el instante final de la transferencia de control. En su lugar, lee el valor antes, lo guarda en la pila, restaura unos cuantos 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 de destino en la pila, pero ese destino se había corrompido antes de que se completara la transferencia». Habíamos supuesto que la corrupción de la ranura de dirección de retorno tenía que ocurrir in situ, porque no conocíamos ningún lugar 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 dados por otro hilo. Cuanto más cerca estén esos pasos entre sí, menos probable es que se produzca la condición de carrera.

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

Cuando encontramos esta carrera, nuestra primera reacción fue que tenía que ser demasiado rara para explicar la tasa de fallos observada. Estábamos viendo más de una docena de fallos de retorno a NULL al día en toda la flota. ¿Podía una carrera de una sola instrucción durante la limpieza de excepciones explicar realmente eso?

Recurrimos a la estimación de Fermi. 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 fallos de un host que usa contrapresión es de 10410^{4} segundos, es decir, un fallo cada pocas horas. A escala de flota, eso basta con creces para explicar la frecuencia de fallos observada.

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

El error de GNU libunwind es antiguo: 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 fallos 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 inusualmente alta por coarse_thread_cputime_clock; y a principios de este año hicimos que el manejador de SIGUSR2 usara más pila al añadir una llamada a timer_getoverrun, para poder contabilizar señales fusionadas.

Ese último cambio parece haber sido importante. Si el manejador usa poca pila, puede que no alcance ni sobrescriba la memoria obsoleta de ucontext_t. Antes de ese cambio, no observamos estos fallos en absoluto. Después del cambio, la tasa siguió siendo baja hasta que aumentamos la carga para algunos casos de uso que exigían mucho al mecanismo de contrapresión.

En otras palabras, el error de libunwind siempre había estado ahí, pero el producto de nuestra tasa de excepciones, la tasa de señales y el uso de pila del manejador solo había cruzado recientemente el umbral en el que se volvía visible operativamente.

Este mecanismo también explica la coincidencia de que tanto el error de hardware como el de libunwind fallaran sobre todo dentro de DocumentTree::updateDocument. Los fallos de libunwind estaban muy sesgados hacia este método, porque siempre está activo en el punto en que lanzamos una excepción para aplicar contrapresión de ingesta. También estaba muy seleccionado en los fallos de desalineación de %rsp, porque el nodo con 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. Era un buen intercambio por sí mismo: la implementación de libgcc se ha beneficiado de mucho trabajo para reducir la contención de bloqueos, algo importante al escalar a VM grandes.

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

El poder de un diagnóstico a nivel de población

Este viaje de depuración nos enseñó mucho sobre los detalles concretos del enlazado dinámico, los metadatos de desenrollado DWARF, la entrega de señales de Linux, la ABI System V y la maquinaria de excepciones de C++. Pero la lección principal fue más sencilla que todo eso.

El paso más importante no fue la lectura ingeniosa de ensamblador ni el conocimiento profundo de los detalles. Fue construir un conjunto de datos de alta calidad. Sin este conjunto de datos, estábamos mezclando dos fenómenos distintos en una sola historia e intentando salir de la confusión mediante razonamiento. Una vez que tuvimos datos de población precisos y completos, la estructura del problema se volvió obvia: una población de fallos pertenecía a un host defectuoso y la otra, a una carrera en libunwind. Cuando los datos mejoraron, la depuración se hizo 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 las mejoras continuas de nuestras herramientas operativas. La fiabilidad no consiste solo en corregir errores después de que ocurran: 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