Passer au contenu principal
OpenAI

30 juin 2026

Ingénierie

Épidémiologie des core dumps : corriger un bug vieux de 18 ans

Utiliser l’analyse à l’échelle d’une population pour déboguer des crashs complexes dans notre infrastructure de données.

Chargement...

Les modèles et agents d’OpenAI s’appuient de plus en plus sur une infrastructure de données scalable pour trouver les données utiles à l’inférence, quand les modèles traitent votre question. Certains services sont écrits en C++ : son contrôle bas niveau du système nous aide à maximiser les performances et à réduire la mémoire. Ces gains comptent à grande échelle, mais l’absence de sûreté mémoire en C++ permet à des bugs de crasher en écrivant à de mauvaises adresses mémoire.

Il y a quelques mois, nous avons observé plusieurs plantages au sein du service Rockset, un composant développé sur mesure de notre infrastructure de données ChatGPT, essentiel à de nombreux plugins de données ainsi qu'à la recherche dans les conversations. Dans chacun de ces plantages, une fonction C++ apparemment normale semblait s'achever avant de renvoyer l'exécution vers une adresse mémoire invalide, ce qui amenait le noyau à interrompre le programme, le pointeur d'instruction ne pointant plus vers une zone de code exécutable. Il arrivait que l'emplacement de l'adresse de retour dans la trame d'appel contienne la valeur NULL. Dans d'autres cas, le registre du processeur contenant le pointeur de pile semblait décalé de huit octets, comme si %rsp avait été décrémenté au cours d'une exécution pourtant normale. Dans les deux cas, le plantage survenait au moment du retour de la fonction.

Ce ne sont pas des défaillances normales pour du code applicatif. Une écriture parasite qui touche seulement une adresse de retour sauvegardée est possible, mais très improbable. Un bug qui désaligne %rsp de 8 sans assembleur inline, setcontext ni longjmp (que nous n’utilisons pas) est encore plus étrange : le code compilé ne modifie ce registre que dans le prologue et l’épilogue d’une fonction. Toutes nos hypothèses (et celles de ChatGPT) se heurtaient à de solides contre-indices ; le bug semblait donc impossible.

Ce que nous prenions pour un seul problème s’est révélé être deux bugs indépendants, découverts par hasard en même temps. D’abord, une corruption matérielle silencieuse sur un hôte Azure, dont le CPU calculait tout simplement mal. Ensuite, une condition de concurrence vieille de 18 ans dans GNU libunwind, bug passé inaperçu dans une bibliothèque open source très utilisée.

Cet article explique comment nous avons identifié et corrigé des crashs apparemment inexplicables en raisonnant comme des épidémiologistes et en créant un bon jeu de données sur tous les crashs.

Première tentative : examiner quelques core dumps de près

Commençons par Rockset. C’est un système de données cloud-native pour la recherche et l’analytique temps réel, utilisé chez OpenAI pour de nombreux besoins internes, comme les connecteurs de synchronisation (Rockset a été acquis par OpenAI en 2024). Des mises à jour en streaming maintiennent à jour l’index de la base de connaissances d’un espace de travail, afin que ChatGPT trouve les informations utiles pour répondre ou agir.

La couche d’exécution de Rockset est écrite en C++. C++ donne un accès bas niveau au CPU, bon pour les performances et l’efficacité, mais des bugs applicatifs peuvent alors causer des accès mémoire invalides et des segfaults. Pour les traquer, nous utilisons le gestionnaire de signal fatal de folly pour journaliser une trace de pile lors d’un crash, puis envoyons les core dumps correspondants (instantanés du programme au crash) vers Azure Blob Storage pour analyse. Toutes les feuilles de traitement de requêtes de Rockset sont répliquées, ce qui limite l’impact d’un crash pour les clients. Mais chaque segfault signale un bug à corriger pour atteindre nos objectifs de fiabilité et de qualité.

Au départ, nous avons traité ces cores comme un débogage classique : inspecter quelques core dumps en détail, formuler des hypothèses, puis les éliminer une à une.

