Оптимизация существующего умножения матриц 3D Numpy - PullRequest
2 голосов
/ 17 января 2020

У меня есть код, который только что был завершен. Работает как задумано. Я решил использовать dot в Numpy, поскольку, по моему ограниченному опыту, это быстрее, чем обычные формы умножения матриц, если в вашей системе установлен BLAS. Тем не менее, вы заметите, чем мне пришлось перенести много вещей. Я уверен, что в этом случае перевешивает пользу от использования dot.

. Я предоставляю математическое уравнение, найденное в статье. Обратите внимание, что это рекурсия

enter image description here

Вот моя реализация кода:

Сначала я предоставляю необходимые компоненты и их размеры

P = array([[0.73105858, 0.26894142],
           [0.26894142, 0.73105858]])  # shape (K,K)

B = array([[6.07061629e-09, 0.00000000e+00],
           [0.00000000e+00, 2.57640371e-10]])  # shape (K,K)

dP = array([[[ 0.19661193, -0.19661193],
             [ 0.        ,  0.        ]],

           [[ 0.        ,  0.        ],
            [ 0.19661193, -0.19661193]],

           [[ 0.        ,  0.        ],
            [ 0.        ,  0.        ]],

           [[ 0.        ,  0.        ],
            [ 0.        ,  0.        ]]])  # shape (L,K,K)

dB = array([[[ 0.00000000e+00,  0.00000000e+00],
             [ 0.00000000e+00,  0.00000000e+00]],

            [[ 0.00000000e+00,  0.00000000e+00],
             [ 0.00000000e+00,  0.00000000e+00]],

            [[-1.16721049e-09,  0.00000000e+00],
             [ 0.00000000e+00,  0.00000000e+00]],

            [[ 0.00000000e+00,  0.00000000e+00],
             [ 0.00000000e+00, -1.27824683e-09]]])  # shape (L,K,K)

a = array([[[ 7.60485178e-08,  5.73923956e-07]],

           [[-5.54100398e-09, -8.75213012e-08]],

           [[-1.25878643e-08, -1.48361081e-07]],

           [[-2.73494035e-08, -1.74585971e-07]]])  # shape (L,1,K)

alpha = array([[0.11594542, 0.88405458]])  # shape (1,K)

c = 1  # scalar

Вот фактический Numpy расчет. Обратите внимание на все варианты использования транспонирования.

term1 = (a/c).dot(P).dot(B)
term2 = dP.transpose((0,2,1)).dot(alpha.T).transpose((0,2,1)).dot(B)
term3 = dB.dot(  P.T.dot(alpha.T) ).transpose((0,2,1))
a = term1 + term2 + term3

Затем нужно получить:

>>> a
array([[[ 1.38388584e-10, -5.87312190e-12]],

       [[ 1.05516813e-09, -4.47819530e-11]],

       [[-3.76451117e-10, -2.88160549e-17]],

       [[-4.06412069e-16, -8.65984406e-10]]])

Обратите внимание, что форма alpha, а также a была выбрана мной. Их можно изменить, если вы обнаружите, что они обеспечивают превосходную производительность.

Я хотел бы отметить, что я думаю, что существующий код работает быстро. На самом деле, очень быстро. Тем не менее, я продолжаю задаваться вопросом, можно ли сделать лучше. Пожалуйста, дайте ему go. Я профилировал свой код (в котором много Numpy вещания и векторизации), и это не обязательно является узким местом, так как для оценки на моей очень старой машине требуется 23 микросекунды. Тем не менее, это один шаг рекурсии. Это означает, что он оценивается N раз последовательно. Следовательно, даже малейший выигрыш будет иметь большое значение для большой последовательности.

Спасибо за ваше время.

РЕДАКТИРОВАТЬ / ОБНОВИТЬ:

Спасибо @ max9111, который предложил мне посмотреть на этот вопрос здесь , я удалось обработать некоторый код Numba, который работает быстрее, чем расчет Numpy для a. Это занимает 14 микросекунд, в отличие от исходных 23.

Вот оно:

