矩阵乘法这个词,但凡写过几行跟数据打交道的代码,基本都绕不开它。我最早接触matmul是在做一个小型的图像处理 Demo 时,当时需要把一批特征向量做线性变换,手写循环跑得慢不说,还容易把维度搞错,后来换成matmul一行搞定,那种“原来还能这样”的感觉至今记得。这篇内容就想把matmul这个东西从头到尾捋一遍——它是什么、为什么值得单独拿出来学、在代码里怎么用、有哪些坑。不管你是刚学编程的新手,还是已经用过但一直没搞明白细节的老手,应该都能从里面找到点有用的东西。我会尽量用大白话配合实际代码来讲,把那些看起来吓人的矩阵维度、广播规则、性能差异都拆开揉碎,让你看完就能直接上手用。
1. 先搞清楚 matmul 到底在算什么
1.1 从“两个表格相乘”说起
很多人一看到矩阵乘法就头大,其实它的本质特别朴素。你可以把矩阵想象成一张二维表格,matmul做的就是让左边表格的“行”和右边表格的“列”逐一配对,把对应位置的数字乘起来再求和。举个最简单的例子,左边是一个 2 行 3 列的矩阵,右边是一个 3 行 2 列的矩阵,结果就是一个 2 行 2 列的矩阵。结果里第 i 行第 j 列的那个数,就是左矩阵第 i 行的三个数和右矩阵第 j 列的三个数分别相乘再相加。
用代码表达最直观:
import numpy as np A = np.array([[1, 2, 3], [4, 5, 6]]) # 形状 (2, 3) B = np.array([[7, 8], [9, 10], [11, 12]]) # 形状 (3, 2) C = np.matmul(A, B) print(C) # [[ 58 64] # [139 154]]拿结果里的58来说,它就是1*7 + 2*9 + 3*11 = 7 + 18 + 33 = 58。这个计算过程没有任何魔法,就是老老实实的乘加。理解了这个,后面所有的维度规则、性能讨论才有落脚点。
这里有个关键约束必须记住:左矩阵的列数必须等于右矩阵的行数。上面例子里左边是 3 列,右边是 3 行,刚好对上。如果对不上,matmul会直接报错,这也是它比手写循环更安全的地方——维度不匹配当场就暴露,不会让你在错误的结果上继续跑。
1.2 matmul 和普通乘法的区别,别搞混了
这是新手最容易踩的坑。在 NumPy 里,*和matmul完全是两码事。*是逐元素相乘,要求两个矩阵形状一致(或者能广播),对应位置直接相乘;而matmul才是真正的矩阵乘法,遵循行乘列的规则。
A = np.array([[1, 2], [3, 4]]) B = np.array([[5, 6], [7, 8]]) print(A * B) # 逐元素相乘 # [[ 5 12] # [21 32]] print(np.matmul(A, B)) # 矩阵乘法 # [[19 22] # [43 50]]你看,同样两个矩阵,结果完全不同。我见过不少人在做线性代数运算时顺手写了*,结果数值全错还找不到原因。判断标准很简单:如果你要的是“行和列配对求和”,就用matmul;如果你要的是“每个位置各自乘”,才用*。这个区分在深度学习、图形变换、统计分析里尤其重要,因为那些场景下矩阵乘法是核心运算,用错了整个结果就废了。
另外提一句,np.dot在二维情况下和matmul结果一样,但在高维数组上行为不同——dot会把后面的维度整体做乘积,而matmul更接近“批量矩阵乘法”的语义。现在新代码我基本都用matmul或者它的运算符形式@,语义更清晰,也不容易在高维时出岔子。
1.3 为什么值得单独学:它几乎是所有计算的骨架
你可能会问,不就是个乘法吗,至于单独拿出来讲?还真至于。矩阵乘法在现代计算里的地位,相当于砖头之于房子。图像处理里的卷积可以展开成矩阵乘法,神经网络的每一层本质就是输入矩阵乘权重矩阵,推荐系统里的用户-物品打分是矩阵乘法,甚至很多图算法、物理仿真、密码学运算底层都靠它。
我做过一个简单的对比实验:同样是把一个 1000 维的向量做线性变换一万次,手写双重循环在 Python 里跑了将近 8 秒,换成matmul只用了不到 0.1 秒。这个差距不是一点半点,背后是底层 BLAS 库对缓存、向量化指令的深度优化。所以学matmul不只是学语法,更是学一种“把计算交给专业工具”的思维方式。你写的循环再精巧,也很难拼过经过几十年打磨的数值库。
2. 维度规则与广播:matmul 最容易翻车的地方
2.1 一维和二维混着用会怎样
matmul对一维数组有特殊处理,这点很多人不知道。当左边是一维、右边是二维时,一维会被临时当成“一行”来参与计算,算完再把那一维去掉。反过来,左边二维、右边一维时,一维被当成“一列”,结果是一个一维数组。
v = np.array([1, 2, 3]) # 形状 (3,) M = np.array([[1, 0, 0], [0, 1, 0], [0, 0, 1]]) # 形状 (3, 3) print(np.matmul(v, M)) # 结果形状 (3,),相当于行向量乘矩阵 print(np.matmul(M, v)) # 结果形状 (3,),相当于矩阵乘列向量这个行为在数学上是合理的,但在代码里容易让人迷惑,因为形状悄悄变了。我的建议是:只要涉及矩阵运算,尽量保持二维形状,用reshape或者np.atleast_2d把一维显式转成二维,这样形状变化一目了然,调试时也少受罪。尤其是做批量数据处理时,一维和二维混用导致的形状错乱,往往要花很久才能定位。
2.2 批量矩阵乘法:三维四维怎么算
实际项目里经常遇到“一批矩阵各自相乘”的需求,比如一批样本各自过一个线性层。这时候matmul的批量语义就派上用场了。规则是:前面若干维是“批量维”,最后两维才是真正参与乘法的矩阵维,批量维必须能对上(相等或可广播)。
# 一批 4 个 2x3 矩阵,乘一批 4 个 3x2 矩阵 A = np.random.rand(4, 2, 3) B = np.random.rand(4, 3, 2) C = np.matmul(A, B) # 结果形状 (4, 2, 2) print(C.shape)这里4是批量维,两边一致,所以每个批次各自做 2x3 乘 3x2,得到 4 个 2x2。如果批量维不一致但满足广播规则,比如一边是(4, 2, 3)、另一边是(3, 2),matmul也会尝试广播。广播规则本身不复杂,但组合起来容易记混,我一般会先用小数组验证形状,确认无误再上真实数据。
提示:批量维度对不上时,报错信息往往只告诉你“维度不匹配”,不会指出是哪一维出的问题。养成打印
.shape的习惯,比盯着报错猜要快得多。
2.3 形状检查的实用套路
我现在的习惯是,任何matmul调用前后都加一句形状断言,尤其在调试阶段:
def safe_matmul(A, B): assert A.shape[-1] == B.shape[-2], \ f"内维不匹配: {A.shape} x {B.shape}" result = np.matmul(A, B) return result这个断言检查的是最后两维的内维是否相等,也就是“左列等于右行”。批量维的兼容性交给matmul自己判断,但内维这个最常见的错误点先卡住。实测下来,这一行断言能省掉大量排查时间。等代码稳定后可以去掉,或者用日志级别控制。
3. 不同工具里的 matmul:选对场景很重要
3.1 NumPy、PyTorch、TensorFlow 的差异
同样是matmul,不同库的默认行为和性能特征差别不小。NumPy 的matmul走的是 CPU 上的 BLAS 实现,适合中小规模数据;PyTorch 的matmul会自动根据张量所在设备(CPU 或 GPU)调度,GPU 上大规模矩阵乘法能快出数量级;TensorFlow 则更偏向图计算,matmul在静态图模式下能做更多编译期优化。
| 工具 | 典型场景 | 设备支持 | 批量语义 | 备注 |
|---|---|---|---|---|
| NumPy | 数据分析、科学计算 | CPU | 支持 | 轻量,上手快 |
| PyTorch | 深度学习训练推理 | CPU/GPU | 支持 | 自动求导友好 |
| TensorFlow | 生产级模型部署 | CPU/GPU/TPU | 支持 | 图优化强 |
选型上没有绝对优劣,关键看你的数据规模和后续流程。如果只是做几十维的小矩阵运算,NumPy 足够,启动开销还小;如果数据量大到几千维、还要反复迭代,GPU 上的 PyTorch 优势就非常明显。我个人的经验是,原型阶段用 NumPy 快速验证逻辑,确认算法没问题后再迁到 PyTorch 做规模化,这样调试成本最低。
3.2 运算符 @ 的便利与陷阱
Python 3.5 之后引入了@运算符,写起来比np.matmul(A, B)简洁很多:
C = A @ B大部分情况下@和matmul完全等价,但要注意它只对支持该协议的对象有效。如果你自定义了一个类,想让它支持@,需要实现__matmul__方法。另外在某些老版本库或者特殊数据结构上,@可能不被支持,这时候还是得老老实实调函数。我一般在新项目里用@,读起来更像数学公式,但在需要明确表达“这是矩阵乘法”的公共接口里,会写全np.matmul,可读性更好。
3.3 稀疏矩阵场景的特殊处理
当矩阵里大量元素是零时,用稠密的matmul会浪费大量计算和内存。这时候应该换用稀疏矩阵工具,比如 SciPy 里的稀疏格式,它提供了自己的乘法接口。稀疏矩阵乘法的结果通常还是稀疏的,能进一步节省空间。
from scipy import sparse A = sparse.csr_matrix([[1, 0, 0], [0, 0, 2]]) B = sparse.csr_matrix([[0, 3], [4, 0], [0, 5]]) C = A @ B # 稀疏乘法 print(C.toarray())什么时候该用稀疏?一个粗略的判断是零元素占比超过七成,且矩阵规模较大,稀疏存储的收益就很明显了。但稀疏乘法在极度稀疏时快,在中等稀疏度时可能反而不如稠密实现,因为索引开销上来了。这个临界点跟具体数据和硬件有关,建议实测对比再决定。
4. 性能优化:让 matmul 跑得更快
4.1 数据类型对速度的影响
很多人忽略了一点:matmul的速度和数据类型强相关。默认的float64精度高但慢,换成float32往往能快将近一倍,精度损失在大多数机器学习场景里可以接受。整型运算通常比浮点快,但要注意溢出问题。
A32 = A.astype(np.float32) B32 = B.astype(np.float32) # 对比 float64 和 float32 的耗时我在一个 2000x2000 的矩阵乘法上做过测试,float64大约 1.2 秒,float32降到 0.7 秒左右,差距相当可观。如果你的场景对精度要求没那么苛刻,比如特征变换、近似计算,优先考虑降精度。但涉及累加大量数值时,float32的舍入误差会累积,这时候要么保留float64,要么用 Kahan 求和之类的补偿技巧。
4.2 内存布局:行优先还是列优先
NumPy 默认是行优先(C order),但底层 BLAS 在某些情况下对列优先(Fortran order)更友好。如果你的矩阵是从列优先的来源拿到的,别急着转成行优先,先测一下直接算的性能。有时候保持原布局反而更快,因为转置本身也要花时间。
A_f = np.asfortranarray(A) # 对比 A 和 A_f 参与 matmul 的耗时另一个相关技巧是避免不必要的拷贝。matmul内部会处理布局转换,但如果你在调用前自己做了一堆transpose、reshape,可能引入额外开销。能通过参数表达的就别手动转,让库去优化。
4.3 分块计算应对超大矩阵
当矩阵大到内存放不下时,整块matmul会直接爆内存。这时候需要分块(tiling):把大矩阵切成小块,逐块相乘再累加。思路类似把一大箱货物分批搬运,虽然总工作量不变,但内存峰值可控。
def blocked_matmul(A, B, block=512): n, m = A.shape m2, p = B.shape C = np.zeros((n, p), dtype=A.dtype) for i in range(0, n, block): for j in range(0, p, block): for k in range(0, m, block): C[i:i+block, j:j+block] += \ np.matmul(A[i:i+block, k:k+block], B[k:k+block, j:j+block]) return C分块大小的选择有讲究:太小则循环开销大,太大则缓存利用不充分。经验值在几百到一千左右,具体要看 CPU 缓存大小。这个手写分块主要用于理解原理,实际生产中更推荐用专门的大矩阵计算库,它们的分块策略经过深度调优,比手写的稳。
注意:分块累加时,
+=的初始值必须是正确形状和类型的零矩阵,否则会触发形状错误或者意外的类型提升。
5. 实战中踩过的坑与排查清单
5.1 维度不匹配的典型报错
最常见的报错就是内维对不上,信息类似“shapes not aligned”。排查顺序我总结成三步:先打印两个操作数的.shape,确认最后两维;再检查是不是批量维被误当成了矩阵维;最后看有没有一维数组混进来导致形状被“压缩”。大部分问题在前两步就能定位。
还有一种隐蔽情况是转置忘了做。数学公式里写的是A^T B,代码里直接写了A @ B,形状刚好能对上但语义错了,结果数值不对却不报错。这种最坑,因为程序不崩,你很难第一时间发现。我的对策是在关键计算处加注释标明每个矩阵的数学含义,配合单元测试用已知答案的小例子验证。
5.2 数值精度与溢出问题
整型矩阵相乘时,结果很容易超出原类型范围。比如两个int32的大数相乘再累加,结果可能溢出成负数。解决办法是提前把输入转成更大的类型,比如int64或者浮点。
A = A.astype(np.int64) B = B.astype(np.int64) C = np.matmul(A, B)浮点方面,float32在累加几千项后误差可能达到可观的程度。如果结果对精度敏感,用float64,或者采用分块累加减少单次累加长度。我遇到过一次特征变换后结果偏差 0.5% 的情况,查了半天发现是float32累加误差,换成float64立刻正常。
5.3 常见问题速查表
| 现象 | 可能原因 | 排查动作 |
|---|---|---|
| 报维度不匹配 | 内维不等或批量维冲突 | 打印 shape,检查最后两维 |
| 结果数值全错但不报错 | 误用*或漏了转置 | 用小例子手算对比 |
| 结果为负数或异常大 | 整型溢出 | 检查 dtype,提升精度 |
| 内存爆掉 | 矩阵过大未分块 | 改用分块或稀疏 |
| 速度异常慢 | dtype 精度过高或布局不佳 | 降精度、测布局 |
5.4 我的几条实操心得
第一,永远先用小矩阵验证逻辑。两行三列的小例子手算一遍,跟代码结果对上,再去跑真实数据,能挡掉八成低级错误。第二,形状断言不要省,尤其在多人协作的代码里,一个断言就是一份文档。第三,性能优化前先测量,别凭感觉换 dtype 或者改布局,用timeit跑几组对比数据,让数字说话。第四,批量场景优先用批量 matmul,别写循环一个个算,批量接口的底层优化不是循环能比的。
最后分享一个我常用的调试小函数,把形状、dtype、耗时一次性打出来,排查时特别顺手:
import time def debug_matmul(A, B): print(f"A: {A.shape} {A.dtype}") print(f"B: {B.shape} {B.dtype}") t0 = time.perf_counter() C = np.matmul(A, B) t1 = time.perf_counter() print(f"C: {C.shape} {C.dtype} 耗时 {t1-t0:.4f}s") return C这个函数在调参和对比不同实现时帮了我大忙,尤其是定位“到底是形状问题还是性能问题”的时候,一眼就能看出来。矩阵乘法这东西,入门时觉得规则繁琐,用熟了会发现它其实是把复杂计算变得可控的利器,把维度规则吃透、把工具选对,剩下的就是放心交给底层库去跑。