La plupart des crashs survenaient dans une méthode appelée DocumentTree::updateDocument. Dans ces crashs, updateDocument semblait avoir appelé une fonction inconnue X ; la pile s’était corrompue pendant X, puis X était revenue vers une adresse non exécutable. Parfois, la frame de X tout juste dépilée semblait valide, sauf que son adresse de retour sauvegardée valait NULL. Dans d’autres cas, le pointeur de pile semblait faux, mais la frame valide suivante restait apparemment updateDocument.

Nous ignorions quand la pile se corrompait, ce qui ouvrait un immense espace de recherche. updateDocument est une grande méthode très inlinée : il y avait trop de candidats pour X.

Était-ce un bug dans notre code C++ ? Un problème de compilateur ou d’édition de liens ? Un problème dans une bibliothèque d’exécution ? Un bug du noyau Linux lié aux signaux ou au changement de contexte ? Quelque chose d’encore plus rare ? Si c’était une écriture parasite, pourquoi notre staging ASAN ne l’avait-il pas vue ?

Nous avons essayé d’utiliser nos logs applicatifs pour trouver tous les cas, mais Les bogues de pile sont difficiles à diagnostiquer à partir des seuls journaux : les traces journalisées sont elles-mêmes corrompues ou absentes. Impossible de créer une requête de logs sans faux positifs ni faux négatifs. Nous avons inspecté plus de cores à la main et trouvé d’autres exemples, mais l’effort était trop lourd pour produire un jeu de données fiable.

À ce stade, nous avons écarté à tort un bug matériel : les crashs touchaient plusieurs régions et types de matériel, donc nous cherchions encore une cause purement logicielle. Pendant quelques jours, nous avons disséqué un crash avec %rsp désaligné, en reconstruisant l’avant-crash à partir de la pile et des registres. Cela a donné quelques indices, mais notre conviction initiale — une cause unique pour tous les bugs — nous empêchait d’avancer.

Indices dans la pile

Avant le tournant de l’enquête, expliquons quelles informations nous tirions des fichiers core.

Rockset est compilé avec -fno-omit-frame-pointer : la frame active est toujours accessible via %rbp, et les appelants forment une liste chaînée de pointeurs de frame.

Sous Linux x86_64, l’ABI System V AMD64 réserve aussi 128 octets sous %rsp : la red zone. Cette zone est disponible au code utilisateur et, surtout, le noyau promet de ne pas l’écraser lors de la livraison d’un signal, comme le prévoit l’ABI.

La red zone a été centrale pour déboguer un crash après retour, car elle garde des informations antérieures au retour. Lorsqu’un SIGSEGV est déclenché, le gestionnaire fatal de folly s’exécute sur la pile du thread qui crashe. Les frames de pile devenues inactives (fonction déjà revenue) sont écrasées par ce gestionnaire, sauf les 128 derniers octets. D’où des constats comme : « la frame de X tout juste dépilée semblait valide, sauf une adresse de retour NULL ». La red zone conserve une partie des frames inactives, parfois seulement leur fin.

Schéma de pile montrant des frames de pile corrompues pouvant écraser des adresses de retour et provoquer des crashs.

Nous avons trouvé un crash avec pile désalignée où toutes les fonctions impliquées étaient très petites. Nous avons ainsi vu que %rsp s’était désaligné pendant une fonction assez simple, puis que d’autres appels avaient réussi. Le programme n’a crashé que lorsque la fonction active a fini par revenir. Ces chemins n’utilisaient ni exceptions, ni assembleur inline, ni setcontext, ni longjmp ; si le pointeur de pile avait vraiment changé comme le core l’indiquait, aucun bug utilisateur plausible ne l’expliquait.

Cela nous a orientés vers le noyau.

Rockset utilise les signaux plus intensément que la plupart des programmes. L’exécution des requêtes est découpée en nombreuses tâches légères qui échangent des données. C’est utile pour traiter efficacement de fortes charges QPS, mais cela complique le décompte CPU par requête, car plusieurs requêtes partagent le même pool de threads.