import numba as nb
@nb.njit(fastmath=True,parallel=True,boundscheck=False)
def get_next_a(a,alpha,P,dP,B,dB,c):
    N,M,_ = dP.shape
    new_a = np.zeros((N,1,M),dtype=np.float64)
    new_a = np.zeros((N,1,M))
    entry = 0
    for idx in nb.prange(N):
        for i in range(M):
            for j in range(M):
                term1 =  a[idx,0,j]*P[j,i]*B[i,i]/c
                term2 = alpha[0,j]*dP[idx,j,i]*B[i,i] 
                term3 = alpha[0,j]*P[j,i]*dB[idx,i,i]
                entry += term1 + term2 + term3
            new_a[idx,0,i] = entry
            entry = 0
    return new_a

Видно, что get_next_a возвращает желаемый результат. Однако, когда я вызываю его в чистой функции python, которая содержит Numpy, он жалуется. Вот фрагмент моего действительного кода:

def forward_recursions(X,working_params):

#    P,dP,B,dB,pi,dpi = setup(X,working_params) 
    # Dummy Data and Parameters instead of setup
    working_params = np.random.uniform(0,2,size=100)
    X = np.random.uniform(0,1,size=1000)
    P = np.random.uniform(0,1,size=(10,10))
    norm = P.sum(axis=1)
    P = P/norm[:,None]
    dP = np.random.uniform(-1,1,size=(100,10,10))
    # We pretend that all 1000 of the 10 x 10 matrices 
    # only have diagonal entries
    B = np.random.uniform(0,1,size=(1000,10,10)) 
    dB = np.random.uniform(0,1,size=(1000,100,10,10))
    pi = np.random.uniform(0,1,size=10)
    norm = pi.sum()
    pi = (pi/norm).reshape(1,10)
    dpi = np.random.uniform(0,1,size=(100,1,10))

    T = len(X)
    N = len(working_params)
    M = np.int(np.sqrt(N))
    ones = np.ones((M,1))


    alpha = pi.dot(B[0])
    scale = alpha.dot(ones)
    alpha = alpha/scale
    ll = np.log(scale)
    a = dpi.dot(B[0]) + dB[0].dot(pi.T).transpose((0,2,1))
    for t in range(1,T):

        old_scale = scale
        alpha = alpha.dot(P).dot(B[t])
        scale = alpha.dot(ones)
        ll += np.log(scale)
        alpha = alpha/scale

        # HERE IS THE NUMBA FUNCTION

        a = get_next_a(a,alpha,P,dP,B[t],dB[t],old_scale)

    dll = a.dot(ones).reshape((N,1))/scale
    return ll,dll,a

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

Я получаю ошибку:

TypingError: Invalid use of Function(<built-in function iadd>) with argument(s) of type(s): (Literal[int](0), array(float64, 2d, C))
Known signatures:
 * (int64, int64) -> int64
 * (int64, uint64) -> int64
 * (uint64, int64) -> int64
 * (uint64, uint64) -> uint64
 * (float32, float32) -> float32
 * (float64, float64) -> float64
 * (complex64, complex64) -> complex64
 * (complex128, complex128) -> complex128
 * parameterized
In definition 0:
    All templates rejected with literals.
In definition 1:
    All templates rejected without literals.
In definition 2:
    All templates rejected with literals.
In definition 3:
    All templates rejected without literals.
In definition 4:
    All templates rejected with literals.
In definition 5:
    All templates rejected without literals.
In definition 6:
    All templates rejected with literals.
In definition 7:
    All templates rejected without literals.
In definition 8:
    All templates rejected with literals.
In definition 9:
    All templates rejected without literals.
In definition 10:
    All templates rejected with literals.
In definition 11:
    All templates rejected without literals.
In definition 12:
    All templates rejected with literals.
In definition 13:
    All templates rejected without literals.
In definition 14:
    All templates rejected with literals.
In definition 15:
    All templates rejected without literals.
This error is usually caused by passing an argument of a type that is unsupported by the named function.
[1] During: typing of intrinsic-call at <ipython-input-251-50e636317ef8> (13)

Я не понимаю, что это значит. Вы, наверное, знаете, как я могу исправить что-то подобное. Большое спасибо за ваше время.

Ответы [ 2 ]

1 голос
/ 17 января 2020

Q : … если можно было сделать лучше?

Ваш код «как есть» выполняется на моем (кажется, даже более старый) машина не в отправленном ~ 23 [us], но ~ 45 [ms] для первого вызова и, наслаждаясь всеми предварительными выборками в иерархии iCACHE и dCACHE где-то между ~77..1xx [us] :

