Passer au contenu principal
OpenAI

30 juin 2026

Ingénierie

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

Utiliser l’analyse à l’échelle de la population pour déboguer des plantages 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 évolutive pour trouver, à l’inférence, les données pertinentes à votre question. Certains de ces services sont écrits en C++, dont le contrôle bas niveau maximise les performances et réduit l’usage mémoire. Ces gains comptent à grande échelle, mais l’absence de sûreté mémoire en C++ permet à des bogues de planter le programme en écrivant à de mauvaises adresses mémoire.

Il y a quelques mois, nous avons vu des plantages dans Rockset, composant sur mesure de l’infrastructure de données de ChatGPT, essentiel à plusieurs plugiciels de données et à la recherche dans les conversations. Dans chaque cas, une fonction C++ normale semblait se terminer puis revenir à une adresse invalide; le noyau arrêtait alors le programme, car le pointeur d’instruction ne pointait plus vers du code. Parfois, l’adresse de retour dans le cadre de pile valait NULL. Parfois, le registre CPU du pointeur de pile semblait décalé de 8 octets, comme si %rsp avait été décrémenté en pleine exécution normale. Dans les deux cas, le plantage survenait au retour.

Ce ne sont pas des modes de panne normaux pour du code applicatif. Une écriture errante qui touche seulement une adresse de retour sauvegardée est possible, mais très improbable. Un bogue qui désaligne %rsp de 8 sans assembleur en ligne, setcontext ni longjmp (que nous n’utilisons pas) est encore plus étrange, car le code compilé ne modifie ce registre que dans le prologue et l’épilogue d’une fonction. Toutes les hypothèses que nous, ou ChatGPT, avons imaginées étaient contredites par de solides indices; le bogue semblait donc impossible.

Ce que nous pensions être un seul problème s’est révélé être deux bogues sans lien, découverts par hasard en même temps. D’abord, une corruption matérielle silencieuse sur un hôte Azure, dont le CPU calculait mal. Ensuite, une condition de concurrence vieille de 18 ans dans GNU libunwind, bogue inaperçu d’une bibliothèque libre très utilisée.

Ce billet raconte comment nous avons trouvé et corrigé des plantages apparemment inexplicables en pensant comme des épidémiologistes et en bâtissant un excellent jeu de données sur l’ensemble des plantages.

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

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

La couche d’exécution de Rockset est écrite en C++. Le C++ donne un accès bas niveau au CPU, utile à la performance et à l’efficacité, mais des bogues applicatifs peuvent alors causer des accès mémoire invalides et des erreurs de segmentation. Pour les repérer, le gestionnaire de signaux fatals de folly journalise une trace de pile au plantage, puis nous téléversons les core dumps correspondants (instantanés de l’état du programme) dans le stockage blob Azure pour analyse. Toutes les feuilles de traitement des requêtes de Rockset sont répliquées, ce qui limite l’impact client d’un plantage. Chaque erreur de segmentation reste toutefois un bogue à corriger pour atteindre nos objectifs de fiabilité et de qualité.

Au départ, nous avons traité ces dumps comme un débogage classique : inspecter quelques core dumps de très près, formuler des hypothèses et les éliminer une à une.

La plupart des plantages survenaient dans une méthode appelée DocumentTree::updateDocument. Dans ces cas, updateDocument semblait avoir appelé une fonction inconnue X; la pile s’était corrompue pendant X, puis X était revenue à une adresse non exécutable. Parfois, le cadre tout juste dépilé de X semblait valide, sauf que son adresse de retour sauvegardée valait NULL. Dans d’autres cas, le pointeur de pile semblait erroné, mais le prochain cadre valide semblait toujours être updateDocument.

Nous ignorions quand la pile se corrompait, ce qui ouvrait un immense espace de recherche. updateDocument est une grande méthode avec beaucoup d’inlining; les candidates pour X étaient donc innombrables.

Était-ce un bogue 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 bogue du noyau Linux lié aux signaux ou au changement de contexte? Quelque chose d’encore plus rare? Si c’était une écriture errante, pourquoi notre préproduction ASAN ne l’avait-elle pas détectée?

