Kodokon kodokon.com

Performance : profiling, GIL, générateurs vs listes, pièges mémoire

Mesurez avant d'optimiser : timeit et cProfile, ce que le GIL bloque vraiment, la frugalité des générateurs et les pièges mémoire des chaînes et des tranches.

11 min · 3 questions

Ouvrir cette leçon dans Kodokon

Ne devinez jamais, mesurez. timeit isole un micro-benchmark fiable : il désactive le garbage collector et répète l'exécution pour lisser le bruit. cProfile dresse la carte des appels d'un programme entier - triez par cumtime (temps cumulé, sous-appels inclus) pour identifier les vrais coupables. La règle d'or de tout travail de performance : la ligne lente n'est presque jamais celle que vous soupçonnez.

BASH
python -m timeit "sum(x * x for x in range(1000))"
python -m timeit -s "s = 'abc' * 100" "s.upper()"
python -m cProfile -s cumtime -m timeit "min(range(50))"
L'option -s de timeit prépare le contexte hors mesure.

Le GIL (Global Interpreter Lock) garantit qu'un seul thread exécute du bytecode Python à la fois dans un processus. Conséquence : les threads n'accélèrent pas le calcul pur. Ils restent en revanche parfaits pour l'I/O, car le GIL est relâché pendant les lectures réseau ou disque, ainsi que par de nombreuses bibliothèques C comme NumPy. Pour saturer plusieurs cœurs en calcul, passez par multiprocessing ou concurrent.futures.ProcessPoolExecutor. Python 3.13 propose une variante expérimentale « free-threaded » sans GIL, encore rare en production.

PYTHON
import sys

squares_list = [x * x for x in range(1_000_000)]
squares_gen = (x * x for x in range(1_000_000))

print(sys.getsizeof(squares_list), "bytes")
print(sys.getsizeof(squares_gen), "bytes")
print(sum(squares_gen))
Environ 8 Mo pour la liste, environ 200 octets pour le générateur.

Le générateur pèse quelques centaines d'octets quelle que soit la longueur de la séquence : il ne stocke que son état d'exécution et produit chaque valeur à la demande. Chaînez-les en pipelines - sum(x * x for x in data) - pour traiter des flux sans jamais matérialiser de liste intermédiaire. Contrepartie à connaître : un générateur est à usage unique. Une fois épuisé, il ne produit plus rien, silencieusement - une source classique de bugs où la deuxième itération semble « vide ».

PYTHON
import timeit

def concat(n: int) -> str:
    out = ""
    for i in range(n):
        out += str(i)
    return out

def join(n: int) -> str:
    return "".join(str(i) for i in range(n))

t1 = timeit.timeit(lambda: concat(20_000), number=10)
t2 = timeit.timeit(lambda: join(20_000), number=10)
print(f"concat: {t1:.3f} s / join: {t2:.3f} s")
Mesurez sur votre machine : join reste le choix portable et sûr.

Quiz de validation

Vérifiez que vous avez bien retenu les points clés de cette leçon.

  1. Qu'empêche exactement le GIL ?
    • Toute forme de parallélisme en Python, y compris multiprocessing
    • L'exécution simultanée de bytecode Python par plusieurs threads d'un même processus - l'I/O et beaucoup de code C le relâchent
    • L'utilisation de plusieurs threads dans un même programme
    • L'écriture concurrente dans une même variable
  2. Pourquoi sys.getsizeof d'un générateur reste-t-il minuscule face à la liste équivalente ?
    • Le générateur ne stocke aucun élément : il conserve seulement son état d'exécution et calcule chaque valeur à la demande
    • getsizeof ne sait pas mesurer les générateurs
    • Les générateurs compressent leurs données en interne
  3. Pourquoi ''.join(parts) bat-il out += part répété en boucle ?
    • join utilise plusieurs threads pour assembler la chaîne
    • C'est un mythe : les performances sont identiques depuis Python 3
    • Les str étant immuables, chaque += recopie la chaîne entière (coût quadratique) ; join calcule la taille finale et n'alloue qu'une fois
    • join évite la création des objets str intermédiaires du générateur