J'ai un code d'analyse qui effectue des opérations numériques lourdes à l'aide de numpy. Juste pour la curiosité, j'ai essayé de le compiler avec du cython avec peu de changements, puis je l'ai réécrit en utilisant des boucles pour la partie numpy.
À ma grande surprise, le code basé sur les boucles était beaucoup plus rapide (8x). Je ne peux pas publier le code complet, mais j'ai mis au point un calcul sans rapport très simple qui montre un comportement similaire (bien que la différence de synchronisation ne soit pas si grande):
Version 1 (sans cython)
import numpy as np
def _process(array):
rows = array.shape[0]
cols = array.shape[1]
out = np.zeros((rows, cols))
for row in range(0, rows):
out[row, :] = np.sum(array - array[row, :], axis=0)
return out
def main():
data = np.load('data.npy')
out = _process(data)
np.save('vianumpy.npy', out)
Version 2 (construction d'un module avec cython)
import cython
cimport cython
import numpy as np
cimport numpy as np
DTYPE = np.float64
ctypedef np.float64_t DTYPE_t
@cython.boundscheck(False)
@cython.wraparound(False)
@cython.nonecheck(False)
cdef _process(np.ndarray[DTYPE_t, ndim=2] array):
cdef unsigned int rows = array.shape[0]
cdef unsigned int cols = array.shape[1]
cdef unsigned int row
cdef np.ndarray[DTYPE_t, ndim=2] out = np.zeros((rows, cols))
for row in range(0, rows):
out[row, :] = np.sum(array - array[row, :], axis=0)
return out
def main():
cdef np.ndarray[DTYPE_t, ndim=2] data
cdef np.ndarray[DTYPE_t, ndim=2] out
data = np.load('data.npy')
out = _process(data)
np.save('viacynpy.npy', out)
Version 3 (construction d'un module avec cython)
import cython
cimport cython
import numpy as np
cimport numpy as np
DTYPE = np.float64
ctypedef np.float64_t DTYPE_t
@cython.boundscheck(False)
@cython.wraparound(False)
@cython.nonecheck(False)
cdef _process(np.ndarray[DTYPE_t, ndim=2] array):
cdef unsigned int rows = array.shape[0]
cdef unsigned int cols = array.shape[1]
cdef unsigned int row
cdef np.ndarray[DTYPE_t, ndim=2] out = np.zeros((rows, cols))
for row in range(0, rows):
for col in range(0, cols):
for row2 in range(0, rows):
out[row, col] += array[row2, col] - array[row, col]
return out
def main():
cdef np.ndarray[DTYPE_t, ndim=2] data
cdef np.ndarray[DTYPE_t, ndim=2] out
data = np.load('data.npy')
out = _process(data)
np.save('vialoop.npy', out)
Avec une matrice 10000x10 enregistrée dans data.npy, les délais sont les suivants:
$ python -m timeit -c "from version1 import main;main()"
10 loops, best of 3: 4.56 sec per loop
$ python -m timeit -c "from version2 import main;main()"
10 loops, best of 3: 4.57 sec per loop
$ python -m timeit -c "from version3 import main;main()"
10 loops, best of 3: 2.96 sec per loop
Est-ce attendu ou y a-t-il une optimisation qui me manque? Le fait que les versions 1 et 2 donnent le même résultat est en quelque sorte attendu, mais pourquoi la version 3 est-elle plus rapide?
Ps.- Ce n'est PAS le calcul que je dois faire, juste un exemple simple qui montre la même chose.
Comme mentionné dans les autres réponses, la version 2 est essentiellement la même que la version 1 car cython est incapable de creuser dans l'opérateur d'accès à la baie afin de l'optimiser. Il y a 2 raisons à cela
Tout d'abord, il y a une certaine quantité de surcharge dans chaque appel à une fonction numpy, par rapport au code C optimisé. Cependant, ces frais généraux deviendront moins importants si chaque opération concerne de grandes baies
Deuxièmement, il y a la création de tableaux intermédiaires. Ceci est plus clair si vous envisagez une opération plus complexe telle que out[row, :] = A[row, :] + B[row, :]*C[row, :]
. Dans ce cas, un tableau entier B*C
doit être créé en mémoire, puis ajouté à A
. Cela signifie que le cache du processeur est écrasé, car les données sont lues et écrites dans la mémoire plutôt que d'être conservées dans le processeur et utilisées immédiatement. Surtout, ce problème s'aggrave si vous avez affaire à de grands tableaux.
D'autant plus que vous déclarez que votre vrai code est plus complexe que votre exemple, et qu'il montre une accélération beaucoup plus grande, je soupçonne que la deuxième raison est probablement le principal facteur dans votre cas.
Soit dit en passant, si vos calculs sont suffisamment simples, vous pouvez surmonter cet effet en utilisant numexpr , bien que le cython soit bien sûr utile dans de nombreuses autres situations, il peut donc être la meilleure approche pour vous.
Avec une légère modification, la version 3 devient deux fois plus rapide:
@cython.boundscheck(False)
@cython.wraparound(False)
@cython.nonecheck(False)
def process2(np.ndarray[DTYPE_t, ndim=2] array):
cdef unsigned int rows = array.shape[0]
cdef unsigned int cols = array.shape[1]
cdef unsigned int row, col, row2
cdef np.ndarray[DTYPE_t, ndim=2] out = np.empty((rows, cols))
for row in range(rows):
for row2 in range(rows):
for col in range(cols):
out[row, col] += array[row2, col] - array[row, col]
return out
Le goulot d'étranglement dans votre calcul est l'accès à la mémoire. Votre tableau d'entrée est ordonné en C, ce qui signifie que le déplacement le long du dernier axe fait le plus petit saut en mémoire. Par conséquent, votre boucle interne doit être le long de l'axe 1 et non de l'axe 0. Cette modification réduit de moitié le temps d'exécution.
Si vous devez utiliser cette fonction sur de petits tableaux d'entrée, vous pouvez réduire la surcharge en utilisant np.empty
au lieu de np.ones
. Pour réduire encore la surcharge, utilisez PyArray_EMPTY
à partir de l'API numpy C.
Si vous utilisez cette fonction sur de très grands tableaux d'entrée (2 ** 31), les entiers utilisés pour l'indexation (et dans la fonction range
) déborderont. Pour une utilisation en toute sécurité:
cdef Py_ssize_t rows = array.shape[0]
cdef Py_ssize_t cols = array.shape[1]
cdef Py_ssize_t row, col, row2
au lieu de
cdef unsigned int rows = array.shape[0]
cdef unsigned int cols = array.shape[1]
cdef unsigned int row, col, row2
Horaire:
In [2]: a = np.random.Rand(10000, 10)
In [3]: timeit process(a)
1 loops, best of 3: 3.53 s per loop
In [4]: timeit process2(a)
1 loops, best of 3: 1.84 s per loop
où process
est votre version 3.
Je recommanderais d'utiliser l'indicateur -a pour que cython génère le fichier html qui montre ce qui est traduit en c pur par rapport à l'appel de l'API python:
http://docs.cython.org/src/quickstart/cythonize.html
La version 2 donne à peu près le même résultat que la version 1, car tout le gros du travail est effectué par l'API Python (via numpy) et cython ne fait rien pour vous. En fait sur ma machine, numpy est construite contre MKL, donc quand je compile le code c généré par cython en utilisant gcc, la version 3 est en fait un peu plus lente que les deux autres.
Cython brille lorsque vous effectuez une manipulation de tableau que numpy ne peut pas faire de manière `` vectorisée '', ou lorsque vous faites quelque chose de mémoire intensive qui vous permet d'éviter de créer un grand tableau temporaire. J'ai obtenu 115x accélérations en utilisant cython vs numpy pour certains de mon propre code:
https://github.com/synapticarbors/pylangevin-integrator
Une partie de cela appelait le répertoire randomkit au niveau du code c au lieu de l'appeler via numpy.random
, mais la majeure partie de cela était cython traduisant le calcul intensif pour les boucles en c pur sans appels à python.
La différence peut être due au fait que les versions 1 et 2 effectuent un appel de niveau Python vers np.sum()
pour chaque ligne, tandis que la version 3 se compile probablement en une boucle C pure et serrée.
L'étude de la différence entre la source C générée par Cython dans les versions 2 et 3 devrait être éclairante.
Je suppose que la surcharge principale que vous enregistrez est les tableaux temporaires créés. Vous créez un très grand tableau array - array[row, :]
, puis réduisez-le dans un tableau plus petit à l'aide de sum
. Mais la construction de ce grand tableau temporaire ne sera pas gratuite, surtout si vous avez besoin d'allouer de la mémoire.