Nous avons tenté de trouver toutes les occurrences dans nos journaux applicatifs, mais les bogues de corruption de pile se classent mal ainsi, car les traces journalisées sont elles-mêmes corrompues ou absentes. Aucune requête de journaux n’évitait à la fois les faux positifs et les faux négatifs. Nous avons inspecté d’autres dumps à la main et trouvé quelques exemples de plus, mais l’effort était trop grand pour produire un jeu de données fiable.

À ce stade, nous avons écarté à tort un bogue matériel : les plantages touchaient plusieurs régions et types de matériel, alors nous cherchions encore une cause purement logicielle. Pendant quelques jours, nous avons creusé un seul plantage avec %rsp désaligné, en reconstruisant l’avant-plantage à partir de la pile et des registres. Cela a fourni des pistes, mais comme nous tenions à notre conclusion initiale — une cause unique pour tous les bogues —, nous sommes restés bloqués.

Indices tirés de la pile

Avant le point tournant, il faut expliquer quelles informations nous tirions des fichiers core.

Rockset est compilé avec -fno-omit-frame-pointer; le cadre de pile actif est donc toujours accessible par %rbp, et les appelants forment une liste chaînée de pointeurs de cadre.

Sous Linux x86_64, l’ABI System V AMD64 réserve aussi 128 octets sous %rsp comme zone rouge. Cette région est offerte au code en espace utilisateur et, surtout, le noyau promet de ne pas l’écraser lors de la livraison d’un signal, selon le contrat ABI.

La zone rouge a été cruciale pour déboguer un plantage après retour, car elle garde des traces d’avant le retour. Quand un SIGSEGV survient, le gestionnaire de signaux fatals de folly s’exécute sur la pile du fil en panne. Les cadres de pile inactifs, dont la fonction est revenue, sont écrasés par le gestionnaire de signal, sauf leurs 128 derniers octets. C’est pourquoi nous pouvons dire : « le cadre tout juste dépilé de X semblait valide, sauf une adresse de retour NULL ». La zone rouge préserve une partie des cadres inactifs, parfois seulement leur fin.

Schéma de pile montrant des cadres de pile corrompus pouvant écraser des adresses de retour et provoquer des plantages.

Nous avons trouvé un plantage à pile désalignée où toutes les fonctions en cause é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 planté que lorsque la fonction active a tenté de revenir. Aucun de ces chemins de code n’utilisait d’exceptions, d’assembleur en ligne, setcontext ni longjmp; si le pointeur de pile avait vraiment changé ainsi, aucun bogue plausible en espace utilisateur n’expliquait le problème.

Cela nous a poussés vers le noyau.

Rockset utilise les signaux plus intensément que la plupart des programmes. L’exécution des requêtes est divisée en nombreuses tâches légères qui échangent des données. C’est essentiel pour gérer efficacement de forts volumes de requêtes, mais cela complique la comptabilisation CPU par requête, car plusieurs requêtes partagent le même pool de fils.

Notre solution, coarse_thread_cputime_clock, approxime clock_gettime(CLOCK_THREAD_CPUTIME_ID, ...) à coût assez faible pour être échantillonnée à chaque frontière de tâche. L’API timer_create peut livrer périodiquement un signal selon diverses mesures du temps, dont le temps CPU accumulé. Nous livrons un signal (SIGUSR2) tous les quelques millisecondes de temps CPU; le gestionnaire met alors à jour une valeur locale au fil. 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 bogue du noyau lié au changement de contexte ou aux signaux semblait plausible. Nous avons lu des rapports de bogues, le code du noyau et les correctifs Azure propres au noyau. Nous avons essayé des tests de charge extrême. Nous n’avons rien trouvé de 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 un tel problème.

La première ressemble au travail d’un médecin : se concentrer sur un patient, multiplier les tests et diagnostiquer un cas à partir d’indices détaillés.

La seconde ressemble à l’épidémiologie : examiner toute la population et chercher des tendances invisibles dans un cas isolé. Le bogue a-t-il commencé avec une version précise? Est-il corrélé à une référence matérielle (CPU et modèle de serveur), à une région ou à une version du noyau? Plusieurs grappes distinctes se cachent-elles derrière un même syndrome apparent?

Nous étions surtout en mode médecin. Le vrai changement a été de recueillir des données de population de grande qualité.

Nettoyage des données

