
Vous pensez que votre code Python est lent ? Arrêtez de deviner et commencez à mesurer
Je travaillais sur un scénario l’autre jour et ça me rendait dingue. Cela a fonctionné, bien sûr, mais c’était juste… lent. Vraiment lent. J’avais le sentiment que cela pourrait être beaucoup plus rapide si je pouvais comprendre où le hold-up était.
Ma première pensée a été de commencer à peaufiner les choses. J’ai pu optimiser le chargement des données. Ou réécrire cette boucle for ? Mais je me suis arrêté. Je suis déjà tombé dans ce piège, passant des heures à « optimiser » un morceau de code pour constater que cela ne faisait pratiquement aucune différence sur le temps d’exécution global. Donald Knuth avait raison lorsqu’il a déclaré : « L’optimisation prématurée est la racine de tous les maux. »
J’ai décidé d’adopter une approche plus méthodique. Au lieu de deviner, j’allais le découvrir avec certitude. J’avais besoin de profiler le code pour obtenir des données précises sur les fonctions qui consommaient la majorité des cycles d’horloge.
Dans cet article, je vais vous expliquer le processus exact que j’ai utilisé. Nous prendrons un script Python délibérément lent et utiliserons deux outils fantastiques pour identifier ses goulots d’étranglement avec une précision chirurgicale.
Le premier de ces outils s’appelle cProfilun puissant profileur intégré à Python. L’autre s’appelle serpentviz, un outil génial que transforme la sortie du profileur en une carte visuelle interactive.
Mise en place d’un environnement de développement
Avant de commencer à coder, configurons notre environnement de développement. La meilleure pratique consiste à créer un environnement Python distinct dans lequel vous pouvez installer tous les logiciels nécessaires et expérimenter, sachant que tout ce que vous faites n’aura aucun impact sur le reste de votre système. J’utiliserai conda pour cela, mais vous pouvez utiliser n’importe quelle méthode avec laquelle vous êtes familier.
#create our test environment
conda create -n profiling_lab python=3.11 -y
# Now activate it
conda activate profiling_lab
Maintenant que notre environnement est configuré, nous devons installer Snakeviz pour nos visualisations et numpy pour l’exemple de script. cProfile est déjà inclus avec Python, il n’y a donc plus rien à faire là-bas. Comme nous exécuterons nos scripts avec un Jupyter Notebook, nous l’installerons également.
# Install our visualization tool and numpy
pip install snakeviz numpy jupyter
Maintenant, tapez jupyter notebook dans votre invite de commande. Vous devriez voir un notebook Jupyter ouvert dans votre navigateur. Si cela ne se produit pas automatiquement, vous verrez probablement un écran d’informations après le jupyter notebook commande. Près du bas, vous trouverez une URL que vous devrez copier et coller dans votre navigateur pour lancer le bloc-notes Jupyter.
Votre URL sera différente de la mienne, mais elle devrait ressembler à ceci : –
http://127.0.0.1:8888/tree?token=3b9f7bd07b6966b41b68e2350721b2d0b6f388d248cc69da
Une fois nos outils prêts, il est temps d’examiner le code que nous allons corriger.
Notre scénario « problème »
Pour tester correctement nos outils de profilage, nous avons besoin d’un script qui présente des problèmes de performances évidents. J’ai écrit un programme simple qui simule des problèmes de traitement avec la mémoire, les itérations et les cycles du processeur, ce qui en fait un candidat parfait pour notre enquête.
# run_all_systems.py
import time
import math
# ===================================================================
CPU_ITERATIONS = 34552942
STRING_ITERATIONS = 46658100
LOOP_ITERATIONS = 171796964
# ===================================================================
# --- Task 1: A Calibrated CPU-Bound Bottleneck ---
def cpu_heavy_task(iterations):
print(" -> Running CPU-bound task...")
result = 0
for i in range(iterations):
result += math.sin(i) * math.cos(i) + math.sqrt(i)
return result
# --- Task 2: A Calibrated Memory/String Bottleneck ---
def memory_heavy_string_task(iterations):
print(" -> Running Memory/String-bound task...")
report = ""
chunk = "report_item_abcdefg_123456789_"
for i in range(iterations):
report += f"|{chunk}{i}"
return report
# --- Task 3: A Calibrated "Thousand Cuts" Iteration Bottleneck ---
def simulate_tiny_op(n):
pass
def iteration_heavy_task(iterations):
print(" -> Running Iteration-bound task...")
for i in range(iterations):
simulate_tiny_op(i)
return "OK"
# --- Main Orchestrator ---
def run_all_systems():
print("--- Starting FINAL SLOW Balanced Showcase ---")
cpu_result = cpu_heavy_task(iterations=CPU_ITERATIONS)
string_result = memory_heavy_string_task(iterations=STRING_ITERATIONS)
iteration_result = iteration_heavy_task(iterations=LOOP_ITERATIONS)
print("--- FINAL SLOW Balanced Showcase Finished ---")
Étape 1 : Collecte des données avec cProfile
Notre premier outil, cProfile, est un profileur déterministe intégré à Python. Nous pouvons l’exécuter à partir du code pour exécuter notre script et enregistrer des statistiques détaillées sur chaque appel de fonction.
import cProfile, pstats, io
pr = cProfile.Profile()
pr.enable()
# Run the function you want to profile
run_all_systems()
pr.disable()
# Dump stats to a string and print the top 10 by cumulative time
s = io.StringIO()
ps = pstats.Stats(pr, stream=s).sort_stats("cumtime")
ps.print_stats(10)
print(s.getvalue())
Voici le résultat.
--- Starting FINAL SLOW Balanced Showcase ---
-> Running CPU-bound task...
-> Running Memory/String-bound task...
-> Running Iteration-bound task...
--- FINAL SLOW Balanced Showcase Finished ---
275455984 function calls in 30.497 seconds
Ordered by: cumulative time
List reduced from 47 to 10 due to restriction <10>
ncalls tottime percall cumtime percall filename:lineno(function)
2 0.000 0.000 30.520 15.260 /home/tom/.local/lib/python3.10/site-packages/IPython/core/interactiveshell.py:3541(run_code)
2 0.000 0.000 30.520 15.260 {built-in method builtins.exec}
1 0.000 0.000 30.497 30.497 /tmp/ipykernel_173802/1743829582.py:41(run_all_systems)
1 9.652 9.652 14.394 14.394 /tmp/ipykernel_173802/1743829582.py:34(iteration_heavy_task)
1 7.232 7.232 12.211 12.211 /tmp/ipykernel_173802/1743829582.py:14(cpu_heavy_task)
171796964 4.742 0.000 4.742 0.000 /tmp/ipykernel_173802/1743829582.py:31(simulate_tiny_op)
1 3.891 3.891 3.892 3.892 /tmp/ipykernel_173802/1743829582.py:22(memory_heavy_string_task)
34552942 1.888 0.000 1.888 0.000 {built-in method math.sin}
34552942 1.820 0.000 1.820 0.000 {built-in method math.cos}
34552942 1.271 0.000 1.271 0.000 {built-in method math.sqrt}
Nous disposons d’un tas de chiffres qui peuvent être difficiles à interpréter. C’est là que Snakeviz prend tout son sens.
Étape 2 : Visualiser le goulot d’étranglement avec Snakeviz
C’est là que la magie opère. Snakeviz prend la sortie de notre fichier de profilage et la convertit en un graphique interactif basé sur un navigateur, ce qui facilite la recherche des goulots d’étranglement.
Utilisons donc cet outil pour visualiser ce que nous avons. Comme j’utilise un Jupyter Notebook, nous devons d’abord le charger.
%load_ext snakeviz
Et nous le gérons comme ça.
%%snakeviz
main()
Le résultat se compose de deux parties. La première est une visualisation comme celle-ci.

