9. Python pour le calcul scientifique#
« Nous devrions oublier les petites optimisations, disons environ 97 % du temps : l’optimisation prématurée est la source de tous les maux. » – Donald Knuth
9.1. Vue d’ensemble#
Python est le langage le plus populaire pour de nombreux aspects du calcul scientifique.
Cela s’explique par
la nature accessible et expressive du langage lui-même,
l’immense gamme de bibliothèques scientifiques de haute qualité,
le fait que le langage et les bibliothèques soient open source,
le rôle central que joue Python dans la science des données, l’apprentissage automatique et l’IA.
Dans les cours précédents, nous avons utilisé quelques bibliothèques scientifiques Python, notamment NumPy et Matplotlib.
Cependant, notre attention principale portait sur le langage Python de base, plutôt que sur les bibliothèques.
Nous nous tournons maintenant vers les bibliothèques scientifiques et leur accordons toute notre attention.
Dans ce cours introductif, nous aborderons les sujets suivants :
Quels sont les principaux éléments de l’écosystème scientifique Python ?
Comment s’articulent-ils entre eux ?
Comment la situation évolue-t-elle au fil du temps ?
En plus de ce qui est inclus dans Anaconda, ce cours nécessitera
!pip install quantecon
Commençons par quelques importations :
import numpy as np
import quantecon as qe
import matplotlib.pyplot as plt
import random
9.2. Principales bibliothèques scientifiques#
Passons brièvement en revue les bibliothèques scientifiques de Python.
9.2.1. Pourquoi en avons-nous besoin ?#
Nous avons besoin des bibliothèques scientifiques de Python pour deux raisons :
Python est petit
Python est lent
Python est petit
Le cœur de Python est petit par conception – cela facilite l’optimisation, l’accessibilité et la maintenance
Les bibliothèques scientifiques fournissent les routines que nous ne voulons pas – et ne devrions probablement pas – écrire nous-mêmes
intégration numérique, interpolation, algèbre linéaire, recherche de racines, etc.
Python est lent
Une autre raison pour laquelle nous avons besoin des bibliothèques scientifiques est que le Python pur est relativement lent.
Les bibliothèques scientifiques accélèrent l’exécution en utilisant trois stratégies principales :
Vectorisation : fourniture de code machine compilé et d’interfaces qui rendent ce code accessible
Compilation JIT : compilateurs qui convertissent des instructions de type Python en code machine rapide au moment de l’exécution
Parallélisation : répartition des tâches sur plusieurs threads / CPU / GPU / TPU
Nous discuterons de ces idées en profondeur ci-dessous.
9.2.2. L’écosystème scientifique de Python#
À QuantEcon, les bibliothèques scientifiques que nous utilisons le plus souvent sont
Voici comment elles s’articulent :
NumPy pose les fondations en fournissant un type de données tableau de base (pensez aux vecteurs et aux matrices) et des fonctions pour agir sur ces tableaux (par exemple, la multiplication matricielle).
SciPy s’appuie sur NumPy en ajoutant des méthodes numériques couramment utilisées en science (interpolation, optimisation, recherche de racines, etc.).
Matplotlib est utilisé pour générer des figures, en mettant l’accent sur le tracé de données stockées dans des tableaux NumPy.
JAX inclut des opérations de traitement de tableaux similaires à NumPy, la différentiation automatique, un compilateur juste-à-temps centré sur la parallélisation, et une intégration automatisée avec des accélérateurs matériels tels que les GPU.
Pandas fournit des types et des fonctions pour manipuler les données.
Numba fournit un compilateur juste-à-temps qui fonctionne bien avec NumPy et aide à accélérer le code Python.
Nous discuterons de toutes ces bibliothèques en détail dans cette série de cours.
9.3. Pourquoi le Python pur est-il lent ?#
Comme mentionné ci-dessus, le code numérique écrit en Python pur est relativement lent.
Essayons de comprendre ce qui explique les vitesses d’exécution lentes.
9.3.1. Vérification de type#
Une source de surcoût dans les opérations en Python pur est la vérification de type.
Essayons de comprendre les enjeux.
9.3.1.1. Typage dynamique#
Considérons cette opération Python
a, b = 10, 10
a + b
20
Même pour cette opération simple, l’interpréteur Python a pas mal de travail à faire.
Par exemple, dans l’instruction a + b, l’interpréteur doit savoir quelle
opération invoquer.
Si a et b sont des chaînes de caractères, alors a + b nécessite une concaténation de chaînes
a, b = 'foo', 'bar'
a + b
'foobar'
Si a et b sont des listes, alors a + b nécessite une concaténation de listes
a, b = ['foo'], ['bar']
a + b
['foo', 'bar']
En conséquence, lors de l’exécution de a + b, Python doit d’abord vérifier le type des
objets, puis appeler l’opération correcte.
Cela implique un surcoût.
Si nous exécutons cette expression de manière répétée dans une boucle serrée, le surcoût devient important.
9.3.1.2. Types statiques#
Les langages compilés évitent ces surcoûts avec des types explicites et statiques.
Par exemple, considérons le code C suivant, qui additionne les entiers de 1 à 10
#include <stdio.h>
int main(void) {
int i;
int sum = 0;
for (i = 1; i <= 10; i++) {
sum = sum + i;
}
printf("sum = %d\n", sum);
return 0;
}
Les variables i et sum sont explicitement déclarées comme des entiers.
De plus, lorsque nous faisons une instruction telle que int i, nous faisons une promesse au compilateur
que i sera toujours un entier, tout au long de l’exécution du programme.
De ce fait, la signification de l’addition dans l’expression sum + i est totalement sans ambiguïté.
Il n’y a pas besoin de vérification de type et donc pas de surcoût.
9.3.2. Accès aux données#
Un autre frein à la vitesse pour les langages de haut niveau est l’accès aux données.
Pour illustrer, considérons le problème de la sommation de données — disons, une collection d’entiers.
9.3.2.1. Sommation avec du code compilé#
En C ou en Fortran, un tableau d’entiers est stocké dans un seul bloc contigu de mémoire
Par exemple, un entier de 64 bits est stocké dans 8 octets de mémoire.
Un tableau de \(n\) tels entiers occupe \(8n\) octets consécutifs.
De plus, le type de données est connu au moment de la compilation.
Ainsi, chaque point de données successif peut être accédé en avançant dans l’espace mémoire d’une quantité connue et fixe.
9.3.2.2. Sommation en Python pur#
Python essaie de reproduire ces idées dans une certaine mesure.
Par exemple, dans l’implémentation standard de Python (CPython), les éléments d’une liste sont placés dans des emplacements mémoire qui sont dans un certain sens contigus.
Cependant, ces éléments de liste ressemblent davantage à des pointeurs vers des données qu’à des données réelles.
Ainsi, il y a toujours un surcoût impliqué dans l’accès aux valeurs de données elles-mêmes.
Un tel surcoût est un coupable majeur en ce qui concerne l’exécution lente.
9.3.3. Résumé#
La discussion ci-dessus signifie-t-elle que nous devrions simplement passer à C ou Fortran pour tout ?
La réponse est : Certainement pas !
Pour tout programme donné, relativement peu de lignes seront jamais critiques en termes de temps.
Il est donc bien plus efficace d’écrire la majeure partie de notre code dans un langage à haute productivité comme Python.
De plus, même pour les lignes de code qui sont critiques en termes de temps, nous pouvons désormais égaler ou surpasser les binaires compilés à partir de C ou Fortran en utilisant les bibliothèques scientifiques de Python.
À ce propos, nous soulignons que, ces dernières années, accélérer le code est devenu essentiellement synonyme de parallélisation.
Cette tâche est mieux laissée aux compilateurs spécialisés !
9.4. Accélérer Python#
Dans cette section, nous examinons trois techniques connexes pour accélérer le code Python.
Ici, nous nous concentrerons sur les idées fondamentales.
Plus tard, nous examinerons des bibliothèques spécifiques et comment elles implémentent ces idées.
9.4.1. Vectorisation#
Une méthode pour éviter le trafic mémoire et la vérification de type est la programmation par tableaux.
De nombreux économistes désignent généralement la programmation par tableaux par le terme « vectorisation ».
Note
En informatique, ce terme a une signification légèrement différente.
L’idée clé est d’envoyer les opérations de traitement de tableaux par lots à du code machine natif précompilé et efficace.
Le code machine lui-même est généralement compilé à partir de C ou de Fortran soigneusement optimisé.
Par exemple, lorsqu’on travaille dans un langage de haut niveau, l’opération d’inversion d’une grande matrice peut être sous-traitée à du code machine efficace qui est précompilé à cette fin et fourni aux utilisateurs dans le cadre d’un package.
Les principaux avantages sont
la vérification de type est payée par tableau, plutôt que par élément, et
les tableaux contenant des éléments du même type de données sont efficaces en termes d’accès mémoire.
L’idée de la vectorisation remonte à MATLAB, qui utilise la vectorisation de manière intensive.
NumPy utilise un modèle similaire, inspiré de MATLAB
9.4.2. Vectorisation vs boucles Python pures#
Essayons une comparaison rapide de vitesse pour illustrer comment la vectorisation peut accélérer le code.
Voici du code non vectorisé, qui utilise une boucle Python native pour générer, mettre au carré, puis additionner un grand nombre de variables aléatoires :
n = 1_000_000
with qe.Timer():
y = 0 # Accumulera et stockera la somme
for i in range(n):
x = random.uniform(0, 1)
y += x**2
0.2523 seconds elapsed
Le code vectorisé suivant utilise NumPy, que nous étudierons bientôt en détail, pour parvenir au même résultat.
rng = np.random.default_rng()
with qe.Timer():
x = rng.uniform(0, 1, n)
y = np.sum(x**2)
0.0068 seconds elapsed
Comme vous pouvez le voir, le deuxième bloc de code s’exécute beaucoup plus rapidement.
Il décompose la boucle en trois opérations de base
tirer
nuniformesles mettre au carré
les additionner
Ces opérations sont envoyées comme opérateurs par lots à du code machine optimisé.
9.4.3. Compilateurs JIT#
Au mieux, la vectorisation produit du code rapide et simple.
Cependant, elle n’est pas sans inconvénients.
Un problème est qu’elle peut être très gourmande en mémoire.
Cela est dû au fait que la vectorisation tend à créer de nombreux tableaux intermédiaires avant de produire le calcul final.
Un autre problème est que tous les algorithmes ne peuvent pas être vectorisés.
En raison de ces problèmes, la plupart du calcul haute performance s’éloigne de la vectorisation traditionnelle et se tourne vers l’utilisation de compilateurs juste-à-temps.
Dans les cours ultérieurs de cette série, nous apprendrons comment les bibliothèques Python modernes exploitent les compilateurs juste-à-temps pour générer du code machine rapide, efficace et parallélisé.
9.5. Parallélisation#
La croissance de la fréquence d’horloge des CPU (c’est-à-dire la vitesse à laquelle une seule chaîne logique peut être exécutée) a considérablement ralenti ces dernières années.
Les concepteurs de puces et les programmeurs ont répondu à ce ralentissement en cherchant une voie différente vers une exécution rapide : la parallélisation.
Cela implique
d’augmenter le nombre de CPU intégrés dans chaque machine
de connecter des accélérateurs matériels tels que les GPU et les TPU
Pour les programmeurs, le défi a été d’exploiter ce matériel en exécutant de nombreux processus en parallèle.
Ci-dessous, nous discutons de la parallélisation pour le calcul scientifique, en mettant l’accent sur
les outils de parallélisation en Python et
comment ces outils peuvent être appliqués à des problèmes économiques quantitatifs.
9.5.1. Parallélisation sur les CPU#
Passons en revue les deux principaux types de parallélisation basée sur les CPU couramment utilisés dans le calcul scientifique et discutons de leurs avantages et inconvénients.
9.5.1.1. Multithreading#
Le multithreading signifie exécuter plusieurs threads d’exécution au sein d’un seul processus.
Tous les threads partagent le même espace mémoire, ils peuvent donc lire et écrire dans les mêmes tableaux sans copier de données.
Par exemple, lorsqu’une opération numérique sur un grand tableau s’exécute sur un ordinateur portable moderne, la charge de travail peut être répartie sur les multiples cœurs de CPU de la machine, chaque cœur traitant une portion du tableau.
Note
Le Python natif peine à implémenter le multithreading en raison de certaines caractéristiques de conception héritées. Mais ce n’est pas une restriction pour les bibliothèques scientifiques comme NumPy et Numba. Les fonctions importées de ces bibliothèques et le code compilé en JIT s’exécutent dans des environnements d’exécution de bas niveau où les restrictions héritées de Python ne s’appliquent pas.
9.5.1.2. Multiprocessing#
Le multiprocessing signifie exécuter plusieurs processus indépendants, chacun avec son propre espace mémoire séparé.
Comme la mémoire n’est pas partagée, les processus communiquent en s’échangeant des données.
Le multiprocessing peut s’exécuter sur une seule machine ou être distribué sur un cluster de machines connectées par un réseau.
9.5.1.3. Que devrions-nous utiliser ?#
Pour le travail numérique sur une seule machine, le multithreading est généralement préféré — il est léger et le modèle de mémoire partagée est très pratique.
Le multiprocessing devient important lors du passage à l’échelle au-delà d’une seule machine.
Pour la grande majorité de ce que nous faisons dans ces cours, le multithreading suffira.
9.5.2. Accélérateurs matériels#
Une source de parallélisme plus spectaculaire provient d’accélérateurs matériels spécialisés, en particulier les GPU (Graphics Processing Units, unités de traitement graphique).
Les GPU ont été conçus à l’origine pour le rendu graphique, qui nécessite d’effectuer la même opération sur de nombreux pixels simultanément.
Cette architecture — des milliers de cœurs simples exécutant la même instruction sur différents points de données — s’avère idéale pour le calcul scientifique.
Note
Un cœur est une unité de traitement indépendante au sein d’une puce — un circuit qui peut exécuter des instructions par lui-même. Un CPU possède généralement un petit nombre de cœurs puissants, chacun capable de gérer des séquences complexes d’opérations. Un GPU regroupe plutôt des milliers de cœurs plus petits et plus simples, chacun conçu pour effectuer des opérations arithmétiques de base. La puissance du GPU vient du fait que tous ces cœurs travaillent simultanément sur différentes parties du même problème.
Lorsqu’un calcul peut être exprimé comme des opérations indépendantes sur de grands tableaux de données, les GPU peuvent être plusieurs ordres de grandeur plus rapides que les CPU.
Les TPU (Tensor Processing Units, unités de traitement tensoriel), conçus par Google pour l’apprentissage automatique, suivent une philosophie similaire, en optimisant pour des opérations matricielles parallèles massives.
9.5.3. Accéder aux ressources GPU#
De nombreuses stations de travail et ordinateurs portables sont désormais équipés de GPU performants, et un seul GPU moderne est souvent suffisant pour des projets de recherche individuels.
Les bibliothèques Python modernes comme JAX, largement abordée dans cette série de cours, détectent et utilisent automatiquement les GPU disponibles avec un minimum de modifications de code.
Pour les problèmes à plus grande échelle, les serveurs multi-GPU (souvent 4 à 8 GPU par machine) sont de plus en plus courants.
Avec un logiciel approprié, les calculs peuvent être distribués sur plusieurs GPU, que ce soit au sein d’un seul serveur ou sur un cluster.
Nous explorerons le calcul GPU plus en détail dans les cours ultérieurs, en l’appliquant à une gamme d’applications économiques.