El rendimiento del intérprete de llamadas en cola de Python 3.14
(blog.nelhage.com)- El nuevo intérprete de llamadas en cola de CPython al principio parecía mejorar en promedio entre 10% y 15% en pyperformance, pero al ajustar la línea base la mejora real se reduce a alrededor de 1% a 5% según la configuración
- La gran mejora no se debía tanto al efecto exclusivo de la nueva implementación, sino más bien a que evitaba una regresión de LLVM 19; la variable clave fue que Clang 19 no logró reproducir correctamente el despacho previo basado en computed goto
- En un Intel Raptor Lake i5-13500, la compilación con clang19 fue 1.09 veces más lenta que con clang18 y clang19.tc fue 1.03 veces más rápida, pero en un Apple M1 MacBook Air clang19 fue 1.12 veces más lenta y clang19.tc se quedó en un nivel de 1.00 veces más lento
- Debido al límite de tail duplication de LLVM, la cantidad de saltos indirectos bajó de 332 en clang18 a 3 en clang19, por lo que la estructura prevista del intérprete basado en computed goto prácticamente desapareció
- El enfoque de llamadas en cola sigue siendo una mejora relevante, pero para código sensible al rendimiento un método como
musttail, que exige explícitamente cierta optimización del compilador, puede ser más robusto
El efecto de la línea base que parecía una mejora de rendimiento
- Hace aproximadamente un mes, el proyecto CPython integró una nueva estrategia de implementación para el intérprete de bytecode
- Los resultados iniciales mostraban una mejora promedio de rendimiento de 10% a 15% en varias plataformas y en los benchmarks de pyperformance
- Un análisis posterior mostró que esa gran mejora se debía principalmente a que evitaba por casualidad una regresión de LLVM 19
- Frente a GCC, clang-18 o LLVM 19 con ciertos flags de ajuste, la mejora baja a alrededor de 1% a 5%
- El intérprete de llamadas en cola sí produce una mejora real de velocidad, pero el avance es más moderado de lo que sugerían las cifras iniciales
- Si se compiló con clang-19 o una versión posterior, es posible que la ruta anterior en realidad fuera entre 10% y 15% más lenta
- Simon Willison reprodujo una mejora de 10% al comparar una compilación de
python-build-standalonecon Python 3.13
- Simon Willison reprodujo una mejora de 10% al comparar una compilación de
Configuración del benchmark y cifras clave
- Se compararon varias compilaciones de CPython en un servidor Intel y en un Apple M1 MacBook Air
- El servidor Intel era un Raptor Lake i5-13500 operado por Hetzner
- Todas las compilaciones usaron LTO y PGO
- Para reproducir las compilaciones se usó una configuración de nix
- Los elementos comparados fueron los siguientes
clang18: Clang 18.1.8, computed gotogcc: GCC 14.2.1, computed goto, solo en Intelclang19: Clang 19.1.7, computed gotoclang19.tc: Clang 19.1.7, nuevo intérprete de llamadas en colaclang19.taildup: Clang 19.1.7, computed goto y flag de ajuste-mllvmpara evitar la regresión
- Los resultados promedio de pyperformance tomando
clang18como línea base fueron los siguientes- Raptor Lake i5-13500:
clang19: 1.09 veces más lentoclang19.taildup: 1.01 veces más rápidoclang19.tc: 1.03 veces más rápidogcc: 1.02 veces más rápido
- Apple M1 MacBook Air:
clang19: 1.12 veces más lentoclang19.taildup: 1.02 veces más lentoclang19.tc: 1.00 veces más lento
- Raptor Lake i5-13500:
- El intérprete de llamadas en cola mostró cierta mejora frente a clang-18, pero fue menor que la caída de rendimiento provocada al pasar a clang-19
- No se pudo medir
clang18.tc- Porque el intérprete de llamadas en cola depende de una función nueva del compilador incorporada en Clang 19
- Esa limitación obligó a probar más combinaciones de benchmarks para entender la situación
La regresión de LLVM 19 que desarmó el despacho
- Un intérprete tradicional de bytecode procesa opcodes con una instrucción
switchdentro de un buclewhile- El compilador normalmente compila ese
switchcomo una tabla de saltos y un salto indirecto
- El compilador normalmente compila ese
- Desde hace mucho se sabe que estos intérpretes pueden acelerarse si se duplica la lógica de despacho en el cuerpo de cada opcode
- En lugar de volver al inicio del bucle al final de cada opcode, cada uno incluye por separado la lógica para decodificar la siguiente instrucción e indexar la tabla de saltos
- Los compiladores de C ofrecen la posibilidad de obtener la dirección de una etiqueta y usarla como computed goto, y CPython usaba este estilo de bucle de intérprete antes del trabajo de llamadas en cola
- Clang/LLVM, por razones de rendimiento del compilador, fusiona internamente varios
gotode computed goto en una sola instrucción LLVMindirectbr- Después, en la etapa de generación de código, aplica tail duplication para volver a duplicar la lógica de bifurcación en cada ubicación
- Este flujo está documentado a alto nivel en una antigua entrada del blog de LLVM
- LLVM 19 introdujo un límite en el pass de tail duplication para evitar aumentos severos de tiempo de compilación o uso de memoria en ciertos casos
- En CPython, ese límite hizo que Clang dejara los saltos de despacho en su estado fusionado
- Como resultado, el objetivo de la implementación basada en computed goto quedó prácticamente anulado
- Este problema ya había sido identificado antes en implementaciones de otros lenguajes con bucles de intérprete similares, pero no se sabía que afectara a CPython
- La diferencia se ve directamente al desensamblar el código objeto y contar los saltos indirectos
_PyEval_EvalFrameDefaulten la compilación con clang18:jmp *332 veces_PyEval_EvalFrameDefaulten la compilación con clang19:jmp *3 veces
La posición ambigua que mostró computed goto
- Que el cambio en la lógica de tail duplication causó la regresión se confirma porque, al corregirlo, se recupera el rendimiento del nivel de clang-18
- Aun así, la magnitud de la regresión no queda explicada por completo
- Históricamente se ha citado que duplicar el despacho de opcodes acelera un intérprete entre 20% y 100%
- En procesadores modernos con mejores predictores de bifurcación, estudios más recientes muestran mejoras menores, de alrededor de 2% a 4%
- Python también admite un intérprete antiguo con una sola instrucción
switchcomo opción de configuraciónclang18.nocg: 1.01 veces más rápido que clang18clang19.nocg: 1.02 veces más lento que clang18clang19: 1.09 veces más lento que clang18
- Que
clang19.nocghaya resultado más rápido queclang19añade otro giro inesperado- Clang 18 o Clang 19 con los flags apropiados también duplican la lógica de despacho en el intérprete basado en
switch
- Clang 18 o Clang 19 con los flags apropiados también duplican la lógica de despacho en el intérprete basado en
- La comparación de la cantidad de saltos indirectos también refleja esta diferencia
clang18: 332clang18.nocg: 306clang19.nocg: 3clang19: 3
- En Clang moderno, el intérprete completo con computed goto podría ser una complejidad innecesaria
- Porque el compilador puede aplicar la misma transformación al código basado en
switch - En sentido inverso, computed goto por sí solo tampoco bastó para garantizar la transformación
- Porque el compilador puede aplicar la misma transformación al código basado en
- GCC 14.2.1 no duplicó el
switch, pero al usar computed goto sí implementó el comportamiento esperado
Corrección y métodos para evitarlo
- LLVM pull request 114990 se fusionó justo después de publicarse el artículo y corrigió la regresión
- Incluso en benchmarks previos a la fusión se confirmó que esa corrección recuperaba el rendimiento esperado
- En las versiones publicadas antes de la corrección, el flag de ajuste agregado por el PR que introdujo la regresión permite modificar el umbral de corte de tail duplication
- En clang-19, si ese límite se fija en un valor muy grande, se puede recuperar un comportamiento similar
- En compilaciones con LTO, pasar ese parámetro es más complicado
- Tail duplication ocurre durante la generación de código, y en una compilación LTO esa generación ocurre en tiempo de enlazado, no en tiempo de compilación
- Por eso el flag debe pasarse no solo al compilador, sino también a
lld
- Un ejemplo de la configuración usada fue pasar
-mllvm -tail-dup-pred-size=5000enOPTyLDFLAGSdurante la etapa de./configure
El problema de la línea base que reveló el benchmarking
- Los benchmarks pueden medir con precisión la diferencia de rendimiento entre compilaciones concretas, pero para extender ese resultado a una “mejora general de rendimiento” hacen falta supuestos adicionales
- El benchmark del intérprete de llamadas en cola mostró resultados entre 10% y 15% más rápidos frente al intérprete previo con computed goto, pero la línea base era demasiado compleja para generalizar esa conclusión
- En trabajo de rendimiento, con qué línea base se compara es un problema difícil una y otra vez
- Incluso si se entiende teóricamente el mejor enfoque conocido, ajustar correctamente el sistema operativo real, las opciones del compilador y los flags es un problema aparte
- Benchmarks públicos hechos sobre hardware antiguo o a escalas difíciles de reproducir pueden no ser adecuados para comparaciones directas
- En artículos de aprendizaje automático también suele ser más importante preguntar primero “¿contra qué línea base se comparó?” que “¿qué se hizo?” al evaluar afirmaciones de mejora algorítmica
- Es fácil obtener resultados llamativos si se compara contra una línea base mal ajustada
Compiladores optimizadores y musttail
- El caso de computed goto muestra que pueden chocar distintas expectativas sobre un compilador optimizador
- El compilador debe respetar la intención del programador y preservar el mismo comportamiento
- Al mismo tiempo, para hacer el código más rápido también debe aplicar transformaciones complejas y poco intuitivas
- clang-19 compiló correctamente el intérprete con computed goto desde el punto de vista del comportamiento del programa, pero produjo una salida completamente distinta a la intención de optimización
- Otras versiones de Clang aplican al intérprete simple basado en
switch()la misma optimización que se pretendía - El computed goto a nivel de código fuente y la duplicación de despacho a nivel de código máquina aparecen casi como conceptos ortogonales
- Como el resultado de ejecución es el mismo, las herramientas actuales tienen dificultades para expresar esta diferencia de forma consistente
- El intérprete de llamadas en cola se basa en el atributo
musttailmusttailno cambia el comportamiento observable tradicional del programa, sino que se parece más a una conversación con el optimizador- El compilador debe poder realizar cierta optimización y, si no puede, se exige que falle la compilación
- Este estilo podría ser una forma más robusta de escribir código sensible al rendimiento incluso mientras el compilador sigue evolucionando
- También valdría la pena estudiar si un atributo hipotético como
[[clang::musttailduplicate]]podría reemplazar el computed goto del buclewhiledel intérprete
La reproducibilidad y los límites que aportó nix
nixfue de gran ayuda para gestionar varias compilaciones del intérprete de Python- Durante el experimento se construyeron y evaluaron decenas de intérpretes de Python con los compiladores
gcc,clang-18,clang-19yclang-20, junto con varias combinaciones de flags
- Durante el experimento se construyeron y evaluaron decenas de intérpretes de Python con los compiladores
- Gracias a
nix, fue posible mantener versiones en paralelo de manera reproducible y aislada- Se podía saber con certeza qué compilador y qué flags se usaron en cada compilación
- La definición de la matriz de compilaciones también podía manejarse con abstracciones cortas
- Incluso fue posible compilar un LLVM personalizado con el parche de corrección del bug y luego usar ese compilador para construir Python con unas 10 líneas de código
- También hubo desventajas
nixdifiere de la forma habitual de usar software, así que no es posible descartar por completo que esas diferencias hayan influido en los benchmarks o en las conclusiones- Por ejemplo, al inicio se descubrió que nix compila proyectos por defecto con ciertos flags de hardening, y que esos flags afectaban de forma desproporcionada al intérprete de llamadas en cola
- Nix ofrece mucha potencia para escalar y personalizar, pero encontrar una forma concreta de hacer ciertas personalizaciones requirió bastante prueba y error y explorar el código fuente de
nixpkgs
1 comentarios
Opiniones de Hacker News
Soy el autor del PR que incorporó el intérprete con llamadas de cola en CPython.
Primero, gracias a Nelson, que pasó casi un mes encontrando la causa de este problema.
Ni yo, y probablemente tampoco el equipo de CPython, esperábamos que el compilador usado como línea base tuviera un bug así; cometí un error grande y me siento muy avergonzado y apenado.
También publiqué una disculpa: https://fidget-spinner.github.io/posts/apology-tail-call.htm...
Se anunció una mejora de rendimiento del 10 al 15%, pero con un compilador sin bugs está más cerca del 1 al 5%; y esa cifra tampoco estaba completamente equivocada, solo era válida bajo ciertas condiciones.
Creó una mejora, la midió y el PR fue revisado, así que hizo lo que tenía que hacer. Dio la casualidad de que la versión de clang usada para la medición tenía un problema y eso hizo que las cifras se malinterpretaran; parece un error razonable en el que cualquiera podría caer.
Aun así, trajo una mejora de rendimiento significativa y también encontró una regresión del compilador, así que las cifras incorrectas parecen algo menor en comparación. Tampoco tengo claro quién salió realmente perjudicado por esto, y no me parece algo que requiera una disculpa.
Si en un sistema tan antiguo como el intérprete de Python se mantiene un 3 a 5%, eso por sí solo ya es un gran logro y hay motivos de sobra para sentirse orgulloso.
Después de unos 30 años, uno empieza a sospechar de cualquier mejora de rendimiento significativa en sistemas que llevan mucho tiempo existiendo, especialmente si supera el 1%.
Sí existen mejoras reales, pero no son comunes; a menudo solo se movió el tiempo a otra parte y por eso no aparece en el benchmark. Además, los benchmarks se hacen en entornos controlados para aislar el efecto, mientras que el software real corre junto con todo tipo de otras cosas en una VM o en el escritorio.
He visto muchas veces que una mejora que parecía claramente grande en un entorno aislado desaparece o se vuelve negativa al entrar en un entorno de producción.
CPython lo tiene aún más difícil porque debe apuntar a muchos entornos, y no hay un único objetivo de producción sobre el cual decir: “si no se acelera en producción, entonces no se aceleró de verdad”. Intentar mejorar el rendimiento en ese mundo es realmente difícil.
En definitiva, el tuning y la medición de rendimiento son muy difíciles, y si hay algo por lo que sentirse apenado es solo por haber aprendido ese hecho.
Espero que no tengan miedo de equivocarse. Al final, todo el mundo se equivoca. Basta con decir, como ahora, “parece que esto lo arruinamos nosotros”, y decidir cómo manejarlo y cómo evitarlo en el futuro.
[1] Esto es común no solo en rendimiento, sino también en procesos humanos. Por ejemplo, aunque el equipo de una herramienta de revisión de código diga “redujimos el tiempo de revisión de código en un 15% y aceleramos el flujo de trabajo de todos”, en realidad podría haber generado más trabajo en otra parte del sistema, sin acelerar el flujo completo, y simplemente haber movido ese 15% a un lugar que no se midió.
En teoría, con este grafo de flujo de control y este perfil, el compilador debería tener suficiente información para generar código óptimo para un intérprete tradicional basado en
switch(). Pero en la práctica, cuando una función es tan grande y está tan interconectada, uno termina peleándose con el compilador.Derrama a memoria variables importantes que uno quisiera mantener en registros, adelanta manipulaciones del stack frame que uno querría limitar alrededor de llamadas a funciones fallback, y fusiona rutas de código idénticas que uno quería separar por la predicción de ramas. Puede sentirse como tocar el piano con guantes puestos.
Aquí ocurrió precisamente esa “fusión de rutas de código idénticas”, y el compilador “con bug” fusionó rutas idénticas y empeoró el rendimiento.
El compilador “corregido” ya no lo hace, pero esa corrección al final se parece más a un ajuste de heurísticas internas del compilador. No hay garantía de que este u otros compiladores mantengan en el futuro esas heurísticas de una forma que nos favorezca.
En cambio, un intérprete con llamadas de cola permite expresar en el propio intérprete el patrón de código máquina que se quiere. Si se usan juntos los atributos
musttail,noinlineypreserve_none, se puede restringir el problema para depender mucho menos de las heurísticas del optimizador.Por eso, el beneficio del intérprete con llamadas de cola va más allá de una simple mejora de rendimiento del 3 al 5%; en algunos compiladores puede ser una mejora de rendimiento confiable aún mayor.
¿Se podrían mejorar los benchmarks para evitar casos similares?
[0] https://github.com/faster-cpython/benchmarking-public
Hacer benchmarking correctamente es realmente endemoniadamente difícil. Hay demasiados factores que te engañan.
Hace poco pensé que había encontrado una forma de hacer que cierto algoritmo fuera alrededor de un 15% más rápido. Al menos eso decían todos los benchmarks.
Pero incluso cuando dupliqué la función más rápida en el arnés de pruebas y en realidad no la llamé, sino que seguí llamando solo a la versión lenta original, todavía era 15% más rápida. Es decir, código que ni siquiera se ejecutaba hizo más rápido al código original.
Por supuesto, era un problema de disposición del código y la memoria: algo se movió y terminó encajando mejor en la caché de la CPU.
Es realmente difícil saber si la mejora de velocidad obtenida se debe a que el código en realidad “mejoró”, o si simplemente tuviste suerte y conseguiste una mejor alineación en algún lado.
Casey Muratori está escribiendo en Substack una serie muy interesante sobre este tema.
Felicitaciones al autor por haber investigado a fondo lo que pasaba realmente. El intérprete con llamadas de cola de Python 3.14 sigue siendo una buena mejora, y ganar unos cuantos puntos porcentuales en el runtime de un lenguaje es un logro difícil.
Pero no era un almuerzo gratis mágico del 15%.
Lo más importante es que esto muestra muy bien la importancia del rigor en los benchmarks y de probar en múltiples entornos. También reveló un bug del compilador que podría beneficiar a todos.
Es el tipo de análisis profundo que te hace volver a verificar la próxima gran afirmación de mejora de performance. Una pregunta para pensar más es cuántos de los numerosos resultados de “X% más rápido” que vemos hoy en realidad son artefactos del benchmark o regresiones desconocidas.
¿Cómo podemos evitar mejor estas trampas en el futuro?
¿No se benchmarkea el compilador en sí? ¿Los benchmarks existentes del lado del compilador o de Python no usaban ese compilador?
Este es un buen ejemplo de lo poco acertado que es decir que C está “cerca de la máquina” o que es “ensamblador portable”. Los optimizadores modernos cambian la lógica agresivamente si no hay efectos observables.
En el artículo también se decía que “clang-19 compila el intérprete con computed goto de forma ‘correcta’ en el sentido de que el binario resultante produce todos los valores esperados, pero al mismo tiempo esa salida va completamente en contra de la intención de la optimización. Además, otras versiones del compilador aplican una optimización al intérprete ‘ingenuo’ basado en
switch(), implementando exactamente la misma optimización que ‘pretendíamos’ lograr reescribiendo el código fuente”.En C puedes confiar en que
a += 1incrementa un valor numérico, pero en C++ la misma expresión puede asignar memoria, desenrollar la pila de llamadas o hacer cosas desconocidas. Del mismo modo,a = "a"en C es una simple asignación de puntero, mientras que en C++ puede implicar asignación de memoria, etc.La frase “C es ensamblador portable” no significa que cada sentencia se compile directamente a una instrucción de máquina equivalente.
No sorprende que el compilador, al tocar la estructura del bucle, haya hecho que el intérprete con llamadas de cola en su conjunto no fuera tan efectivo como se anunció.
Las implementaciones paranoicas de intérpretes incluso vuelven a escribir ensamblador a mano. LuaJIT es famoso por haber implementado un sistema de macros para hacer portable entre arquitecturas una implementación muy eficiente del bucle en ensamblador. Por eso también es divertido experimentar con estas cosas.
Hace unos años también preparé un artículo y pruebas sobre formas populares de implementar bucles de intérprete:
https://github.com/vkazanov/bytecode-interpreters-post
Los predictores de saltos modernos, si el tramo de ejecución es lo suficientemente largo y el comportamiento del código interpretado en sí es estable, pueden predecir casi perfectamente incluso un único salto indirecto.
Hay un paper que estudió esto tanto en hardware real como con predictores de saltos simulados específicos: https://inria.hal.science/hal-01100647/document
Los experimentos que hice en este proyecto también respaldan anecdóticamente la misma conclusión. No lo incluí en el artículo, pero miré algunos intérpretes con contadores de CPU de hardware y
perf stat, y las predicciones erróneas de saltos no aparecieron como el factor dominante.Evaluar el rendimiento de una build de Python es extremadamente difícil, porque hay demasiadas técnicas de build que pueden mejorar el rendimiento.
Hace poco, la gente de astral también se encontró con este problema al mostrar que las builds de conda-forge eran notablemente más rápidas que la mayoría:
https://github.com/astral-sh/python-build-standalone/pull/54...
Me da curiosidad cómo se comporta el intérprete con llamadas de cola cuando se usa junto con otras optimizaciones de build existentes.
El autor prueba, mediante algoritmos genéticos, combinaciones de varios compiladores y flags de optimización.
Discusión relacionada:
https://docs.python.org/3.14/whatsnew/3.14.html#whatsnew314-... --> https://news.ycombinator.com/item?id=42999672 (66 puntos | hace 25 días | 22 comentarios)
https://blog.reverberate.org/2025/02/10/tail-call-updates.ht... --> https://news.ycombinator.com/item?id=43076088 (124 puntos | hace 18 días | 92 comentarios)
Buen artículo. Me llamó la atención un detalle
En uno de los artículos citados, https://simonwillison.net/2025/Feb/13/python-3140a5/, escribió: “3.14.0a5 fue 1.12 veces más rápido que 3.13 en el benchmark, en mi M2 MacBook Pro extremadamente sobrecargada”
Esa parte es bastante confusa. ¿Quiere decir que corrió el benchmark mientras la computadora estaba sobrecargada con otros procesos? Si es así, ¿no vuelve los resultados completamente poco confiables?
Pensaba que estos benchmarks se hacían en entornos muy controlados para eliminar variables externas
Para lo de CPython, ver https://github.com/faster-cpython/benchmarking-public
Algunas personas aquí dicen que 10% es “grande” y 1% es “normal”, pero optimizaciones como el inlining parcial de Fibonacci con doble recursión pueden reducir exponencialmente tanto la carga de trabajo real como el tiempo
Con argumentos de dos dígitos puede ser más de 10 veces, es decir, miles por ciento. Estrictamente hablando, es exponencial respecto de la diferencia en profundidad de recursión, no respecto del tamaño del problema [1]
Los compiladores de C también pueden reaccionar de forma muy sensible a las métricas de inlining de código, así que que esa enorme mejora de velocidad aparezca en la práctica puede depender mucho de la forma del código
Por eso, parte del problema es que las CPU se han vuelto muy sofisticadas y complejas, pero otro aspecto es que los compiladores más allá de
-O0o-O1también se han vuelto sofisticados y complejosEl artículo es bueno y vale la pena leerlo, pero también es uno de muchísimos ejemplos de cómo la interacción entre dos cosas complejas puede producir resultados muy sorprendentes. Esto también es cierto fuera de la computación
La gente tiende mucho a simplificar en exceso, por más veces que se repita esta lección
Además, el artículo usa al menos dos CPU, Intel y Apple M1, y dos compiladores, gcc y clang, pero en entornos reales de despliegue puede haber muchas más generaciones e implementaciones de Intel, AMD y ARM, además de otros compiladores. En otras palabras, solo se está muestreando una parte muy pequeña de la complejidad total
Para hacerlo de forma más científica, especialmente con diferencias como “1.01 veces”, las mediciones de tiempo deberían tener algún tipo de barras de error. La desviación estándar de la media, o en estos casos quizá la desviación estándar del valor mínimo, podría ser mejor [2]
Para reducir el error de medición, probablemente también haga falta fijar la planificación de los núcleos de CPU en el sistema operativo
[1] https://stackoverflow.com/questions/360748/computational-com...
[2] https://github.com/c-blake/bu/blob/main/doc/tim.md
Hace poco hice benchmarks de Python 3.9 a 3.13, y hasta 3.11 todo venía mejorando
Pero Python 3.12 y 3.13 fueron alrededor de 10% más lentos que 3.11
Pensé que mi propio benchmark quizá no era lo suficientemente bueno, pero aun así lo desplegué en un servicio crítico y vi el mismo cambio en las métricas recopiladas
¿Alguien más tuvo el mismo problema?
[0]: https://github.com/python/cpython/issues/123540