Notre solution, coarse_thread_cputime_clock, approxime clock_gettime(CLOCK_THREAD_CPUTIME_ID, ...) à coût assez faible pour échantillonner à la fin de chaque tâche. L’API timer_create peut planifier la livraison périodique d’un signal selon plusieurs notions du temps, dont le cumul de temps CPU. Nous planifions un signal (SIGUSR2) toutes les quelques millisecondes de CPU ; son gestionnaire met alors à jour une valeur locale au thread. Même si beaucoup de tâches ne voient pas l’horloge grossière avancer pendant leur exécution, la somme des deltas estime sans biais le temps CPU réel d’une requête.

Comme nous livrons des signaux très souvent, un rare bug du noyau lié au changement de contexte ou aux signaux semblait plausible. Nous avons lu des rapports de bugs, le code du noyau et les correctifs noyau propres à Azure. Nous avons tenté des stress tests. Nous n’avons rien trouvé qui paraisse lié.

Nous avons alors pris du recul et essayé une autre approche.

Médecin ou épidémiologiste ?

Il y a deux grandes façons de déboguer ce type de problème.

La première est celle du médecin : se concentrer sur un patient, multiplier les examens et diagnostiquer un cas à partir d’indices détaillés.

La seconde est celle de l’épidémiologiste : regarder toute la population et chercher des motifs invisibles dans un cas isolé. Le bug a-t-il commencé avec une version précise ? Est-il corrélé à une SKU matérielle (modèle exact de CPU et de serveur), une région ou une version du noyau ? Plusieurs clusters distincts se cachent-ils derrière un seul syndrome apparent ?

Nous étions surtout restés en mode médecin. Le déclic a été de décider de collecter de bonnes données sur toute la population.

Nettoyer les données

Nos tentatives précédentes pour trouver automatiquement tous les cas avaient échoué, car nous recherchions ces informations dans les journaux. Les core dumps contiennent bien plus d’informations, mais leur examen manuel ne passait pas à l’échelle. Nous avons décidé de créer un pipeline capable d’analyser automatiquement les core dumps.

Nous avons demandé à ChatGPT d’écrire un script qui téléchargeait un préfixe de chaque core, extrayait les registres, filtrait les faux positifs connus via les logs et étiquetait le crash : retour vers null, pile désalignée ou autre. Puis nous l’avons lancé en parallèle sur tous les core dumps Rockset de production de l’année précédente.

Ce fut le tournant.

Une fois le jeu de données propre, les corrélations sont apparues aussitôt. Ce que nous prenions pour un seul bug étrange était en fait deux populations de crashs distinctes.

Les cores de retour vers null étaient répartis sur de nombreux clusters et régions. Leur fréquence avait récemment augmenté, mais sans date de début nette ni frontière d’infrastructure claire.

Les crashs avec pile désalignée avaient un profil tout autre. Ils venaient tous d’une seule région, avaient une date de début claire et ne touchaient jamais des nœuds actifs depuis longtemps. Même avec plusieurs VM Azure (machines virtuelles cloud), le motif évoquait une machine physique défectueuse affectant toute VM qui y atterrissait.

Nuage de points des taux de crash par cluster au fil du temps, montrant la plupart des crashs concentrés dans les clusters 2, 3 et 6, avec un pic dans le cluster 1 vers la fin de la période.

C’est là que nous avons compris que nous confondions deux bugs. En mélangeant les contre-exemples des deux bugs, nous ne pouvions pas trouver d’explication unique et cohérente.

Bug n° 1 : le mauvais hôte

Avec une liste propre de nœuds Kubernetes et d’horodatages, nous avons pu remonter les crashs de pile désalignée à un seul hôte physique, facile à mettre sur liste de blocage.

Nous n’avons pas pu reproduire la corruption de registres sur cet hôte dans un environnement contrôlé, même après plusieurs semaines de stress tests. Une fois l’hôte problématique retiré du service, cependant, les crashs de pile désalignée ont disparu.