>>> from zmq import Stopwatch; aClk = Stopwatch()
>>> import numpy as np
>>>
>>> aClk.start(); a = ( a / c ).dot( P ).dot( B ) + dP.transpose( ( 0, 2, 1) ).dot( alpha.T ).transpose( ( 0, 2, 1 ) ).dot( B ) + dB.dot(  P.T.dot( alpha.T ) ).transpose( ( 0, 2, 1 ) ); aClk.stop()
44679
>>> aClk.start(); a = ( a / c ).dot( P ).dot( B ) + dP.transpose( ( 0, 2, 1) ).dot( alpha.T ).transpose( ( 0, 2, 1 ) ).dot( B ) + dB.dot(  P.T.dot( alpha.T ) ).transpose( ( 0, 2, 1 ) ); aClk.stop()
149
>>> aClk.start(); a = ( a / c ).dot( P ).dot( B ) + dP.transpose( ( 0, 2, 1) ).dot( alpha.T ).transpose( ( 0, 2, 1 ) ).dot( B ) + dB.dot(  P.T.dot( alpha.T ) ).transpose( ( 0, 2, 1 ) ); aClk.stop()
113
>>> aClk.start(); a = ( a / c ).dot( P ).dot( B ) + dP.transpose( ( 0, 2, 1) ).dot( alpha.T ).transpose( ( 0, 2, 1 ) ).dot( B ) + dB.dot(  P.T.dot( alpha.T ) ).transpose( ( 0, 2, 1 ) ); aClk.stop()
128
>>> aClk.start(); a = ( a / c ).dot( P ).dot( B ) + dP.transpose( ( 0, 2, 1) ).dot( alpha.T ).transpose( ( 0, 2, 1 ) ).dot( B ) + dB.dot(  P.T.dot( alpha.T ) ).transpose( ( 0, 2, 1 ) ); aClk.stop()
82
>>> aClk.start(); a = ( a / c ).dot( P ).dot( B ) + dP.transpose( ( 0, 2, 1) ).dot( alpha.T ).transpose( ( 0, 2, 1 ) ).dot( B ) + dB.dot(  P.T.dot( alpha.T ) ).transpose( ( 0, 2, 1 ) ); aClk.stop()
100
>>> aClk.start(); a = ( a / c ).dot( P ).dot( B ) + dP.transpose( ( 0, 2, 1) ).dot( alpha.T ).transpose( ( 0, 2, 1 ) ).dot( B ) + dB.dot(  P.T.dot( alpha.T ) ).transpose( ( 0, 2, 1 ) ); aClk.stop()
77
>>> aClk.start(); a = ( a / c ).dot( P ).dot( B ) + dP.transpose( ( 0, 2, 1) ).dot( alpha.T ).transpose( ( 0, 2, 1 ) ).dot( B ) + dB.dot(  P.T.dot( alpha.T ) ).transpose( ( 0, 2, 1 ) ); aClk.stop()
97
>>> a
array([[[ 1.38387304e-10, -5.87323502e-12]],
       [[ 1.05516829e-09, -4.47819355e-11]],
       [[-3.76450816e-10, -2.60843400e-20]],
       [[-1.41384088e-18, -8.65984377e-10]]])

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

>>> aClk.start(); a = ( a / c ).dot( P ).dot( B ) + dP.transpose( ( 0, 2, 1) ).dot( alpha.T ).transpose( ( 0, 2, 1 ) ).dot( B ) + dB.dot(  P.T.dot( alpha.T ) ).transpose( ( 0, 2, 1 ) ); aClk.stop()
97
>>> a
array([[[ 1.38387304e-10, -5.87323502e-12]],    
       [[ 1.05516829e-09, -4.47819355e-11]],
       [[-3.76450816e-10, -2.60843400e-20]],
       [[-1.41384088e-18, -8.65984377e-10]]])

Это означает, что код, как есть, выполняет большую работу, чтобы в итоге получить инвариантное значение a (воссозданное удостоверение за счет затрат ~ XY [нас], делающих это - вы единственный, кто решает, подходит ли это для вашего целевого приложения или нет)


Замечания о желаемом месте для улучшения:

Ну, учитывая N ~ 1E(3..6) и K ~ 10 и L ~ 100 , там не так много ожидать от любых усилий по улучшению, спонсируемых для повторного решения (до настоящего времени a результата идентификации) с sh для улучшения производительности.

