11 KiB
Les Performances en Python
par
Julien Palard julien@palard.fr
Bien choisir sa structure de donnée
C'est bien choisir l'algorihtme qu'on va utiliser.
Comparaison asymptotique
Les notations les plus utilisées :
O(1) Constant
O(log n) Logarithmique
O(n) Linéaire
O(n log n) Parfois appelée « linéarithmique »
O(n²) Quadratique
O(nᶜ) Polynomiale
O(cⁿ) Exponentielle
O(n!) Factorielle
notes:
Il faut les grapher pour s'en rendre compte : cf. include/big.o.py
Comparaison asymptotique
Exemples.
O(1)
def get_item(a_list: list, an_idx):
return a_list[an_idx]
ou
def is_in(a_set: set, a_value):
return a_value in a_set
notes:
Peu importe la taille de la liste, accéder à un élément prend le même temps.
O(log n)
Attention c'est toujours en base deux.
Exemple typique : chercher dans un annuaire.
notes:
Un annuaire deux fois plus gros ne vous demandera pas deux fois plus de temps mais peut-être une opération de plus.
O(log n)
#!sed -n '/def index/,/raise ValueError/p' include/find_in_list.py
O(n)
#!sed -n '/def dumb_index/,/raise ValueError/p' include/find_in_list.py
O(n log n)
C'est n
fois un log n
, par exemple rayer n
personnes dans un annuaire.
Typique d'algorithmes de tris.
Les mesures de complexité
- De temps (CPU consommé).
- D'espace (Mémoire consommée).
- Dans le meilleur des cas.
- Dans le pire des cas.
- Dans le cas moyen.
- Amorti.
- ...
Les mesures de complexité
Il n'est pas forcément nécessaire d'apprendre par cœur toutes les complexités de chaque opération.
Pas toute suite.
Les bases
Mais retenir par cœur la complexité de quelques structures élémentaires permet d'éviter les « erreurs de débutants ».
Rappel des unités de temps
- 1 milliseconde (1 ms) c'est un millième de seconde.
- 1 microseconde (1 μs) c'est un millionième de seconde.
- 1 nanoseconde (1 ns) c'est un milliardième de seconde.
Rappel des unités de temps
- milli c'est
10 ** -3
, c'est0.001
. - micro c'est
10 ** -6
, c'est0.000_001
. - nano c'est
10 ** -9
, c'est0.000_000_001
.
Le cas typique
$ python -m pyperf timeit \
> --setup 'container = list(range(10_000_000))' \
> '10_000_001 in container'
#!cache -- python -m pyperf timeit --fast -s 'container = list(range(10_000_000))' '10_000_001 in container'
$ python -m pyperf timeit \
> --setup 'container = set(range(10_000_000))' \
> '10_000_001 in container'
#!cache -- python -m pyperf timeit --fast -s 'container = set(range(10_000_000))' '10_000_001 in container'
Pourquoi une si grande différence !?
notes:
C'est l'heure du live coding !
Les outils
Les outils en ligne de commande
time
, un outil POSIX, mais aussi une fonction native de bash :
$ time python -c 'container = set(range(10_000_000))'
#!cache -- time -p python -c 'container = set(range(10_000_000))'
Mais time
ne teste qu'une fois.
notes:
real 0m0.719s # C'est le temps « sur le mur » user 0m0.521s # Temps CPU passé « dans Python » sys 0m0.195s # Temps CPU passé dans le kernel
Hyperfine
hyperfine
teste plusieurs fois :
$ hyperfine "python -c 'container = set(range(10_000_000))'"
Benchmark 1: python -c 'container = set(range(10_000_000))'
Time (mean ± σ): 735.6 ms ± 11.2 ms
Petite parenthèse
Mais attention, démarrer un processus Python n'est pas gratuit :
$ hyperfine "python -c pass"
Benchmark 1: python -c pass
Time (mean ± σ): 19.4 ms ± 0.6 ms
notes:
N'essayez pas de retenir les chiffres, retenez les faits.
Petite parenthèse
Et puis il peut dépendre de la version de Python, des options de compilation, ... :
$ hyperfine "~/.local/bin/python3.10 -c pass" # Avec pydebug
Benchmark 1: ~/.local/bin/python3.10 -c pass
Time (mean ± σ): 37.6 ms ± 0.6 ms
$ hyperfine "/usr/bin/python3.10 -c pass" # Sans pydebug
Benchmark 1: /usr/bin/python3.10 -c pass
Time (mean ± σ): 19.1 ms ± 0.8 ms
notes:
Leur parler de --enable-optimizations
(PGO).
timeit
Timeit c'est dans la stdlib de Python, ça s'utilise en ligne de commande ou depuis Python.
pyperf
C'est l'équivalent d'hyperfine mais exécutant du Python plutôt qu'un programme :
$ ~/.local/bin/python3.10 -m pyperf timeit pass
.....................
Mean +- std dev: 7.33 ns +- 0.18 ns
$ /usr/bin/python3.10 -m pyperf timeit pass
.....................
Mean +- std dev: 6.10 ns +- 0.11 ns
notes:
Avec hyperfine on teste combien de temps ça prend à Python de
démarrer puis d'exécuter pass
, ici on teste combien de temps ça
prend d'exécuter pass
.
cProfile
time, timeit, hyperfine, pyperf c'est bien pour mesurer, comparer.
cProfile nous aider à trouver la fonction coupable.
cProfile, exemple
#!sed -n '/def fib/,/return approx/p' include/phi1.py
cProfile, exemple
Testons :
#!sed -n '/import sys/,$p' include/phi1.py
$ time python phi1.py 10
#!cache -- time -p python include/phi1.py 10
C'est déjà lent, et pour 20
c'est interminable...
cProfile, exemple
Sortons cProfile :
$ python -m cProfile --sort cumulative phi1.py 10
...
#!cache -- python -m cProfile --sort cumulative include/phi1.py 10 | sed -n '/fib\|function calls/{s/ \+/ /g;s/^ *//;p}'
...
C'est donc fib
la coupable :
- C'est ~100% du temps (
cumtime
). - C'est ~100% des appels de fonctions.
cProfile, exemple
Cachons les résultats de fib
:
#!sed -n '/import cache/,/return fib/p' include/phi2.py
cProfile, exemple
Et on repasse dans cProfile !
$ python -m cProfile --sort cumulative phi2.py 10
#!cache -- python -m cProfile --sort cumulative include/phi2.py 10 | sed -n '/fib\|function calls/{s/ \+/ /g;s/^ *//;p}'
C'est mieux !
cProfile, exemple
On essaye d'aller plus loin ?
$ python -m cProfile --sort cumulative phi2.py 2000
#!cache -- python -m cProfile --sort cumulative include/phi2.py 2000 | head -n 2 | sed 's/^ *//g;s/seconds/s/g'
Ça tient, mais peut-on faire mieux ?
cProfile, exemple
Divisons par 10 le nombre d'appels, on réduira mécaniquement par 10 le temps d'exécution ?
#!sed -n '/def approx_phi_up_to/,/return step1/p' include/phi3.py
cProfile, exemple
$ python -m cProfile --sort cumulative phi3.py 2000
#!cache -- python -m cProfile --sort cumulative include/phi3.py 2000 | head -n 2 | sed 's/^ *//g;s/seconds/s/g'
cProfile, exemple
En cachant approx_phi
?
#!sed -n '10,/return step1/p' include/phi4.py
notes:
Notez l'astuce pour que le step2
d'un
tour soit le step1
du suivant...
cProfile, exemple
$ python -m cProfile --sort cumulative phi4.py 2000
RecursionError
!? En effet, en avançant par si grands pas, le cache
de fib
n'est pas chaud, et il peut vite devoir descendre
profondément en récursion...
cProfile, exemple
Il est temps de sortir une implémentation de fib
plus robuste, basée
sur l'algorithme « matrix exponentiation » :
#!sed -n '/def fib/,/return fib/p' include/phi5.py
cProfile, exemple
$ python -m cProfile --sort cumulative phi5.py 2000
#!cache -- python -m cProfile --sort cumulative include/phi5.py 2000 | head -n 2 | sed 's/^ *//g;s/seconds/s/g'
notes:
Mieux.
Snakeviz
python -m pip install snakeviz
python -m cProfile -o phi5.prof phi5.py 2000
#!if [ ! -f .cache/phi5.prof ]; then python -m cProfile -o .cache/phi5.prof include/phi5.py 2000 >/dev/null 2>&1; fi
python -m snakeviz phi5.prof
#!if [ ! -f output/phi5-snakeviz.png ]; then python -m snakeviz -s .cache/phi5.prof & TOKILL=$!; sleep 1; cutycapt --min-width=1024 --delay=500 --url=http://127.0.0.1:8080/snakeviz/%2Ftmp%2Fphi5.prof --out=output/phi5-snakeviz.png ; kill $TOKILL; fi
Snakeviz
Scalene
$ python -m pip install scalene
$ scalene phi5.py 100000
##!if [ ! -f output/phi5.html ]; then scalene include/phi5.py 100000 --html --outfile output/phi5.html --cli >&2; fi
##!if [ ! -f output/phi5-scalene.png ]; then cutycapt --min-width=1024 --delay=100 --url=file://$(pwd)/output/phi5.html --out=output/phi5-scalene.png; fi
Scalene
Atelier
Générateur de prénoms français.
Notes: voir includes/prenom-*.py
TODO
Cython
Cython est un dialecte de Python transpilable en C.
Cython démo
#!cat include/collatz_length.py
Cython démo
#!rm -f include/*.so # Ensure we're not hitting the cythonized one here...
$ python -m pyperf timeit \
> -s 'from collatz_length import collatz_length'
> 'collatz_length(837799)'
#!cache -- python -m pyperf timeit --fast --setup 'from include.collatz_length import collatz_length' 'collatz_length(837799)'
$ cythonize --inplace collatz_length.py
#!cythonize --inplace include/collatz_length.py
$ python -m pyperf timeit \
> -s 'from collatz_length import collatz_length'
> 'collatz_length(837799)'
#!cache -- python -m pyperf timeit --fast -s 'from include.collatz_length import collatz_length' 'collatz_length(837799)' # faster
#!# Beware, the cythonized use `-s` while the non cythonized uses `--setup` just to have two cache buckets :D
Cython annotate
$ cython -a collatz_length.py
#!cython -a include/collatz_length.py
#!cutycapt --min-width=1024 --delay=500 --url=file://$(pwd)/include/collatz_length.html --out=output/collatz_length.png
Cython annotated
#!cat include/collatz_length_annotated.py
$ cythonize --inplace collatz_length_annotated.py
#!cythonize --inplace include/collatz_length_annotated.py
$ python -m pyperf timeit \
> -s 'from collatz_length_annotated import collatz_length'
> 'collatz_length(837799)'
#!cache -- python -m pyperf timeit --fast -s 'from include.collatz_length_annotated import collatz_length' 'collatz_length(837799)'
Cython annotate again
$ cython -a include/collatz_length_annotated.py
#!cython -a include/collatz_length_annotated.py
#!cutycapt --min-width=1024 --delay=500 --url=file://$(pwd)/include/collatz_length_annotated.html --out=output/collatz_length_annotated.png
Numba
Numba est un JIT
: « Just In Time compiler ».
#!cat include/collatz_length_numba.py
Numba démo
$ python -m pyperf timeit \
> -s 'from include.collatz_length_numba import collatz_length' \
> 'collatz_length(837799)'
#!cache -- python -m pyperf timeit --fast -s 'from include.collatz_length_numba import collatz_length' 'collatz_length(837799)'