Создание четырех вложенных циклов еще быстрее с Numba - PullRequest
0 голосов
/ 09 мая 2018

Я немного новичок в работе с Numba, но я понял суть этого. Интересно, есть ли еще более продвинутые трюки, чтобы сделать четыре вложенных цикла for еще быстрее, чем у меня сейчас? В частности, мне нужно вычислить следующий интеграл:

enter image description here

Где B - двумерный массив, а S0 и E - определенные параметры. Мой код следующий:

import numpy as np
from numba import njit, double

def calc_gb_gauss_2d(b,s0,e,dx):
    n,m=b.shape
    norm = 1.0/(2*np.pi*s0**2)
    gb = np.zeros((n,m))
    for i in range(n):
        for j in range(m):
            for ii in range(n):
                for jj in range(m):
                    gb[i,j]+=np.exp(-(((i-ii)*dx)**2+((j-jj)*dx)**2)/(2.0*(s0*(1.0+e*b[i,j]))**2))
            gb[i,j]*=norm
    return gb

calc_gb_gauss_2d_nb = njit(double[:, :](double[:, :],double,double,double))(calc_gb_gauss_2d)

Для и входной массив размером 256x256 скорость расчета:

In [4]: a=random.random((256,256))

In [5]: %timeit calc_gb_gauss_2d_nb(a,0.1,1.0,0.5)
The slowest run took 8.46 times longer than the fastest. This could mean that an intermediate result is being cached.
1 loop, best of 3: 1min 1s per loop

Сравнение скорости вычислений на чистом Python и Numba дает мне такую ​​картину: enter image description here

Есть ли способ оптимизировать мой код для повышения производительности?

1 Ответ

0 голосов
/ 09 мая 2018

Используя numpy и некоторые математические функции, можно ускорить ваш код, чтобы он стал на порядок быстрее текущей версии numba. Мы также увидим, что использование numba в улучшенной функции делает ее еще быстрее.

Довольно часто злоупотребление numba - часто можно написать код только для numpy, что весьма эффективно - это также имеет место и здесь.

Проблема с имеющимся кодом numpy: не нужно обращаться к отдельным элементам, а использовать встроенные функции numpy - они работают так же быстро, как и большую часть времени. Только если невозможно использовать эти numpy-функции, можно использовать numba или cython.

Однако самой большой проблемой здесь является постановка проблемы. Для фиксированных i и j у нас есть следующая формула для вычисления (я немного упростил ее):

 g[i,j]=sum_ii sum_jj exp(value_ii+value_jj)
       =sum_ii sum_jj exp(value_ii)*exp(value_jj)
       =sum_ii exp(value_ii) * sum_jj exp(value_jj)

Чтобы оценить последнюю формулу, нам нужно O(n+m) операций, но для первой, наивной формулы O(n*m) - большая разница!

Первая версия, использующая numpy-функциональность, может быть похожа на:

def calc_ead(b,s0,e,dx):
    n,m=b.shape
    norm = 1.0/(2*np.pi*s0**2)
    gb = np.zeros((n,m))
    vI=np.arange(n)
    vJ=np.arange(m)
    for i in range(n):
        for j in range(m):
            II=(i-vI)*dx
            JJ=(j-vJ)*dx
            denom=2.0*(s0*(1.0+e*b[i,j]))**2
            expII=np.exp(-II*II/denom)
            expJJ=np.exp(-JJ*JJ/denom)
            gb[i,j]=norm*(expII.sum()*expJJ.sum())
    return gb

А теперь, по сравнению с оригинальной реализацией numba:

>>> a=np.random.random((256,256))

>>> print(calc_gb_gauss_2d_nb(a,0.1,1.0,0.5)[1,1])
15.9160709993
>>> %timeit -n1 -r1 calc_gb_gauss_2d_nb(a,0.1,1.0,0.5)
1min 6s ± 0 ns per loop (mean ± std. dev. of 1 run, 1 loop each)

и теперь numpy-функция:

>>> print(calc_ead(a,0.1,1.0,0.5)[1,1])
15.9160709993
>>> %timeit -n1 -r1 calc_ead(a,0.1,1.0,0.5)
1.8 s ± 0 ns per loop (mean ± std. dev. of 1 run, 1 loop each)

Есть два замечания:

  1. результаты совпадают.
  2. NumPy версия в 37 раз быстрее, для больших проблем эта разница станет еще больше.

Очевидно, что вы можете использовать нумбу для еще большего ускорения. Тем не менее, это по-прежнему хорошая идея использовать numpy-функциональность, когда это возможно - довольно удивительно, насколько тонкими могут быть самые простые вещи - например, даже вычисление суммы :

>>> nb_calc_ead = njit(double[:, :](double[:, :],double,double,double))(calc_ead)
>>>print(nb_calc_ead(a,0.1,1.0,0.5)[1,1])
15.9160709993
>>>%timeit -n1 -r1 nb_calc_ead(a,0.1,1.0,0.5)
587 ms ± 0 ns per loop (mean ± std. dev. of 1 run, 1 loop each)

Есть еще один фактор 3!

Эта проблема может быть распараллелена, но это не тривиально, чтобы сделать это правильно. Моя дешевая попытка использовать явное распараллеливание цикла :

from numba import njit, prange
import math

@njit(parallel=True)                 #needed, so it is parallelized
def parallel_nb_calc_ead(b,s0,e,dx):
    n,m=b.shape
    norm = 1.0/(2*np.pi*s0**2)
    gb = np.zeros((n,m))
    vI=np.arange(n)
    vJ=np.arange(m)
    for i in prange(n):             #outer loop = explicit prange-loop
        for j in range(m):
            denom=2.0*(s0*(1.0+e*b[i,j]))**2
            expII=np.zeros((n,))
            expJJ=np.zeros((m,))
            for k in range(n):
                II=(i-vI[k])*dx
                expII[k]=math.exp(-II*II/denom)

            for k in range(m):
                JJ=(j-vJ[k])*dx
                expJJ[k]=math.exp(-JJ*JJ/denom)
            gb[i,j]=norm*(expII.sum()*expJJ.sum())
    return gb

А теперь:

>>> print(parallel_nb_calc_ead(a,0.1,1.0,0.5)[1,1])
15.9160709993
>>> %timeit -n1 -r1 parallel_nb_calc_ead(a,0.1,1.0,0.5)
349 ms ± 0 ns per loop (mean ± std. dev. of 1 run, 1 loop each)

означает почти другой фактор 2 (на моей машине только два процессора, в зависимости от аппаратного обеспечения ускорение может быть больше). Кстати, мы почти в 200 раз быстрее оригинальной версии.

Могу поспорить, что можно улучшить вышеуказанный код, но я туда не пойду.


Список текущей версии, с которой сравнивается calc_ead:

import numpy as np
from numba import njit, double

def calc_gb_gauss_2d(b,s0,e,dx):
    n,m=b.shape
    norm = 1.0/(2*np.pi*s0**2)
    gb = np.zeros((n,m))
    for i in range(n):
        for j in range(m):
            for ii in range(n):
                for jj in range(m):
                    gb[i,j]+=np.exp(-(((i-ii)*dx)**2+((j-jj)*dx)**2)/(2.0*(s0*(1.0+e*b[i,j]))**2))
            gb[i,j]*=norm
    return gb

calc_gb_gauss_2d_nb = njit(double[:, :](double[:, :],double,double,double))(calc_gb_gauss_2d)
...