Оптимизация двойного цикла в python

Я пытаюсь оптимизировать следующий цикл :

def numpy(nx, nz, c, rho):
    for ix in range(2, nx-3):
        for iz in range(2, nz-3):
            a[ix, iz]  = sum(c*rho[ix-1:ix+3, iz])
            b[ix, iz]  = sum(c*rho[ix-2:ix+2, iz])
    return a, b

Я пробовал различные решения и обнаружил, что использование numba для расчета суммы продукта приводит к улучшению производительности:

import numpy as np
import numba as nb
import time

@nb.autojit
def sum_opt(arr1, arr2):
    s = arr1[0]*arr2[0]
    for i in range(1, len(arr1)):
        s+=arr1[i]*arr2[i]
    return s

def numba1(nx, nz, c, rho):
    for ix in range(2, nx-3):
        for iz in range(2, nz-3):        
            a[ix, iz]  = sum_opt(c, rho[ix-1:ix+3, iz])
            b[ix, iz]  = sum_opt(c, rho[ix-2:ix+2, iz])
    return a, b

@nb.autojit
def numba2(nx, nz, c, rho):
    for ix in range(2, nx-3):
        for iz in range(2, nz-3):        
            a[ix, iz]  = sum_opt(c, rho[ix-1:ix+3, iz])
            b[ix, iz]  = sum_opt(c, rho[ix-2:ix+2, iz])
    return a, b
nx = 1024
nz = 256    

rho = np.random.rand(nx, nz)
c = np.random.rand(4)
a = np.zeros((nx, nz))
b = np.zeros((nx, nz))

ti = time.clock()
a, b = numpy(nx, nz, c, rho)
print 'Time numpy  : ' + `round(time.clock() - ti, 4)`

ti = time.clock()
a, b = numba1(nx, nz, c, rho)
print 'Time numba1 : ' + `round(time.clock() - ti, 4)`

ti = time.clock()
a, b = numba2(nx, nz, c, rho)
print 'Time numba2 : ' + `round(time.clock() - ti, 4)`

это приводит к

время numpy: 4.1595

время numba1: 0.6993

время numba2: 1.0135

использование версии numba функции sum (sum_opt) работает очень хорошо. Но мне интересно, почему версия numba функции двойного цикла (numba2) приводит к более медленному времени выполнения. Я попытался использовать jit вместо autojit, указав типы аргументов, но это было хуже.

Я также заметил, что цикл сначала на самом маленьком цикле медленнее, чем цикл сначала на самом большом цикле. Есть ли объяснение ?

ли это, я уверен, что эта функция двойного цикла может быть улучшена много векторизации проблемы (например,этой) или с помощью другого метода (map ?) но я немного запутался в этих методах.

в других частях моего кода я использовал методы нарезки numba и numpy для замены всех явных циклов, но в этом конкретном случае я не знаю, как его настроить.

какие идеи ?

редактировать

Спасибо за все ваши комментарии. Я немного поработал над этой проблемой:

import numba as nb
import numpy as np
from scipy import signal
import time


@nb.jit(['float64(float64[:], float64[:])'], nopython=True)
def sum_opt(arr1, arr2):
    s = arr1[0]*arr2[0]
    for i in xrange(1, len(arr1)):
        s+=arr1[i]*arr2[i]
    return s

@nb.autojit
def numba1(nx, nz, c, rho, a, b):
    for ix in range(2, nx-3):
        for iz in range(2, nz-3):        
            a[ix, iz]  = sum_opt(c, rho[ix-1:ix+3, iz])
            b[ix, iz]  = sum_opt(c, rho[ix-2:ix+2, iz])
    return a, b


@nb.jit(nopython=True)
def numba2(nx, nz, c, rho, a, b):
    for ix in range(2, nx-3):
        for iz in range(2, nz-3):        
            a[ix, iz]  = sum_opt(c, rho[ix-1:ix+3, iz])
            b[ix, iz]  = sum_opt(c, rho[ix-2:ix+2, iz])
    return a, b

@nb.jit(['float64[:,:](int16, int16, float64[:], float64[:,:], float64[:,:])'], nopython=True)
def numba3a(nx, nz, c, rho, a):
    for ix in range(2, nx-3):
        for iz in range(2, nz-3):        
            a[ix, iz]  = sum_opt(c, rho[ix-1:ix+3, iz])
    return a

@nb.jit(['float64[:,:](int16, int16, float64[:], float64[:,:], float64[:,:])'], nopython=True)
def numba3b(nx, nz, c, rho, b):
    for ix in range(2, nx-3):
        for iz in range(2, nz-3):        
            b[ix, iz]  = sum_opt(c, rho[ix-2:ix+2, iz])
    return b

