3 points par GN⁺ 2025-03-11 | 1 commentaires | Partager sur WhatsApp
  • Le projet CPython a récemment introduit une nouvelle stratégie d’implémentation pour l’interpréteur de bytecode. Les premiers résultats montraient un gain de performance moyen de 10 à 15 % sur diverses plateformes
  • Cependant, ce gain de performance provenait surtout d’un contournement d’une régression de LLVM 19. Avec de meilleures références de comparaison (par ex. GCC, clang-18, LLVM 19 avec certains drapeaux d’ajustement), le gain retombe à 1-5 %

Résultats de performance

  • Plusieurs builds de l’interpréteur CPython ont été benchmarkés avec différents compilateurs et options de configuration. Les tests ont été effectués sur un serveur Intel et un Macbook Air Apple M1.
  • Tous les builds utilisaient LTO et PGO. La moyenne rapportée par pypeformance/pyperf compare_to a été utilisée avec clang18 comme référence.
  • Comparaison des performances des compilateurs
    • Macbook Air Apple M1 :
      • clang18 : référence
      • clang19 : 1,12x plus lent
      • clang19.taildup : 1,02x plus lent
      • clang19.tc : 1,00x plus lent
      • gcc : N/A
  • L’interpréteur à appels de queue montrait toujours une amélioration de vitesse par rapport à clang-18, mais la baisse de performance lors du passage à clang-19 était encore plus marquée.

Régression LLVM

Bref contexte

  • Les interpréteurs de bytecode traditionnels sont composés d’une instruction switch à l’intérieur d’une boucle while. La plupart des compilateurs compilent le switch en table de saut.
  • Les compilateurs C modernes prennent en charge le motif consistant à prendre l’adresse d’un label et à l’utiliser comme un "goto calculé". CPython utilisait ce motif jusqu’au travail sur les appels de queue.

Régression LLVM 19

  • LLVM 19 a imposé une limite au passage de tail-duplication, en arrêtant la duplication lorsque la taille de l’IR dépassait un certain seuil. Dans CPython, cela a eu pour effet de fusionner tous les sauts de dispatch, annulant complètement l’objectif de l’implémentation fondée sur le goto calculé.

Autres anomalies

  • Il est certain que la modification de la logique de duplication des appels de queue a provoqué la régression, mais cela n’explique pas complètement l’ampleur de la régression.
  • Sur les processeurs modernes, un gain de vitesse de 2 à 4 % est plus courant.

Le goto calculé est-il nécessaire ?

  • Le benchmark clang19.nocg affirme être plus rapide que clang19. Cela montre qu’un compilateur peut appliquer les mêmes optimisations avec un interpréteur basé sur switch.

Correctif

  • La pull request LLVM 114990 a corrigé la régression. Ce correctif rétablit les performances attendues.

Réflexions

À propos du benchmarking

  • Lors de l’optimisation de systèmes, on construit des benchmarks et une méthodologie de benchmarking, puis on évalue les changements proposés.
  • Les benchmarks exigent davantage d’hypothèses et de confiance pour généraliser un point de données donné.

Référence de base

  • Lorsqu’on propose une nouvelle solution ou une nouvelle méthode, il est courant de la comparer à "la meilleure approche actuellement connue".

À propos du génie logiciel

  • Les systèmes logiciels sont complexes, interconnectés et évoluent rapidement.
  • Les compilateurs optimisants sont dans une relation de tension : ils doivent optimiser le code tout en respectant l’intention du programmeur.

Compilateurs optimisants

  • L’attribut musttail représente un nouveau type de fonctionnalité de compilateur liée à l’optimisation. Il pourrait offrir un style plus puissant pour écrire du code sensible aux performances.

Encore un mot sur nix

  • nix a été très utile dans ce projet. Il a grandement aidé à gérer et compiler plusieurs versions de l’interpréteur Python.

1 commentaires

 
GN⁺ 2025-03-11
Avis sur Hacker News
  • Bonjour. Je suis l’auteur de la PR qui a introduit l’interpréteur à tail-calls dans CPython

    • Je tiens d’abord à remercier Nelson, qui a passé presque un mois à remonter à la racine du problème
    • J’ai aussi très honte et je suis vraiment désolé d’avoir commis une erreur aussi importante
    • Même l’équipe CPython ne s’attendait pas à ce qu’un tel bug existe dans le compilateur que nous utilisions
    • J’ai publié ici un billet d’excuses : lien
  • Le benchmarking est vraiment un exercice très difficile

    • J’ai récemment trouvé un moyen de rendre un algorithme environ 15 % plus rapide
    • Pourtant, pendant les tests, le code d’origine est devenu 15 % plus rapide sans même appeler la version plus rapide de la fonction
    • C’était un problème de disposition du code et de la mémoire, car l’alignement avec le cache CPU était meilleur
    • Casey Muratori est en train de faire une série intéressante sur ce sujet
  • Je salue l’auteur pour avoir mis au jour la vérité sur ce problème

    • L’interpréteur à tail-calls de Python 3.14 reste malgré tout une bonne amélioration
    • Cet épisode a montré l’importance de la rigueur dans le benchmarking et des tests dans des environnements variés
    • Il a aussi permis de découvrir un bug de compilateur qui peut désormais profiter à tout le monde
    • Je me demande combien de résultats annonçant « X % plus rapide » sont en réalité dus à des artefacts de benchmark ou à des régressions inconnues
  • Voilà un bon exemple montrant que le C n’est pas un langage « proche de la machine »

    • clang-19 compile « correctement » l’interpréteur à goto calculé, mais génère une sortie complètement différente de l’intention d’optimisation
    • D’autres versions de compilateurs appliquent aussi des optimisations à un interpréteur « naïf » basé sur switch()
  • En ajustant la manière dont le compilateur organise la boucle, l’interpréteur à tail-calls n’est pas aussi efficace qu’annoncé

    • L’architecture et la version du CPU comptent énormément
    • La machine abstraite du C n’est pas assez bas niveau pour exprimer correctement l’intention
    • Certaines implémentations d’interpréteurs particulièrement paranoïaques reviennent à l’écriture directe en assembleur
    • luajit implémente un système de macros pour rendre une boucle assembleur efficace portable entre architectures
  • Évaluer les performances d’un build Python est très difficile

    • Récemment, l’équipe d’astral a montré que les builds conda-forge sont plus rapides que la plupart des autres builds
    • Je me demande comment l’interpréteur à tail-calls se comporte avec d’autres optimisations de build
  • Discussion connexe :

  • Excellent article

    • L’un des articles cités mentionne que 3.14.0a5 est 1,12 fois plus rapide que 3.13
    • Je me demande s’il n’y a pas confusion avec un benchmark exécuté alors que d’autres processus surchargeaient la machine
    • Les benchmarks devraient être réalisés dans un environnement strictement contrôlé afin d’éliminer les variables externes
  • J’ai récemment fait du benchmarking de Python 3.9 à 3.13

    • Les performances se sont améliorées jusqu’à 3.11, mais 3.12 et 3.13 étaient environ 10 % plus lents que 3.11
    • Je pensais que mes propres benchmarks n’étaient pas suffisants, mais j’ai observé le même changement après déploiement sur un service critique
  • Je me demande comment ce type d’optimisation est lié à l’optimisation des tail-calls

    • L’implémentation de la table de saut de l’interpréteur ne devrait pas affecter la création des frames de pile