Nos tentatives précédentes pour trouver automatiquement tous les cas avaient échoué parce que nous cherchions du texte dans les journaux. Les core dumps contiennent beaucoup plus d’information, mais leur examen manuel ne passait pas à l’échelle. Nous avons donc bâti un pipeline d’analyse automatique des core dumps.

Nous avons demandé à ChatGPT d’écrire un script qui téléchargeait le début de chaque fichier core, extrayait les registres, filtrait les faux positifs connus avec les journaux et étiquetait le plantage : retour-vers-null, pile désalignée ou autre. Nous l’avons ensuite exécuté en parallèle sur tous les core dumps de production Rockset de l’année précédente.

Ce fut le point tournant.

Avec un jeu de données propre, les corrélations sont apparues aussitôt. Ce que nous prenions pour un seul bogue étrange était en fait deux populations de plantages distinctes.

Les dumps de retour-vers-null étaient dispersés entre de nombreuses grappes et régions. Leur fréquence avait récemment augmenté, sans date de début nette ni frontière d’infrastructure claire.

Les plantages à 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 en marche depuis longtemps. Même s’ils touchaient plusieurs VM Azure (machines virtuelles dans le nuage), le profil évoquait une machine physique défectueuse causant des problèmes à toute VM qui s’y retrouvait.

Nuage de points des taux de plantage par grappe au fil du temps, montrant la plupart des plantages concentrés dans les grappes 2, 3 et 6, avec un pic dans la grappe 1 vers la fin de la période.

C’est alors que nous avons compris que nous confondions deux bogues. En mélangeant des contre-exemples issus des deux bogues, nous ne pouvions pas trouver une explication unique et cohérente.

Bogue no 1 : le mauvais hôte

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

Nous n’avons pas réussi à reproduire la corruption de registres sur cet hôte dans un environnement contrôlé, même après plusieurs semaines de tests de charge. Toutefois, dès que l’hôte problématique a été retiré du service, les plantages à pile désalignée ont disparu.

Retirer le mauvais hôte n’est pas une solution permanente, puisqu’il n’empêche pas une nouvelle occurrence du même problème. Nous pouvons toutefois modifier le logiciel pour qu’un problème similaire soit facilement détecté et traité s’il se reproduit. Nous avons amélioré notre gestionnaire de signaux fatals pour inclure l’état des registres, afin de détecter une récidive à partir des 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 mauvais nœuds à 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 plantages du mauvais hôte isolés, les autres dumps de retour-vers-null sont devenus beaucoup plus faciles à comprendre. Nous avions auparavant écarté le déroulement d’exceptions parce que nous pensions avoir des contre-exemples : des plantages dans des chemins de code où les exceptions n’étaient certainement pas utilisées. Mais ces contre-exemples provenaient tous de la grappe de corruption matérielle.

Quand nous avons réexaminé les dumps restants avec cela en tête, nous avons découvert que cette conclusion était exactement à l’envers : les plantages survenaient tous pendant le déroulement d’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 l’appariement réel se fait dynamiquement à l’exécution.

Le déroulement d’exception n’est pas 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, cherchent dynamiquement les gestionnaires de nettoyage et blocs catch, puis transfèrent le contrôle vers l’un de ces emplacements. Le transfert de contrôle inclut le déroulement de tous les cadres de pile intermédiaires, y compris ceux des fonctions auxiliaires.

Sur le plan opérationnel, cela ressemble beaucoup plus à un longjmp ou à un changement de fibre qu’à un appel-retour normal. Les registres sauvegardés par l’appelé doivent être restaurés, tout comme les registres de cadre de pile %rbp et %rsp.

Notre binaire lie deux bibliothèques contenant des implémentations des fonctions qui effectuent le déroulement d’exceptions C++ : libgcc et GNU libunwind. Les définitions de GNU libunwind ont été celles choisies par l’éditeur de liens dynamique. Cela nous a surpris; nous nous attendions à ce que l’implémentation de libgcc l’emporte à cause des règles de versionnement des symboles, mais 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 assoupli une autre hypothèse formulée quand nous pensions qu’il n’y avait qu’un seul bogue.

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

Cela a considérablement resserré 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 code source de GNU libunwind et vu qu’il synthétise un ucontext_t sur la pile, y inscrit l’état de registres voulu pour le cadre du gestionnaire de nettoyage, puis passe un pointeur vers cette structure à une routine assembleur interne : _Ux86_64_setcontext.

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