Ce que vous voyez est un graphique « glaçon » descendant. De haut en bas, il représente la hiérarchie des appels.
Tout en haut : Python exécute notre script (
Suivant : l’exécution __main__ du script (
La partie de traitement gourmande en mémoire n’est pas étiquetée sur le graphique. En effet, la proportion de temps associée à cette tâche est bien inférieure à celle allouée aux deux autres fonctions intensives. En conséquence, nous voyons un bloc beaucoup plus petit et sans étiquette à droite du bloc cpu_heavy_task.
Notez que, pour l’analyse, il existe également un style de graphique Snakeviz appelé Tableau des rayons de soleil. Il ressemble un peu à un diagramme circulaire, sauf qu’il contient un ensemble de cercles et d’arcs concentriques de plus en plus grands.. L’idée est que le temps nécessaire à l’exécution des fonctions est représenté par l’étendue angulaire de la taille de l’arc du cercle. La fonction racine est un cercle au milieu de la visualisation. La fonction racine s’exécute en appelant les sous-fonctions situées en dessous, etc. Nous n’examinerons pas ce type d’affichage dans cet article.
Une confirmation visuelle, comme celle-ci, peut avoir bien plus d’impact que de regarder un tableau de chiffres. Je n’avais plus besoin de deviner où chercher ; les données me regardaient droit en face.
La visualisation est rapidement suivie d’un bloc de texte détaillant les timings de différentes parties de votre code, un peu comme la sortie de l’outil cprofile. Je n’en montre que la première douzaine de lignes, car il y en avait plus de 30 au total.
ncalls tottime percall cumtime percall filename:lineno(function)
----------------------------------------------------------------
1 9.581 9.581 14.3 14.3 1062495604.py:34(iteration_heavy_task)
1 7.868 7.868 12.92 12.92 1062495604.py:14(cpu_heavy_task)
171796964 4.717 2.745e-08 4.717 2.745e-08 1062495604.py:31(simulate_tiny_op)
1 3.848 3.848 3.848 3.848 1062495604.py:22(memory_heavy_string_task)
34552942 1.91 5.527e-08 1.91 5.527e-08 ~:0(<built-in method math.sin>)
34552942 1.836 5.313e-08 1.836 5.313e-08 ~:0(<built-in method math.cos>)
34552942 1.305 3.778e-08 1.305 3.778e-08 ~:0(<built-in method math.sqrt>)
1 0.02127 0.02127 31.09 31.09 <string>:1(<module>)
4 0.0001764 4.409e-05 0.0001764 4.409e-05 socket.py:626(send)
10 0.000123 1.23e-05 0.0004568 4.568e-05 iostream.py:655(write)
4 4.594e-05 1.148e-05 0.0002735 6.838e-05 iostream.py:259(schedule)
...
...
...
Étape 3 : le correctif
Bien sûr, des outils comme cprofiler et Snakeviz ne vous le disent pas. comment pour résoudre vos problèmes de performances, mais maintenant que je savais exactement où se trouvaient les problèmes, je pouvais appliquer des correctifs ciblés.
# final_showcase_fixed_v2.py
import time
import math
import numpy as np
# ===================================================================
CPU_ITERATIONS = 34552942
STRING_ITERATIONS = 46658100
LOOP_ITERATIONS = 171796964
# ===================================================================
# --- Fix 1: Vectorization for the CPU-Bound Task ---
def cpu_heavy_task_fixed(iterations):
"""
Fixed by using NumPy to perform the complex math on an entire array
at once, in highly optimized C code instead of a Python loop.
"""
print(" -> Running CPU-bound task...")
# Create an array of numbers from 0 to iterations-1
i = np.arange(iterations, dtype=np.float64)
# The same calculation, but vectorized, is orders of magnitude faster
result_array = np.sin(i) * np.cos(i) + np.sqrt(i)
return np.sum(result_array)
# --- Fix 2: Efficient String Joining ---
def memory_heavy_string_task_fixed(iterations):
"""
Fixed by using a list comprehension and a single, efficient ''.join() call.
This avoids creating millions of intermediate string objects.
"""
print(" -> Running Memory/String-bound task...")
chunk = "report_item_abcdefg_123456789_"
# A list comprehension is fast and memory-efficient
parts = [f"|{chunk}{i}" for i in range(iterations)]
return "".join(parts)
# --- Fix 3: Eliminating the "Thousand Cuts" Loop ---
def iteration_heavy_task_fixed(iterations):
"""
Fixed by recognizing the task can be a no-op or a bulk operation.
In a real-world scenario, you would find a way to avoid the loop entirely.
Here, we demonstrate the fix by simply removing the pointless loop.
The goal is to show the cost of the loop itself was the problem.
"""
print(" -> Running Iteration-bound task...")
# The fix is to find a bulk operation or eliminate the need for the loop.
# Since the original function did nothing, the fix is to do nothing, but faster.
return "OK"
# --- Main Orchestrator ---
def run_all_systems():
"""
The main orchestrator now calls the FAST versions of the tasks.
"""
print("--- Starting FINAL FAST Balanced Showcase ---")
cpu_result = cpu_heavy_task_fixed(iterations=CPU_ITERATIONS)
string_result = memory_heavy_string_task_fixed(iterations=STRING_ITERATIONS)
iteration_result = iteration_heavy_task_fixed(iterations=LOOP_ITERATIONS)
print("--- FINAL FAST Balanced Showcase Finished ---")
Nous pouvons maintenant réexécuter le cprofiler sur notre code mis à jour.
import cProfile, pstats, io
pr = cProfile.Profile()
pr.enable()
# Run the function you want to profile
run_all_systems()
pr.disable()
# Dump stats to a string and print the top 10 by cumulative time
s = io.StringIO()
ps = pstats.Stats(pr, stream=s).sort_stats("cumtime")
ps.print_stats(10)
print(s.getvalue())
#
# start of output
#
--- Starting FINAL FAST Balanced Showcase ---
-> Running CPU-bound task...
-> Running Memory/String-bound task...
-> Running Iteration-bound task...
--- FINAL FAST Balanced Showcase Finished ---
197 function calls in 6.063 seconds
Ordered by: cumulative time
List reduced from 52 to 10 due to restriction <10>
ncalls tottime percall cumtime percall filename:lineno(function)
2 0.000 0.000 6.063 3.031 /home/tom/.local/lib/python3.10/site-packages/IPython/core/interactiveshell.py:3541(run_code)
2 0.000 0.000 6.063 3.031 {built-in method builtins.exec}
1 0.002 0.002 6.063 6.063 /tmp/ipykernel_173802/1803406806.py:1(<module>)
1 0.402 0.402 6.061 6.061 /tmp/ipykernel_173802/3782967348.py:52(run_all_systems)
1 0.000 0.000 5.152 5.152 /tmp/ipykernel_173802/3782967348.py:27(memory_heavy_string_task_fixed)
1 4.135 4.135 4.135 4.135 /tmp/ipykernel_173802/3782967348.py:35(<listcomp>)
1 1.017 1.017 1.017 1.017 {method 'join' of 'str' objects}
1 0.446 0.446 0.505 0.505 /tmp/ipykernel_173802/3782967348.py:14(cpu_heavy_task_fixed)
1 0.045 0.045 0.045 0.045 {built-in method numpy.arange}
1 0.000 0.000 0.014 0.014 <__array_function__ internals>:177(sum)
C’est un résultat fantastique qui démontre la puissance du profilage. Nous avons consacré nos efforts aux parties du code qui comptaient. Pour être minutieux, j’ai également exécuté Snakeviz sur le script corrigé.
%%snakeviz
run_all_systems()

Le changement le plus notable est la réduction de la durée totale d’exécution, d’environ 30 secondes à environ 6 secondes. Il s’agit d’une accélération 5x, obtenue en éliminant les trois principaux goulots d’étranglement visibles dans le profil « avant ».
Examinons chacun individuellement.
1. La tâche iteration_heavy_task
Avant (le problème)
Dans la première image, la grande barre de gauche, iteration_heavy_task, représente le plus gros goulot d’étranglement, consommant 14,3 secondes.
- Pourquoi était-ce lent ? Cette tâche était une « mort par mille coupures » classique. La fonction simulation_tiny_op n’a presque rien fait, mais elle a été appelée des millions de fois depuis une boucle for Python pure. L’immense surcharge de l’interpréteur Python démarrant et arrêtant un appel de fonction à plusieurs reprises était la seule source de la lenteur.
Le correctif
La version corrigée, iteration_heavy_task_fixed, reconnaissait que l’objectif pouvait être atteint sans boucle. Dans notre vitrine, cela signifiait supprimer complètement la boucle inutile. Dans une application réelle, cela impliquerait de trouver une seule opération « en masse » pour remplacer l’opération itérative.
Après (le résultat)
Dans la deuxième image, la barre iteration_heavy_task est complètement disparu. Il est désormais si rapide que sa durée d’exécution n’est qu’une infime fraction de seconde et est invisible sur le graphique. Nous avons réussi à éliminer un problème de 14,3 secondes.
2. La tâche cpu_heavy_task
Avant (le problème)
Le deuxième goulot d’étranglement majeur, clairement visible sous la forme de la grande barre orange à droite, est cpu_heavy_task, qui a pris 12,9 secondes.
- Pourquoi était-ce lent ? Comme la tâche d’itération, cette fonction était également limitée par la vitesse de la boucle Python for. Même si les opérations mathématiques à l’intérieur étaient rapides, l’interprète devait traiter chacun des millions de calculs individuellement, ce qui est très inefficace pour les tâches numériques.
Le correctif
Le correctif était vectorisation en utilisant la bibliothèque NumPy. Au lieu d’utiliser une boucle Python, cpu_heavy_task_fixed a créé un tableau NumPy et effectué simultanément toutes les opérations mathématiques (np.sqrt, np.sin, etc.) sur l’ensemble du tableau. Ces opérations sont exécutées dans du code C hautement optimisé et précompilé, contournant complètement la lente boucle de l’interpréteur Python.
Après (le résultat).
Tout comme le premier goulot d’étranglement, la barre cpu_heavy_task a disparu du diagramme « après ». Son temps d’exécution a été réduit de 12,9 secondes à quelques millisecondes.
3. La tâche memory_heavy_string_task
Avant (le problème) :
Dans le premier diagramme, la tâche memory-heavy_string_task était en cours d’exécution, mais sa durée d’exécution était réduite par rapport aux deux autres problèmes plus importants, elle a donc été reléguée dans le petit espace non étiqueté à l’extrême droite. C’était un problème relativement mineur.
Le correctif
La solution à cette tâche consistait à remplacer le système inefficace rapport += «… » concaténation de chaînes avec une méthode beaucoup plus efficace : construire une liste de toutes les parties de chaîne puis appeler « ».rejoindre() une seule fois à la fin.
Après (le résultat)
Dans le deuxième diagramme, nous voyons le résultat de notre succès. Après avoir éliminé les deux goulots d’étranglement de plus de 10 secondes, la tâche de chaîne lourde en mémoire est désormais la tâche corrigée. nouveau goulot d’étranglement dominantreprésentant 4,34 secondes de la durée totale d’exécution de 5,22 secondes.
Snakeviz nous permet même de regarder à l’intérieur de cette fonction fixe. Le nouveau contributeur le plus important est la barre orange intitulée
Résumé
Cet article fournit un guide pratique pour identifier et résoudre les problèmes de performances dans le code Python, en faisant valoir que les développeurs devraient utiliser des outils de profilage pour mesure performances au lieu de se fier à l’intuition ou aux conjectures pour identifier la source des ralentissements.
J’ai démontré un flux de travail méthodique en utilisant deux outils clés : –
- cProfil: profileur intégré de Python, utilisé pour collecter des données détaillées sur les appels de fonction et les temps d’exécution.
- serpentviz: Un outil de visualisation qui transforme les données de cProfile en un graphique interactif en « glaçon », permettant d’identifier facilement quelles parties du code consomment le plus de temps.
L’article utilise une étude de cas d’un script délibérément lent conçu avec trois goulots d’étranglement distincts et importants :
- Une tâche liée à l’itération : Une fonction appelée des millions de fois dans une boucle, mettant en valeur le coût en termes de performances de la surcharge des appels de fonction de Python (« mort par mille coupures »).
- Une tâche liée au processeur : Une boucle for effectuant des millions de calculs mathématiques, mettant en évidence l’inefficacité du Python pur pour les travaux numériques lourds.
- Une tâche liée à la mémoire : Une grande chaîne construite de manière inefficace en utilisant une concaténation += répétée.
En analysant la sortie de Snakeviz, j’ai identifié ces trois problèmes et appliqué des correctifs ciblés.
- Le goulot d’étranglement des itérations a été résolu par éliminant la boucle inutile.
- Le goulot d’étranglement du processeur a été résolu grâce à la vectorisation à l’aide de NumPy, qui exécute des opérations mathématiques dans un code C compilé rapide.
- Le goulot d’étranglement de la mémoire a été résolu en ajoutant des parties de chaîne à une liste et en utilisant un seul « » efficace..rejoindre() appel.
Ces correctifs ont entraîné une accélération spectaculaire, réduisant le temps d’exécution du script de plus en plus 30 secondes juste au-dessus 6 secondes. J’ai conclu en démontrant que, même une fois les problèmes majeurs résolus, le profileur peut être à nouveau utilisé pour identifier nouveaudes goulots d’étranglement plus petits, illustrant que l’ajustement des performances est un processus itératif guidé par la mesure.



