Явное умножение матриц намного быстрее, чем numpy.matmul?

В коде Python мне нужно в какой-то момент умножить два больших списка матриц 2x2 по отдельности. В коде оба этих списка представляют собой пустые массивы формы (n, 2, 2). Ожидаемый результат в другом массиве (n,2,2), где матрица 1 является результатом умножения матрицы 1 первого списка на матрицу 1 второго списка и т. д.

После некоторого профилирования я обнаружил, что умножение матриц является узким местом в производительности. Из любопытства я попытался написать умножение матриц «явно». Ниже приведен пример кода с измеренным временем выполнения.

import timeit
import numpy as np

def explicit_2x2_matrices_multiplication(
    mats_a: np.ndarray, mats_b: np.ndarray
) -> np.ndarray:
    matrices_multiplied = np.empty_like(mats_b)
    for i in range(2):
        for j in range(2):
            matrices_multiplied[:, i, j] = (
                mats_a[:, i, 0] * mats_b[:, 0, j] + mats_a[:, i, 1] * mats_b[:, 1, j]
            )

    return matrices_multiplied


matrices_a = np.random.random((1000, 2, 2))
matrices_b = np.random.random((1000, 2, 2))

assert np.allclose( # Checking that the explicit version is correct 
    matrices_a @ matrices_b,
    explicit_2x2_matrices_multiplication(matrices_a, matrices_b),
)

print(  # 1.1814142999992328 seconds
    timeit.timeit(lambda: matrices_a @ matrices_b, number=10000)
)
print(  # 1.1954495010013488 seconds
    timeit.timeit(lambda: np.matmul(matrices_a, matrices_b), number=10000)
)
print(  # 2.2304022700009227 seconds
    timeit.timeit(lambda: np.einsum('lij,ljk->lik', matrices_a, matrices_b), number=10000)
)
print(  # 0.19581600800120214 seconds
    timeit.timeit(
        lambda: explicit_2x2_matrices_multiplication(matrices_a, matrices_b),
        number=10000,
    )
)

Как проверено в коде, эта функция дает те же результаты, что и обычная матрица __matmul__. Однако скорость не одинакова: на моей машине явное выражение работает до 10 раз быстрее.

Для меня это довольно неожиданный результат. Я ожидал, что выражение numpy будет быстрее или, по крайней мере, сравнимо с более длинной версией Python, а не на порядок медленнее, как мы видим здесь. Мне было бы любопытно понять, почему разница в производительности настолько резкая.

Я использую numpy версии 1.25 и Python версии 3.10.6.

Мое первоначальное предположение состоит в том, что вы представили «худший случай», когда накладные расходы на вещание Numpy являются доминирующими затратами. Я рекомендую вам также включить einsum сравнение.

Reinderien 20.07.2023 15:03

Спасибо за комментарий. Я добавил сравнение с einsum для полноты картины. С кодом, который я использовал, эта функция была самой медленной из всех, примерно в 2 раза быстрее базовой линии __matmul__.

TheFamousRat 20.07.2023 15:50
matmul — это «пакетная» версия точки, более быстрая, чем [a.dot(b) for a,b in zip(matrixa, matrixb)], но все еще примерно линейная в вашем размере 1000. В отличие от вашего «явного» цикла только 4 раза».
hpaulj 20.07.2023 16:34

Производительность numpy оптимизирована для ввода «любого размера», тогда как ваш код оптимизирован для 2x2 и ничего больше. Если вам не нужны произвольные входные данные или у вас есть ограниченное количество входных данных, выделенный («развернутый») код всегда будет лучше кода общего назначения просто потому, что нет накладных расходов, какими бы малыми они ни были.

Mike 'Pomax' Kamermans 20.07.2023 19:41

@Mike'Pomax'Kamermans Это справедливое замечание. Это то, что я уже испытал с np.linalg.det для матриц 2x2, где явная версия также в 10 раз быстрее, чем универсальная версия. Мне было интересно, есть ли в этом что-то большее, учитывая, что скорость на порядок быстрее казалась довольно большой, особенно для операций с меньшими матрицами. Хотя вы, конечно, правы, более универсальный код всегда будет иметь больше накладных расходов.

