提问人:TheFamousRat 提问时间:7/20/2023 最后编辑:TheFamousRat 更新时间:7/21/2023 访问量:261
显式矩阵乘法比 numpy.matmul 快得多?
Explicit matrix multiplication much faster than numpy.matmul?
问:
在 Python 代码中,我需要在某些时候分别将两个 2x2 矩阵的大列表相乘。在代码中,这两个列表都是形状为 (n,2,2) 的 numpy 数组。另一个 (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,
)
)
如代码中测试的那样,此函数生成的结果与常规矩阵结果相同。然而,速度不一样:在我的机器上,显式表达式的速度要快 10 倍。__matmul__
这对我来说是一个非常令人惊讶的结果。我本来希望 numpy 表达式更快,或者至少与更长的 Python 版本相当,而不是像我们在这里看到的那样慢一个数量级。我很好奇为什么性能差异如此巨大。
我正在运行 numpy 版本 1.25 和 Python 版本 3.10.6。
答:
验证第一个“批次”维度的粗略线性度: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 代码利用强大的泛型迭代器将给定的计算(如矩阵乘法)应用于多个数组切片。这样的迭代器对于实现广播和生成相对简单的 实现很有用。问题是,当迭代次数很大而数组很小时,它们也相当昂贵。这正是您的用例中发生的情况。虽然可以通过优化 Numpy 代码来减轻 Numpy 迭代器的开销,但在这个特定用例中,没有办法将开销减少到可以忽略不计的时间。事实上,每个矩阵只有 12 个浮点运算要执行。相对较新的 x86-64 处理器可以使用标量 FMA 单元在不到 10 纳秒的时间内计算每个矩阵。事实上,人们可以使用 SIMD 指令,因此只需几纳秒即可计算出每个矩阵。einsum
首先,我们可以通过对沿第一个轴的向量进行矩阵乘法操作来消除内部 Numpy 迭代器的开销。这正是这样做的!explicit_2x2_matrices_multiplication
虽然应该快得多,但它仍然是次优的:它执行非连续的内存读取,创建几个无用的临时数组,并且每个 Numpy 调用都会引入很小的开销。更快的解决方案是编写 Numba/Cython 代码,以便底层编译器可以生成针对 2x2 矩阵优化的非常好的指令序列。在这种情况下,好的编译器甚至可以生成 SIMD 指令,这对 Numpy 来说是不可能的。下面是生成的代码:explicit_2x2_matrices_multiplication
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 CPU 处理 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 个 CPU 周期 (2.7 ns)!我的机器在实践中能够达到 300 GFlops(理论上为 432 GFlops),但只有 50 GFlops 使用 1 个内核和 12.5 GFlops 使用标量代码(理论上为 18 GFlops)。操作的粒度太小,多线程无法使用(生成线程的开销至少为几微秒)。此外,SIMD 代码很难使 de FMA 单位饱和,因为输入数据布局需要 SIMD 随机排序,因此 50 GFlops 实际上是一个乐观的上限。因此,我们可以肯定地说,Numba 实现是一个非常有效的实现。尽管如此,由于 SIMD 指令,可以编写更快的代码(我预计在实践中可以提高大约 x2 的速度)。话虽如此,使用 SIMD 内部函数编写原生代码以帮助编译器生成快速的 SIMD 代码确实不是一件容易的事(更不用说最终代码会很丑陋且难以维护)。因此,SIMD 实现可能不值得付出努力。
评论
einsum
__matmul__
matmul
是 dot 的“批处理”版本,比 1000 大小的维度更快,但仍然大致线性。相比之下,你的“显式”只循环 4 次。[a.dot(b) for a,b in zip(matrixa, matrixb)]