Le retour des frame pointers
(brendangregg.com)- Avec Fedora et Ubuntu 24.04 LTS qui reviennent par défaut à des builds avec frame pointers pour les composants de l’OS comme libc, le profiling Linux et les Flame Graph peuvent désormais afficher des piles plus complètes
- Sans frame pointer, un profiler peut interrompre le stack walking au niveau de bibliothèques comme libc, ou suivre des frames erronées, ce qui casse les Flame Graph CPU et off-CPU
- Le changement apporté à gcc sur i386 en 2004 visait à libérer un registre supplémentaire, mais il s’est étendu jusqu’à x86-64 et a laissé un coût durable pour les profilers système et l’observabilité basée sur eBPF
- D’après les retours d’expérience de Netflix avec Java et libc, le coût était généralement inférieur à 1 % ; certains rapports indiquaient 1 à 2 %, et jusqu’à 10 % dans des microbenchmarks particuliers ou des workloads atypiques
- Il existe des alternatives comme LBR, DWARF, les stack walkers eBPF, ORC, SFrames ou Shadow Stacks, mais la méthode la plus pratique aujourd’hui pour trouver des gains de performance en production est d’activer les frame pointers par défaut
Pourquoi les Flame Graph se cassent
- Un CPU Flame Graph peut sembler normal en apparence, mais sur un système où libc a été compilée sans frame pointers, certains échantillons peuvent être regroupés à tort au-dessus de [unknown]
- Dans l’exemple, 15 % des échantillons à gauche sont au mauvais endroit, et les frames applicatives manquent
- Le profiler passe les frames du kernel, atteint le syscall et le wrapper de syscall de libc, puis échoue à résoudre le symbole de la frame suivante
- La cause est que, du fait des optimisations du compilateur, le registre de frame pointer n’est pas utilisé comme point de référence de la stack frame, mais pour stocker des données
- Le profiler ne peut pas savoir si cette valeur est un simple nombre, et tente de l’interpréter comme une adresse de fonction
- Si la valeur ne pointe pas vers la frame suivante, le stack walking s’arrête
- Si, par hasard, elle ressemble à un pointeur valide, une junk frame erronée peut apparaître
- Si la valeur pointe vers elle-même, une tour de junk frames peut se former jusqu’à atteindre la limite maximale de frames de perf
- Les Off-CPU Flame Graphs peuvent être encore plus fortement cassés en l’absence de frame pointers, car les fonctions read/write et mutex de libc y apparaissent souvent
- Si l’application elle-même est aussi buildée sans frame pointers, ce n’est pas seulement la couche des bibliothèques, mais toute la trace de pile qui devient instable
Ce que fournissent les frame pointers
- L’ABI x86-64 définit que
%rbppeut être utilisé comme base pointer de la stack frame, c’est-à-dire comme frame pointer - Les profilers et debuggers externes comme Linux perf et eBPF utilisent cette information pour parcourir les stack traces et visualiser les résultats sous forme de Flame Graph
- L’ABI x86-64 comporte une note indiquant que l’utilisation de
%rbpest facultative- Indexer la stack frame avec
%rsppermet de supprimer deux instructions dans le prologue et l’epilogue %rbppeut être utilisé comme registre généraliste supplémentaire
- Indexer la stack frame avec
La suppression de 2004 et ses conséquences sur 20 ans
- En 2004, gcc a été modifié dans son backend i386 pour adopter par défaut un comportement équivalent à
-fomit-frame-pointer -ffixed-ebp - i386 ne disposait que de quatre registres généralistes, donc libérer
%ebpaugmentait fortement le nombre de registres disponibles- Parmi les raisons du changement figurait aussi la volonté d’obtenir de meilleures performances que le compilateur
iccd’Intel - À l’époque, l’hypothèse était aussi que les debuggers ne seraient pas cassés, car ils prenaient en charge d’autres techniques de stack walking
- Parmi les raisons du changement figurait aussi la volonté d’obtenir de meilleures performances que le compilateur
- Ce changement a également été appliqué à x86-64
- x86-64 dispose d’au moins 12 registres, donc le gain d’un registre supplémentaire n’est pas aussi important que sur i386
- Les profilers système actuels comme eBPF n’existaient pas à l’époque, et ce changement peut les casser
- En 2004, Eric Schrock estimait que, sur amd64, le gain d’un 17e registre généraliste ne compensait pas suffisamment la perte de capacité de debugging
- Il avait jugé que si
/usr/bincommençait à être compilé sans frame pointers, la situation deviendrait incontrôlable - Sous Linux, la même tendance s’est propagée non seulement à
/usr/bin, mais aussi à/usr/libet au code applicatif
- Il avait jugé que si
Java, libc et le retour d’expérience de Netflix
- En 2014, lorsque l’auteur a rejoint Netflix, l’absence de prise en charge des frame pointers dans Java cassait toutes les piles applicatives
- Un correctif a été créé pour le compilateur c2 de la JVM, puis Oracle l’a retravaillé et ajouté à JDK8u60 sous la forme de l’option
-XX:+PreserveFramePointer - Le changement côté Java a permis de trouver plusieurs améliorations de performance dans le code applicatif, mais libc continuait de casser certains échantillons CPU et la plupart des Off-CPU Flame Graphs
- Par la suite, Netflix a compilé en interne une libc incluant les frame pointers pour la production, et a aussi travaillé avec Canonical sur une libc précompilée pour Ubuntu
- Pendant un temps, l’utilisation de
libc6-profa été recommandée libc6-profétait unelibc6incluant les frame pointers
- Pendant un temps, l’utilisation de
Overhead de performance et cas d’exception
- Lors du déploiement en production, le coût de l’ajout des frame pointers à libc et Java était généralement inférieur à 1 %
- Une application a exceptionnellement montré un overhead de 10 %
- Il s’agissait d’une application atypique qui générait des stack traces de plus de 1 000 frames via Groovy
- Linux perf n’arrivait pas non plus à gérer cela, et Arnaldo Carvalho de Melo de Red Hat a ajouté le sysctl
kernel.perf_event_max_stackpour le workload de Netflix - Comme cet environnement était une machine virtuelle dépourvue de fonctionnalités de profiling matériel de bas niveau, il n’a pas été possible de vérifier si les 10 % étaient entièrement dus aux frame pointers
- D’autres rapports ont également observé un coût d’environ 1 % ou 2 %
- Les microbenchmarks peuvent se dégrader jusqu’à 10 %
- Quand de petites fonctions sont exécutées en boucle, les instructions supplémentaires peuvent affecter la chaleur du cache L1 ou les limites de lignes de cache
- Dans ce cas, ajouter quoi que ce soit à une fonction hot peut produire le même effet, indépendamment du frame pointer lui-même
- Le benchmark Python
scimark_sparse_mat_multpouvait lui aussi atteindre 10 %- Dans l’analyse d’Andrii Nakryiko, il s’agissait d’un cas particulier où, dans une grande fonction, gcc utilisait des offsets relatifs à
%rbpplutôt que des offsets%rsp, ce qui nécessitait davantage d’octets et entraînait un problème de performance - Il a aussi été rapporté ensuite que Python avait été corrigé pour pouvoir réactiver les frame pointers par défaut
- Dans l’analyse d’Andrii Nakryiko, il s’agissait d’un cas particulier où, dans une grande fonction, gcc utilisait des offsets relatifs à
- Les améliorations de performance trouvées grâce aux frame pointers allaient de 5 % à 500 %, et un coût généralement inférieur à 1 % est considéré comme justifiable
- Pour des appareils où le profiling ou le debugging ne sont pas possibles, il est acceptable de compiler sans frame pointers, mais les principales cibles ici sont Linux en entreprise et les serveurs backend
Activation par défaut dans Fedora et Ubuntu
- De grandes entreprises comme Meta, Google et Netflix utilisaient déjà en interne une libc incluant les frame pointers afin de disposer de capacités de profiling
- La première tentative pour faire remonter l’activation par défaut dans Fedora a donné lieu à un long débat
- La discussion Fedora est devenue un fil de 116 messages
- Un participant a demandé que Meta ou Netflix fournissent une infrastructure de side repository pour les tests, benchmarks et mesures de taille du code
- Jonathan Corbet a résumé cela dans Fedora's tempest in a stack frame
- Fedora a ensuite réexaminé et accepté la proposition, devenant la première distribution à réactiver les frame pointers
- Ubuntu a aussi annoncé Ubuntu 24.04 LTS avec les frame pointers par défaut
- Il a également été rapporté qu’Arch Linux était en train d’activer les frame pointers
- Le stack walking dans les bibliothèques de l’OS s’améliore avec ce changement, mais les runtimes applicatifs peuvent nécessiter une configuration séparée
- Java fournit l’option
-XX:+PreserveFramePointer - Go active la prise en charge des frame pointers par défaut depuis quelques années
- Java fournit l’option
Candidats au stack walking après les frame pointers
- LBR (Last Branch Record) : fonctionnalité matérielle Intel limitée à 16 ou 32 frames, insuffisante pour la plupart des piles applicatives, mais utilisable en dernier recours pour obtenir certaines informations de pile
- BTS (Branch Trace Store) : fonctionnalité Intel avec une limite de profondeur de pile moins contraignante, mais qui implique un coût lié aux load/store mémoire et au traitement des interruptions de dépassement du buffer BTS
- AET (Architectural Event Trace) : tracer basé sur JTAG permettant de tracer les événements CPU bas niveau, BIOS et device, et qui semble utilisable aussi pour les stack traces, mais sans expérience directe d’utilisation
- DWARF : format de debuginfo binaire utilisé depuis longtemps dans les debuggers
- Il a été signalé qu’il y avait eu des travaux JIT-to-DWARF pour les runtimes JIT
- Il n’est pas attendu que ce soit pratique pour une JVM Java dont c2 tourne en continu sur un serveur de production chargé
- Le coût du stack walking DWARF lui-même est également élevé
- Stack walking eBPF : un tracer externe peut parcourir l’intérieur d’un runtime comme la JVM sans prise en charge du runtime
- Cela peut nécessiter de nombreuses lectures en user space à l’intérieur du runtime, avec un overhead potentiellement élevé
- Comme c’est fragile face aux changements du runtime, il est préférable que ce soit distribué et maintenu avec la base de code du langage
- ORC (oops rewind capability) : stack unwinder léger du kernel Linux, qui permet aux kernels récents de conserver le stack walking tout en supprimant les frame pointers
- SFrames (Stack Frames) : méthode légère de unwinding de piles utilisateur basée sur ORC
- Shadow Stacks : fonctionnalité de sécurité d’Intel et AMD qui pousse les adresses de retour des fonctions sur une pile matérielle séparée pour les vérifier au moment du retour, et qui semble aussi exploitable pour les stack traces
Ce qui change dès maintenant
- L’avantage de performance sur i386, le jugement de compatibilité avec les debuggers de l’époque et la concurrence avec
icc, qui justifiaient l’omission des frame pointers en 2004, ne correspondent plus à la situation de 2024 - La suppression des frame pointers appliquée à x86-64 était déjà jugée à l’époque comme insuffisamment bénéfique au regard de la perte de capacité de debugging
- Avec le retour des frame pointers dans Fedora et Ubuntu, les utilisateurs des releases 2024 auront des CPU Flame Graphs plus faciles à comprendre et pourront, pour la première fois, exploiter correctement les Off-CPU Flame Graphs
- Les continuous profilers pourront aussi obtenir plus facilement des profils plus complets sans demander aux clients de modifier leur OS
- À l’avenir, des approches comme SFrames ou Shadow Stacks permettront peut-être de refaire des stack traces sans frame pointers, mais l’amélioration possible aujourd’hui consiste à les activer par défaut
1 commentaires
Avis sur Hacker News
Je me souviens du début des années 2000, quand l’omission du pointeur de frame de pile a commencé à se répandre.
J’étudiais alors l’informatique dans une université d’un pays pauvre du tiers-monde, et comme les ordinateurs étaient vieux et lents, la plupart des devoirs utilisaient des compilateurs plutôt que des interpréteurs.
Il y avait beaucoup de cours intéressants où l’on implémentait des structures de données bas niveau, des compilateurs, des routines numériques en assembleur, jusqu’à des pilotes de périphériques Minix ; et quand un programme se comportait bizarrement, on l’attachait avec gdb et on déboguait en remontant la pile directement au niveau assembleur.
Puis, soudain,
-fomit-frame-pointerest devenu à la mode, les traces de pile ont commencé à ne plus avoir aucun sens, et déboguer des segfaults ou des instructions illégales est devenu beaucoup plus difficile.Au final, pour éviter les sessions de débogage cassées, je me suis mis à utiliser Python pour presque tout ; j’ai perdu un ou deux ordres de grandeur en performance, mais apprendre Python m’a été utile par la suite.
-fno-omit-frame-pointer.Content de voir Fedora mentionnée. Maintenir les pointeurs de frame activés à l’échelle de toute la distribution a été un combat assez usant.
Ex. : https://pagure.io/fesco/issue/3084
Le mythe selon lequel le surcoût des pointeurs de frame serait élevé persiste, à cause d’un cas Python qui ralentissait de +10 %, mais il a été corrigé depuis.
Le surcoût réellement mesuré est inférieur à 1 %, et les bénéfices obtenus dans certaines applications sont bien plus importants.
Cela ne colle pas non plus très bien avec les mesures côté noyau Linux, où j’ai vu plutôt une fourchette de 5 à 10 % : https://lore.kernel.org/lkml/20170602104048.jkkzssljsompjdwy...
Sur diverses charges comme netperf, des microbenchmarks de l’allocateur de pages, pgbench ou sqlite, activer les pointeurs de frame entraînait 5 à 10 % de surcoût, et le point important est que l’impact a été observé avec PostgreSQL et SQLite.
Les SGBD sont l’une des bonnes façons de pousser fortement un système.
OCaml 5 utilise des piles distinctes pour le code OCaml et le code C ; GDB peut les relier grâce aux informations DWARF, mais les graphes d’appels DWARF de perf ne le peuvent pas : https://github.com/ocaml/ocaml/issues/12563#issuecomment-193...
Si vous avez besoin d’un argument pour conserver les pointeurs de frame dans les prochaines versions, OCaml 5 peut servir d’exemple.
Je viens seulement d’apprendre que Fedora 39 active déjà les pointeurs de frame par défaut ; pour le profilage courant, je travaillais encore surtout sur des systèmes similaires à CentOS 7, avec
perf record --call-graph dwarf -F 47 -a.La raison pour laquelle on a supprimé les pointeurs de frame n’est pas un mythe : elle vient de la réalité de l’époque pré-64 bits, et ce n’est pas si ancien.
Aujourd’hui encore, ce genre d’optimisation a du sens si l’on veut donner une nouvelle vie à de vieux systèmes 64 bits.
Idéalement, cela devrait aussi rester le défaut sur les systèmes où la sécurité est critique, et tout n’a pas besoin d’être optimisé pour « l’observabilité ».
Il y a une chose qu’Apple a bien faite sur ARM : faire en sorte que le pointeur de frame x29 pointe toujours vers un enregistrement de frame valide.
Certaines fonctions, comme les fonctions leaf ou les tail calls, peuvent ne pas créer d’entrée dans la liste, mais le résultat est que les traces de pile restent toujours significatives, même sans informations de débogage.
https://developer.apple.com/documentation/xcode/writing-arm6...
En 2005, chez Google, j’étais dans le camp opposé, et mon raisonnement de l’époque était simple.
Même si
$BIG_COMPANYdécidait de tout compiler avec des pointeurs de frame, le reste de la communauté ne le ferait pas ; on se retrouverait donc à poursuivre un débat impossible à gagner avec une communauté bien plus vaste.Au final, ce débat est devenu un débat de près de 20 ans, et plus tard, après avoir écrit des patchs pour faire fonctionner libunwind dans gperftools, j’ai fini par maintenir libunwind pendant quelques années.
Je suis maintenant passé à un autre domaine de l’informatique et je ne suis plus qu’un observateur passif, mais c’est intéressant de lire cette histoire depuis le point de vue du camp opposé.
Si l’on doit passer RBP comme pointeur de frame, on pourrait tout aussi bien avoir deux piles
L’une, pointée par RBP, stockerait les frames d’activation, et l’autre, pointée par RSP, ne stockerait que les adresses de retour
La pile d’appels serait alors littéralement un tableau plat d’adresses de retour, donc il ne serait même plus nécessaire de « parcourir la pile »
Je ne vois pas vraiment pourquoi on stocke, au départ, l’adresse de retour près des variables locales, et les inconvénients semblent très nombreux
L’approche proposée nécessite deux guard pages, double les manipulations de pile, et double aussi les risques de cache miss
Je connais la raison, mais, comme pour beaucoup de choses, cela n’avait vraiment de sens qu’il y a une trentaine d’années, et ses conséquences ont été intéressantes
Curieusement, l’article Wikipédia ne semble pas vraiment mettre en avant comme grande caractéristique du modèle le fait que Forth puisse accéder à la fois à la pile de paramètres et à la pile de retour
https://en.wikipedia.org/wiki/Forth_(programming_language)
Il faudrait montrer que le coût de les déplacer vers une page séparée et de gérer deux pointeurs est réellement inférieur à celui des stack cookies/protecteurs de pile, qui fournissent déjà une protection seulement là où elle est nécessaire
Il n’y a pas non plus de véritable avantage de sécurité par rapport aux protecteurs de pile actuels. Avec des lectures/écritures arbitraires, on peut de toute façon finir par contourner l’intégrité du flot de contrôle
Virgil n’utilise pas de pointeur de frame. S’il n’y a pas d’allocation dynamique sur la pile, la taille de la frame d’une fonction donnée est fixe et peut être retrouvée par une simple recherche binaire dans une table
La technique de Virgil utilise en plus des plages basées sur l’index de page pour réduire la recherche à quelques comparaisons en moyenne ; elle combine les informations d’unwinding et les stackmaps pour le GC, tout en utilisant très peu d’espace
Le code principal se trouve dans https://github.com/titzer/virgil/blob/master/rt/native/Nativ..., et le reste du code dans le même répertoire implémente le décodage des métadonnées
À mon avis, le pointeur de frame n’a de sens que lorsque la taille de la frame est dynamique, c’est-à-dire quand on alloue des données sur la pile
Utiliser un mécanisme dynamique alors qu’un mécanisme statique suffit est étrange ; cela semble surtout venir de l’absence d’accord sur une ABI d’encodage des métadonnées ou sur les routines d’unwinding
La mesure de 1 à 2 % est crédible, et du même ordre que le coût des vérifications de bornes des tableaux
Accorder un traitement spécial à un coût de 1 % pour le débogage et le profiling, tout en rechignant à ajouter une couche de sécurité, révèle des priorités très étranges
std::vectoren C++ désactive les vérifications de bornes par défaut ; à mon avis, c’est parce que C++ a été conçu par des gens complètement fous, et pour euxÀ part cela, aucun langage sans vérification de bornes ne me vient immédiatement à l’esprit
Bon article. J’ai regretté la disparition des pointeurs de frame
Sur beaucoup de systèmes, y compris Linux, beaucoup de gens ont longtemps souffert de l’absence de pointeurs de frame et ont essayé de les conserver dans autant d’environnements que possible
Voir Linux grand public les réintroduire donne l’impression d’une forme de reconnaissance, mais c’est aussi un peu frustrant
Sur les dérivés de Debian, c’est lent parce que, pour des raisons de licence, perf n’embarque que le chemin d’unwinding lent ; mais avec de bons outils, on remarque à peine la différence
Je me demande ce qui m’échappe
Dans l’ensemble, je suis favorable aux frame pointers, mais après avoir travaillé quelques années dans ce domaine, j’ai quelques remarques
Beaucoup de déroulements de pile basés sur les frame pointers ne prennent pas en compte un problème qui n’existe pas avec les informations de déroulement DWARF. La mise en place d’une frame n’est pas atomique : elle se fait avec deux instructions,
push $rbpetmov $rsp $rbp, et si l’instantané est pris pendant lepush, on peut manquer la frame parenteOn pourrait peut-être l’atténuer en inspectant le code, mais il peut aussi y avoir des
push %rbpsans rapport avec une stack frame, donc cela ressemble plutôt à une heuristiqueNous avons aussi développé la solution de déroulement rapide dans le noyau, basée sur BPF, mentionnée par Brendan : https://web.archive.org/web/20231222054207/https://www.polar...
Cette approche n’utilise pas directement le DWARF CFI, mais le convertit dans un format à accès arbitraire utilisable depuis BPF
Pour l’instant, elle ne prend en charge que les sections JIT avec frame pointers, mais je pense qu’il est possible d’implémenter le déroulement de l’interpréteur JVM de façon à l’articuler avec le déroulement natif
Idéalement, l’activation des frame pointers devrait se faire au cas par cas, et le benchmarking est essentiel
Selon le secteur et la nature du logiciel, les compromis entre performance, observabilité et indicateurs business peuvent varier fortement
Fedora a fait ici un travail très solide et rigoureux
De plus, un système de build capable de modifier ce réglage à l’échelle de tout le système, y compris les bibliothèques de dépendance, est très utile non seulement pour les tests, mais aussi pour le déploiement en production
Enfin, j’attends avec intérêt SFrame, sur lequel travaille Indu. Cela devrait résoudre beaucoup des problèmes actuels tout en laissant aux utilisateurs le choix d’utiliser ou non les frame pointers, mais il faudra peut-être des années pour que l’infrastructure soit en place et que tout le monde mette à niveau
Pour comprendre ce qui se passe réellement, il faut une analyse de tout le système, et avec la structure actuelle des distributions Linux binaires comme Fedora ou Debian, il n’existe pratiquement pas d’autre option
ENTER N,0réserve N d’espace sur la pile pour les variables locales et équivaut grosso modo àPUSH EBP,MOV ESP,ESP,SUB SP,NJe ne me souviens toutefois pas si
ENTERexiste en x86-64Cela dit, ce n’est toujours pas atomique entre
CALLet la mise en place de la frame : si l’instantané est pris aprèsCALLmais avantENTER, on n’obtient pas la configuration de la frameLa raison pour laquelle
ENTERest peu utilisé est qu’il a été jugé trop lentLEAVEest utilisé parce qu’il est aussi rapide que, voire plus rapide que, la séquence d’instructions qu’il remplace, maisENTERvoit son second opérande ruiner les performancesCet opérande sert à permettre aux fonctions imbriquées d’accéder aux stack frames supérieures, et son coût d’utilisation est très élevé
Je trouve intéressant de comprendre enfin d’où venaient les montagnes de
[unknown]que je voyais dans les profilsCela dit, ce n’est pas facile à justifier. Une différence de performance de 2 % est en réalité assez importante
Ce serait bien de pouvoir contrôler plus finement l’inclusion des frame pointers
Avec un profiling fin, on pourrait déterminer si certaines fonctions ou unités de compilation ont besoin de frame pointers
Je ne serais pas surpris que les résultats montrent que seules quelques tâches ralentissent fortement avec les frame pointers, tandis que le reste est peu affecté
__attribute__((optimize("no-omit-frame-pointer")))__attribute__((optimize("omit-frame-pointer")))Ce type de benchmark est assez artificiel, il ne faut donc pas lui faire entièrement confiance, et les applications réelles donnent souvent des résultats très différents
Le profiling est important ; en profilant soigneusement du code, il m’est arrivé d’accélérer plusieurs sections jusqu’à 20 %
Si une application est très sensible aux pertes de performance, on peut activer les frame pointers et profiler en laboratoire, puis les omettre dans la version livrée aux clients
La prise en charge du code JIT n’est malheureusement pas très bonne, mais LLVM dispose d’excellents hooks pour enregistrer chaque méthode générée et son adresse
Il est donc relativement facile de construire un déroulement de pile en mode mixte simple, mais c’est principalement possible à l’intérieur du processus
La famille DNN d’Intel semble déverser les informations dans un fichier public lisible par perf, mais les kernels de oneDNN eux-mêmes réutilisent constamment RBP, ce qui rend cela pratiquement inutile
L’affirmation de l’article selon laquelle « les runtimes JIT comme la JVM Java n’ont pas d’informations DWARF » est également surprenante
Je me demande si c’est désactivé par défaut, ou littéralement indisponible
Les recherches mènent le plus souvent à des discussions de personnes souhaitant inclure la partie JNI/C dans les stacks JVM : https://github.com/async-profiler/async-profiler/issues/215