Développement d'un profileur graphique Python FunctionTrace





Aujourd'hui, nous partageons avec vous la traduction d'un article du créateur de FunctionTrace, un profileur Python avec une interface graphique intuitive qui peut profiler des applications multiprocesseurs et multithread et utilise un ordre de grandeur moins de ressources que les autres profileurs Python. Peu importe que vous appreniez simplement le développement Web en Python ou que vous l'utilisiez depuis longtemps - il est toujours bon de comprendre ce que fait votre code. Sur la façon dont ce projet est apparu, sur les détails de son développement - plus loin sous la coupe.






introduction



Le Firefox Profiler était la pierre angulaire de Firefox à l'époque du projet Quantum . Lorsque vous ouvrez l'entrée d' exemple, vous voyez une puissante interface d'analyse des performances basée sur le Web qui comprend des arborescences d'appels, des diagrammes de pile, des diagrammes d'incendie, etc. Toutes les actions de filtrage, de mise à l'échelle, de découpage et de transformation des données sont enregistrées dans une URL qui peut être partagée. Les résultats peuvent être partagés dans un rapport de bogue, vos résultats peuvent être documentés, comparés à d'autres enregistrements, ou des informations peuvent être transmises pour des recherches supplémentaires. Firefox DevEdition a un fil de profilage intégré. Ce flux facilite la communication. Notre objectif est de permettre à tous les développeurs, même en dehors de Firefox, de collaborer de manière productive.



Auparavant, Firefox Profiler importait d'autres formats en commençant par les profils Linux perf et Chrome . Au fil du temps, les développeurs ont ajouté plus de formats. Aujourd'hui, les premiers projets voient le jour pour adapter Firefox aux outils d'analyse. FunctionTrace est l'un de ces projets. Matt raconte comment l'instrument a été fabriqué.



FonctionTrace



J'ai récemment créé un outil pour aider les développeurs à mieux comprendre ce qui se passe dans leur code Python. FunctionTrace est un profileur sans échantillonnage pour Python qui s'exécute sur des applications non modifiées avec une surcharge très faible - moins de 5%. Il est important de noter qu'il est intégré à Firefox Profiler. Cela vous permet d'interagir graphiquement avec les profils, ce qui facilite la découverte de modèles et la modification de votre base de code.



Je vais passer en revue les objectifs de développement de FunctionTrace et partager les détails techniques de la mise en œuvre. À la fin, nous jouerons avec une petite démo .





Un exemple de profil FunctionTrace ouvert dans Firefox Profiler.



La dette technologique comme motivation



Les bases de code ont tendance à grossir avec le temps. Surtout lorsque vous travaillez sur des projets complexes avec beaucoup de monde. Certaines langues traitent mieux ce problème. Par exemple, les capacités de l'EDI Java existent depuis des décennies. Ou Rust et son typage fort, qui rend le refactoring très facile. Parfois, il semble qu'à mesure que les bases de code dans d'autres langages se développent, il devient plus difficile à maintenir. Cela est particulièrement vrai pour les anciens codes Python. Au moins, nous sommes tous Python 3 en ce moment, non?



Faire des changements à grande échelle ou refactoriser un code inconnu peut être extrêmement difficile. Il est beaucoup plus facile pour moi de changer correctement le code lorsque je vois toutes les interactions du programme et ce qu'il fait. Souvent, je me retrouve même à réécrire des morceaux de code que je n'ai jamais eu l'intention de toucher: l'inefficacité est évidente quand je le vois dans la visualisation.



Je voulais comprendre ce qui se passait dans le code sans avoir à lire des centaines de fichiers. Mais je n'ai pas trouvé les outils qui correspondent à mes besoins. De plus, j'ai perdu tout intérêt à créer moi-même un tel outil en raison de la quantité de travail d'interface utilisateur impliquée. Et l'interface était nécessaire. Mes espoirs d'une compréhension rapide de l'exécution du programme ont été ravivés lorsque je suis tombé sur le profileur Firefox.



Le profileur a fourni tous les éléments difficiles à mettre en œuvre - une interface utilisateur intuitive et open source qui affiche des graphiques de pile, des marqueurs de journal limités dans le temps, des graphiques de feu et donne une stabilité, dont la nature est liée à un célèbre navigateur Web. Tout outil capable d'écrire un profil JSON correctement formaté peut réutiliser toutes les capacités d'analyse graphique mentionnées précédemment.



FonctionTrace design



Heureusement, j'avais déjà prévu une semaine de vacances après avoir découvert le profileur Firefox. Et j'avais un ami qui voulait développer un instrument avec moi. Il a également pris un jour de congé cette semaine-là.



Objectifs



Nous avions plusieurs objectifs lorsque nous avons commencé à développer FunctionTrace:



  1. La possibilité de voir tout ce qui se passe dans le programme.
  2. .
  3. , .