Le ucontext_t synthétisé vit dans l’un des cadres de pile déroulés 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 notre fréquent SIGUSR2.

Bogue no 2 : le bogue 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, composées surtout d’instructions mov qui chargent la mémoire vers un registre de destination :

Texte en clair

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 décalage 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 zone rouge) et n’est plus interdite au noyau.

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

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

C’est le bogue.

Pourquoi les dumps se faisaient passer pour de simples mauvais retours

Cet assembleur explique aussi une observation qui nous déroutait : pourquoi la fonction X avait NULL dans l’emplacement d’adresse de retour du cadre de pile précédent.

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 moment du transfert de contrôle. À la place, il lit la valeur plus tôt, la sauvegarde 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 dumps à « une fonction est revenue vers NULL » était en fait « le dérouleur 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 bogue 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 exécutées par un autre fil. 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 seule instruction! Un signal doit être livré après la modification de %rsp, mais avant que l’instruction suivante charge %rip. Plusieurs instructions simples comme celle-ci peuvent s’exécuter par cycle sur un CPU moderne superscalaire et à exécution dans le désordre; la fenêtre de concurrence est donc d’environ cent picosecondes.

Quand nous avons trouvé cette concurrence, notre première réaction a été qu’elle devait être trop rare pour expliquer le taux de plantage observé. Nous observions plus d’une douzaine de plantages retour-vers-null par jour dans le parc. Une concurrence d’une seule instruction pendant le nettoyage d’exception pouvait-elle vraiment expliquer cela?

Nous nous sommes tournés vers l’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 d’exception ou bloc catch a une probabilité d’environ 10810^{-8} de perdre la course.

Rockset utilise les exceptions dans son mécanisme interne de rétropression à 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, pour un hôte utilisant la rétropression, de 10410^{4} secondes, soit un plantage toutes les quelques heures. À l’échelle du parc, c’est largement suffisant pour expliquer la fréquence observée.

Pourquoi le bogue de libunwind est-il apparu maintenant?

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

Alors pourquoi se manifeste-t-il maintenant?

Le taux de plantage est à peu près proportionnel au nombre d’exceptions lancées et 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 beaucoup d’exceptions dans le contrôle normal de surcharge; nous livrons SIGUSR2 exceptionnellement 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 afin de 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 ni écraser la mémoire ucontext_t périmée. Avant ce changement, nous n’observions pas ces plantages du tout. Après le changement, le taux est resté bas jusqu’à ce que nous augmentions la charge pour certains cas d’utilisation qui sollicitaient le mécanisme de rétropression.

Autrement dit, le bogue 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 voulant que le bogue matériel et celui de libunwind plantaient surtout dans DocumentTree::updateDocument. Les plantages de 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 rétropression d’ingestion. Elle était aussi très présente dans les plantages par désalignement de %rsp, car le nœud matériel défectueux appartenait à une référence utilisée pour l’ingestion en bloc, qui passe la majeure partie de son temps CPU dans cette méthode.

Notre atténuation immédiate a été de passer de GNU libunwind au dérouleur de libgcc. C’était un bon compromis en soi : l’implémentation de libgcc a profité de beaucoup de travail pour réduire la contention de verrous, un point important à l’échelle de grandes VM.

Nous avons aussi envoyé en amont un reproducteur autonome et un correctif(s'ouvre dans une nouvelle fenêtre) à GNU libunwind, puis vérifié que les autres dérouleurs n’avaient pas de problème semblable.

La puissance d’un diagnostic à l’échelle de la population

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

L’étape la plus importante n’a pas été la lecture ingénieuse de l’assembleur ni la connaissance approfondie des détails. Elle a été de bâtir un jeu de données de grande 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 réunies, la structure du problème est devenue évidente : une population de plantages venait d’un mauvais hôte, l’autre d’une concurrence 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, cela compte beaucoup. Cette enquête a renforcé notre engagement envers l’instrumentation approfondie, les enquêtes automatisées et l’amélioration continue de nos outils opérationnels. La fiabilité ne consiste pas seulement à corriger les bogues après coup : elle consiste à bâtir les données, les flux de travail et les compétences qui transforment des problèmes impossibles en problèmes diagnostiquables et solvables.

Auteurs

By Nathan Bronson, Member of Technical Staff