21. Performance Python

cProfile, py-spy, timeit, optimisation, __slots__, numba, cython, memory profiling, vectorisation.


21.1 Principe : mesurer avant d’optimiser

“Premature optimization is the root of all evil.” — Knuth

  1. Profiler → trouver les goulots
  2. Optimiser → le strict nécessaire
  3. Mesurer → vérifier le gain

21.2 timeit — mesurer des fragments

import timeit
 
# Depuis Python
temps = timeit.timeit('"-".join(str(n) for n in range(100))', number=10000)
 
# Depuis IPython/REPL
# %timeit [x**2 for x in range(1000)]
# Mesure précise d'une fonction
def à_tester():
    return sum(range(1_000_000))
 
t = timeit.timeit(à_tester, number=100)
print(f"Moyenne: {t/100*1000:.2f}ms")

21.3 cProfile — profilage global

import cProfile, pstats
 
def lent():
    total = 0
    for i in range(1_000_000):
        total += i ** 2
    return total
 
cProfile.run("lent()", "profil_stats")
 
# Analyse
p = pstats.Stats("profil_stats")
p.sort_stats("cumtime").print_stats(10)  # top 10 par temps cumulé
p.sort_stats("time").print_stats(10)     # top 10 par temps propre

Depuis la ligne de commande :

python3 -m cProfile -o profil_stats mon_script.py
python3 -m pstats profil_stats
# > sort cumtime
# > stats 20

SnakeViz — visualisation

pip install snakeviz
snakeviz profil_stats
# Ouvre un graphique interactif dans le navigateur

21.4 py-spy — profilage sans modification

pip install py-spy
 
# Profiler un script en cours d'exécution
py-spy record -o profil.svg -- python3 mon_script.py
 
# S'attacher à un PID
py-spy top --pid 12345

Utile pour : scripts en production, sans modification du code.

21.5 Memory profiling

pip install memory-profiler
from memory_profiler import profile
 
@profile
def fonction_lourde():
    a = [i ** 2 for i in range(100_000)]
    b = [i ** 3 for i in range(100_000)]
    return sum(a) + sum(b)
 
fonction_lourde()
python3 -m memory_profiler mon_script.py

Tracemalloc (stdlib)

import tracemalloc
 
tracemalloc.start()
 
# ... code à surveiller ...
snapshot = tracemalloc.take_snapshot()
stats = snapshot.statistics("lineno")
 
for stat in stats[:10]:
    print(stat.size, stat.count, stat.traceback)

21.6 __slots__ — réduire la mémoire

Vue en leçons 10 et 10b. Impact mesuré :

# Sans __slots__
class Point:
    def __init__(self, x, y):
        self.x = x
        self.y = y
 
# Avec __slots__
class PointSlots:
    __slots__ = ("x", "y")
    def __init__(self, x, y):
        self.x = x
        self.y = y
 
import sys
p1 = Point(1, 2)
p2 = PointSlots(1, 2)
print(sys.getsizeof(p1))   # ~56 (sans compter __dict__)
print(sys.getsizeof(p2))   # ~40 (pas de __dict__)

Économie : 30-50% de mémoire par instance, plus rapide à l’accès.

21.7 numba — compilation JIT

from numba import jit
import numpy as np
 
@jit(nopython=True)  # compilation en machine code
def somme_carrés(arr):
    total = 0
    for i in range(len(arr)):
        total += arr[i] ** 2
    return total
 
arr = np.random.rand(1_000_000)
résultat = somme_carrés(arr)  # premier appel: compilation; second: natif

Gain : 10-100× pour des boucles Python pures.

Limites nopython=True : pas de listes d’objets, pas de try, pas de dict arbitraire. Support limité de NumPy.

@njit = @jit(nopython=True)

from numba import njit
 
@njit
def mandelbrot(c, max_iter):
    z = 0j
    for n in range(max_iter):
        if abs(z) > 2:
            return n
        z = z * z + c
    return max_iter

@vectorize — UFuncs NumPy

from numba import vectorize
 
@vectorize
def sigmoid(x):
    return 1 / (1 + np.exp(-x))
 
