Sommaire
Qu'est-ce que Numba ?
Numba est un compilateur juste à temps pour CPython spécialisé dans l'optimisation de code scientifique.
… CPython ?
CPython est l'implémentation par défaut (ou "de référence", en langage politiquement correct) de Python. Les autres implémentations s'appellent PyPy, Jython, etc.
… Spécialisé ?
Numba ne prétend pas être capable d'optimiser tous les types de code Python, ni même toutes les constructions du langage (même si cela n'est pas exclu comme objectif à long terme). Numba est capable d'optimiser les calculs numériques et scientifiques impliquant des entiers, des flottants, des complexes ; il est capable de reconnaître certaines types de données bien connus, comme les tableaux Numpy.
La liste des fonctionnalités optimisables grandit évidemment de version en version, par exemple la version 0.14 supporte désormais les types numpy.datetime64
et numpy.timedelta64
, qui permettent de faire des calculs sur des séries temporelles.
… Juste à temps ?
Numba fonctionne lors de l'exécution de votre code Python. Il suffit d'apposer le décorateur "@numba.jit
" à une fonction pour la faire optimiser par Numba (certains aiment prier pour qu'il arrive à l'optimiser). Il n'y a pas de phase de compilation séparée, ni de ligne de commande à lancer.
Plateformes supportées
Numba utilise LLVM, ce qui permet une portabilité relativement aisée. Actuellement, les systèmes sous Linux, OS X et Windows sont supportés. Côté matériel, x86, x86-64 sont supportés et il y a également un backend CUDA (avec des limitations).
Un exemple
Partons de notre ami l'ensemble de Mandelbrot. Pour cela, posons la fonction suivante dans un fichier nommé mandel.py
:
import numpy
def mandelbrot(width, height):
x_min, x_max = -2.0, 1.0
y_min, y_max = -1.0, 1.0
arr = numpy.zeros((height, width), dtype=int)
xs = numpy.linspace(x_min, x_max, num=width)
ys = numpy.linspace(y_min, y_max, num=height)
max_iters = 20
for idx_x, x in enumerate(xs):
for idx_y, y in enumerate(ys):
c = complex(x, y)
z = 0.0j
for i in range(max_iters):
z = z * z + c
if z.real * z.real + z.imag * z.imag >= 4:
# Hors de l'ensemble
break
else:
# Dans l'ensemble
arr[idx_y, idx_x] = 1
return arr
Cette fonction renvoie un tableau d'entiers Numpy indiquant si un point fait partie de l'ensemble de Mandelbrot (1) ou non (0).
Maintenant, lançons un Python depuis le même répertoire :
>>> import mandel
>>> print(mandel.mandelbrot(20, 15))
[[0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0]
[0 0 0 0 0 0 0 0 0 0 0 0 1 0 0 0 0 0 0 0]
[0 0 0 0 0 0 0 0 0 0 0 0 1 0 0 0 0 0 0 0]
[0 0 0 0 0 0 0 0 0 0 1 1 1 1 0 0 0 0 0 0]
[0 0 0 0 0 0 0 0 0 1 1 1 1 1 1 0 0 0 0 0]
[0 0 0 0 0 0 0 0 0 1 1 1 1 1 1 1 0 0 0 0]
[0 0 0 0 0 1 1 1 1 1 1 1 1 1 1 1 0 0 0 0]
[0 1 1 1 1 1 1 1 1 1 1 1 1 1 1 0 0 0 0 0]
[0 0 0 0 0 1 1 1 1 1 1 1 1 1 1 1 0 0 0 0]
[0 0 0 0 0 0 0 0 0 1 1 1 1 1 1 1 0 0 0 0]
[0 0 0 0 0 0 0 0 0 1 1 1 1 1 1 0 0 0 0 0]
[0 0 0 0 0 0 0 0 0 0 1 1 1 1 0 0 0 0 0 0]
[0 0 0 0 0 0 0 0 0 0 0 0 1 0 0 0 0 0 0 0]
[0 0 0 0 0 0 0 0 0 0 0 0 1 0 0 0 0 0 0 0]
[0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0]]
Ouf, ça ressemble :-) Maintenant compilons cette même fonction avec Numba :
>>> from numba import jit
>>> opt = jit(mandel.mandelbrot)
>>> print(opt(20, 15))
[[0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0]
[0 0 0 0 0 0 0 0 0 0 0 0 1 0 0 0 0 0 0 0]
[0 0 0 0 0 0 0 0 0 0 0 0 1 0 0 0 0 0 0 0]
[0 0 0 0 0 0 0 0 0 0 1 1 1 1 0 0 0 0 0 0]
[0 0 0 0 0 0 0 0 0 1 1 1 1 1 1 0 0 0 0 0]
[0 0 0 0 0 0 0 0 0 1 1 1 1 1 1 1 0 0 0 0]
[0 0 0 0 0 1 1 1 1 1 1 1 1 1 1 1 0 0 0 0]
[0 1 1 1 1 1 1 1 1 1 1 1 1 1 1 0 0 0 0 0]
[0 0 0 0 0 1 1 1 1 1 1 1 1 1 1 1 0 0 0 0]
[0 0 0 0 0 0 0 0 0 1 1 1 1 1 1 1 0 0 0 0]
[0 0 0 0 0 0 0 0 0 1 1 1 1 1 1 0 0 0 0 0]
[0 0 0 0 0 0 0 0 0 0 1 1 1 1 0 0 0 0 0 0]
[0 0 0 0 0 0 0 0 0 0 0 0 1 0 0 0 0 0 0 0]
[0 0 0 0 0 0 0 0 0 0 0 0 1 0 0 0 0 0 0 0]
[0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0]]
Et lançons un benchmark d'une scientificité rigoureuse sur un tableau de 200x200 :
>>> import timeit
>>> timeit.timeit("mandel.mandelbrot(200, 200)", setup="import mandel", number=1)
0.20483311700081686
>>> timeit.timeit("opt(200, 200)", setup="from __main__ import opt", number=1)
0.003343598000355996
Sur ce cas d'usage d'une grande importance, Numba est ainsi 60 fois plus rapide que l'interpréteur CPython !
Principe de fonctionnement
Numba est un optimiseur fonction par fonction. Il part de la fonction que vous décorez avec numba.jit
et lance une inférence de types pour essayer d'assigner un type précis à chaque variable et valeur intermédiaire dans la fonction. Pour cela, il utilise des règles dédiées à chaque opérateur, fonction standard, etc.
Pour définir les types de départ du processus d'inférence, il y a en réalité deux possibilités :
- Dans l'utilisation de base de
numba.jit
, l'utilisateur ne passe pas de paramètres : c'est alors lors de l'appel de la fonction décorée que les types des arguments sont utilisés pour lancer le processus d'inférence (la compilation est ici paresseuse).
- Dans une utilisation avancée, l'utilisateur peut décrire explicitement les types à l'entrée de la fonction (cela lui permet, par exemple, de choisir des flottants simple précision plutôt que double précision) ; dans ce cas, la compilation a lieu immédiatement.
L'inférence de type est l'étage fondamental qui permet ensuite de produire du code travaillant sur des types bas niveau, plutôt qu'au niveau du modèle objet de Python, qui est beaucoup plus difficile à optimiser. Bien entendu, la chaîne de traitement (pipeline) est constituée de multiples étages :
- décodage du bytecode CPython
- construction d'un graphe de flot de contrôle (CFG)
- génération d'une représentation intermédiaire (IR) spécifique à Numba
- inférence de types, produisant une détermination du type de chaque variable manipulée par la fonction
- génération de code LLVM correspondant à l'exécution de l'IR sur les types déterminés précédemment
- appel à LLVM pour la génération du code machine, et encapsulation du tout dans un objet dédié (wrapper) qui lance le code machine quand il est appelé
Grâce à LLVM, Numba n'a pas à se charger des optimisations bas niveau (propagation de constantes, vectorisation SIMD, etc.) et peut se concentrer sur les traitements spécifiques au langage Python.
Pour une présentation plus détaillée, il convient de lire la documentation (en anglais) : architecture de Numba.
Limitations
À l'heure actuelle, plutôt que de se lancer dans une liste interminable de limitations, il est plus raisonnable de lister ce qui est supporté.
Types supportés
- les scalaires numériques, que ce soit les types Python (
int
, float
, complex
…) ou NumPy (numpy.complex64
, etc.)
- les booléens
- les dates et intervalles de temps NumPy (
numpy.datetime64
et numpy.timedelta64
)—mais pas les types standard datetime.datetime
et datetime.timedelta
- les tuples des types ci-dessus
- les tableaux NumPy des types ci-dessus
Constructions supportées
Numba supporte la plupart des constructions syntaxiques du langage, sauf le traitement des exceptions (try... except
, etc.), les gestionnaires de contexte (with
), les générateurs (yield
) et quelques autres détails.
Fonctions supportées
Numba supporte une grande partie des opérateurs numériques, des fonctions standard (notamment le module math
), et certaines fonctions de NumPy. Il est également capable d'optimiser les appels à des fonctions externes via ctypes ou cffi.
Différences sémantiques
Le caractère volontaire (opt-in) de la compilation permet à Numba de présenter des différences sémantiques par rapport au langage Python. Je ne les listerai pas ici, mais une différence évidente a trait aux entiers : les entiers de Python sont de largeur arbitraire, ceux de Numba sont de largeur fixe (déterminée à l'inférence de types, ou forcée par l'utilisateur). Un dépassement lors d'opérations mathématiques entraîne simplement une troncation.
Notons qu'une grande partie de ces différences reflète en réalité le comportement de NumPy (dont les entiers sont aussi à largeur fixe, par exemple).
Numba et l'écosystème Python
Le fait que Numba fonctionne au-dessus de CPython lui permet de s'intégrer facilement à l'écosystème existant. Comme on l'aura compris, l'intégration avec NumPy est également une fonctionnalité de premier plan. D'une manière générale, la stratégie de Continuum (cf. ci-dessous) est de maximiser les possibilités d'interaction entre les multiples briques de calcul scientifique disponibles pour Python.
Statut du projet
Numba est supporté par Continuum Analytics, qui paye à cet effet plusieurs développeurs (dont l'auteur de cette dépêche). Le développement est en partie financé par des clients et des fonds de recherche américains. Nous recevons également des contributions extérieures. Bien entendu, le projet est libre (licence de type MIT).
Contribuer
Numba est un projet jeune auquel manquent de nombreuses fonctionnalités ; il y a donc beaucoup d'opportunités de contribution. Le processus est détaillé dans la documentation. Les compétences nécessaires dépendent de ce à quoi vous vous intéressez :-) La majeure partie du code de Numba est en Python ; il y a quelques parties en C, mais on les évite assez facilement. Nul besoin d'être en expert en NumPy ou en LLVM pour commencer (je ne les connaissais presque pas).
Au débotté, voici quelques directions possibles de contribution, par ordre de difficulté :
- améliorer la documentation
- améliorer le retour utilisateur, qui est actuellement assez fruste (messages d'erreur, informations sur la fonction compilée)
- corriger des bugs (!)
- ajouter le support de nouvelles fonctions (de la bibliothèque standard ou de NumPy)
- ajouter le support de nouveaux types
- ajouter le support de nouvelles constructions (comme la gestion des exceptions ou les générateurs)