TheFamousRat 20.07.2023 19:46
matmul интенсивно использует функции BLAS, которые оптимизированы для dot на двух больших двумерных массивах. Он отличается от np.dot тем, как он обрабатывает начальные измерения массивов 3d+. Я не исследовал, как скомпилированный код обрабатывает эти «пакеты».
hpaulj 20.07.2023 19:55

Для очень маленьких матриц выгодно не вызывать общий гемм (накладные расходы на вызов, оптимизированные для больших матриц), а реализовать его самостоятельно или использовать специализированные функции BLAS. Очень похожий вопрос: stackoverflow.com/a/59356461/4045774

max9111 21.07.2023 09:17
Почему в Python есть оператор "pass"?
Почему в Python есть оператор "pass"?
Оператор pass в Python - это простая концепция, которую могут быстро освоить даже новички без опыта программирования.
Некоторые методы, о которых вы не знали, что они существуют в Python
Некоторые методы, о которых вы не знали, что они существуют в Python
Python - самый известный и самый простой в изучении язык в наши дни. Имея широкий спектр применения в области машинного обучения, Data Science,...
Основы Python Часть I
Основы Python Часть I
Вы когда-нибудь задумывались, почему в программах на Python вы видите приведенный ниже код?
LeetCode - 1579. Удаление максимального числа ребер для сохранения полной проходимости графа
LeetCode - 1579. Удаление максимального числа ребер для сохранения полной проходимости графа
Алиса и Боб имеют неориентированный граф из n узлов и трех типов ребер:
Оптимизация кода с помощью тернарного оператора Python
Оптимизация кода с помощью тернарного оператора Python
И последнее, что мы хотели бы показать вам, прежде чем двигаться дальше, это
Советы по эффективной веб-разработке с помощью Python
Советы по эффективной веб-разработке с помощью Python
Как веб-разработчик, Python может стать мощным инструментом для создания эффективных и масштабируемых веб-приложений.
3
7
88
2
Перейти к ответу Данный вопрос помечен как решенный

Ответы 2

Проверка грубой линейности matmul по первому, «пакетному» измерению:

Ваши (1000,2,2) массивы:

In [353]: timeit matrices_a@matrices_b
251 µs ± 767 ns per loop (mean ± std. dev. of 7 runs, 1,000 loops each)

и с половинным и десятым размером:

In [354]: timeit matrices_a[:500]@matrices_b[:500]
129 µs ± 783 ns per loop (mean ± std. dev. of 7 runs, 10,000 loops each)
In [355]: timeit matrices_a[:100]@matrices_b[:100]
28.7 µs ± 532 ns per loop (mean ± std. dev. of 7 runs, 10,000 loops each)

Ваш явный

In [360]: explicit_2x2_matrices_multiplication(matrices_a, matrices_b).shape
Out[360]: (1000, 2, 2)
In [361]: timeit explicit_2x2_matrices_multiplication(matrices_a, matrices_b)
59.9 µs ± 1.91 µs per loop (mean ± std. dev. of 7 runs, 10,000 loops each)

np.einsum не пытается изменить порядок или другую оптимизацию:

