L'optimiseur de peephole de CPython parcourt le bytecode à la recherche de blocs inaccessibles — des séquences d'instructions suivant un saut inconditionnel (JUMP_ABSOLUTE, JUMP_FORWARD, RETURN_VALUE, RAISE_VARARGS) qui ne sont liées à aucun point d'entrée d'autres branches. Lorsqu'il les identifie, il supprime ces instructions mortes pour réduire la pression sur le cache et améliorer la densité des instructions.
Puisque les tables de gestion des exceptions de Python, les constructions de boucle et les sauts conditionnels stockent les emplacements cibles sous forme d'offsets bytes absolus dans la séquence co_code de l'objet de code, l'optimiseur doit construire une carte de relocation qui suit le nombre de bytes supprimés avant chaque instruction survivante. Il parcourt ensuite toutes les instructions de saut et les plages des gestionnaires d'exception, ajustant leurs offsets cibles en soustrayant le nombre cumulatif de suppressions à la position cible. Cela garantit que les blocs SETUP_FINALLY, les boucles FOR_ITER et les sauts définis par l'utilisateur atteignent le bon opcode même après que le bytecode précédent a été compacté.
Une équipe de pipeline de données a remarqué que le script de démarrage de leur utilitaire ETL contenait de vastes blocs de journalisation de débogage protégés par des drapeaux if DEBUG:, où DEBUG était une constante de niveau module définie sur False. Malgré la condition étant statiquement fausse, le bytecode compilé contenait encore la logique de journalisation après compilation, augmentant la taille du fichier .pyc de 40 % et dégradant légèrement la localité du cache d'instructions sur les serveurs de production.
Ils ont évalué trois approches distinctes.
Premièrement, ils ont envisagé d'utiliser un préprocesseur C ou la templating Jinja2 pour supprimer le code de débogage avant le déploiement. Cette approche garantirait zéro bytecode de débogage en production, mais elle introduisait une dépendance complexe à une étape de construction et risquait une divergence subtile entre les bases de code de développement et de production, compliquant le débogage des problèmes de production où le code source ne correspondait plus au bytecode en cours d'exécution.
Deuxièmement, ils ont évalué le refactoring de tous les blocs de débogage en fonctions séparées dans un sous-module, espérant que les fonctions non appelées ne seraient pas chargées. Cependant, le système d'importation de Python compile tous les modules en même temps, et les fonctions non appelées restent comme objets de code dans le dictionnaire du module ; l'optimiseur de peephole ne réalise pas d'élimination de code mort interprocédural, donc la taille du bytecode est restée inchangée.
Troisièmement, ils ont étudié le pipeline de compilation de CPython et ont découvert que l'optimiseur de peephole supprime automatiquement le code suivant les constructions if False: parce que le compilateur émet un saut inconditionnel autour du bloc, et le passage de peephole supprime la queue inaccessible. En vérifiant avec le module dis que RETURN_VALUE ou JUMP_FORWARD n'étaient suivis d'aucun code inutilisé, ils ont confirmé que l'optimisation était active. Ils ont choisi de s'appuyer sur ce mécanisme intégré, garantissant que DEBUG était un littéral False plutôt qu'une variable calculée à l'exécution, ce qui a réduit la taille du bytecode compilé de 35 % sans outils supplémentaires.
Pourquoi l'optimiseur de peephole refuse-t-il de supprimer le code inaccessible lorsque la cible du saut précédent est adressée par une instruction de saut calculée ?
Les sauts calculés déterminent leur destination à l'exécution en fonction d'une valeur sur la pile, comme dans les instructions MATCH ou les motifs de dispatch dynamique. Puisque l'optimiseur ne peut pas savoir de manière statique quels offsets pourraient être ciblés, il doit supposer de manière conservatrice que toute instruction pourrait être un point d'entrée. Par conséquent, il ne supprime que le code qui est prouvablement inaccessible via une analyse statique des sauts inconditionnels et des graphes de flux de contrôle, préservant tout bloc qui pourrait être la cible d'un dispatch dynamique pour éviter un comportement indéfini.
Comment l'optimiseur gère-t-il les tables de gestion des exceptions (co_exceptiontable) lors de la suppression des instructions NOP utilisées comme espaces réservés pour les sauts ?
Lorsque le compilateur génère des sauts vers des emplacements futurs non encore connus, il émet souvent des instructions NOP (pas d'opération) comme espaces réservés ou remplissage, puis corrige les cibles de saut plus tard. Lors de l'optimisation de peephole, ces NOP sont supprimés pour économiser de l'espace. L'optimiseur maintient une correspondance bidirectionnelle entre les offsets d'origine et finaux. Lors du traitement de la table des exceptions — qui stocke les offsets start, end et handler pour les blocs try/except — il applique le delta cumulatif des bytes supprimés à chaque entrée. Si un NOP tombe dans une plage d'exception, sa suppression déplace l'offset end vers la gauche, garantissant que la plage de bytecode protégée reste précise et que les exceptions sont capturées aux bonnes limites.
Qu'est-ce qui empêche l'optimiseur de peephole de réorganiser les instructions indépendantes pour améliorer l'efficacité du pipeline, comme on le voit dans les compilateurs C ?
Le bytecode de Python est étroitement couplé à la sémantique de la pile d'évaluation et aux tables de numéros de ligne utilisées pour la génération de traces. Réorganiser des instructions — par exemple, déplacer un LOAD_CONST avant un LOAD_NAME — pourrait changer l'état de la pile lorsque se produit une exception, altérant le numéro de ligne rapporté dans les traces ou violant les invariants de profondeur de pile requis par la boucle de l'interpréteur. De plus, parce que Python permet l'introspection des objets de cadre et f_lasti (le pointeur d'instructions), toute réorganisation arbitraire pourrait casser les débogueurs et les profileurs qui s'appuient sur une correspondance déterministe entre les offsets et le code source. Ainsi, l'optimiseur est limité à supprimer le code inaccessible et à rediriger les sauts sans changer l'ordre relatif des instructions exécutables.