Retirer le mauvais hôte n’est pas une solution permanente, au sens où cela n’empêche pas une nouvelle occurrence du même problème. Nous pouvons toutefois modifier le logiciel pour que, si un problème similaire revient, il soit facilement détecté et traité. Nous avons amélioré notre gestionnaire de signal fatal pour inclure l’état des registres, afin de détecter une récidive depuis les seuls journaux (sans core dump). Nous avons modifié le plan de contrôle pour que les VM soient généralement réutilisées plutôt que recyclées, ce qui facilite beaucoup la détection des nœuds défectueux à notre niveau de la pile d’infrastructure. Nous avons aussi mis à jour nos runbooks (et les modèles mentaux de l’équipe) pour inclure cette possibilité.

Une fois les crashs du mauvais hôte séparés, les cœurs restants renvoyant la valeur NULL sont devenus beaucoup plus faciles à analyser. Plus tôt, nous avions écarté le déroulement des exceptions parce que nous pensions avoir des contre-exemples : des crashs dans des chemins de code où les exceptions n’étaient clairement pas utilisées. Mais ces contre-exemples venaient tous du cluster de corruption matérielle.

Une fois les cœurs restants réexaminés avec cela en tête, nous avons constaté que cette conclusion était exactement inversée : tous les crashs se produisaient pendant le déroulement des exceptions.

La gestion des exceptions est un transfert de contrôle dynamique

Quand C++ lance une exception, le runtime doit déterminer quel bloc catch doit la recevoir et quels destructeurs ou gestionnaires de nettoyage doivent s’exécuter en chemin. Le compilateur émet ces métadonnées, mais la mise en correspondance réelle se fait dynamiquement à l’exécution.

Le déroulement des exceptions n’est pas réellement effectué par la fonction qui invoque throw, mais par des fonctions auxiliaires appelées par le code compilé qui en résulte. Ces routines d’exécution examinent la pile, récupèrent les métadonnées des fonctions trouvées sur la pile, recherchent dynamiquement des gestionnaires de nettoyage et des blocs catch, puis transfèrent le contrôle vers l’un de ces emplacements. Transférer le contrôle implique de dérouler toutes les frames de pile intermédiaires, y compris celles des fonctions auxiliaires.

Opérationnellement, c’est bien plus proche d’un longjmp ou d’un changement de fibre que d’un appel et retour normal. Les registres sauvegardés par l’appelé doivent être restaurés, ainsi que les registres de frame de pile %rbp et %rsp.

Notre binaire est lié à deux bibliothèques contenant des implémentations des fonctions qui déroulent les exceptions C++ : libgcc et GNU libunwind. Les définitions de GNU libunwind étaient celles choisies par l’éditeur de liens dynamique. Cela nous a surpris : nous pensions que l’implémentation de libgcc l’emporterait grâce aux règles de versionnage des symboles ; pourtant, l’inspection des binaires en cours d’exécution a montré que ce n’était pas le cas.

Défaire une dernière hypothèse

À ce stade, notre hypothèse de travail a changé, car nous avons relâché une autre hypothèse formulée lorsque nous pensions qu’il n’y avait qu’un seul bug.

Peut-être ne voyions-nous pas une fonction ordinaire revenir vers NULL. Peut-être voyions-nous un transfert de déroulement — en pratique une restauration de registres façon setcontext — où le pointeur d’instruction de destination était devenu NULL avant le transfert du contrôle. Autrement dit, des données incorrectes provenant de la bibliothèque de déroulement, plutôt qu’un emplacement d’adresse de retour incorrect sur la pile.

Cela a considérablement réduit le problème. Soit GNU libunwind calculait le mauvais état de destination, soit il calculait le bon état et quelque chose le corrompait avant son application.

Nous avons lu le source de GNU libunwind et constaté qu’il synthétise un ucontext_t sur la pile, renseigne l’état de registres voulu pour la frame du gestionnaire de nettoyage, puis transmet un pointeur vers cette structure à une routine assembleur interne : _Ux86_64_setcontext.

À ce stade, nous avions toutes les pièces.

Le ucontext_t synthétisé vit dans l’une des frames de pile déroulées par _Ux86_64_setcontext pendant l’exécution de cette fonction. _Ux86_64_setcontext lisait-il la structure après avoir modifié %rsp, moment où la structure ne faisait plus partie de la pile active ? Cela la rendrait vulnérable à l’écrasement par la livraison d’un signal, comme nos fréquents SIGUSR2.