In [362]: print(np.einsum_path('ijk,ikl->ijl',matrices_a, matrices_b, optimize='greedy
     ...: ')[1])
  Complete contraction:  ijk,ikl->ijl
         Naive scaling:  4
     Optimized scaling:  4
      Naive FLOP count:  1.600e+04
  Optimized FLOP count:  1.600e+04
   Theoretical speedup:  1.000
  Largest intermediate:  4.000e+03 elements
--------------------------------------------------------------------------
scaling                  current                                remaining
--------------------------------------------------------------------------
   4                ikl,ijk->ijl                                 ijl->ijl
Ответ принят как подходящий

TL; DR: все методы, представленные в вопросе, очень неэффективны. На самом деле, Numpy явно неоптимален, и нет никакого способа эффективно вычислить это только с помощью Numpy. Тем не менее, есть более быстрые решения, чем те, которые указаны в вопросе.


Объяснение и более быстрая реализация

Код Numpy использует мощные универсальные итераторы для применения заданного вычисления (например, умножения матриц) к нескольким срезам массива. Такие итераторы полезны для реализации вещания, а также для создания относительно простой реализации einsum. Дело в том, что они также довольно дороги, когда количество итераций огромно, а массив небольшой. Это именно то, что происходит в вашем случае использования. Хотя накладные расходы итераторов Numpy можно уменьшить за счет оптимизации кода Numpy, в этом конкретном случае использования невозможно сократить накладные расходы до незначительного времени. Действительно, на каждую матрицу приходится выполнять только 12 операций с плавающей запятой. Относительно новый процессор x86-64 может вычислять каждую матрицу менее чем за 10 наносекунд, используя скалярные единицы FMA. Фактически, можно использовать инструкцию SIMD, чтобы вычислить каждую матрицу всего за несколько наносекунд.

Во-первых, мы можем в основном устранить накладные расходы внутреннего итератора Numpy, самостоятельно выполняя умножение матриц, оперируя векторами вдоль первой оси. Это именно то, что делает explicit_2x2_matrices_multiplication!

Хотя explicit_2x2_matrices_multiplication должен быть значительно быстрее, он все еще неоптимален: он выполняет несмежное чтение памяти, создает несколько бесполезных временных массивов, и каждый вызов Numpy вносит небольшие накладные расходы. Более быстрое решение — написать код Numba/Cython, чтобы базовый компилятор мог генерировать очень хорошую последовательность инструкций, оптимизированную для матриц 2x2. В этом случае хорошие компиляторы могут даже генерировать SIMD-инструкции, что невозможно для Numpy. Вот полученный код:

import numba as nb
@nb.njit('(float64[:,:,::1], float64[:,:,::1])')
def compute_fastest(matrices_a, matrices_b):
    assert matrices_a.shape == matrices_b.shape
    assert matrices_a.shape[1] == 2 and matrices_a.shape[2] == 2

    n = matrices_a.shape[0]
    result_matrices = np.empty((n, 2, 2))
    for k in range(n):
        for i in range(2):
            for j in range(2):
                result_matrices[k,i,j] = matrices_a[k,i,0] * matrices_b[k,0,j] + matrices_a[k,i,1] * matrices_b[k,1,j]

    return result_matrices

Результаты производительности

Вот результаты производительности на моей машине с процессором i5-9600KF для матриц 1000x2x2:

Naive einsum:                           214   µs
matrices_a @ matrices_b:                102   µs
explicit_2x2_matrices_multiplication:    24   µs
compute_fastest:                          2.7 µs   <-----

Обсуждение

Реализация Numba достигает 4,5 GFlops. Каждая матрица вычисляется всего за 12 циклов процессора (2,7 нс)! Моя машина на практике может достигать 300 GFlops (теоретически 432 GFlops), но только 50 GFlops с 1 ядром и 12,5 GFlops при использовании скалярного кода (теоретически 18 GFlops). Детализация операции слишком мала, чтобы несколько потоков могли быть полезными (накладные расходы на порождающий поток составляют как минимум несколько микросекунд). Кроме того, код SIMD вряд ли может насытить единицы FMA, потому что структура входных данных требует перетасовки SIMD, поэтому 50 GFlops на самом деле является оптимистичной верхней границей. В результате мы можем с уверенностью сказать, что реализация Numba — довольно эффективная реализация. Тем не менее, благодаря SIMD-инструкциям можно написать более быстрый код (на практике я ожидаю ускорения примерно в 2 раза). При этом написание нативного кода с использованием встроенных функций SIMD, помогающих компилятору генерировать быстрый код SIMD, на самом деле далеко не просто (не говоря уже о том, что окончательный код будет уродливым и сложным в обслуживании). Таким образом, реализация SIMD может не стоить затраченных усилий.

Другие вопросы по теме