Стремление к улучшению целевая обработка будет повторяться последовательно больше, чем ~1,000x… меньше, чем ~ 1,000,000x, что означает:

  • Проблемы, связанные с ОЗУ, не являются кардинальными, так как эффект кэша для частей c stati, все они имеют Размеры, составляющие всего несколько [MB], будут повторно использоваться из кэша с минимально возможными задержками
  • Проблемы, связанные с ЦП, уже решены в процессе проектирования и разработки numpy -инструментов (использования SIMD-ресурсов). трюков с процессором и выравниванием по вектору, где это возможно - так что ожидать от кодирования на уровне пользователя не так уж и много)

Последний, но не менее важный комментарий: " стоит "- транспонирования - numpy больше ничего не делает для того, чтобы транспонировать матрицу, но изменение порядка индексации - ничего больше. Если это может иметь какой-то эффект, можно ожидать скорее от рассмотрения типа упорядочения FORTRAN или языкового типа упорядочения основного хранилища данных C. ячеек в физической памяти, но в масштабах, но 1E1 x 1E1 ~ 1E2 x 1E1 x 1E1 в максимуме, это ставит этот аспект становится незначительным и хорошо маскируется в кеше характер обработки с нулевой обратной записью или другими воздействиями, связанными с производительностью.


РЕЗУЛЬТАТ:

Учитывая все факты и дальнейшие наблюдения, приведенные выше, Самый дешевый и самый разумный шаг для действительно получения более высокой пропускной способности вычисленных здесь вычислений - это перейти на линейно работающую степень свободы - чем выше [GHz] ЦП, тем лучше (линейный рост производительности здесь) имея как можно большее количество регистров AVX-512 и как можно больше кешей L1i + L1d (стратегии H с отображением аффинности любого другого шума O / S очевидны для H P C -целевые показатели производительности) и использовать уже умные numpy инструменты, точно настроенные для этого сочетания ресурсов ЦП для матричной обработки (если необходимо go за пределами float64 IEEE-754 представление, начинается другая история).

Не ожидайте, что код уровня пользователя будет лучше, чем этот, numpy -обработанная SIMD-ориентированная обработка может и доставит.

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

0 голосов
/ 19 января 2020

Я решил опубликовать ответ на свой вопрос. Я хотел бы поблагодарить @ max9111 и @ user3666197 за их любезную помощь и советы. Я использовал то, чему меня научили, для создания оптимизированного кода с использованием Numba.

У меня были некоторые проблемы с Numba. Можно заметить, что в моей оптимизированной версии Numba возникли проблемы с выполнением функции Python (как было указано в моем разделе редактирования / обновления). Теперь я понимаю, почему.

Нумба действительно потрясающая, так как она действительно быстрая. Кроме того, есть очень точные расчеты по сравнению с Numpy. Numpy производит более короткий код, и для меня математически более лаконично читать. Кроме того, Numpy бесперебойно работает с нативным Python и, как следствие, без проблем работает с кодом. Лично я думаю, что Numpy - это самый быстрый способ создания работающего скрипта, который гарантированно будет работать быстро. Нумба играет не так хорошо, как Numpy. На самом деле, это и Numpy не очень хорошо ладят. Например, любимый Numpy dot не может быть использован в Numba.

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

Вложенность, однако, работает, но вы понимаете, что вложенная функция также должна быть скомпилированной функцией Numba. Мой код не работал, потому что setup была вложенной Python функцией. Поэтому Нумба не мог распознать это TypingError: Invalid use of Function(<built-in function iadd>). Итак, если вы хотите вложить функции, вы должны убедиться, что все ваши функции являются функциями Numba. Это на самом деле довольно ограничивает и быстро уберет красоту написания сценариев в Python. В результате именно поэтому я упомянул не вложенные функции в Numba, потому что это дает вам свободу не выполнять все свои функции Numba.

Почему бы не хотеть, чтобы все ваши функции были в Numba, если это так быстро? Простое вещание и векторизация Numpy пугают быстрее, чем Numba, для некоторых приложений, таких как вычисление функции плотности вероятности для многих точек данных и параметров. Кроме того, вы все еще хотите использовать такие вещи, как numpy .linalg.solve et c.

Вот моя оптимизированная версия:

