Делаем четыре вложенных цикла for еще быстрее с помощью Numba

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

введите здесь описание изображения

Где 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 дает мне такую ​​картину: введите здесь описание изображения

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


person Ohm    schedule 09.05.2018    source источник
comment
Вычислить (2.0*(s0*(1.0+e*b[i,j]))**2) в цикле j вместо самого внутреннего цикла.   -  person Moses Koledoye    schedule 09.05.2018
comment
Кроме того, ваш вопрос больше подходит для проверки кода, поскольку ваш код работает, и вы ищете пути его улучшения.   -  person Moses Koledoye    schedule 09.05.2018
comment
Большое спасибо ... так что я должен удалить этот вопрос отсюда и перенести его на проверку кода?   -  person Ohm    schedule 09.05.2018
comment
Да, это было бы хорошо.   -  person Phrancis    schedule 09.05.2018
comment
Я бы сказал, посмотрите, сколько вопросов к numba на CodeReview. Думаю, здесь у тебя больше шансов...   -  person ead    schedule 09.05.2018
comment
1) Не используйте явное объявление типа. (Вы не можете явно объявить, что входные массивы непрерывны в памяти, что необходимо для SIMD-векторизации). Взгляните на numba.pydata.org/numba-doc/ dev/user/performance-tips.html (ключевое слово fastmath=True и использование Intel SVML могут сильно повлиять на производительность). Также используйте последнюю версию Numba, недавно были внесены некоторые оптимизации в отношении производительности распараллеленных функций.   -  person max9111    schedule 14.05.2018


Ответы (1)


Используя 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 раз быстрее, для больших задач эта разница станет еще больше.

Очевидно, вы можете использовать numba для еще большего ускорения. Тем не менее, по-прежнему рекомендуется использовать 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)
person ead    schedule 09.05.2018
comment
Это очень помогает, спасибо. Особенно проницательным является ваш комментарий о том, как сократить до O(n+m) операций, я не знал о таком соображении. Как кто-то предложил в комментариях, я перенес этот вопрос на проверку кода. Интересно, как мы можем перенести ваш ответ туда (и я думаю, удалить этот пост, чтобы не было двух сообщений на одну и ту же тему) - codereview.stackexchange.com/q/194023/105140 - person Ohm; 10.05.2018
comment
@Ohm Я все еще думаю, что этот вопрос подходит для SO: он очень специфичен, и только потому, что вы проделали некоторую работу, не означает, что вы должны публиковать его в обзоре кода. Посмотрим правде в глаза, пользователи, отвечающие здесь на тупой вопрос, не очень активны в просмотре кода. - person ead; 10.05.2018
comment
На какой версии Numba и процессоре вы тестировали свой код? На моей машине (Core i7 4771, Numba 0.39 dev.). Ваша распараллеленная версия не проходит тест np.allclose() по сравнению с исходной реализацией, но ваша однопоточная версия проходит все тесты (отклонение до 4% для некоторых значений). Относительная скорость по моим наблюдениям также значительно отличается (исходная версия занимает 205 с, ваша однопоточная версия — 250 мс, распараллеленная версия — 60 мс). - person max9111; 25.05.2018
comment
@ max9111 спасибо, что заглянули. У меня не будет доступа к машине в течение некоторого времени, и я не знаю версию numba наизусть. Но я посмотрю на это, как только снова попаду в руки. Несколько удивительно, что параллельная функция дает разные результаты, потому что, насколько я вижу, нет условий гонки. - person ead; 25.05.2018
comment
Я немного посмотрел на это. Параллельная версия также не проходит тест, если удалены параметры prange и parallel=True. Обе реализации ваших реализаций выглядят очень похоже, но дают другие результаты. Может проблема с компилятором.... - person max9111; 25.05.2018
comment
@ max9111 проблема, вероятно, в опечатке JJ=i-vJ[k]*dx: должно быть j, а не i. Это должно стать для меня уроком использования np.allclose, а не чего-то ленивого и менее водонепроницаемого. - person ead; 25.05.2018