Bug n° 2 : le bug de libunwind

La réponse était oui.

Voici les six dernières instructions de _Ux86_64_setcontext dans la version de GNU libunwind que nous utilisions ; elles consistent surtout en instructions mov qui chargent depuis la mémoire vers un registre de destination :

Texte brut

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 pointe vers le ucontext_t alloué sur la pile, et les macros UC_MCONTEXT_* se développent simplement en l’offset fixe où un registre donné est stocké.)

La première instruction marque le début de la fenêtre de concurrence. Elle met à jour %rsp pour pointer vers le nouveau bas de la pile active. Dès que cela se produit, la structure pointée par %rdi ne fait plus partie de la pile active (ni de la red zone) et n’est plus interdite au noyau.

D’ordinaire, cela ne pose pas de problème, mais si un signal arrive exactement au bon (ou mauvais ?) moment, le noyau construit la frame de signal à %rsp-128. Cela peut écraser la mémoire pointée par %rdi.

Si cela se produit avant que l’instruction suivante lise UC_MCONTEXT_GREGS_RIP(%rdi), alors le pointeur d’instruction restauré peut être corrompu. Dans nos crashs, il devenait NULL.

Voilà le bug.

Pourquoi les cores se faisaient passer pour de simples mauvais retours

Cet assembleur explique aussi l’une des observations qui nous déroutaient : pourquoi la fonction X avait un NULL dans l’emplacement d’adresse de retour de la frame de pile précédente.

setcontext était écrit pour restaurer tous les registres, y compris %rdi ; il ne peut donc pas utiliser ce registre pour lire UC_MCONTEXT_GREGS_RIP(%rdi) au dernier instant du transfert de contrôle. À la place, il lit la valeur plus tôt, l’enregistre sur la pile, restaure quelques registres de plus, puis utilise retq pour lire la valeur sauvegardée et transférer le contrôle.

Ce qui ressemblait dans les cores à « une fonction a renvoyé la valeur NULL » était en réalité « le mécanisme de déroulement a synthétisé une adresse de retour cible sur la pile, mais cette cible a été corrompue avant la fin du transfert ». Nous avions supposé que la corruption de l’emplacement d’adresse de retour devait se produire sur place, car nous ne connaissions aucun endroit où des données (corruptibles) étaient écrites volontairement dans cet emplacement.

Une fenêtre de concurrence d’une seule instruction

Ce qui rend ce bug absurde, c’est l’étroitesse de cette fenêtre de concurrence. Dans ce type de condition de concurrence, l’événement externe (le signal) doit survenir entre deux étapes effectuées par un autre thread. Plus ces étapes sont proches, moins la condition de concurrence a de chances de se produire.

Ici, la fenêtre vulnérable fait littéralement une instruction ! Un signal doit être livré après la modification de %rsp, mais avant que l’instruction suivante ne charge %rip. Sur un CPU moderne superscalaire à exécution dans le désordre, plusieurs instructions simples comme celle-ci peuvent s’exécuter par cycle ; la fenêtre de concurrence est donc de l’ordre de cent picosecondes.

Quand nous avons trouvé cette course, notre première réaction a été de penser qu’elle devait être trop rare pour expliquer le taux de crash observé. Nous observions plus d’une douzaine de crashs retour-vers-null par jour sur l’ensemble de la flotte. Une course d’une seule instruction pendant le nettoyage d’une exception pouvait-elle vraiment expliquer cela ?

Nous nous sommes tournés vers une estimation de Fermi. Si la fenêtre vulnérable est de l’ordre de 101010^{-10} seconde et que SIGUSR2 arrive toutes les 10210^{-2} secondes de temps CPU, alors chaque gestionnaire de nettoyage ou bloc catch a une probabilité d’environ 10810^{-8} de perdre la course.

Rockset utilise les exceptions dans son mécanisme interne de backpressure à l’ingestion. Un seul hôte surchargé peut lancer de l’ordre de 10410^{4} exceptions par seconde. Cela implique un temps moyen entre pannes de 10410^{4} secondes pour un hôte utilisant la backpressure, soit un crash toutes les quelques heures. À l’échelle de la flotte, c’est largement suffisant pour expliquer la fréquence de crash observée.