Le premier objectif a eu un impact significatif sur la conception. Les deux derniers ajoutaient de la complexité technique. Nous savions tous les deux par l'expérience passée avec des outils similaires que la frustration est que nous ne verrons pas d'appels de fonction trop courts. Lorsque vous enregistrez un enregistrement de suivi de 1 ms, mais que vous avez des fonctions importantes et plus rapides, vous manquez beaucoup de ce qui se passe dans votre programme.



Nous savions également que nous devions suivre tous les appels de fonction. Par conséquent, nous n'avons pas pu utiliser le profileur d'échantillonnage. De plus, j'ai récemment passé du temps avec du code dans lequel les fonctions Python exécutent d'autres codes Python, souvent via un script d'intermédiaire shell. Sur cette base, nous voulions pouvoir tracer les processus enfants.



Mise en œuvre initiale



Pour prendre en charge plusieurs processus et descendants, nous avons opté pour un modèle client-serveur. Les clients Python envoient des données de trace au serveur Rust. Le serveur agrège et compresse les données avant de générer un profil, qui peut être utilisé par le profileur Firefox. Nous avons choisi Rust pour plusieurs raisons, notamment une frappe forte, la recherche de performances constantes et une utilisation de la mémoire prévisible, ainsi que la facilité de prototypage et de refactoring.



Nous avons prototypé le client sous la forme d'un module Python appelé python -m functiontrace code.py. Cela a facilité l'utilisation des hooks de trace intégrés pour journaliser l'exécution. L'implémentation d'origine ressemblait à ceci:



def profile_func(frame, event, arg):
    if event == "call" or event == "return" or event == "c_call" or event == "c_return":
        data = (event, time.time())
        server.sendall(json.dumps(data))

sys.setprofile(profile_func)




Le serveur écoute sur une socket de domaine Unix . Les données sont ensuite lues à partir du client et converties en JSON par le profileur Firefox .



