6.19. Performances

Les choix de conception qui rendent numpy rapide sur la caméra – appels de bibliothèque sur des tableaux entiers, tampons typés compacts, vues qui partagent leurs données avec leur source – exposent aussi un ensemble d’habitudes qu’il est utile de connaître. La page Forme et pas (strides) a déjà traité la règle de disposition du dernier axe ; cette page recense les habitudes d’allocation et de dtype qui comptent le plus dans une boucle de streaming.

6.19.1. Choisir un dtype raisonnable

Le dtype par défaut de chaque constructeur est float. Pour des données qui sont naturellement sur 8 bits ou 16 bits – échantillons ADC, pixels d’image, lectures de capteur – passez explicitement dtype= à l’un des types entiers

adc = np.array(adc_samples, dtype=np.uint16)

L’économie de RAM est de 2x pour uint16 et de 4x pour uint8 par rapport au float par défaut sur 4 octets. Les calculs sont aussi plus rapides car les chemins de code entiers de numpy sont plus serrés que les chemins génériques en flottant. La règle de dépassement entier décrite sur Dtypes s’applique – convertissez vers un type plus large avant une opération arithmétique susceptible de déborder.

6.19.2. Préférer un ndarray à un itérable

La plupart des réductions et des fonctions universelles acceptent soit un itérable, soit un ndarray

np.sum([1, 2, 3, 4, 5])               # works, but slow
np.sum(np.array([1, 2, 3, 4, 5]))     # ~3x faster

La forme itérable oblige numpy à parcourir l’entrée un objet Python à la fois, en convertissant chacun en nombre avant de pouvoir l’utiliser. Sur un ndarray, la conversion est déjà faite et l’appel s’exécute directement à travers le tampon compact.

Lorsque les mêmes données sont utilisées plus d’une fois, construisez le ndarray une seule fois et faites-le circuler. Lorsque les données n’existent que sous forme de liste Python et ne sont consommées qu’une fois, le coût de conversion peut l’emporter sur le gain de vitesse – le constructeur array() lui-même doit parcourir la liste et allouer.

6.19.3. Préférer les vues aux copies

Le tranchage, l’indexation sur un seul axe d’un tableau de rang supérieur, reshape(), transpose() et frombuffer() renvoient tous des vues qui partagent leurs données avec la source. Elles sont essentiellement gratuites.

copy(), flatten(), l’indexation booléenne (a[mask]) et toute expression arithmétique allouent une copie. N’y recourez que lorsqu’un tampon indépendant est réellement nécessaire.

En cas de doute, ndinfo() affiche l’emplacement du tampon sous-jacent ; deux tableaux qui rapportent la même adresse partagent leurs données. Le tableau complet vue/copie se trouve sur Vues et copies.

6.19.4. Allouer une fois, puis écrire

Le plus gros piège de performance sur la caméra est l’allocation de nouveaux tableaux à l’intérieur d’une boucle qui s’exécute plusieurs fois par seconde. Chaque nouveau ndarray demande de la RAM à la caméra, et de fréquentes nouvelles allocations la gaspillent.

La plupart des fonctions universelles acceptent out= afin que le résultat puisse être écrit dans un tableau déjà existant

x = np.linspace(0, 2 * np.pi, num=512)
y = np.zeros(512)        # allocate once

while True:
    np.sin(x, out=y)
    # use y ...

image.Image.to_ndarray() accepte buffer= pour la même raison ; spectrogram() et les convertisseurs de type from_int32_buffer() acceptent à la fois out= et scratchpad=. Allouez tout une fois et réutilisez-le.

6.19.5. Utiliser les opérateurs en place

b = b + 1 alloue un temporaire de la taille de b, copie, puis réaffecte. b += 1 modifie b directement

# makes a temporary
b = b + 1

# no temporary
b += 1

La même idée s’applique aux expressions composées. a + b * c alloue un temporaire pour b * c. Décomposer l’expression en sous-affectations simples écrivant dans un tampon pré-alloué élimine les temporaires

# one temporary for (a + b), another for the ``* 2``
out = (a + b) * 2

# zero temporaries
out[:]  = a
out    += b
out    *= 2

6.19.6. Construire le résultat, ne pas y faire d’ajouts

ndarray n’a pas de append – volontairement. Agrandir un tableau impliquerait d’allouer un nouveau tampon plus grand et d’y copier l’ancien contenu. Sur un microcontrôleur, pré-allouez la taille finale et remplissez-la

out = np.zeros(N, dtype=np.float)
for i in range(N):
    out[i] = some_calculation(i)

Lorsque N n’est réellement pas connu à l’avance, écrivez dans une list Python et convertissez une fois à la fin avec array().

6.19.7. Affectation par tranche au lieu de nouveaux tableaux

De nombreux schémas du type « construire un nouveau tableau à partir de morceaux d’autres tableaux » peuvent s’exprimer sous forme d’affectations par tranche dans un tampon pré-alloué plutôt que par une nouvelle allocation à chaque appel.

Une fenêtre glissante sur un flux d’échantillons – le fondement d’un filtre à moyenne glissante – en est le cas canonique. Le tampon contient les N derniers échantillons ; à chaque itération, on supprime le plus ancien et on ajoute le plus récent. La forme évidente reconstruit le tampon à chaque itération

while True:
    sample = read_sample()
    buf = np.concatenate((buf[1:],              # new buffer every loop
                          np.array([sample])))
    avg = np.mean(buf)

Cela représente une nouvelle allocation – et une copie de N - 1 éléments – par échantillon. La forme par affectation de tranche décale en place

N   = 16
buf = np.zeros(N, dtype=np.float)               # allocate once

while True:
    sample   = read_sample()
    buf[:-1] = buf[1:]                          # shift left by one
    buf[-1]  = sample                           # append at the end
    avg      = np.mean(buf)

buf[:-1] = buf[1:] est la ligne intéressante : deux vues qui se chevauchent dans le même tampon, la tranche de droite lue depuis une extrémité et écrite à l’autre. numpy parcourt la mémoire sous-jacente dans l’ordre qui rend le décalage en place sûr. Aucun nouveau tableau n’est jamais alloué à l’intérieur de la boucle.

6.19.8. Attention aux masques booléens dans les boucles de streaming

L’indexation booléenne et where() produisent un nouveau tableau à chaque appel – la taille du résultat dépend des données, donc aucun tampon pré-alloué ne peut absorber l’allocation. La construction répétée de masques dans une boucle de streaming remplit la RAM de tableaux jetables. Un gc.collect() périodique récupère l’espace

import gc

for i in range(1000):
    mask = a < threshold
    _    = a[mask]
    if i % 100 == 0:
        gc.collect()

La même mise en garde s’applique aux expressions booléennes composées comme (a > lo) & (a < hi) – chaque opérateur alloue un nouveau tableau de booléens. Lorsqu’un masque est réutilisé, construisez-le une fois et conservez-le

mask = a < threshold
foo[mask] = 0
bar[mask] = 1