Pourquoi le bug de libunwind est-il apparu maintenant ?

Le bug de GNU libunwind est ancien : plus de 18 ans, présent dans la première version x86_64 qui prenait en charge le déroulement des exceptions C++.

Alors pourquoi apparaît-il maintenant ?

Le taux de crash est à peu près proportionnel au nombre d’exceptions lancées et au nombre de signaux livrés. Il dépend aussi de la quantité de pile consommée par le gestionnaire de signal.

Rockset est inhabituel sur ces trois axes. Nous lançons des exceptions à haut débit dans le cadre du contrôle normal de surcharge ; nous livrons SIGUSR2 très souvent à cause de coarse_thread_cputime_clock ; et plus tôt cette année, nous avons accru l’usage de pile du gestionnaire SIGUSR2 en ajoutant un appel à timer_getoverrun, pour comptabiliser les signaux fusionnés.

Ce dernier changement semble avoir été important. Si le gestionnaire utilise assez peu de pile, il peut ne pas atteindre et écraser la mémoire ucontext_t périmée. Avant ce changement, nous n’observons pas ces crashs du tout. Après le changement, le taux est resté faible jusqu’à ce que nous augmentions la charge pour certains cas d’usage qui sollicitaient le mécanisme de backpressure.

Autrement dit, le bug de libunwind a toujours été là, mais le produit de notre taux d’exceptions, de notre taux de signaux et de l’usage de pile du gestionnaire n’a franchi que récemment le seuil où il devenait visible en production.

Ce mécanisme explique aussi la coïncidence selon laquelle le bug matériel et le bug de libunwind crashaient surtout dans DocumentTree::updateDocument. Les crashs dus à libunwind étaient fortement biaisés vers cette méthode, car elle est toujours active au moment où nous lançons une exception pour appliquer la backpressure d’ingestion. Elle était aussi fortement représentée dans les crashs de désalignement de %rsp, car le nœud matériel défectueux appartenait à une SKU que nous utilisons pour l’ingestion en masse, qui passe la majeure partie de son temps CPU dans cette méthode.

Notre atténuation immédiate a été de remplacer GNU libunwind par le mécanisme de déroulement de pile de libgcc. C’était déjà un bon compromis : l’implémentation de libgcc a bénéficié de nombreux travaux pour réduire la contention de verrous, ce qui compte lors du passage à de grandes VM.

Nous avons aussi soumis en amont un reproducteur autonome et un correctif(ouverture dans une nouvelle fenêtre) à GNU libunwind, et vérifié que les autres mécanismes de déroulement n’avaient pas de problème similaire.

La puissance d’un diagnostic à l’échelle d’une population

Ce parcours de débogage nous a beaucoup appris sur les détails du linkage dynamique, des métadonnées de déroulement DWARF, de la livraison des signaux Linux, de l’ABI System V et du mécanisme des exceptions C++. Mais la leçon principale était plus simple que tout cela.

L’étape la plus importante n’a pas été la lecture astucieuse de l’assembleur ni la connaissance approfondie des détails. C’était de constituer un jeu de données de haute qualité. Sans ce jeu de données, nous mélangions deux phénomènes distincts en une seule histoire et tentions de raisonner pour sortir de la confusion. Une fois les données de population exactes et complètes en main, la structure du problème est devenue évidente : une population de crashs appartenait à un hôte défectueux, l’autre à une course dans libunwind. Quand les données se sont améliorées, le débogage est devenu plus facile.

Pour des systèmes d’infrastructure comme Rockset, c’est crucial. Cette enquête a renforcé notre engagement envers l’instrumentation approfondie, les investigations automatisées et l’amélioration continue de nos outils opérationnels. La fiabilité ne consiste pas seulement à corriger les bugs après coup : elle consiste à bâtir les données, les workflows et les compétences qui rendent les problèmes impossibles diagnostiquables et solubles.

Auteurs

By Nathan Bronson, Member of Technical Staff