3 puntos por GN⁺ 2025-03-11 | 1 comentarios | Compartir por WhatsApp
  • El proyecto CPython introdujo recientemente una nueva estrategia de implementación para el intérprete de bytecode. Los resultados iniciales mostraron una mejora promedio de rendimiento de 10-15% en varias plataformas
  • Sin embargo, esta mejora de rendimiento fue principalmente el resultado de evitar un problema de regresión en LLVM 19. Al compararlo con referencias mejores (por ejemplo, GCC, clang-18, LLVM 19 con ciertas banderas de ajuste), la mejora de rendimiento se reduce a 1-5%

Resultados de rendimiento

  • Se hicieron benchmarks de varias compilaciones del intérprete de CPython usando distintos compiladores y opciones de configuración. Se probó en un servidor Intel y en una Macbook Air con Apple M1.
  • Todas las compilaciones usaron LTO y PGO. Se usó como referencia clang18 y el promedio reportado en pypeformance/pyperf compare_to.
  • Comparación de rendimiento entre compiladores
    • Macbook Air con Apple M1 :
      • clang18: referencia
      • clang19: 1.12 veces más lento
      • clang19.taildup: 1.02 veces más lento
      • clang19.tc: 1.00 veces más lento
      • gcc: N/A
  • El intérprete con llamada en cola seguía mostrando una mejora de velocidad frente a clang-18, pero la degradación de rendimiento al pasar a clang-19 fue más drástica.

Regresión en LLVM

Contexto breve

  • Un intérprete tradicional de bytecode está compuesto por una sentencia switch dentro de un bucle while. La mayoría de los compiladores compilan switch como una tabla de saltos.
  • Los compiladores modernos de C soportan el patrón de tomar la dirección de labels y usarla como un "goto calculado". CPython usó este patrón hasta el trabajo de llamada en cola.

Regresión en LLVM 19

  • LLVM 19 impuso límites al paso de tail-duplication para que deje de duplicar cuando el tamaño del IR supera cierto umbral. Como resultado, en CPython todos los saltos de despacho se fusionaron, anulando por completo el propósito de la implementación basada en goto calculado.

Otras anomalías

  • Hay confianza en que el cambio en la lógica de duplicación para llamadas en cola causó la regresión, pero no se puede explicar completamente la magnitud de esa regresión.
  • En procesadores modernos, una mejora de velocidad de 2-4% es más común.

¿Es necesario el goto calculado?

  • El benchmark clang19.nocg afirma ser más rápido que clang19. Esto muestra que el compilador puede aplicar la misma optimización usando un intérprete basado en switch.

Corrección

  • El pull request 114990 de LLVM corrigió la regresión. Esta corrección restaura el rendimiento esperado.

Reflexiones

Sobre los benchmarks

  • Al optimizar un sistema, se construyen benchmarks y una metodología de benchmarking, y se evalúan los cambios propuestos.
  • Los benchmarks requieren más supuestos y creencias para generalizar puntos de datos específicos.

Línea base

  • Al proponer una solución o método nuevo, es común compararlo con "el mejor enfoque conocido actualmente".

Sobre la ingeniería de software

  • Los sistemas de software son complejos, están interconectados y cambian rápidamente.
  • Los compiladores optimizadores viven en una tensión entre respetar la intención del programador y optimizar el código.

Compiladores optimizadores

  • El atributo musttail representa un nuevo tipo de funcionalidad de compilador relacionada con la optimización. Puede ofrecer un estilo más potente para escribir código sensible al rendimiento.

Una cosa más sobre nix

  • nix fue muy útil en este proyecto. Ayudó mucho a administrar y compilar múltiples versiones del intérprete de Python.

1 comentarios

 
GN⁺ 2025-03-11
Comentarios de Hacker News
  • Hola. Soy el autor del PR que introdujo el intérprete con tail-calling en CPython

    • Primero, me gustaría agradecer a Nelson por dedicar casi un mes a encontrar la raíz de este problema
    • También me siento muy avergonzado y lo siento mucho por haber cometido un error tan grande
    • Ni el equipo de CPython esperaba que el compilador que usamos tuviera un bug como este
    • Publiqué aquí una entrada de blog de disculpa: enlace
  • Hacer benchmarking realmente es una tarea muy difícil

    • Recientemente descubrí una forma de hacer que un algoritmo fuera aproximadamente un 15% más rápido
    • Sin embargo, durante las pruebas, el código original se volvió un 15% más rápido incluso sin llamar a la versión más rápida de la función
    • Esto se debió a un problema de distribución del código y la memoria, porque la alineación con la caché de la CPU encajaba mejor
    • Casey Muratori está haciendo una serie interesante sobre este tema
  • Aplausos al autor por llegar al fondo de la verdad de este problema

    • El intérprete con tail-call de Python 3.14 sigue siendo una buena mejora
    • Este incidente nos enseñó la importancia del rigor en el benchmarking y de probar en entornos diversos
    • Además, ahora se descubrió un bug del compilador que puede beneficiar a todos
    • Me pregunto cuántos resultados de "X% más rápido" en realidad se deben a artefactos del benchmarking o a regresiones desconocidas
  • Este es un buen ejemplo de que C no es un lenguaje "cercano a la máquina"

    • clang-19 compila "correctamente" el intérprete con goto computado, pero genera una salida completamente distinta de la intención de optimización
    • Otras versiones del compilador también aplican optimizaciones al intérprete "ingenuo" basado en switch()
  • Al ajustar la forma en que el compilador organiza el bucle, el intérprete con tail-call no resulta tan efectivo como se anunció

    • La arquitectura y la versión de la CPU importan muchísimo
    • La máquina abstracta de C no es lo suficientemente de bajo nivel como para expresar bien la intención
    • Algunas implementaciones de intérpretes particularmente paranoicas vuelven a escribir ensamblador directamente
    • luajit implementa un sistema de macros para hacer portables entre arquitecturas las implementaciones eficientes de bucles en ensamblador
  • Evaluar el rendimiento de los builds de Python es muy difícil

    • Recientemente, el equipo de astral mostró que los builds de conda-forge son más rápidos que la mayoría de los otros builds
    • Me pregunto cómo interactúa el intérprete con tail-call con otras optimizaciones del build
  • Discusión relacionada:

  • Excelente artículo

    • En uno de los artículos citados se menciona que 3.14.0a5 es 1.12 veces más rápido que 3.13
    • Me confunde si el benchmark se ejecutó mientras otros procesos estaban sobrecargando el sistema
    • Los benchmarks deben realizarse en un entorno estrictamente controlado para eliminar variables externas
  • Recientemente hice benchmarking desde Python 3.9 hasta 3.13

    • Hasta 3.11, el rendimiento mejoró, pero 3.12 y 3.13 fueron aproximadamente un 10% más lentos que 3.11
    • Pensé que mis propios benchmarks no eran suficientes, pero observé el mismo cambio incluso al desplegarlo en un servicio principal
  • Me pregunto cómo se relaciona este tipo de optimización con la optimización de tail-call

    • La implementación de la tabla de saltos del intérprete no debería afectar la creación de stack frames