Le profileur prend en charge divers types de profils tels que les journaux de performances . Mais nous avons décidé de générer JSON du format du profileur interne. Cela nécessite moins d'espace et de maintenance que l'ajout d'un nouveau format pris en charge. Il est important de noter que le profileur maintient la compatibilité descendante entre les versions de profil. Cela signifie que tout profil conçu pour la version actuelle du format est automatiquement converti vers la dernière version lors du téléchargement ultérieur. Le profileur fait également référence aux chaînes avec des identificateurs entiers. Cela permet des économies d'espace importantes grâce à la déduplication (alors qu'il est simple à utiliserindexmap ).



Plusieurs optimisations



La plupart du temps, le code d'origine a fonctionné. À chaque appel et retour de fonction, Python a appelé le hook. Le hook a envoyé un message JSON au serveur via le socket pour le convertir au format souhaité. Mais c'était incroyablement lent. Même après avoir groupé les appels de socket, nous avons vu au moins huit fois la surcharge de certains programmes de test.



Après avoir constaté ces coûts, nous sommes descendus au niveau C en utilisant l' API C pour Python . Et ils ont obtenu un coefficient de surcharge de 1,1 sur les mêmes programmes. Après cela, nous avons pu effectuer une autre optimisation de clé, en remplaçant les appels time.time()aux opérations rdtsc viaclock_gettime()... Nous avons réduit la surcharge de performances des fonctions d'appel à plusieurs instructions et à la génération de 64 bits de données. C'est beaucoup plus efficace que le chaînage des appels Python et de l'arithmétique complexe sur un chemin critique.



J'ai mentionné que le traçage de plusieurs threads et processus enfants est pris en charge. C'est l'une des parties les plus difficiles du client, il vaut donc la peine de discuter de certains détails de niveau inférieur.



Prise en charge de plusieurs flux



Le gestionnaire de tous les threads est installé via threading.setprofile(). Nous nous enregistrons via un gestionnaire comme celui-ci lorsque nous configurons l'état du thread. Cela garantit que Python est en cours d'exécution et que le GIL est maintenu. Cela simplifie certaines des hypothèses:



// This is installed as the setprofile() handler for new threads by
// threading.setprofile().  On its first execution, it initializes tracing for
// the thread, including creating the thread state, before replacing itself with
// the normal Fprofile_FunctionTrace handler.
static PyObject* Fprofile_ThreadFunctionTrace(..args..) {
    Fprofile_CreateThreadState();

    // Replace our setprofile() handler with the real one, then manually call
    // it to ensure this call is recorded.
    PyEval_SetProfile(Fprofile_FunctionTrace);
    Fprofile_FunctionTrace(..args..);
    Py_RETURN_NONE;
}




Lorsque le hook est appelé Fprofile_ThreadFunctionTrace(), il alloue la structure ThreadState. Cette structure contient les informations nécessaires au thread pour consigner les événements et communiquer avec le serveur. Nous envoyons ensuite un message d'initialisation au serveur de profils. Ici, nous informons le serveur de démarrer un nouveau flux et fournissons quelques informations initiales: heure, PID, etc. Après l'initialisation, nous remplaçons le hook par Fprofile_FunctionTrace()un qui effectue le traçage réel.



Prise en charge des processus enfants



Lorsque vous travaillez avec plusieurs processus, nous supposons que les processus enfants sont démarrés via l'interpréteur Python. Malheureusement, les processus enfants ne sont pas appelés avec -m functiontrace, nous ne savons donc pas comment les retrouver. Pour garantir que les processus enfants sont surveillés, la variable d'environnement $ PATH est modifiée au démarrage . Cela garantit que Python pointe vers un exécutable qui connaît functiontrace:



# Generate a temp directory to store our wrappers in.  We'll temporarily
# add this directory to our path.
tempdir = tempfile.mkdtemp(prefix="py-functiontrace")
os.environ["PATH"] = tempdir + os.pathsep + os.environ["PATH"]

# Generate wrappers for the various Python versions we support to ensure
# they're included in our PATH.
wrap_pythons = ["python", "python3", "python3.6", "python3.7", "python3.8"]
for python in wrap_pythons:
    with open(os.path.join(tempdir, python), "w") as f:
        f.write(PYTHON_TEMPLATE.format(python=python))
        os.chmod(f.name, 0o755)




Un interpréteur avec un argument -m functiontraceest appelé à l'intérieur du wrapper. Enfin, une variable d'environnement est ajoutée au démarrage. La variable indique quel socket est utilisé pour communiquer avec le serveur de profils. Si le client s'initialise et voit une variable d'environnement déjà définie, il reconnaît le processus enfant. Il se connecte ensuite à l'instance de serveur existante, permettant à sa trace d'être corrélée avec celle du client d'origine.



FunctionTrace maintenant



L'implémentation de FunctionTrace a aujourd'hui beaucoup en commun avec l'implémentation décrite ci-dessus. À un haut niveau le client est suivi par un appel FunctionTrace comme ceci: python -m functiontrace code.py. Cette ligne charge un module Python pour une certaine personnalisation, puis appelle le module C pour définir divers hooks de trace. Ces hooks incluent les sys.setprofilehooks d'allocation de mémoire mentionnés ci-dessus , ainsi que des hooks personnalisés avec des fonctionnalités intéressantes telles que builtins.printou builtins.__import__. De plus, une instance functiontrace-serverest générée, un socket est configuré pour communiquer avec elle et il est garanti que les futurs threads et processus enfants communiquent avec le même serveur.



À chaque événement de trace, le client Python envoie une entrée MessagePack... Il contient des informations d'événement minimales et un horodatage dans la mémoire tampon du flux. Lorsque le tampon est plein (tous les 128 Ko), il est vidé sur le serveur via le socket partagé et le client continue de faire son travail. Le serveur écoute chaque client de manière asynchrone, consommant rapidement les traces dans un tampon séparé pour éviter de les bloquer. Le thread correspondant à chaque client peut ensuite analyser chaque événement de trace et le convertir au format final approprié. Une fois tous les clients connectés fermés, les journaux de chaque rubrique sont combinés dans un journal de profil complet. Enfin, le tout est envoyé dans un fichier, qui peut ensuite être utilisé avec le profileur de Firefox.



Leçons apprises



Un module Python C fournit beaucoup plus de puissance et de performances, mais a en même temps un coût élevé. Plus de code est nécessaire, une bonne documentation est plus difficile à trouver, peu de fonctionnalités sont facilement disponibles. Les modules C semblent être un outil sous-utilisé pour écrire des modules Python hautes performances. Je dis cela en me basant sur certains des profils FunctionTrace que j'ai vus. Nous recommandons un équilibre. Écrivez la plupart du code non performant et critique en Python et appelez des boucles internes ou du code de configuration C pour les parties de votre programme où Python ne brille pas.



L'encodage et le décodage JSON peuvent être incroyablement lents lorsqu'il n'y a pas besoin de lisibilité. Nous sommes passés àMessagePackpour la communication client-serveur et a trouvé qu'il était tout aussi facile de travailler avec, tout en réduisant de moitié certains temps de référence!



La prise en charge du profilage multi-thread en Python est assez difficile. On comprend pourquoi cela n'a pas été une fonctionnalité clé dans les précédents profileurs Python. Il a fallu plusieurs approches différentes et beaucoup d'erreurs de segmentation avant que nous ayons une bonne idée de la façon de travailler avec le GIL tout en maintenant des performances élevées.



image


Vous pouvez obtenir une profession demandée à partir de zéro ou augmenter vos compétences et votre salaire en suivant des cours en ligne SkillFactory:





E







All Articles