web-dev-qa-db-fra.com

Pourquoi ce code numba est 6 fois plus lent que le code numpy?

Y a-t-il une raison pour laquelle le code suivant s'exécute en 2s,

def euclidean_distance_square(x1, x2):
    return -2*np.dot(x1, x2.T) + np.expand_dims(np.sum(np.square(x1), axis=1), axis=1) + np.sum(np.square(x2), axis=1)

tandis que le code numba suivant s'exécute en 12s?

@jit(nopython=True)
def euclidean_distance_square(x1, x2):
   return -2*np.dot(x1, x2.T) + np.expand_dims(np.sum(np.square(x1), axis=1), axis=1) + np.sum(np.square(x2), axis=1)

Mon x1 est une matrice de dimension (1, 512) et x2 est une matrice de dimension (3000000, 512). C'est assez bizarre que numba puisse être tellement plus lent. Suis-je mal utilisé?

J'ai vraiment besoin d'accélérer cela car j'ai besoin d'exécuter cette fonction 3 millions de fois et 2 secondes est encore beaucoup trop lent.

J'ai besoin de l'exécuter sur le processeur car comme vous pouvez le voir, la dimension de x2 est si énorme qu'elle ne peut pas être chargée sur un GPU (ou du moins mon GPU), pas assez de mémoire.

14
user2675516

C'est assez bizarre que numba puisse être tellement plus lent.

Ce n'est pas trop bizarre. Lorsque vous appelez des fonctions NumPy dans une fonction numba, vous appelez la version numba de ces fonctions. Celles-ci peuvent être plus rapides, plus lentes ou tout aussi rapides que les versions NumPy. Vous pourriez être chanceux ou malchanceux (vous avez été malchanceux!). Mais même dans la fonction numba, vous créez toujours beaucoup de temporaires parce que vous utilisez les fonctions NumPy (un tableau temporaire pour le résultat du point, un pour chaque carré et la somme, un pour le point plus la première somme) afin que vous ne profitiez pas de les possibilités avec numba.

Suis-je mal utilisé?

Essentiellement: oui.

J'ai vraiment besoin d'accélérer ça

D'accord, je vais essayer.

Commençons par dérouler la somme des carrés le long des appels de l'axe 1:

import numba as nb

@nb.njit
def sum_squares_2d_array_along_axis1(arr):
    res = np.empty(arr.shape[0], dtype=arr.dtype)
    for o_idx in range(arr.shape[0]):
        sum_ = 0
        for i_idx in range(arr.shape[1]):
            sum_ += arr[o_idx, i_idx] * arr[o_idx, i_idx]
        res[o_idx] = sum_
    return res


@nb.njit
def euclidean_distance_square_numba_v1(x1, x2):
    return -2 * np.dot(x1, x2.T) + np.expand_dims(sum_squares_2d_array_along_axis1(x1), axis=1) + sum_squares_2d_array_along_axis1(x2)

Sur mon ordinateur, c'est déjà 2 fois plus rapide que le code NumPy et presque 10 fois plus rapide que votre code Numba d'origine.