def convol(nx, nz, c, aa, bb):
    s1 = rho[1:nx-1,2:nz-3]
    s2 = rho[0:nx-2,2:nz-3]
    kernel = c[:,None][::-1]
    aa[2:nx-3,2:nz-3] = signal.convolve2d(s1, kernel, boundary='symm', mode='valid')
    bb[2:nx-3,2:nz-3] = signal.convolve2d(s2, kernel, boundary='symm', mode='valid')
    return aa, bb


nx = 1024
nz = 256 
rho = np.random.rand(nx, nz)
c = np.random.rand(4)
a = np.zeros((nx, nz))
b = np.zeros((nx, nz))

ti = time.clock()
for i in range(1000):
    a, b = numba1(nx, nz, c, rho, a, b)
print 'Time numba1 : ' + `round(time.clock() - ti, 4)`

ti = time.clock()
for i in range(1000):
    a, b = numba2(nx, nz, c, rho, a, b)
print 'Time numba2 : ' + `round(time.clock() - ti, 4)`

ti = time.clock()
for i in range(1000):
    a = numba3a(nx, nz, c, rho, a)
    b = numba3b(nx, nz, c, rho, b)
print 'Time numba3 : ' + `round(time.clock() - ti, 4)`

ti = time.clock()
for i in range(1000):
    a, b = convol(nx, nz, c, a, b)
print 'Time convol : ' + `round(time.clock() - ti, 4)`

ваше решение очень элегантное Divakar, но я должен использовать эту функцию большое количество время в моем коде. Итак, для 1000 итераций это приводит к

время numba1: 3.2487

время numba2: 3.7012

время numba3: 3.2088

convol время : 22.7696

autojit и jit очень близки. Однако, при использовании jit, представляется важным указать все типы аргументов.

Я не знаю, есть ли способ указать типы аргументов в jit декоратора, когда функция имеет несколько выходов. Кто-то ?

пока я не нашел другого решения, кроме использования numba. Новые идеи приветствуются !

4 ответов


Numba очень быстро, в nopython режим но с вашим кодом он должен вернуться к object режим, который намного медленнее. Вы можете видеть, что это происходит, если вы проходите nopython=True до jit оформителя.

он компилируется в nopython режим (по крайней мере, в Numba версии 0.18.2), если вы передаете a и b в качестве аргументов:

import numba as nb

@nb.jit(nopython=True)
def sum_opt(arr1, arr2):
    s = arr1[0]*arr2[0]
    for i in range(1, len(arr1)):
        s+=arr1[i]*arr2[i]
    return s

@nb.jit(nopython=True)
def numba2(nx, nz, c, rho, a, b):
    for ix in range(2, nx-3):
        for iz in range(2, nz-3):        
            a[ix, iz]  = sum_opt(c, rho[ix-1:ix+3, iz])
            b[ix, iz]  = sum_opt(c, rho[ix-2:ix+2, iz])
    return a, b

обратите внимание, что в заметки есть упоминание о autojit быть осужденным в пользу jit.


очевидно, вы еще не удовлетворены. Так как насчет решения, основанного на stride_tricks?

from numpy.lib.stride_tricks import as_strided

def stridetrick_einsum(c, rho, out):
    ws = len(c)
    nx, nz = rho.shape

    shape = (nx-ws+1, ws, nz)
    strides = (rho.strides[0],) + rho.strides
    rho_windowed = as_strided(rho, shape, strides)

    np.einsum('j,ijk->ik', c, rho_windowed, out=out)

a = np.zeros((nx, nz))
stridetrick_einsum(c, rho[1:-1,2:-3], a[2:-3,2:-3])
b = np.zeros((nx, nz))
stridetrick_einsum(c, rho[0:-2,2:-3], b[2:-3,2:-3])

более того, так как a и b очевидно, почти точно такие же, вы можете вычислить их за один раз, а затем скопировать значения:

a = np.zeros((nx, nz))
stridetrick_einsum(c, rho[:-1,2:-3], a[1:-3,2:-3])
b = np.zeros((nx, nz))
b[2:-3,2:-3] = a[1:-4,2:-3]
a[1,2:-3] = 0.0

вы в основном выполняете 2D свертку там, с небольшой модификацией, что ваше ядро не реверсируется, как обычно convolution операция. Итак, в основном, есть две вещи, которые нам нужно сделать здесь, чтобы использовать signal.convolve2d, чтобы решить наши дела -

  • нарезать входной массив rho чтобы выбрать часть, которая используется в исходной версии кода loopy. Это будут входные данные для свертка.
  • обратный ядра, c и кормить его вместе с нарезанным данных signal.convolve2d.

обратите внимание, что это должно быть сделано для исчисления как a и b отдельно.

вот реализация -

import numpy as np
from scipy import signal

# Slices for convolutions to get a and b respectively        
s1 = rho[1:nx-1,2:nz-3]
s2 = rho[0:nx-2,2:nz-3]
kernel = c[:,None][::-1]  # convolution kernel

