Неожиданно медленный сверточный код cython

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

M = [conv(e_j, f)*P_i*v_i]_{i,j},

где i изменяется от 1 до n, а j изменяется от 1 до m. Здесь e_j — вектор-индикатор (строка) размера n с единицей только в столбце j, f — фильтр длины s, P_i — матрица (n+s-1) на k, которая выбирает соответствующие k элементов из свертка, а v_i — вектор-столбец длины k.

Требуется O (n * s) операций для вычисления каждой записи M, поэтому в целом O (n * s * n * m) для вычисления M. Для n = 6, m = 7, s = 3, одно ядро ​​​​моего компьютера (8GLOPs) должен уметь вычислять M примерно за 0,094 микросекунды. Тем не менее, моя очень простая реализация cython, следуя примеру, приведенному в документации Cython, требует более 2 миллисекунд, чтобы вычислить пример с этими параметрами. Это разница примерно в 4 порядка!

Вот файл shar с реализацией Cython и тестовым кодом. Скопируйте и вставьте его в файл и запустите «bash ‹fname›» в чистом каталоге, чтобы получить код, затем запустите «bash ./test.sh», чтобы увидеть ужасную производительность.

cat > fastcalcM.pyx <<'EOF'

import numpy as np
cimport numpy as np
cimport cython
from scipy.signal import convolve

DTYPE=np.float32
ctypedef np.float32_t DTYPE_t

@cython.boundscheck(False)
def calcM(np.ndarray[DTYPE_t, ndim=1, negative_indices=False] filtertaps, int
        n, int m, np.ndarray[np.int_t, ndim=2, negative_indices=False]
        keep_indices, np.ndarray[DTYPE_t, ndim=2, negative_indices=False] V):
    """ Computes a numrows-by-k matrix M whose entries satisfy
        M_{i,k} = [conv(e_j, f)^T * P_i * v_i],
        where v_i^T is the i-th row of V, and P_i samples the entries from
        conv(e_j, f)^T indicated by the ith row of the keep_indices matrix """

    cdef int k = keep_indices.shape[1]

    cdef np.ndarray M = np.zeros((n, m), dtype=DTYPE)
    cdef np.ndarray ej = np.zeros((m,), dtype=DTYPE)
    cdef np.ndarray convolution
    cdef int rowidx, colidx, kidx

    for rowidx in range(n):
        for colidx in range(m):
            ej[colidx] = 1
            convolution = convolve(ej, filtertaps, mode='full')
            for kidx in range(k):
                M[rowidx, colidx] += convolution[keep_indices[rowidx, kidx]] * V[rowidx, kidx]
            ej[colidx] = 0

    return M

EOF
#-----------------------------------------------------------------------------
cat > test_calcM.py << 'EOF'

import numpy as np
from fastcalcM import calcM

filtertaps = np.array([-1, 2, -1]).astype(np.float32)
n, m = 6, 7
keep_indices = np.array([[1, 3], 
                         [4, 5],
                         [2, 2], 
                         [5, 5], 
                         [3, 4], 
                         [4, 5]]).astype(np.int)
V = np.random.random_integers(-5, 5, size=(6, 2)).astype(np.float32)

print calcM(filtertaps, n, m, keep_indices, V)

EOF
#-----------------------------------------------------------------------------
cat > test.sh << 'EOF'

python setup.py build_ext --inplace
echo -e "%run test_calcM\n%timeit calcM(filtertaps, n, m, keep_indices, V)" > script.ipy
ipython script.ipy

EOF
#-----------------------------------------------------------------------------
cat > setup.py << 'EOF'

from distutils.core import setup
from Cython.Build import cythonize
import numpy

setup(
    name="Fast convolutions",
    include_dirs = [numpy.get_include()],
    ext_modules = cythonize("fastcalcM.pyx")
)

EOF

Я подумал, что, возможно, причиной может быть вызов scipy convolve (я не уверен, что cython и scipy хорошо работают вместе), поэтому я реализовал свой собственный код свертки, аналогичный примеру в документации Cython, но это привело к тому, что общий код примерно в 10 раз медленнее.

Любые идеи о том, как приблизиться к теоретически возможной скорости, или причины, почему разница так велика?


person AatG    schedule 14.05.2014    source источник


Ответы (1)


Во-первых, набор M, eg и convolution не позволяет быстро индексировать. То, что вы напечатали, на самом деле не особенно полезно.

Но это не имеет значения, потому что у вас есть два накладных расходов. Первый — преобразование между типами Cython и Python. Вам следует хранить нетипизированные массивы, если вы хотите часто передавать их в Python, чтобы предотвратить необходимость преобразования. По этой причине просто перенос этого на Python ускорил его (1 мс → 0,65 мкс).

Затем я профилировал его:

