El regreso de los frame pointers
(brendangregg.com)- Fedora y Ubuntu 24.04 LTS están volviendo a compilar por defecto los componentes del sistema operativo, como
libc, incluyendo frame pointers, lo que permite que el profiling en Linux y los Flame Graph vean stacks más completos - Sin frame pointers, el profiler puede detener el stack walking en capas de librerías como
libco seguir frames incorrectos, lo que puede romper los Flame Graph de CPU y Off-CPU - El cambio de
gccpara i386 en 2004 fue una decisión para conseguir un registro adicional, pero terminó extendiéndose a x86-64 y dejó un costo de largo plazo para los profilers del sistema y la observabilidad basada en eBPF - En la experiencia de Netflix con Java y
libc, el costo normalmente fue de menos de 1%; algunos reportes muestran entre 1% y 2%, y en microbenchmarks especiales o cargas anómalas llegó hasta 10% - Existen alternativas como LBR, DWARF, eBPF stack walker, ORC, SFrames y Shadow Stacks, pero hoy la forma más práctica de encontrar mejoras de rendimiento en producción es habilitar frame pointers por defecto
Por qué se rompen los Flame Graph
- Un CPU Flame Graph puede verse normal a simple vista, pero en sistemas donde
libcfue compilada sin frame pointers, algunas muestras pueden agruparse incorrectamente sobre [unknown]- En el ejemplo, el 15% de las muestras de la izquierda está en una posición incorrecta y faltan frames de la aplicación
- El profiler atraviesa los frames del kernel, llega al syscall y al wrapper de syscall en
libc, y luego falla al resolver el símbolo del siguiente frame
- La causa es una optimización del compilador en la que el registro frame pointer se usa para guardar datos en lugar de servir como punto de referencia del stack frame
- El profiler no puede saber si ese valor es solo un número e intenta interpretarlo como una dirección de función
- Si el valor no apunta al siguiente frame, el stack walking se detiene
- Si por casualidad parece un puntero válido, puede generar un junk frame incorrecto
- Si el valor apunta a sí mismo, puede formarse una torre de junk frames hasta alcanzar el límite máximo de frames de
perf
- Los Off-CPU Flame Graph pueden romperse aún más cuando no hay frame pointers, porque suelen aparecer muchas funciones
read/writey de mutex delibc - Si la propia aplicación también fue compilada sin frame pointers, no solo se ven afectadas las librerías, sino todo el stack trace
Qué información aportan los frame pointers
- El ABI de x86-64 define que
%rbppuede usarse como base pointer del stack frame, es decir, como frame pointer - Profilers y debuggers externos como Linux
perfy eBPF usan esa información para recorrer el stack trace y visualizar el resultado como Flame Graph - El ABI de x86-64 incluye una nota al pie que dice que usar
%rbpes opcional- Si se indexa el stack frame con
%rsp, se pueden ahorrar dos instrucciones en el prologue y el epilogue %rbppuede usarse como un registro de propósito general adicional
- Si se indexa el stack frame con
La eliminación en 2004 y 20 años de consecuencias
- En 2004,
gcccambió para que el backend de i386 hiciera por defecto algo equivalente a-fomit-frame-pointer -ffixed-ebp - i386 solo tenía 4 registros de propósito general, así que liberar
%ebpaumentaba de forma importante los registros disponibles- Entre las razones del cambio estaba también lograr mejor rendimiento que el compilador
iccde Intel - En ese momento también se asumía que los debuggers no se romperían porque ya soportaban otras técnicas de stack walking
- Entre las razones del cambio estaba también lograr mejor rendimiento que el compilador
- Ese cambio también se aplicó a x86-64
- x86-64 tiene más de 12 registros, así que la ventaja de conseguir uno más no era tan grande como en i386
- Los profilers de sistema actuales, como eBPF, no existían entonces, y este cambio puede romperlos
- En 2004, Eric Schrock consideró que en amd64 la ventaja de obtener un 17.º registro de propósito general no compensaba la pérdida de capacidad de depuración
- Dejó la evaluación de que una vez que se empezaba a compilar
/usr/binsin frame pointers, la situación se volvía incontrolable - En Linux, esa misma tendencia se expandió no solo a
/usr/bin, sino también a/usr/liby al código de las aplicaciones
- Dejó la evaluación de que una vez que se empezaba a compilar
Experiencia de uso en Java, libc y Netflix
- Cuando se unió a Netflix en 2014, todo el stack de la aplicación estaba roto por la falta de soporte de frame pointers en Java
- Se creó un parche para el compilador c2 de la JVM, y Oracle lo retrabajó para agregar la opción
-XX:+PreserveFramePointeren JDK8u60 - El cambio en Java permitió encontrar varias mejoras de rendimiento en el código de aplicación, pero
libcseguía rompiendo parte de las muestras de CPU y la mayoría de los Off-CPU Flame Graph - Después compilaron internamente una
libccon frame pointers para producción y también trabajaron con Canonical en unalibcprecompilada para Ubuntu- Durante un tiempo se recomendó usar
libc6-prof libc6-proferalibc6con frame pointers incluidos
- Durante un tiempo se recomendó usar
Overhead de rendimiento y casos excepcionales
- Durante la implementación en producción, el costo de agregar frame pointers a
libcy Java normalmente fue de menos de 1% - Hubo un caso excepcional en el que una aplicación mostró 10% de overhead
- Era una aplicación inusual que generaba stack traces de más de 1000 frames a través de Groovy
- Linux
perftampoco podía manejarlo, así que Arnaldo Carvalho de Melo, de Red Hat, agregó el sysctlkernel.perf_event_max_stackpara la carga de trabajo de Netflix - Ese entorno era una máquina virtual sin funciones de profiling de hardware de bajo nivel, así que no se pudo confirmar si ese 10% se debía completamente a los frame pointers
- Otros reportes también muestran costos de alrededor de 1% o 2%
- Los microbenchmarks pueden empeorar hasta 10%
- Cuando se ejecutan funciones pequeñas repetidamente en un loop, las instrucciones adicionales pueden afectar la calidez de la L1 cache o los límites de las cache lines
- En ese caso, el efecto podría producirse no por el frame pointer en sí, sino por agregar cualquier cosa a una función hot
- El benchmark de Python
scimark_sparse_mat_multtambién podía llegar a 10%- Según el análisis de Andrii Nakryiko, fue un caso especial donde en una función grande
gccusó offsets relativos a%rbpen lugar de offsets de%rsp, requiriendo más bytes y causando el problema de rendimiento - Después también hubo noticias de que Python fue modificado para poder reactivar por defecto los frame pointers
- Según el análisis de Andrii Nakryiko, fue un caso especial donde en una función grande
- Las mejoras de rendimiento encontradas gracias a los frame pointers fueron desde 5% hasta 500%, y se considera que un costo normalmente inferior a 1% suele estar justificado
- Si se trata de dispositivos sin posibilidad de profiling o debugging, puede compilarse sin frame pointers, pero el objetivo principal son Linux empresariales y servidores backend
Activación por defecto en Fedora y Ubuntu
- Grandes empresas como Meta, Google y Netflix ya usaban internamente una
libccon frame pointers para asegurar capacidades de profiling - El primer intento de upstream de habilitarlo por defecto en Fedora derivó en una discusión larga
- La discusión en Fedora se convirtió en un hilo con 116 mensajes
- Un participante exigió que Meta o Netflix proporcionaran infraestructura de side repository para pruebas, benchmarks y mediciones del tamaño del código
- Jonathan Corbet resumió esto en Fedora's tempest in a stack frame
- Más tarde Fedora volvió a revisar la propuesta y la aceptó, convirtiéndose en la primera distribución en reactivar los frame pointers
- Ubuntu también anunció frame pointers by default en Ubuntu 24.04 LTS
- También se agregó la noticia de que Arch Linux está avanzando en la activación de frame pointers
- El stack walking de las librerías del sistema mejora con este cambio, pero los runtimes de aplicaciones pueden requerir configuración aparte
- Java ofrece la opción
-XX:+PreserveFramePointer - Go hizo del soporte de frame pointers el valor por defecto hace algunos años
- Java ofrece la opción
Candidatos para stack walking después de los frame pointers
- LBR (Last Branch Record): función de hardware de Intel con un límite de 16 o 32 frames, insuficiente para la mayoría de los stacks de aplicación, aunque puede servir como último recurso para obtener parte de la información del stack
- BTS (Branch Trace Store): función de Intel con menos limitación en profundidad del stack, pero con costos por load/store de memoria y por el manejo de interrupciones por overflow del buffer de BTS
- AET (Architectural Event Trace): tracer basado en JTAG que puede seguir eventos de CPU, BIOS y dispositivos de bajo nivel, y parece poder usarse también para stack traces, aunque no hay experiencia directa con él
- DWARF: formato de debuginfo binario usado desde hace mucho tiempo en debuggers
- Se señaló que hubo trabajo de JIT-to-DWARF en runtimes JIT
- No se espera que sea práctico para JVMs de Java en servidores de producción muy ocupados donde c2 sigue ejecutándose continuamente
- El costo del propio stack walking con DWARF también es alto
- eBPF stack walking: un tracer externo puede recorrer el interior de runtimes como la JVM sin soporte del runtime
- Puede requerir muchas lecturas del user space dentro del runtime, por lo que el overhead puede ser alto
- Como es sensible a cambios del runtime, parece más adecuado que se distribuya y mantenga junto con el code base del lenguaje
- ORC (oops rewind capability): stack unwinder liviano del kernel de Linux que permite conservar el stack walking aunque kernels nuevos eliminen frame pointers
- SFrames (Stack Frames): método liviano de unwinding de stack de usuario basado en ORC
- Shadow Stacks: función de seguridad de Intel y AMD que empuja la dirección de retorno de una función a un stack de hardware separado para validarla al hacer return, y también parece poder aprovecharse para stack traces
Qué cambia desde ahora mismo
- Las razones para omitir frame pointers en 2004 —la ventaja de rendimiento en i386, la evaluación de compatibilidad con debuggers de esa época y la competencia con
icc— ya no encajan con la realidad de 2024 - Ya en ese entonces se consideraba que eliminar frame pointers en x86-64 no ofrecía beneficios suficientes frente a la pérdida de capacidad de depuración
- Con Fedora y Ubuntu revirtiendo este cambio, quienes usen sus lanzamientos de 2024 tendrán CPU Flame Graph más fáciles de entender y podrán aprovechar correctamente los Off-CPU Flame Graph por primera vez
- Los profilers continuos también podrán obtener perfiles más completos sin exigir a los clientes cambios en el sistema operativo
- En el futuro quizá sea posible volver a hacer stack tracing sin frame pointers mediante enfoques como SFrames o Shadow Stacks, pero la mejora disponible hoy es activar frame pointers por defecto
1 comentarios
Opiniones de Hacker News
Recuerdo cuando, a principios de los 2000, empezó a difundirse la omisión del puntero de marco de pila.
En ese entonces estudiaba ciencias de la computación en una universidad de un país pobre del tercer mundo, y por las computadoras viejas y lentas, en la mayoría de las tareas usábamos compiladores en vez de intérpretes.
Había muchas clases interesantes en las que implementábamos desde estructuras de datos de bajo nivel, compiladores y rutinas numéricas en ensamblador hasta drivers de dispositivo para Minix; cuando un programa se comportaba raro, me conectaba con gdb y lo depuraba a nivel ensamblador siguiendo la pila manualmente.
Pero de pronto
-fomit-frame-pointerse puso de moda, los rastros de pila empezaron a dejar de tener sentido y depurar segfaults o instrucciones ilegales se volvió mucho más difícil.Al final, para evitar sesiones de depuración rotas, terminé usando Python para casi todo; perdí uno o dos órdenes de magnitud en rendimiento, pero aprender Python me sirvió después.
-fno-omit-frame-pointer.Me alegra que se mencione Fedora. Mantener los punteros de marco activados en toda la distribución fue una batalla bastante agotadora.
Ej.: https://pagure.io/fesco/issue/3084
Persiste el mito de que el overhead de los punteros de marco es grande, y viene de un caso de Python donde había una ralentización de +10%, que ya fue corregido.
El overhead medido en la práctica es menor al 1%, y las ventajas obtenidas en ciertas aplicaciones son mucho mayores.
Tampoco encaja muy bien con las mediciones del kernel de Linux, donde vi cifras en el rango de 5–10%: https://lore.kernel.org/lkml/20170602104048.jkkzssljsompjdwy...
En cargas de trabajo variadas como netperf, microbenchmarks del asignador de páginas, pgbench y sqlite, activar los punteros de marco produjo un overhead de 5–10%, y es importante que se haya observado impacto en PostgreSQL y SQLite.
Un DBMS es una de las buenas formas de ejercer mucha presión sobre un sistema.
OCaml 5 usa pilas separadas para el código OCaml y el código C, y GDB puede conectarlas con información DWARF, pero los grafos de llamadas DWARF de perf no pueden hacerlo: https://github.com/ocaml/ocaml/issues/12563#issuecomment-193...
Si se necesita una razón para mantener los punteros de marco en lanzamientos futuros, OCaml 5 puede servir como ejemplo.
Recién ahora me entero de que Fedora 39 ya activó los punteros de marco por defecto; mi profiling habitual todavía lo hacía sobre todo en sistemas parecidos a CentOS 7, usando
perf record --call-graph dwarf -F 47 -a.La razón para eliminar los punteros de marco no fue un mito, sino una realidad de la época anterior a los 64 bits, y tampoco fue hace tanto.
Incluso hoy, si se quiere darle nueva vida a sistemas antiguos de 64 bits, este tipo de optimización tiene sentido.
Idealmente también debería ser el valor predeterminado en sistemas donde la seguridad es importante, y no todo tiene que optimizarse para la “observabilidad”.
Hay algo que Apple hizo bien en ARM: hizo que el puntero de marco x29 siempre apunte a un registro de marco válido.
Algunas funciones, como las leaf functions o las tail calls, pueden no crear una entrada en la lista, pero el resultado es que, incluso sin información de depuración, el stack trace siempre sigue siendo significativo.
https://developer.apple.com/documentation/xcode/writing-arm6...
En 2005 yo estaba del lado opuesto en Google, y mi razonamiento de entonces era simple.
Aunque
$BIG_COMPANYdecidiera compilar todo con punteros de marco, el resto de la comunidad no lo haría, así que terminaríamos en una discusión imposible de ganar contra una comunidad mucho más grande.Al final, esa discusión se convirtió en una discusión de casi 20 años; después escribí parches para que libunwind funcionara en gperftools y terminé manteniendo libunwind durante varios años.
Ahora me pasé a otra área de la computación y soy un observador pasivo, pero es interesante leer la historia desde la perspectiva del lado contrario.
Si vas a pasar RBP como puntero de marco, también podrías tener dos pilas
Una apuntada por RBP, que guarde los marcos de activación, y otra apuntada por RSP, que guarde solo las direcciones de retorno
Entonces la pila de llamadas sería literalmente un arreglo plano de direcciones de retorno, así que ni siquiera haría falta “recorrer la pila”
Para empezar, no entiendo por qué se guardan las direcciones de retorno cerca de las variables locales, y parece tener demasiadas desventajas
El esquema que propones necesitaría dos guard pages, duplicaría la manipulación de la pila y también duplicaría la posibilidad de cache misses
Sé la razón, pero como muchas cosas, solo tuvo sentido por última vez hace unos 30 años, y sus efectos fueron interesantes
Curiosamente, el artículo de Wikipedia no parece destacar bien como una gran característica del modelo que Forth pueda acceder tanto a la pila de parámetros como a la pila de retorno
https://en.wikipedia.org/wiki/Forth_(programming_language)
Habría que demostrar que el costo de moverla a una página separada y gestionar dos punteros es realmente menor que el de los stack cookies/protectors, que ya brindan protección solo donde hace falta
Tampoco hay una ventaja de seguridad real frente a los protectores de pila actuales. Si se puede leer/escribir arbitrariamente, al final eso puede llevar a saltarse la integridad del flujo de control
Virgil no usa punteros de marco. Si no hay asignación dinámica en la pila, el tamaño del marco de una función específica es fijo y se puede encontrar con una simple búsqueda en una tabla binaria
La técnica de Virgil usa además rangos basados en índices de página para acotar la búsqueda con solo unas pocas comparaciones en promedio, combina la información de unwinding con el stackmap para el GC y ocupa muy poco espacio
El código principal está en https://github.com/titzer/virgil/blob/master/rt/native/Nativ... y el resto del código en el mismo directorio implementa la decodificación de metadatos
Creo que los punteros de marco solo tienen sentido cuando el tamaño del marco es dinámico, es decir, cuando se asignan datos en la pila
Es raro usar un mecanismo dinámico cuando un mecanismo estático sería suficiente, y parece que se debe sobre todo a que no hay acuerdo sobre una ABI para codificar metadatos o sobre las rutinas de unwinding
La medición de 1–2% es creíble y está a un nivel similar al costo de las comprobaciones de límites de arreglos
Darle un trato especial al costo del 1% para depuración y profiling, pero mostrarse reacio a agregar capas de seguridad, indica unas prioridades muy extrañas
std::vectorde C++ desactiva por defecto las comprobaciones de límites, y creo que eso es porque C++ fue diseñado por gente completamente loca y para gente igualFuera de eso, no se me ocurre de inmediato ningún lenguaje que no tenga comprobaciones de límites
Buen artículo. Me dio pena cuando desaparecieron los punteros de marco
En Linux, igual que en otros sistemas, mucha gente sufrió durante mucho tiempo por la ausencia de punteros de marco e intentó conservarlos en tantos entornos como fuera posible
Ver que el Linux mainstream los trae de vuelta se siente como un reconocimiento, pero al mismo tiempo también es un poco frustrante
En derivados de Debian es lento porque, por razones de licencia, perf solo empaqueta la ruta lenta de unwinding, pero cuando las herramientas son buenas casi no se nota la diferencia
Me pregunto qué me estoy perdiendo
En general estoy a favor de los frame pointers, pero después de trabajar varios años en esta área tengo algunas observaciones.
Muchos desenrollados de pila basados en frame pointers no consideran problemas que sí cubre la información de desenrollado DWARF. La configuración del frame no es atómica: se compone de dos instrucciones,
push $rbpymov $rsp $rbp, y si el snapshot se toma durante elpush, se puede perder el frame padre.Quizá podría mitigarse inspeccionando el código, pero también puede haber
push %rbpsin relación con un stack frame, así que parece más bien una heurística.También desarrollé la solución rápida de desenrollado dentro del kernel basada en BPF que mencionó Brendan: https://web.archive.org/web/20231222054207/https://www.polar...
Este enfoque no usa DWARF CFI tal cual, sino que lo convierte a un formato de acceso aleatorio usable desde BPF.
Actualmente solo soporta secciones JIT con frame pointers, pero creo que sería posible implementar el desenrollado del intérprete de la JVM para que encaje con el desenrollado nativo.
Idealmente, la activación de frame pointers debería hacerse caso por caso, y el benchmarking es clave.
Según la industria y la naturaleza del software, los compromisos entre rendimiento, observabilidad e indicadores de negocio pueden variar mucho.
El equipo de Fedora hizo un trabajo muy bueno y riguroso en este tema.
Además, un sistema de compilación que permita cambiar esta configuración en todo el sistema, incluidas las bibliotecas dependientes, es muy útil no solo para pruebas sino también para llevarlo a producción.
Por último, tengo expectativas sobre SFrame, en el que está trabajando Indu. Parece que resolverá muchos de los problemas actuales, dejando que los usuarios elijan si usar frame pointers o no, aunque puede tomar varios años hasta que la infraestructura esté lista y todos actualicen.
Para entender qué ocurre realmente se necesita análisis de todo el sistema, y con la estructura actual de distribuciones Linux binarias como Fedora o Debian, prácticamente no hay otra alternativa.
ENTER N,0reserva N de espacio en la pila para variables locales y equivale aproximadamente aPUSH EBP,MOV ESP,ESP,SUB SP,N.Aunque no recuerdo si
ENTERexiste en x86-64.Aun así, entre
CALLy la configuración del frame no es atómico, así que si el snapshot se toma después deCALLy antes deENTER, no se obtiene la configuración del frame.La razón por la que
ENTERcasi no se usa es que se consideró demasiado lenta.LEAVEse usa porque es igual de rápida o más rápida que la secuencia de instrucciones que reemplaza, peroENTERtiene un segundo operando que arruina el rendimiento.Ese operando sirve para que funciones anidadas accedan a stack frames superiores, y su costo de uso es muy alto.
Me parece interesante entender por fin de dónde venían esas montañas de
[unknown]que aparecían en los perfiles.Aun así, no es fácil justificarlo. Una diferencia de rendimiento del 2% en realidad es bastante grande.
Sería bueno poder controlar con más granularidad si se incluyen frame pointers o no.
Con un perfilado detallado, se podría decidir si una función o unidad de compilación específica necesita frame pointers.
No me sorprendería que el resultado fuera que solo unas pocas tareas se vuelven dramáticamente más lentas al incluir frame pointers y que el resto casi no se ve afectado.
__attribute__((optimize("no-omit-frame-pointer")))__attribute__((optimize("omit-frame-pointer")))Este tipo de benchmarks son algo artificiales y no hay que creerlos por completo; las aplicaciones reales suelen arrojar resultados muy distintos.
El perfilado es importante, y he llegado a acelerar varias secciones hasta un 20% perfilando el código con cuidado.
Si una aplicación es muy sensible a la pérdida de rendimiento, se pueden activar los frame pointers y perfilarla en el laboratorio, y luego omitirlos en la versión que se distribuye a los clientes.
Lamentablemente, el soporte para código JIT no es bueno, pero LLVM tiene excelentes hooks para registrar cada método generado y su dirección.
Por eso es relativamente fácil crear un desenrollado de pila en modo mixto simple, aunque principalmente dentro del propio proceso.
La familia DNN de Intel parece volcar la información a un archivo público que perf puede leer, pero los kernels de oneDNN reutilizan constantemente RBP, así que en la práctica no sirve.
También me sorprende la afirmación del artículo de que “los runtimes JIT como la JVM de Java no tienen información DWARF”.
Me pregunto si está desactivada por defecto o si literalmente no está disponible.
Al buscar, casi siempre se llega a temas sobre querer incluir la parte JNI/C en las pilas de la JVM: https://github.com/async-profiler/async-profiler/issues/215