Getty Images/iStockphoto
Comment accélérer Python et NumPy en évitant la taxe de conversion
Les transferts de données et de mémoire en Python s’accompagnent d’une taxe cachée sur les performances. Voici comment utiliser NumPy pour des performances optimales en évitant les sauts à travers une ligne cachée de conversions.
Il existe un phénomène dans le langage de programmation Python qui affecte l’efficacité de la représentation des données et de la mémoire. Je l’appelle la « ligne invisible ».
Cette ligne invisible peut sembler inoffensive à première vue. Toutefois, elle peut avoir un impact significatif sur les performances d’une application, en particulier si les données stockées dans la mémoire d’une application doivent se déplacer entre des codes gérés par différentes plateformes logicielles. Ce problème est particulièrement prononcé lorsque des données sont échangées entre Python et un moteur d’exécution C++ natif, une situation omniprésente dans le domaine de l’IA et du machine learning.
Prenons l’exemple d’un algorithme simple connu sous le nom de somme préfixe ou de somme cumulative. L’algorithme de la somme des préfixes est assez simple.
Par souci de simplicité, nous créons une liste d’un million de un. Nous la parcourons ensuite par itération, en mettant à jour chaque élément pour qu’il soit la somme de lui-même et de l’élément précédent. Nous avons maintenant la somme préfixe de 1 million de un, qui est une liste de 1 à 1 million et un.
Nous pouvons l’exprimer en Python de la manière suivante :
list = [1] * 1_000_000
for i in range(1, len(list)):
list[i] += list[i-1]
Pour mesurer le temps consacré à chaque élément, nous complétons le script par quelques « statements » de mesure du temps :
from time import time_ns
list = [1] * 1_000_000
tik = time_ns()
for i in range(1, len(list)):
list[i] += list[i-1]
tok = time_ns()
print(f"Time spent per element: {(tok - tik) / len(list)} ns")
Sur mon fidèle ordinateur portable (11 th Gen Intel Core i7-1165G7 cadencé à 2,80 GHz × 8 threads, fonctionnant sous Ubuntu 22.04.3 LTS et Python 3.11.5), le résultat est d’environ 52,88 nanosecondes (ns) par élément.
Est-ce une bonne chose ? Peut-être, mais il est difficile de savoir si l’on roule vite quand on est seul sur la route. Nous allons donc tenter le même calcul avec NumPy et établir une comparaison.
Qu’est-ce que NumPy ?
NumPy est une librairie fondamentale pour le calcul scientifique, largement utilisée par les développeurs Python. Sous l’API Python, il effectue des calculs en C ou en Fortran pour accélérer Python.
NumPy est livré avec une implémentation intégrée de la somme de préfixes, qui se présente comme suit :
import numpy as np
list = [1] * 1_000_000
tik = time_ns()
list = np.cumsum(list)
tok = time_ns()
print(f"Time spent per element: {(tok - tik) / len(list)} ns")
Cette version NumPy fonctionne admirablement, avec un temps de calcul d’environ 28,77 ns par élément, soit près de deux fois plus rapide que la version purement Python. Comparaison établie, nous avons un vainqueur incontestable.
Cependant, avant de nous féliciter et de passer à autre chose, pouvons-nous aller encore plus vite ? Modifions un peu notre script et remplaçons la liste Python par un array NumPy :
import numpy as np
list = np.full(1_000_000, 1)
tik = time_ns()
list = np.cumsum(list)
tok = time_ns()
print(f"Time spent per element: {(tok - tik) / len(list)} ns")
Et voilà que nous obtenons une vitesse fulgurante de 2,43 ns par élément. C’est plus de 10 fois plus rapide que la version NumPy précédente, et environ 20 fois plus rapide que l’implémentation Python.
Comprendre le concept de « ligne invisible »
Comment en est-on arrivé là ?
Démystifions le concept de « ligne invisible ». Prenons par exemple la manière dont nous générons des données en Python :
list = [1] * 1_000_000
Python stocke les données dans la représentation de données et l’espace mémoire approprié. Cependant, des packages tels que NumPy sont mis en œuvre dans des langages de programmation de systèmes tels que C, Rust ou Fortran. Ces langages s’interfacent avec Python, convertissent la représentation des données de Python en leur propre représentation et transfèrent les données de l’espace mémoire de Python à l’espace mémoire natif. Ce processus de franchissement de ligne est invisible, grâce à l’excellente ergonomie de Python, mais cela dissimule les dépenses associées à ces transferts et les rend fallacieusement faciles à négliger.
Voici un autre exemple extrême pour enfoncer le clou :
list = np.full(1_000_000, 1)
tik = time_ns()
for i in range(1, len(list)):
list[i] += list[i-1]
tok = time_ns()
print(f"Time spent per element: {(tok - tik) / len(list)} ns")
Cette implémentation NumPy est presque trois fois plus lente que la version « pure » Python, avec une vitesse de 148,81 ns par élément, car elle franchit la ligne invisible à chaque itération.
Par essence, l’efficacité de NumPy est une danse gracieuse sur la ligne invisible. Ainsi, lorsque vous valserez dans le monde de NumPy, gardez la ligne invisible à l’esprit pour des performances optimales.
Pendant ce temps, j’explore le nouveau langage de programmation Mojo qui peut faire encore mieux. La simple implémentation en boucle de la somme des préfixes donne 0,337532 ns par élément, et une implémentation plus élaborée basée sur les instructions SIMD donne 0,188 ns par élément.
Maxim Zaks est principal software engineer chez Yoyo Labs. Il est spécialisé dans l’ingénierie des données et la création d’applications mobiles ainsi que d’applications web front et back-end.