Parlant d'expérience, l'obtenir 2x plus rapidement que NumPy est généralement la limite (du moins si la version NumPy n'est pas inutilement compliquée ou inefficace), mais vous pouvez en tirer un peu plus en déroulant tout:

import numba as nb

@nb.njit
def euclidean_distance_square_numba_v2(x1, x2):
    f1 = 0.
    for i_idx in range(x1.shape[1]):
        f1 += x1[0, i_idx] * x1[0, i_idx]

    res = np.empty(x2.shape[0], dtype=x2.dtype)
    for o_idx in range(x2.shape[0]):
        val = 0
        for i_idx in range(x2.shape[1]):
            val_from_x2 = x2[o_idx, i_idx]
            val += (-2) * x1[0, i_idx] * val_from_x2 + val_from_x2 * val_from_x2
        val += f1
        res[o_idx] = val
    return res

Mais cela ne donne qu'une amélioration de ~ 10-20% par rapport à la dernière approche.

À ce stade, vous pourriez vous rendre compte que vous pouvez simplifier le code (même si cela ne l'accélérera probablement pas):

import numba as nb

@nb.njit
def euclidean_distance_square_numba_v3(x1, x2):
    res = np.empty(x2.shape[0], dtype=x2.dtype)
    for o_idx in range(x2.shape[0]):
        val = 0
        for i_idx in range(x2.shape[1]):
            tmp = x1[0, i_idx] - x2[o_idx, i_idx]
            val += tmp * tmp
        res[o_idx] = val
    return res

Ouais, ça a l'air assez simple et ce n'est pas vraiment plus lent.

Cependant, dans toute l'excitation, j'ai oublié de mentionner la solution évidente : scipy.spatial.distance.cdist qui a une option sqeuclidean (distance euclidienne au carré):

from scipy.spatial import distance
distance.cdist(x1, x2, metric='sqeuclidean')

Ce n'est pas vraiment plus rapide que numba mais il est disponible sans avoir à écrire votre propre fonction ...

Les tests

Testez l'exactitude et faites les échauffements:

x1 = np.array([[1.,2,3]])
x2 = np.array([[1.,2,3], [2,3,4], [3,4,5], [4,5,6], [5,6,7]])

res1 = euclidean_distance_square(x1, x2)
res2 = euclidean_distance_square_numba_original(x1, x2)
res3 = euclidean_distance_square_numba_v1(x1, x2)
res4 = euclidean_distance_square_numba_v2(x1, x2)
res5 = euclidean_distance_square_numba_v3(x1, x2)
np.testing.assert_array_equal(res1, res2)
np.testing.assert_array_equal(res1, res3)
np.testing.assert_array_equal(res1[0], res4)
np.testing.assert_array_equal(res1[0], res5)
np.testing.assert_almost_equal(res1, distance.cdist(x1, x2, metric='sqeuclidean'))

Calendrier:

x1 = np.random.random((1, 512))
x2 = np.random.random((1000000, 512))

%timeit euclidean_distance_square(x1, x2)
# 2.09 s ± 54.1 ms per loop (mean ± std. dev. of 7 runs, 1 loop each)
%timeit euclidean_distance_square_numba_original(x1, x2)
# 10.9 s ± 158 ms per loop (mean ± std. dev. of 7 runs, 1 loop each)
%timeit euclidean_distance_square_numba_v1(x1, x2)
# 907 ms ± 7.11 ms per loop (mean ± std. dev. of 7 runs, 1 loop each)
%timeit euclidean_distance_square_numba_v2(x1, x2)
# 715 ms ± 15 ms per loop (mean ± std. dev. of 7 runs, 1 loop each)
%timeit euclidean_distance_square_numba_v3(x1, x2)
# 731 ms ± 34.5 ms per loop (mean ± std. dev. of 7 runs, 1 loop each)
%timeit distance.cdist(x1, x2, metric='sqeuclidean')
# 706 ms ± 4.99 ms per loop (mean ± std. dev. of 7 runs, 1 loop each)

Remarque: Si vous disposez de tableaux d'entiers, vous pouvez modifier le code codé en dur 0.0 dans les fonctions numba à 0.

18
MSeifert

Malgré le fait que la réponse de @MSeifert rend cette réponse assez obsolète, je la poste toujours, car elle explique plus en détail pourquoi la version numba était plus lente que la version numpy.

Comme nous le verrons, le principal coupable est les différents modèles d'accès à la mémoire pour numpy et numba.

Nous pouvons reproduire le comportement avec une fonction beaucoup plus simple:

import numpy as np
import numba as nb

def just_sum(x2):
    return np.sum(x2, axis=1)

@nb.jit('double[:](double[:, :])', nopython=True)
def nb_just_sum(x2):
    return np.sum(x2, axis=1)

x2=np.random.random((2048,2048))

Et maintenant les horaires:

>>> %timeit just_sum(x)
2.33 ms ± 71.9 µs per loop (mean ± std. dev. of 7 runs, 100 loops each)
>>> %timeit nb_just_sum(x)
33.7 ms ± 296 µs per loop (mean ± std. dev. of 7 runs, 10 loops each)

cela signifie que numpy est environ 15 fois plus rapide!

Lors de la compilation du code numba avec des annotations (par exemple numba --annotate-html sum.html numba_sum.py) nous pouvons voir, comment la somme est effectuée par numba (voir la liste complète de la somme en annexe):

  1. initialiser la colonne de résultat
  2. ajouter toute la première colonne à la colonne de résultat
  3. ajouter la deuxième colonne entière à la colonne de résultat
  4. etc

Quel est le problème de cette approche? La disposition de la mémoire! Le tableau est stocké dans l'ordre des lignes principales et, par conséquent, sa lecture dans les colonnes entraîne beaucoup plus de ratés de cache que sa lecture dans les lignes (ce que fait numpy). Il y a n excellent article qui explique les effets de cache possibles.

Comme nous pouvons le voir, la somme-implémentation de numba n'est pas encore très mature. Cependant, compte tenu de ce qui précède, la mise en œuvre de numba pourrait être compétitive pour l'ordre des colonnes (c'est-à-dire la matrice transposée):

>>> %timeit just_sum(x.T)
3.09 ms ± 66.6 µs per loop (mean ± std. dev. of 7 runs, 100 loops each)
>>> %timeit nb_just_sum(x.T)
3.58 ms ± 45.8 µs per loop (mean ± std. dev. of 7 runs, 100 loops each)

et c'est vraiment.

Comme le code de @MSeifert l'a montré, le principal avantage de numba est qu'avec son aide, nous pouvons réduire le nombre de tableaux numpy temporaires. Cependant, certaines choses qui semblent faciles ne sont pas faciles du tout et une solution naïve peut être assez mauvaise. Construire une somme est une telle opération - il ne faut pas penser qu'une simple boucle est assez bonne - voir par exemple cette question .


Liste numba-summation:

 Function name: array_sum_impl_axis
in file: /home/ed/anaconda3/lib/python3.6/site-packages/numba/targets/arraymath.py
with signature: (array(float64, 2d, A), int64) -> array(float64, 1d, C)
show numba IR
194:    def array_sum_impl_axis(arr, axis):
195:        ndim = arr.ndim
196:    
197:        if not is_axis_const:
198:            # Catch where axis is negative or greater than 3.
199:            if axis < 0 or axis > 3:
200:                raise ValueError("Numba does not support sum with axis"
201:                                 "parameter outside the range 0 to 3.")
202:    
203:        # Catch the case where the user misspecifies the axis to be
204:        # more than the number of the array's dimensions.
205:        if axis >= ndim:
206:            raise ValueError("axis is out of bounds for array")
207:    
208:        # Convert the shape of the input array to a list.
209:        ashape = list(arr.shape)
210:        # Get the length of the axis dimension.
211:        axis_len = ashape[axis]
212:        # Remove the axis dimension from the list of dimensional lengths.
213:        ashape.pop(axis)
214:        # Convert this shape list back to a Tuple using above intrinsic.
215:        ashape_without_axis = _create_Tuple_result_shape(ashape, arr.shape)
216:        # Tuple needed here to create output array with correct size.
217:        result = np.full(ashape_without_axis, zero, type(zero))
218:    
219:        # Iterate through the axis dimension.
220:        for axis_index in range(axis_len):
221:            if is_axis_const:
222:                # constant specialized version works for any valid axis value
223:                index_Tuple_generic = _gen_index_Tuple(arr.shape, axis_index,
224:                                                       const_axis_val)
225:                result += arr[index_Tuple_generic]
226:            else:
227:                # Generate a Tuple used to index the input array.
228:                # The Tuple is ":" in all dimensions except the axis
229:                # dimension where it is "axis_index".
230:                if axis == 0:
231:                    index_Tuple1 = _gen_index_Tuple(arr.shape, axis_index, 0)
232:                    result += arr[index_Tuple1]
233:                Elif axis == 1:
234:                    index_Tuple2 = _gen_index_Tuple(arr.shape, axis_index, 1)
235:                    result += arr[index_Tuple2]
236:                Elif axis == 2:
237:                    index_Tuple3 = _gen_index_Tuple(arr.shape, axis_index, 2)
238:                    result += arr[index_Tuple3]
239:                Elif axis == 3:
240:                    index_Tuple4 = _gen_index_Tuple(arr.shape, axis_index, 3)
241:                    result += arr[index_Tuple4]
242:    
243:        return result 
9
ead

Ceci est un commentaire à la réponse @MSeifert. Il y a encore d'autres choses à gagner en performances. Comme dans tout code numérique, il est recommandé de penser au type de données suffisamment précis pour votre problème. Souvent, float32 suffit également, parfois même float64 ne suffit pas.

Je veux également mentionner le mot-clé fastmath ici, qui peut donner une autre vitesse 1,7x ici.

[Modifier]

Pour une simple sommation, j'ai examiné le code LLVM et j'ai découvert que la somme était divisée en sommes partielles lors de la vectorisation. (4 sommes partielles pour le double et 8 pour le float avec AVX2). Cela doit être étudié plus avant.

Code

import llvmlite.binding as llvm
llvm.set_option('', '--debug-only=loop-vectorize')

@nb.njit
def euclidean_distance_square_numba_v3(x1, x2):
    res = np.empty(x2.shape[0], dtype=x2.dtype)
    for o_idx in range(x2.shape[0]):
        val = 0
        for i_idx in range(x2.shape[1]):
            tmp = x1[0, i_idx] - x2[o_idx, i_idx]
            val += tmp * tmp
        res[o_idx] = val
    return res

@nb.njit(fastmath=True)
def euclidean_distance_square_numba_v4(x1, x2):
    res = np.empty(x2.shape[0], dtype=x2.dtype)
    for o_idx in range(x2.shape[0]):
        val = 0.
        for i_idx in range(x2.shape[1]):
            tmp = x1[0, i_idx] - x2[o_idx, i_idx]
            val += tmp * tmp
        res[o_idx] = val
    return res

@nb.njit(fastmath=True,parallel=True)
def euclidean_distance_square_numba_v5(x1, x2):
    res = np.empty(x2.shape[0], dtype=x2.dtype)
    for o_idx in nb.prange(x2.shape[0]):
        val = 0.
        for i_idx in range(x2.shape[1]):
            tmp = x1[0, i_idx] - x2[o_idx, i_idx]
            val += tmp * tmp
        res[o_idx] = val
    return res

Horaires

float64
x1 = np.random.random((1, 512))
x2 = np.random.random((1000000, 512))

0.42 v3 @MSeifert
0.25 v4
0.18 v5 parallel-version
0.48 distance.cdist

float32
x1 = np.random.random((1, 512)).astype(np.float32)
x2 = np.random.random((1000000, 512)).astype(np.float32)

0.09 v5

Comment déclarer explicitement des types

En général, je ne recommanderais pas cela. Vos tableaux d'entrée peuvent être contigus C (comme les données de test) Fortran contigus ou stridés. Si vous savez que vos données sont toujours C-contiguos, vous pouvez écrire

@nb.njit('double[:](double[:, ::1],double[:, ::1])',fastmath=True)
def euclidean_distance_square_numba_v6(x1, x2):
    res = np.empty(x2.shape[0], dtype=x2.dtype)
    for o_idx in range(x2.shape[0]):
        val = 0.
        for i_idx in range(x2.shape[1]):
            tmp = x1[0, i_idx] - x2[o_idx, i_idx]
            val += tmp * tmp
        res[o_idx] = val
    return res

Cela offre les mêmes performances que la version v4, mais échouera si les tableaux d'entrée ne sont pas contigus en C ou non de dtype = np.float64.

Vous pouvez aussi utiliser

@nb.njit('double[:](double[:, :],double[:, :])',fastmath=True)
def euclidean_distance_square_numba_v7(x1, x2):
    res = np.empty(x2.shape[0], dtype=x2.dtype)
    for o_idx in range(x2.shape[0]):
        val = 0.
        for i_idx in range(x2.shape[1]):
            tmp = x1[0, i_idx] - x2[o_idx, i_idx]
            val += tmp * tmp
        res[o_idx] = val
    return res

Cela fonctionnera également sur les tableaux stridés, mais sera beaucoup plus lent que la version ci-dessus sur les tableaux C-contigous. (,66s contre 0,25s). Veuillez également noter que votre problème est assez limité par la bande passante mémoire. La différence peut être plus élevée avec les calculs liés au processeur.

Si vous laissez faire Numba le travail pour vous, il sera automatiquement détecté si la matrice est contigüe ou non (fournir des données d'entrée contiguës au premier essai et que des données non contigües entraîneront une recompilation)

8
max9111