# Setup output arrays and fill them with convolution results
a = np.zeros((nx, nz))
b = np.zeros((nx, nz))

a[2:nx-3,2:nz-3] = signal.convolve2d(s1, kernel, boundary='symm', mode='valid')
b[2:nx-3,2:nz-3] = signal.convolve2d(s2, kernel, boundary='symm', mode='valid')

Если вам не нужны дополнительные нули вокруг границ выходных массивов, вы можете просто использовать выходы из signal.convolve2d как они есть, что должно дополнительно повысить производительность.

во время выполнения тестов

In [532]: %timeit loop_based(nx, nz, c, rho)
1 loops, best of 3: 1.52 s per loop

In [533]: %timeit numba1(nx, nz, c, rho)
1 loops, best of 3: 282 ms per loop

In [534]: %timeit numba2(nx, nz, c, rho)
1 loops, best of 3: 509 ms per loop

In [535]: %timeit conv_based(nx, nz, c, rho)
10 loops, best of 3: 15.5 ms per loop

так, для фактического входного datasize, предлагаемые свертки подход, основанный около 100x быстрее, чем код loopy и о 20x лучше, чем самый быстрый numba подход numba1.


вы не используете в полной мере возможности numpy. The numpythonic способ решения вашей проблемы будет чем-то вроде:

cs = np.zeros((nx+1, nz))
np.cumsum(c*rho, axis=0, out=cs[1:])
aa = cs[5:, 2:-3] - cs[1:-4, 2:-3]
bb = cs[4:-1, 2:-3] - cs[:-5, 2:-3]

aa теперь будет держать центральную, ненулевую часть вашего a время:

>>> a[:5, :5]
array([[ 0.        ,  0.        ,  0.        ,  0.        ,  0.        ],
       [ 0.        ,  0.        ,  0.        ,  0.        ,  0.        ],
       [ 0.        ,  0.        ,  2.31296595,  2.15743042,  2.5853117 ],
       [ 0.        ,  0.        ,  2.02697233,  2.83191016,  2.58819583],
       [ 0.        ,  0.        ,  2.4086584 ,  2.45175615,  2.19628507]])
>>>aa[:3, :3]
array([[ 2.31296595,  2.15743042,  2.5853117 ],
       [ 2.02697233,  2.83191016,  2.58819583],
       [ 2.4086584 ,  2.45175615,  2.19628507]])

и аналогично для bb и b.

в моей системе, с вашим образцом ввода, этот код работает более 300x быстрее, чем ваш


как говорится в раздел в блоге continuum,autojit компилирует как раз вовремя, в то время как jit компилирует заранее:

Numba может скомпилировать just-in-time с декоратором autojit или впереди время с JIT декоратор

это означает, что во многих случаях autojit означает, что компилятор может сделать более обоснованное предположение о коде, который он компилирует, и оптимизировать после этого. Я знаю, что just-in-time компиляция впереди время звучит противоречиво, но эй.

но мне интересно, почему версия numba функции двойного цикла (numba2) приводит к замедлению времени выполнения

Numba не увеличивает производительность произвольных вызовов функций. Хотя я не могу сказать наверняка, я предполагаю, что накладные расходы на компиляцию JIT перевешивают выгоду от этого (если есть какая-либо польза вообще).

Я также заметил, что цикл сначала на наименьший цикл медленнее, чем сначала петля На самой Большой петле. Есть ли объяснение ?

это, вероятно, из-за кэш Мисс. В 2-мерный массив выделяется как непрерывный кусок памяти размером rows * columns. То, что извлекается в кэш, основано на комбинации временной (то, что недавно было использовано) и пространственной (то, что близко в памяти к тому, что было использовано) локальности, т. е. то, что считается используемым далее.

при переборе сначала по строкам выполняется итерация в том порядке, в котором данные отображаются в памяти. При итерации по столбцам сначала вы" пропускаете " ширину строки в памяти каждый раз, что делает менее вероятным, что место памяти, к которому вы обращаетесь, было извлечено в кэш.

2D array: [[1,2,3],[4,5,6],[7,8,9]]
In memory:  1 2 3   4 5 6   7 8 9

предположим, чрезмерно упрощенный, глупый алгоритм выборки кэша, который извлекает 3 последующих местоположения памяти.

итерация строки-первая:

In memory:    1    2    3  |  4    5    6  |  7    8    9
Accessed:     1    2    3  |  4    5    6  |  7    8    9
Cache miss:   -    -    -  |  -    -    -  |  -    -    -

переборе первая колонка:

In memory:    1    2    3  |  4    5    6  |  7    8    9
Accessed:     1    4    7  |  2    5    8  |  3    6    9
Cache miss:   -    -    -  |  x    x    x  |  x    x    x