В коде 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.
Спасибо за комментарий. Я добавил сравнение с einsum для полноты картины. С кодом, который я использовал, эта функция была самой медленной из всех, примерно в 2 раза быстрее базовой линии __matmul__.
matmul — это «пакетная» версия точки, более быстрая, чем [a.dot(b) for a,b in zip(matrixa, matrixb)], но все еще примерно линейная в вашем размере 1000. В отличие от вашего «явного» цикла только 4 раза».
Производительность numpy оптимизирована для ввода «любого размера», тогда как ваш код оптимизирован для 2x2 и ничего больше. Если вам не нужны произвольные входные данные или у вас есть ограниченное количество входных данных, выделенный («развернутый») код всегда будет лучше кода общего назначения просто потому, что нет накладных расходов, какими бы малыми они ни были.
@Mike'Pomax'Kamermans Это справедливое замечание. Это то, что я уже испытал с np.linalg.det для матриц 2x2, где явная версия также в 10 раз быстрее, чем универсальная версия. Мне было интересно, есть ли в этом что-то большее, учитывая, что скорость на порядок быстрее казалась довольно большой, особенно для операций с меньшими матрицами. Хотя вы, конечно, правы, более универсальный код всегда будет иметь больше накладных расходов.
matmul интенсивно использует функции BLAS, которые оптимизированы для dot на двух больших двумерных массивах. Он отличается от np.dot тем, как он обрабатывает начальные измерения массивов 3d+. Я не исследовал, как скомпилированный код обрабатывает эти «пакеты».
Для очень маленьких матриц выгодно не вызывать общий гемм (накладные расходы на вызов, оптимизированные для больших матриц), а реализовать его самостоятельно или использовать специализированные функции BLAS. Очень похожий вопрос: stackoverflow.com/a/59356461/4045774






Проверка грубой линейности 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 может не стоить затраченных усилий.
Мое первоначальное предположение состоит в том, что вы представили «худший случай», когда накладные расходы на вещание Numpy являются доминирующими затратами. Я рекомендую вам также включить
einsumсравнение.