@nb.njit(fastmath=True,parallel=True,boundscheck=False)
def forward_recursions_numba(X,
                             P,
                             dP,
                             B,
                             dB,
                             pi,
                             dpi):

#    P,dP,B,dB,pi,dpi = setup(X,working_params) # does not work with numba
#    print(P)

    T = len(X)
    N,M,_ = dP.shape
#    ones = np.ones((M,1))


#    alpha = pi.dot(B[0])
#    scale = alpha.dot(ones)
    alpha = np.zeros((1,M))
    scale = 0
    for i in range(M):
        alpha[0,i] = pi[0,i]*B[0,i,i]
        scale += alpha[0,i]

    alpha = alpha/scale
    ll = np.log(scale)


#    a = dpi.dot(B[0]) + dB[0].dot(pi.T).transpose((0,2,1))
    a = np.zeros((N,1,M))
    for idx in range(N):
        for i in range(M):
            a[idx,0,i] = dpi[idx,0,i]*B[0,i,i] + pi[0,i]*dB[0,idx,i,i]

    for t in range(1,T):
        old_scale = scale
#        alpha = alpha.dot(P).dot(B[t])
#        scale = alpha.dot(ones)
        scale = 0
        alpha_new = np.zeros(alpha.shape)
        for i  in range(M):
            entry = 0
            for j in range(M):
                entry += alpha[0,j]*P[j,i]*B[t,i,i]
            alpha_new[0,i] = entry
            scale += alpha_new[0,i]

        ll += np.log(scale)
        alpha = alpha_new/scale

#        term1 = (a/old_scale).dot(P).dot(B[t])
#        term2 = dP.transpose((0,2,1)).dot(alpha.T).transpose((0,2,1)).dot(B[t])
#        term3 = dB[t].dot(  P.T.dot(alpha.T) ).transpose((0,2,1))
#        a = term1 + term2 + term3
        new_a = np.zeros((N,1,M))
        for idx in nb.prange(N):
            for i in range(M):
                entry = 0
                for j in range(M):
                    term1 =  a[idx,0,j]*P[j,i]*B[t,i,i]/old_scale
                    term2 = alpha[0,j]*dP[idx,j,i]*B[t,i,i] 
                    term3 = alpha[0,j]*P[j,i]*dB[t,idx,i,i]
                    entry += term1 + term2 + term3
                new_a[idx,0,i] = entry
        a = new_a

#    dll = a.dot(ones).reshape((N,1))/scale
    dll = np.zeros((N,1))
    for idx in nb.prange(N):
        dparam = 0
        for i in range(M):
            dparam += a[idx,0,i]
        dll[idx] = dparam/scale

    return ll,dll,a

Если мы запустим ее на некоторых фиктивных данных

X = np.random.uniform(0,1,size=1000)
P = np.random.uniform(0,1,size=(10,10))
norm = P.sum(axis=1)
P = P/norm[:,None]
dP = np.random.uniform(-1,1,size=(100,10,10))
# We pretend that all 1000 of the 10 x 10 matrices 
# only have diagonal entries
B = np.random.uniform(0,1,size=(1000,10,10)) 
dB = np.random.uniform(0,1,size=(1000,100,10,10))
pi = np.random.uniform(0,1,size=10)
norm = pi.sum()
pi = (pi/norm).reshape(1,10)
dpi = np.random.uniform(0,1,size=(100,1,10))

Давайте рассчитаем время:

>>> %timeit forward_recursions_numba(X,P,dP,B,dB,pi,dpi)
51.3 ms ± 389 µs per loop (mean ± std. dev. of 7 runs, 10 loops each)

Давайте сравним его с Numpy точечной версией.

>>> %timeit forward_recursions(X,P,dP,B,dB,pi,dpi)
271 ms ± 1.1 ms per loop (mean ± std. dev. of 7 runs, 1 loop each)

ПРИМЕЧАНИЕ: , чтобы получить Numpy версию, вы можете просто перекомментировать код Numpy, закомментировать циклы Numba и закомментировать декоратор @nb.njit.

Используя lscpu на моем компьютере Lubuntu, мы можем вижу, что мои спецификации Intel(R) Core(TM)2 Quad CPU Q6700 @ 2.66GHz с 6 GB DDR2 RAM (800 MHz)

Так что мой код был значительно оптимизирован. Плюс я многому научился. Большое спасибо всем за вашу помощь и терпение.

...