Оптимизировать двойной цикл в 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)`

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

Числовое время: 4.1595

Время numba1: 0,6993

Время numba2 : 1.0135

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

Я также заметил, что первый цикл на самом маленьком цикле выполняется медленнее, чем первый цикл на самом большом цикле. Есть какое-нибудь объяснение?

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

В других частях моего кода я использовал методы нарезки 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

Свертка времени: 22.7696

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

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

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


person Ipse Lium    schedule 13.05.2015    source источник
comment
напишите итератор для выполнения этой работы, он быстрый, эффективный и потребляет меньше памяти, чем ваши двойные циклы.   -  person vijay shanker    schedule 13.05.2015


Ответы (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
person Community    schedule 13.05.2015
comment
Спасибо, не знал о шагах! Он работает хорошо и быстрее, чем реализация numba, но я немного запутался в этой формулировке. Чтение документа помогает мне понять в случае 2D, но в этом случае это все еще неясно для меня. Например, если мне нужно сделать то же вычисление с rho[ix, iz-1:iz+3], я думаю, мне нужно изменить shape на (nx, ws, nz-ws+1), но останутся ли strides и rho_windowed прежними? А насчет 'j,ijk->ik' в einsum, какие изменения мне нужно внести? - person Ipse Lium; 20.05.2015
comment
@IpseLium, да, я понимаю твое замешательство. Вы правы насчет новой формы. Действительно, вам также нужно будет изменить шаги и einsum вызов. Но намного проще было бы оставить функцию такой же, как в моем ответе, и назвать ее как stridetrick_einsum(c, rho[2:-3,1:-1].T, a[2:-3,2:-3].T). Я не могу проверить это прямо сейчас, но это должно работать. - person ; 20.05.2015

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

  • Разрежьте входной массив rho, чтобы выбрать его часть, которая используется в исходной зацикленной версии вашего кода. Это будут входные данные для свертки.
  • Переверните ядро, 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

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

person Divakar    schedule 13.05.2015

Вы не в полной мере используете возможности numpy. 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.

В моей системе с вашим образцом ввода этот код работает в 300 раз быстрее, чем ваша функция numpy. Согласно вашим таймингам, это будет на один или два порядка быстрее, чем numba.

person Jaime    schedule 13.05.2015
comment
Как умножить c и rho? Их размерности не равны: (4,) и (1024,256). - person Ipse Lium; 13.05.2015
comment
Ой... Я неправильно прочитал ваш код и решил, что это константа. Это замедлит работу, но все же выполнимо, я попытаюсь переработать свое решение... - person Jaime; 14.05.2015

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

Numba может компилировать точно в срок с помощью декоратора autojit или заранее с помощью декоратора jit.

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

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

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

Я также заметил, что первый цикл на самом маленьком цикле выполняется медленнее, чем первый цикл на самом большом цикле. Есть какое-нибудь объяснение?

Вероятно, это связано с промахом кэша. Двумерный массив выделяется как непрерывный кусок памяти размером 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
person EvenLisle    schedule 13.05.2015
comment
Я чувствую, что вы говорите, что autojit работает как HotSpot в Java, где во время выполнения решается, какие функции стоит компилировать. Принимая во внимание, что autojit создает скомпилированную функцию для каждого уникального набора типов аргументов, с которыми она вызывается. То есть ключевое отличие состоит в том, что autojit компилируется во всех экземплярах, тогда как HotSpot компилирует только часто используемые функции. - person Dunes; 13.05.2015