x = np.random.randn(1_000_000)
y = sigmoid(x)  # parallélisé automatiquement

21.8 Vectorisation avec NumPy

Toujours préférer les opérations vectorisées aux boucles :

import numpy as np
 
# LENT (boucle Python)
def lente(a, b):
    résultat = np.empty_like(a)
    for i in range(len(a)):
        résultat[i] = a[i] * b[i] + a[i] ** 2
    return résultat
 
# RAPIDE (vectorisé)
def rapide(a, b):
    return a * b + a ** 2
 
a = np.random.rand(100_000)
b = np.random.rand(100_000)
 
%timeit lente(a, b)    # ~50ms
%timeit rapide(a, b)   # ~0.3ms (150× plus rapide)

21.9 Structures de données adaptées

OpérationStructureComplexité
Test d’appartenanceset / dictO(1)
Test d’appartenancelistO(n)
Ajout/retrait aux extrémitéscollections.dequeO(1)
File prioritaireheapqO(log n)
Recherche triéebisectO(log n)
Regroupementitertools.groupbyO(n)
Cachefunctools.lru_cacheO(1)
# Mauvais : list pour recherche
noms = ["alice", "bob", "charlie"]
if "bob" in noms:  # O(n)
 
# Bon : set
noms = {"alice", "bob", "charlie"}
if "bob" in noms:  # O(1)

21.10 itertools pour l’efficacité mémoire

from itertools import chain, islice, product
 
# LENT : crée une liste en mémoire
tous = [x * y for x in range(1000) for y in range(1000)]
 
# RAPIDE : générateur paresseux
tous = (x * y for x in range(1000) for y in range(1000))

21.11 Conventions Python rapides vs lentes

# LENT
résultat = []
for x in données:
    if condition(x):
        résultat.append(transform(x))
 
# RAPIDE (comprehension)
résultat = [transform(x) for x in données if condition(x)]
# LENT
d = {}
for k, v in paires:
    if k not in d:
        d[k] = []
    d[k].append(v)
 
# RAPIDE (setdefault)
d = {}
for k, v in paires:
    d.setdefault(k, []).append(v)
 
# RAPIDE (defaultdict)
from collections import defaultdict
d = defaultdict(list)
for k, v in paires:
    d[k].append(v)

21.12 local vs global

L’accès aux variables locales est plus rapide :

# LENT
total = 0
for i in range(1_000_000):
    total += i  # variable globale
 
# RAPIDE
def somme():
    total = 0
    for i in range(1_000_000):
        total += i  # variable locale
    return total

21.13 sys.setrecursionlimit

import sys
sys.setrecursionlimit(10_000)  # défaut: 1000

21.14 gc — garbage collector

import gc
 
gc.collect()       # forcer la collecte
gc.disable()       # désactiver (rarement utile)
gc.set_threshold(700, 10, 10)  # seuils de déclenchement

21.15 Outils complémentaires

pip install pyinstrument   # profilage minimaliste
pip install scalene        # profilage CPU + mémoire + GPU
pip install line_profiler  # profilage ligne par ligne (via @profile)
pip install cython         # compilation vers C

Line profiler :

from line_profiler import LineProfiler
 
lp = LineProfiler()
lp.add_function(fonction_lourde)
lp.enable()
fonction_lourde()
lp.disable()
lp.print_stats()

21.16 Checklist optimisation

  1. As-tu profilé ? (Sinon → profiler d’abord)
  2. As-tu utilisé les bonnes structures ? (set/dict, deque, heap)
  3. Peux-tu vectoriser ? (NumPy, boucles → opérations array)
  4. Comprehensions > boucles ? (souvent 2× plus rapide)
  5. __slots__ pour les classes avec beaucoup d’instances ?
  6. @lru_cache pour des appels redondants ?
  7. Local vars > global vars ?
  8. Algo adapté ? (O(n log n) vs O(n²))
  9. Parallélisation ? (multiprocessing pour CPU, threading pour I/O)
  10. Compilation ? (numba pour les boucles, cython pour le code métier)

🔗 ← Retour au cours · ← précédent · Suivant →