Line #      Hits         Time  Per Hit   % Time  Line Contents
==============================================================
    15                                           def calcM(filtertaps, n, m, keep_indices, V):
    16      4111         3615      0.9      0.1      k = keep_indices.shape[1]
    17      4111         8024      2.0      0.1      M = np.zeros((n, m), dtype=np.float32)
    18      4111         6090      1.5      0.1      ej = np.zeros((m,), dtype=np.float32)
    19                                           
    20     28777        18690      0.6      0.3      for rowidx in range(n):
    21    197328       123284      0.6      2.2          for colidx in range(m):
    22    172662       112348      0.7      2.0              ej[colidx] = 1
    23    172662      4076225     23.6     73.6              convolution = convolve(ej, filtertaps, mode='full')
    24    517986       395513      0.8      7.1              for kidx in range(k):
    25    345324       668309      1.9     12.1                  M[rowidx, colidx] += convolution[keep_indices[rowidx, kidx]] * V[rowidx, kidx]
    26    172662       120271      0.7      2.2              ej[colidx] = 0
    27                                           
    28      4111         2374      0.6      0.0      return M

Прежде чем рассматривать что-нибудь еще, разберитесь с convolve.

Почему convolve медленно? Ну, у него много накладных расходов. numpy/scipy обычно это делает; это лучше всего подходит для больших наборов данных. Если вы знаете, что размер вашего массива останется небольшим, просто переопределите convolve в Cython.

О, попробуйте использовать синтаксис буфера. Используйте DTYPE[:, :] для 2D-массива, DTYPE[:] для 1D-массива и т. д. Это протокол просмотра памяти, и он намного лучше. Есть случаи, когда у него больше накладных расходов, но обычно их можно обойти, и в большинстве других способов это лучше.


РЕДАКТИРОВАТЬ:

Вы можете попробовать (рекурсивно) встроить функцию scipy:

import numpy as np
from scipy.signal.sigtools import _correlateND

def calcM(filtertaps, n, m, keep_indices, V):
    k = keep_indices.shape[1]
    M = np.zeros((n, m), dtype=np.float32)
    ej = np.zeros((m,), dtype=np.float32)

    slice_obj = [slice(None, None, -1)] * len(filtertaps.shape)
    sliced_filtertaps_view = filtertaps[slice_obj]

    ps = ej.shape[0] + sliced_filtertaps_view.shape[0] - 1
    in1zpadded = np.zeros(ps, ej.dtype)
    out = np.empty(ps, ej.dtype)

    for rowidx in range(n):
        for colidx in range(m):
            in1zpadded[colidx] = 1

            convolution = _correlateND(in1zpadded, sliced_filtertaps_view, out, 2)

            for kidx in range(k):
                M[rowidx, colidx] += convolution[keep_indices[rowidx, kidx]] * V[rowidx, kidx]

            in1zpadded[colidx] = 0

    return M

Обратите внимание, что здесь используются частные детали реализации.

Это адаптировано для конкретных измерений, поэтому я не знаю, будет ли это работать с вашими фактическими данными. Но это устраняет большую часть накладных расходов. Затем вы можете улучшить это, набрав что-то еще раз:

import numpy as np
cimport numpy as np
from scipy.signal.sigtools import _correlateND

DTYPE=np.float32
ctypedef np.float32_t DTYPE_t

def calcM(filtertaps, int n, int m, np.int_t[:, :] t_keep_indices, DTYPE_t[:, :] t_V):
    cdef int rowidx, colidx, kidx, k
    cdef DTYPE_t[:, :] t_M
    cdef DTYPE_t[:] t_in1zpadded, t_convolution

    k = t_keep_indices.shape[1]
    t_M = M = np.zeros((n, m), dtype=np.float32)
    ej = np.zeros((m,), dtype=np.float32)

    slice_obj = [slice(None, None, -1)] * len(filtertaps.shape)
    sliced_filtertaps_view = filtertaps[slice_obj]

    ps = ej.shape[0] + sliced_filtertaps_view.shape[0] - 1
    t_in1zpadded = in1zpadded = np.zeros(ps, ej.dtype)
    out = np.empty(ps, ej.dtype)

    for rowidx in range(n):
        for colidx in range(m):
            t_in1zpadded[colidx] = 1

            t_convolution = _correlateND(in1zpadded, sliced_filtertaps_view, out, 2)

            for kidx in range(k):
                t_M[rowidx, colidx] += t_convolution[<int>t_keep_indices[rowidx, kidx]] * t_V[rowidx, kidx]

            t_in1zpadded[colidx] = 0

    return M

Это более чем в 10 раз быстрее, но не так высоко, как вы предполагаете. Опять же, этот расчет был немного фальшивым с самого начала;).

person Veedrac    schedule 14.05.2014
comment
Спасибо за подробную информацию! Я немного не понимаю преобразование типов или синтаксис представления памяти, но в целом это звучит важно и полезно знать; Я посмотрю их, реализую ваши предложения и, вероятно, в конечном итоге приму этот ответ. Кстати, как бы вы улучшили оценку времени? Любые рекомендации были бы очень полезны: я пытаюсь выработать привычку точно определять, какой разрыв между моим кодом и оптимальным. Единственный упущенный фактор, о котором я могу думать, — это затраты на доступ к памяти (что, вероятно, искажает мою оценку). - person AatG; 14.05.2014
comment
Обычно вы не можете просто угадывать такие времена. Во-первых, O(nm) не то же самое, что nm. Кроме того, рабочие скорости сильно различаются, и неизбежны большие накладные расходы. Я предполагаю, что _correlateND меняет скорость на точность. Вы всегда можете попробовать реализовать его самостоятельно (или начать с нуля). - person Veedrac; 15.05.2014