Cinder est la version de production interne de Meta axée sur les performances de CPython 3.10. Il contient un certain nombre d'optimisations de performances, notamment la mise en cache en ligne du bytecode, une évaluation rapide des coroutines, un JIT méthode à la fois et un compilateur de bytecode expérimental qui utilise des annotations de type pour émettre un bytecode spécialisé par type qui fonctionne mieux dans le JIT.
Cinder alimente Instagram, là où il a commencé, et est de plus en plus utilisé dans de plus en plus d'applications Python dans Meta.
Pour plus d'informations sur CPython, consultez README.cpython.rst
.
Réponse courte : non.
Nous avons rendu Cinder accessible au public afin de faciliter la conversation sur la possibilité de transférer en amont une partie de ce travail vers CPython et de réduire la duplication des efforts entre les personnes travaillant sur les performances de CPython.
Cinder n’est pas poli ou documenté pour l’usage de quelqu’un d’autre. Nous n’avons pas envie qu’il devienne une alternative à CPython. Notre objectif en rendant ce code disponible est un CPython unifié plus rapide. Ainsi, même si nous exécutons Cinder en production, si vous choisissez de le faire, vous êtes seul. Nous ne pouvons pas nous engager à corriger les rapports de bogues externes ou à examiner les demandes d'extraction. Nous nous assurons que Cinder est suffisamment stable et rapide pour nos charges de travail de production, mais nous ne garantissons pas sa stabilité, son exactitude ou ses performances pour les charges de travail ou cas d'utilisation externes.
Cela dit, si vous avez de l'expérience dans les environnements d'exécution de langages dynamiques et que vous avez des idées pour rendre Cinder plus rapide ; ou si vous travaillez sur CPython et souhaitez utiliser Cinder comme source d'inspiration pour des améliorations de CPython (ou aider les parties en amont de Cinder vers CPython), veuillez nous contacter ; nous serions ravis de discuter!
Cinder devrait être construit comme CPython ; configure
et make -j
. Cependant, comme la plupart du développement et de l'utilisation de Cinder se produisent dans le contexte très spécifique de Meta, nous ne l'exerçons pas beaucoup dans d'autres environnements. En tant que tel, le moyen le plus fiable de créer et d’exécuter Cinder consiste à réutiliser la configuration basée sur Docker à partir de notre flux de travail GitHub CI.
Si vous souhaitez simplement obtenir un Cinder fonctionnel sans le construire vous-même, notre image Docker d'exécution sera la plus simple (aucun clone de dépôt n'est nécessaire !) :
docker run -it --rm ghcr.io/facebookincubator/cinder-runtime:cinder-3.10
Si vous souhaitez le construire vous-même :
git clone https://github.com/facebookincubator/cinder
docker run -v "$PWD/cinder:/vol" -w /vol -it --rm ghcr.io/facebookincubator/cinder/python-build-env:latest bash
./configure && make
Veuillez noter que Cinder est uniquement construit ou testé sur Linux x64 ; tout le reste (y compris macOS) ne fonctionnera probablement pas. L'image Docker ci-dessus est basée sur Fedora Linux et construite à partir d'un fichier de spécifications Docker dans le dépôt Cinder : .github/workflows/python-build-env/Dockerfile
.
Il existe de nouvelles cibles de test qui pourraient être intéressantes. make testcinder
est à peu près la même chose que make test
sauf qu'il ignore quelques tests problématiques dans notre environnement de développement. make testcinder_jit
exécute la suite de tests avec le JIT entièrement activé, donc toutes les fonctions sont exécutées en JIT. make testruntime
exécute une suite de tests unitaires gtest C++ pour le JIT. Et make test_strict_module
exécute une suite de tests pour les modules stricts (voir ci-dessous).
Notez que ces étapes produisent un binaire Cinder Python sans les optimisations PGO/LTO activées, ne vous attendez donc pas à utiliser ces instructions pour obtenir une accélération sur n'importe quelle charge de travail Python.
Cinder Explorer est un terrain de jeu en direct, où vous pouvez voir comment Cinder compile le code Python de la source à l'assembly - vous êtes invités à l'essayer ! N'hésitez pas à déposer des demandes de fonctionnalités et des rapports de bogues. Gardez à l’esprit que Cinder Explorer, comme le reste, est « pris en charge » au mieux.
Instagram utilise une architecture de serveur Web multi-processus ; le processus parent démarre, effectue un travail d'initialisation (par exemple, chargement du code) et lance des dizaines de processus de travail pour gérer les demandes des clients. Les processus de travail sont redémarrés périodiquement pour un certain nombre de raisons (par exemple, fuites de mémoire, déploiements de code) et ont une durée de vie relativement courte. Dans ce modèle, le système d'exploitation doit copier la page entière contenant un objet alloué dans le processus parent lorsque le nombre de références de l'objet est modifié. En pratique, les objets alloués dans le processus parent survivent aux travailleurs ; tout le travail lié au comptage des références est inutile.
Instagram dispose d'une très grande base de code Python et la surcharge due à la copie sur écriture à partir des objets de longue durée de comptage de références s'est avérée importante. Nous avons développé une solution appelée « instances immortelles » pour fournir un moyen de désactiver les objets du comptage de références. Voir Include/object.h pour plus de détails. Cette fonctionnalité est contrôlée par la définition de Py_IMMORTAL_INSTANCES et est activée par défaut dans Cinder. Il s'agit d'une grande victoire pour nous en production (~ 5 %), mais cela ralentit le code en ligne droite. Les opérations de comptage de références sont fréquentes et doivent vérifier si un objet participe ou non au comptage de références lorsque cette fonctionnalité est activée.
"Shadowcode" ou "shadow bytecode" est notre implémentation d'un interpréteur spécialisé. Il observe des cas particuliers d'optimisation dans l'exécution d'opcodes Python génériques et (pour les fonctions chaudes) remplace dynamiquement ces opcodes par des versions spécialisées. Le cœur du shadowcode réside dans Shadowcode/shadowcode.c
, bien que les implémentations des bytecodes spécialisés se trouvent dans Python/ceval.c
avec le reste de la boucle eval. Les tests spécifiques au Shadowcode se trouvent dans Lib/test/test_shadowcode.py
.
Son esprit est similaire à l'interpréteur adaptatif spécialisé (PEP-659) qui sera intégré à CPython 3.11.
Le serveur Instagram est une charge de travail asynchrone lourde, où chaque requête Web peut déclencher des centaines de milliers de tâches asynchrones, dont beaucoup peuvent être exécutées sans suspension (par exemple grâce à des valeurs mémorisées).
Nous avons étendu le protocole vectorcall pour transmettre un nouvel indicateur, Ci_Py_AWAITED_CALL_MARKER
, indiquant que l'appelant attend immédiatement cet appel.
Lorsqu'il est utilisé avec des appels de fonction asynchrones immédiatement attendus, nous pouvons immédiatement (avec impatience) évaluer la fonction appelée, jusqu'à son achèvement ou jusqu'à sa première suspension. Si la fonction se termine sans interruption, nous pouvons renvoyer la valeur immédiatement, sans allocations de tas supplémentaires.
Lorsqu'il est utilisé avec la collecte asynchrone, nous pouvons immédiatement (avec impatience) évaluer l'ensemble des attendus transmis, évitant potentiellement le coût de création et de planification de plusieurs tâches pour les coroutines qui pourraient être complétées de manière synchrone, les futurs terminés, les valeurs mémorisées, etc.
Ces optimisations ont abouti à une amélioration significative (~ 5 %) de l'efficacité du processeur.
Ceci est principalement implémenté dans Python/ceval.c
, via un nouveau drapeau d'appel vectoriel Ci_Py_AWAITED_CALL_MARKER
, indiquant que l'appelant attend immédiatement cet appel. Recherchez les utilisations de la macro IS_AWAITED()
et de cet indicateur d'appel vectoriel.
Le Cinder JIT est un JIT personnalisé méthode à la fois implémenté en C++. Il est activé via l'indicateur -X jit
ou la variable d'environnement PYTHONJIT=1
. Il prend en charge presque tous les opcodes Python et peut atteindre des améliorations de vitesse de 1,5 à 4 fois sur de nombreux tests de performances Python.
Par défaut, lorsqu'il est activé, il compilera JIT chaque fonction appelée, ce qui pourrait bien rendre votre programme plus lent, pas plus rapide, en raison de la surcharge de la compilation JIT des fonctions rarement appelées. L'option -X jit-list-file=/path/to/jitlist.txt
ou PYTHONJITLISTFILE=/path/to/jitlist.txt
peut le pointer vers un fichier texte contenant des noms de fonctions complets (sous la forme path.to.module:funcname
ou path.to.module:ClassName.method_name
), un par ligne, qui doit être compilé JIT. Nous utilisons cette option pour compiler uniquement un ensemble de fonctions chaudes dérivées des données de profilage de production. (Une approche plus typique pour un JIT serait de compiler dynamiquement les fonctions car elles sont fréquemment appelées. Cela n'a pas encore valu la peine pour nous d'implémenter cela, puisque notre architecture de production est un serveur Web pré-fork, et pour raisons de partage de mémoire, nous souhaitons effectuer toute notre compilation JIT dès le début du processus initial avant que les travailleurs ne soient forkés, ce qui signifie que nous ne pouvons pas observer la charge de travail en cours avant de décider quelles fonctions compiler JIT.)
Le JIT se trouve dans le répertoire Jit/
et ses tests C++ se trouvent dans RuntimeTests/
(exécutez-les avec make testruntime
). Il existe également des tests Python dans Lib/test/test_cinderjit.py
; ceux-ci ne sont pas censés être exhaustifs, puisque nous exécutons l'intégralité de la suite de tests CPython sous le JIT via make testcinder_jit
; ils couvrent les cas extrêmes JIT que l'on ne trouve pas autrement dans la suite de tests CPython.
Voir Jit/pyjit.cpp
pour d'autres options -X
et variables d'environnement qui influencent le comportement du JIT. Il existe également un module cinderjit
défini dans ce fichier qui expose certains utilitaires JIT au code Python (par exemple forcer la compilation d'une fonction spécifique, vérifier si une fonction est compilée, désactiver le JIT). Notez que cinderjit.disable()
désactive uniquement la compilation future ; il compile immédiatement toutes les fonctions connues et conserve les fonctions compilées JIT existantes.
Le JIT abaisse d'abord le bytecode Python à une représentation intermédiaire de haut niveau (HIR) ; ceci est implémenté dans Jit/hir/
. HIR correspond assez étroitement au bytecode Python, bien qu'il s'agisse d'une machine à registres au lieu d'une machine à pile, il est d'un niveau un peu inférieur, il est typé et certains détails qui sont obscurcis par le bytecode Python mais importants pour les performances (notamment le comptage de références) sont exposés explicitement dans HIR. HIR est transformé au format SSA, certaines passes d'optimisation y sont effectuées, puis les opérations de comptage de références y sont automatiquement insérées en fonction des métadonnées sur le refcount et les effets mémoire des opcodes HIR.
HIR est ensuite réduit à une représentation intermédiaire de bas niveau (LIR), qui est une abstraction sur l'assemblage, implémentée dans Jit/lir/
. Dans LIR, nous enregistrons l'allocation, quelques passes d'optimisation supplémentaires, puis enfin LIR est réduit à l'assemblage (dans Jit/codegen/
) en utilisant l'excellente bibliothèque asmjit.
Le JIT en est à ses débuts. Bien qu'il puisse déjà éliminer la surcharge des boucles d'interprétation et offrir des améliorations significatives des performances pour de nombreuses fonctions, nous n'avons fait qu'effleurer la surface des optimisations possibles. De nombreuses optimisations courantes du compilateur ne sont pas encore implémentées. Notre priorisation des optimisations dépend en grande partie des caractéristiques de la charge de travail de production Instagram.
Les modules stricts regroupent quelques éléments en un :
1. Un analyseur statique capable de valider que l'exécution du code de niveau supérieur d'un module n'aura pas d'effets secondaires visibles en dehors de ce module.
2. Un type StrictModule
immuable utilisable à la place du type de module par défaut de Python.
3. Un chargeur de module Python capable de reconnaître les modules activés en mode strict (via un import __strict__
en haut du module), de les analyser pour valider l'absence d'effets secondaires d'importation et de les remplir dans sys.modules
en tant qu'objet StrictModule
.
Static Python est un compilateur de bytecode qui utilise des annotations de type pour émettre du bytecode Python spécialisé et vérifié par type. Utilisé avec Cinder JIT, il peut offrir des performances similaires à celles de MyPyC ou Cython dans de nombreux cas, tout en offrant une expérience de développeur purement Python (syntaxe Python normale, aucune étape de compilation supplémentaire). Static Python plus Cinder JIT atteint 18 fois les performances du CPython d'origine sur une version typée du benchmark Richards. Chez Instagram, nous avons utilisé avec succès Static Python en production pour remplacer tous les modules Cython dans la base de code de notre serveur Web principal, sans régression des performances.
Le compilateur Static Python est construit sur le module compiler
Python qui a été supprimé de la bibliothèque standard dans Python 3 et a depuis été maintenu et mis à jour en externe ; ce compilateur est incorporé à Cinder dans Lib/compiler
. Le compilateur Static Python est implémenté dans Lib/compiler/static/
, et ses tests sont dans Lib/test/test_compiler/test_static.py
.
Les classes définies dans les modules Static Python se voient automatiquement attribuer des emplacements typés (en fonction de l'inspection de leurs attributs de classe typés et de leurs affectations annotées dans __init__
), et les chargements et stockages d'attributs par rapport aux instances de ces types utilisent les nouveaux opcodes STORE_FIELD
et LOAD_FIELD
, qui dans le JIT deviennent directs. charge/stocke depuis/vers un décalage de mémoire fixe dans l'objet, sans aucune indirection d'un LOAD_ATTR
ou STORE_ATTR
. Les classes obtiennent également des tables virtuelles de leurs méthodes, à utiliser par les opcodes INVOKE_*
mentionnés ci-dessous. La prise en charge du runtime pour ces fonctionnalités se trouve dans StaticPython/classloader.h
et StaticPython/classloader.c
.
Une fonction Python statique commence par un prologue caché qui vérifie que les types des arguments fournis correspondent aux annotations de type et déclenche TypeError
dans le cas contraire. Les appels d'une fonction Python statique à une autre fonction Python statique ignoreront cet opcode (puisque les types sont déjà validés par le compilateur). Les appels statiques à statiques peuvent également éviter une grande partie de la surcharge d’un appel de fonction Python typique. Nous émettons un opcode INVOKE_FUNCTION
ou INVOKE_METHOD
qui contient des métadonnées sur la fonction ou la méthode appelée ; ceci plus des modules et des types éventuellement immuables (via StrictModule
) (via cinder.freeze_type()
, que nous appliquons actuellement à tous les types dans les modules stricts et statiques dans notre chargeur d'importation, mais qui pourraient à l'avenir devenir une partie inhérente de Static Python) et compiler La connaissance en temps réel de la signature de l'appelé nous permet (dans le JIT) de transformer de nombreux appels de fonction Python en appels directs vers une adresse mémoire fixe en utilisant la convention d'appel x64, avec un peu plus de surcharge qu'un appel de fonction C.
Le Python statique est toujours typé progressivement et prend en charge le code qui n'est que partiellement annoté ou qui utilise des types inconnus en revenant au comportement dynamique normal de Python. Dans certains cas (par exemple, lorsqu'une valeur de type statiquement inconnu est renvoyée par une fonction avec une annotation de retour), un opcode d'exécution CAST
est inséré qui déclenchera TypeError
si le type d'exécution ne correspond pas au type attendu.
Static Python prend également en charge de nouveaux types pour les entiers machine, les booléens, les doubles et les vecteurs/tableaux. Dans le JIT, celles-ci sont traitées comme des valeurs non encadrées, et par exemple, l'arithmétique entière primitive évite toute surcharge de Python. Certaines opérations sur les types intégrés (par exemple, liste ou indice de dictionnaire ou len()
) sont également optimisées.
Cinder prend en charge l'adoption progressive de modules statiques via un chargeur de module strict/statique qui peut détecter automatiquement les modules statiques et les charger en tant que statiques avec une compilation inter-modules. Le chargeur recherchera les annotations import __static__
et import __strict__
en haut d'un fichier et compilera les modules de manière appropriée. Pour activer le chargeur, vous disposez de l'une des trois options suivantes :
1. Installez explicitement le chargeur au niveau supérieur de votre application via from cinderx.compiler.strict.loader import install; install()
.
PYTHONINSTALLSTRICTLOADER=1
dans votre environnement../python -X install-strict-loader application.py
. Alternativement, vous pouvez compiler tout le code de manière statique en utilisant ./python -m compiler --static some_module.py
, qui compilera le module en Python statique et l'exécutera.
Voir CinderDoc/static_python.rst
pour une documentation plus détaillée.