web-dev-qa-db-fra.com

L'exécution de la fonction restante (%) sur les tableaux numpy est beaucoup plus longue que le calcul manuel du reste

Au cours des derniers jours, j'ai travaillé à l'amélioration de l'exécution d'une fonction python qui nécessite entre autres de nombreuses utilisations de la fonction restante (%). Mon principal test est supérieur à 80 000 élément numpy array (augmentant de façon monotone), avec 10000 itérations, bien que j'aie également essayé plusieurs autres tailles.

Finalement, j'ai atteint un point où la fonction restante est un goulot d'étranglement majeur et j'ai essayé diverses solutions. C'est le comportement que j'ai trouvé lors de l'exécution du code suivant:

import numpy as np
import time

a = np.random.Rand(80000)
a = np.cumsum(a)
d = 3
start_time1 = time.time()
for i in range(10000):
    b = a % d
    d += 0.001
end_time1 = time.time()
d = 3
start_time2 = time.time()
for i in range(10000):
    b = a - (d * np.floor(a / d))
    d += 0.001
end_time2 = time.time()
print((end_time1 - start_time1) / 10000)
print((end_time2 - start_time2) / 10000)

La sortie est:

0.0031344462633132934
0.00022937238216400147

lors de l'augmentation de la taille du tableau à 800 000:

0.014903099656105041
0.010498356819152833

(Pour cet article, j'ai exécuté le code une seule fois pour la sortie réelle, tout en essayant de comprendre le problème, j'ai obtenu ces résultats de manière cohérente.)

Bien que cela résout mon problème d'exécution - j'ai du mal à comprendre pourquoi. Suis-je en train de manquer quelque chose? La seule différence à laquelle je peux penser est la surcharge d'un appel de fonction supplémentaire, mais le premier cas est assez extrême (et 1,5 fois le temps d'exécution n'est pas assez bon non plus), et si tel était le cas, je penserais que l'existence de les np.remainder la fonction est inutile.

Edit: J'ai essayé de tester le même code avec des boucles non numpy:

import numpy as np
import time


def Pythonic_remainder(array, d):
    b = np.zeros(len(array))
    for i in range(len(array)):
        b[i] = array[i] % d

def split_Pythonic_remainder(array, d):
    b = np.zeros(len(array))
    for i in range(len(array)):
        b[i] = array[i] - (d * np.floor(array[i] / d))

def split_remainder(a, d):
    return a - (d * np.floor(a / d))

def divide(array, iterations, action):
    d = 3
    for i in range(iterations):
        b = action(array, d)
        d += 0.001

a = np.random.Rand(80000)
a = np.cumsum(a)
start_time = time.time()
divide(a, 10000, split_remainder)
print((time.time() - start_time) / 10000)

start_time = time.time()
divide(a, 10000, np.remainder)
print((time.time() - start_time) / 10000)
start_time = time.time()
divide(a, 10000, Pythonic_remainder)
print((time.time() - start_time) / 10000)

start_time = time.time()
divide(a, 10000, split_Pythonic_remainder)
print((time.time() - start_time) / 10000)

Le résultat que j'obtiens est:

0.0003770533800125122
0.003932329940795899
0.018835473942756652
0.10940513386726379

Je trouve intéressant que l'inverse soit vrai dans le cas non numpy.

31
shaul

Ma meilleure hypothèse est que votre installation NumPy utilise un fmod non optimisé à l'intérieur du % calcul. Voici pourquoi.


Premièrement, je ne peux pas reproduire vos résultats sur une version normale installée par pip de NumPy 1.15.1. Je n'obtiens qu'une différence de performances d'environ 10% (asdf.py contient votre code de synchronisation):

$ python3.6 asdf.py
0.0006543657302856445
0.0006025806903839111

Je peux reproduire un écart de performance majeur avec une construction manuelle (python3.6 setup.py build_ext --inplace -j 4) de la v1.15.1 à partir d'un clone du référentiel NumPy Git, cependant:

$ python3.6 asdf.py
0.00242799973487854
0.0006397026300430298

Cela suggère que mon build installé par pip % est mieux optimisé que ma version manuelle ou ce que vous avez installé.


En regardant sous le capot, il est tentant de regarder le implémentation de virgule flottante % dans NumPy et blâmer le ralentissement sur le calcul floordiv inutile (npy_divmod@c@ calcule les deux // et %):

NPY_NO_EXPORT void
@TYPE@_remainder(char **args, npy_intp *dimensions, npy_intp *steps, void *NPY_UNUSED(func))
{
    BINARY_LOOP {
        const @type@ in1 = *(@type@ *)ip1;
        const @type@ in2 = *(@type@ *)ip2;
        npy_divmod@c@(in1, in2, (@type@ *)op1);
    }
}

mais dans mes expériences, retirer le floordiv n'a fourni aucun avantage. Il semble assez facile pour un compilateur de s'optimiser, alors peut-être qu'il a été optimisé, ou peut-être que c'était juste une fraction négligeable de l'exécution en premier lieu.

Plutôt que le floordiv, concentrons-nous sur une seule ligne dans npy_divmod@c@, l'appel fmod:

mod = npy_fmod@c@(a, b);

Il s'agit du calcul du reste initial, avant la manipulation des cas spéciaux et l'ajustement du résultat pour qu'il corresponde au signe de l'opérande de droite. Si nous comparons les performances de % avec numpy.fmod sur ma version manuelle:

>>> import timeit
>>> import numpy
>>> a = numpy.arange(1, 8000, dtype=float)
>>> timeit.timeit('a % 3', globals=globals(), number=1000)
0.3510419335216284
>>> timeit.timeit('numpy.fmod(a, 3)', globals=globals(), number=1000)
0.33593094255775213
>>> timeit.timeit('a - 3*numpy.floor(a/3)', globals=globals(), number=1000)
0.07980139832943678

Nous constatons que fmod semble être responsable de la quasi-totalité de l'exécution de %.


Je n'ai pas démonté le binaire généré ni l'avoir parcouru dans un débogueur de niveau instruction pour voir exactement ce qui est exécuté, et bien sûr, je n'ai pas accès à votre machine ou votre copie de NumPy. Pourtant, d'après les preuves ci-dessus, fmod semble être un coupable très probable.

11
user2357112