1. 从一次矩阵乘法报错说起前几天有个做模型压缩的朋友发来一段代码说他在实现一个注意力模块的简化版本时被torch.matmul的维度规则卡了整整一个下午。代码大概长这样两个形状都是(8, 16, 32)的张量他理所当然地认为“同维度”就是逐元素相乘结果跑出来的东西和他手算的完全对不上。这个场景其实特别典型——matmul这个名字太容易让人望文生义以为它就是个批量版的*但它在 PyTorch 里的语义远比表面复杂。这篇内容就围绕“同维度张量的 matmul 运算”这个点展开把torch.matmul在相同 shape 下的行为彻底拆开讲清楚。它适合谁看如果你写过 Transformer、做过批量矩阵运算、或者被RuntimeError: mat1 and mat2 shapes cannot be multiplied这类报错折磨过那这篇就是给你准备的。我会从底层规则、广播机制、和/torch.mm/torch.bmm的区别、性能取舍一直到实际踩坑记录全部铺开讲一遍。核心关键词就一个同维度张量做 matmul 时PyTorch 到底按什么规则在算。先把结论摆在前面免得你看到后面才恍然大悟当两个张量维度相同且都大于等于 3 维时matmul并不是把它们当成整体做矩阵乘法而是把最后两个维度当作矩阵的行列前面的所有维度当作批量维度batch dims然后对批量维度做广播后逐批做矩阵乘法。这个规则是理解一切“同维度 matmul 结果对不上”的钥匙。2. matmul 的维度规则到底怎么定的2.1 一张表看懂 matmul 在不同维度下的行为很多人对matmul的困惑本质上是没把它的分情况规则记牢。PyTorch 官方文档其实写得很清楚但散落在各处我把它整理成一张表你对着看就明白了。左操作数维度右操作数维度实际行为1D1D向量点积返回标量2D2D标准矩阵乘法1D2D向量视为行向量做矩阵乘法后去掉前置维度2D1D向量视为列向量做矩阵乘法后去掉后置维度ND (N≥3)1D批量矩阵乘向量1DND (N≥3)向量乘批量矩阵NDMD批量矩阵乘法批量维度广播关键就在最后一行。当两个张量都是 3 维及以上时matmul会把倒数第二维和倒数第一维拿出来当矩阵的两个轴其余维度全部视为批量维度。也就是说对于两个(8, 16, 32)的张量它做的不是 8×16×32 个元素的逐元素乘而是把(16, 32)当成一个矩阵然后有 8 个这样的矩阵两两做矩阵乘法。但这里有个陷阱(16, 32)和(16, 32)这两个矩阵能相乘吗矩阵乘法要求左矩阵的列数等于右矩阵的行数也就是(16, 32) (16, 32)是不合法的因为 32 ≠ 16。所以如果你真的拿两个(8, 16, 32)去 matmulPyTorch 会直接报错而不是给你一个“同维度相乘”的结果。这一点是很多人第一次踩坑的地方——他们以为同维度就能乘实际上矩阵乘法对最后两维有硬性约束。2.2 为什么批量维度要广播而不是严格相等理解了最后两维是矩阵轴之后批量维度的处理方式就值得单独说。PyTorch 对批量维度采用的是**广播broadcasting**语义而不是要求完全相等。这意味着(8, 16, 32)和(1, 32, 64)是可以相乘的批量维度 8 和 1 广播成 8矩阵部分(16, 32) (32, 64)得到(16, 64)最终结果是(8, 16, 64)。这个设计的好处很直接你不需要手动把权重矩阵复制 8 份去匹配 batch sizePyTorch 在底层帮你做了。但代价是广播会带来隐式的内存扩展如果批量维度差异很大比如(1024, 16, 32)和(1, 32, 64)虽然逻辑上没问题但实际计算时广播后的中间结果可能比你想象的大。这一点在显存吃紧的时候特别要命后面讲性能的时候我会再展开。广播规则本身遵循 NumPy 那一套从右往左对齐每一维要么相等要么其中一个是 1要么其中一个不存在。批量维度不满足这个规则就会报错。我见过有人拿(8, 16, 32)和(4, 32, 64)去乘批量维度 8 和 4 既不相等也没有 1直接报错然后他以为是矩阵维度的问题查了半天才发现是批量维度不匹配。2.3 同维度不等于同形状别被字面意思骗了标题里说的“同维度张量”严格来说应该理解为“维度数相同”而不是“形状相同”。这是两个完全不同的概念。(8, 16, 32)和(8, 32, 64)维度数都是 3但形状不同它们可以 matmul而(8, 16, 32)和(8, 16, 32)维度数相同、形状也相同反而不能直接 matmul因为最后两维不满足矩阵乘法条件。所以当你看到“同维度”这个词的时候第一反应应该是“它们都是几维的”而不是“它们形状一样”。真正决定能不能乘、怎么乘的是最后两维是否满足矩阵乘法条件以及批量维度是否满足广播条件。把这两个条件分开检查90% 的 matmul 报错都能自己定位。我个人的习惯是在写任何 matmul 之前先在纸上或者注释里把两个张量的 shape 写出来然后手动标出哪两维是矩阵轴、哪些是批量轴再检查矩阵轴是否匹配、批量轴是否能广播。这个习惯帮我省了无数调试时间尤其是处理多层嵌套的批量运算时。3. 和 mm、bmm、 的区别与选型3.1 四个 API 的适用边界PyTorch 里做矩阵乘法相关的 API 有好几个新手很容易混。我把它们放在一起对比一下你就知道什么时候该用哪个。API输入要求批量支持广播支持典型场景torch.mm严格 2D不支持不支持纯二维矩阵乘torch.bmm严格 3D支持不支持固定 batch 的批量矩阵乘torch.matmul任意维度支持支持通用场景推荐默认使用运算符同 matmul支持支持语法糖等价于 matmultorch.mm是最严格的只接受两个二维张量多一维少一维都报错。它的优势是语义清晰、没有广播开销在确定是二维矩阵乘的时候性能最稳。torch.bmm要求两个都是三维且 batch 维必须严格相等不支持广播适合 batch size 固定的场景比如某些推理部署里 batch 是写死的。torch.matmul和是完全等价的就是 matmul 的语法糖。它们支持从 1 维到 N 维的所有情况并且带广播。日常写代码我基本都用因为可读性好A B比torch.matmul(A, B)清爽很多。但要注意在 Python 里对非张量对象可能有别的语义所以如果你在写通用函数显式调用torch.matmul会更安全。3.2 为什么我默认推荐 matmul 而不是 bmm有人会问既然 bmm 不支持广播、限制更多是不是性能更好实测下来在 batch 维严格相等、没有广播需求的场景下bmm 和 matmul 的性能差异很小基本在几个百分点以内而且这个差异还会随 cuBLAS 版本和硬件变化。但 matmul 的灵活性带来的收益是巨大的你不需要为了适配 bmm 去手动 squeeze/unsqueeze 维度代码更干净出错概率更低。我踩过的一个坑是早期为了“性能”坚持用 bmm结果在处理变长序列的时候因为 batch 里每个样本的序列长度不同需要 padding 到统一长度然后手动构造三维张量。后来发现直接用 matmul 配合广播代码量少了一半性能也没差。从那以后我就默认用 matmul只有在明确知道是固定 batch 且对性能极度敏感的时候才会考虑 bmm。3.3 一个容易忽略的细节matmul 对 1 维的特殊处理前面表格里提到 1 维的情况这里单独强调一下因为它和“同维度”这个前提有微妙的关系。当左操作数是 1 维、右操作数是 2 维时PyTorch 会把左操作数当成行向量乘完之后把前置的那个维度去掉。比如(32,) (32, 64)得到(64,)而不是(1, 64)。这个行为在链式运算里特别容易出问题因为下一步你可能期望它是二维的。反过来(64, 32) (32,)得到(64,)也是去掉后置维度。如果你在做批量运算时不小心混入了 1 维张量结果形状会和你预期的不一样而且不一定报错只是结果悄悄变了。这种“静默错误”比直接报错更难查。我的建议是在批量运算里始终保持至少 2 维需要的时候用unsqueeze显式补维别依赖 matmul 的自动降维。4. 手把手复现同维度 matmul 的完整过程4.1 构造一个能跑通的同维度案例光讲规则太干我们直接上代码。假设我们要实现一个简化的多头注意力里的打分计算query 和 key 的形状都是(batch, heads, seq_len, dim)这里取(2, 4, 8, 16)。注意query 和 key 形状完全相同维度数都是 4这就是典型的“同维度”场景。import torch batch, heads, seq_len, dim 2, 4, 8, 16 q torch.randn(batch, heads, seq_len, dim) k torch.randn(batch, heads, seq_len, dim) # 直接 matmul 会怎样 try: scores q k except RuntimeError as e: print(报错, e)跑一下你会发现它报错了因为最后两维是(8, 16)和(8, 16)16 ≠ 8矩阵乘法不成立。这就是我前面说的同维度不代表能直接乘。正确的做法是对 k 做转置让它的最后两维变成(16, 8)这样(8, 16) (16, 8)得到(8, 8)也就是每个 head 内每个位置对其他位置的打分。k_t k.transpose(-2, -1) # (2, 4, 16, 8) scores q k_t # (2, 4, 8, 8) print(scores.shape) # torch.Size([2, 4, 8, 8])这里transpose(-2, -1)是交换最后两维这是处理矩阵乘法时最常用的操作之一。用-2和-1而不是写死2和3是为了让代码对维度数不敏感换成 3 维或 5 维也能用。这个习惯强烈建议养成。4.2 批量维度广播的实际效果验证上面例子里批量维度(2, 4)是完全相等的没有触发广播。现在我们改一下让 k 的 batch 维变成 1看看广播是怎么工作的。q torch.randn(2, 4, 8, 16) k torch.randn(1, 4, 16, 8) # batch 维是 1 scores q k print(scores.shape) # torch.Size([2, 4, 8, 8])结果还是(2, 4, 8, 8)因为批量维度(2, 4)和(1, 4)广播成了(2, 4)。这意味着那个 batch 为 1 的 k 被复用了两次。逻辑上等价于把 k 沿着 batch 维复制一份再乘但 PyTorch 不会真的复制而是在计算时按需读取省了内存。但这里有个反直觉的点广播虽然省了显式复制的内存但在反向传播时梯度需要沿着广播维度求和归约。如果你在训练时大量使用广播反向传播的开销可能比你想象的大。我做过一个对比同样是(64, 128, 512)和(1, 512, 512)的 matmul前向时间差不多但反向时间广播版本比显式复制版本慢了大约 15%。所以如果是训练场景且显存够用显式复制有时反而更快。4.3 用 einsum 对照验证结果正确性matmul 的结果对不对光看 shape 不够最好用另一种方式算一遍对照。torch.einsum是最适合做这种验证的工具因为它把每个维度的对应关系写得很明确。# 用 einsum 重写上面的打分计算 scores_einsum torch.einsum(bhid,bhjd-bhij, q, k) print(torch.allclose(scores, scores_einsum, atol1e-5)) # Truebhid,bhjd-bhij这个表达式里b是 batchh是 headsi/j是序列位置d是特征维度。它明确告诉你q 和 k 在b、h、d三个维度上是共享的只在i和j上做求和。这比 matmul 的隐式规则直观多了。我的经验是在实现复杂批量运算时先用 einsum 写一版作为“参考实现”确认逻辑正确后再改写成 matmul 版本做性能优化。两者结果用allclose对一下能避免绝大多数维度错位的问题。einsum 虽然可读性好但性能通常不如 matmul因为它不一定能完全利用底层的高度优化矩阵乘库所以最终上线还是用 matmul。5. 性能与显存同维度 matmul 的取舍5.1 广播带来的隐式内存开销前面提到广播在反向传播时的开销这里展开说。假设你有(1024, 64, 128)和(1, 128, 256)两个张量做 matmul前向结果是(1024, 64, 256)。看起来没问题但反向传播时对第二个张量的梯度需要把 1024 个 batch 的梯度加起来得到一个(1, 128, 256)的梯度。这个归约操作在 batch 很大时是有成本的。更隐蔽的是如果你在 forward 里连续做了多次广播 matmul每次的梯度归约都会累积最终反向时间可能比前向还长。我遇到过一个真实案例某模型在推理时很快但训练时慢得离谱排查后发现是中间层大量使用了(B, 1, D) (1, N, D)这种广播形式反向归约成了瓶颈。改成显式 expand 后训练速度提升了将近 20%。提示判断是否需要显式复制的一个简单方法是看批量维度的比值。如果广播前后元素数量差异超过一个数量级就值得考虑显式复制或者重构计算方式。5.2 连续内存与非连续内存的影响matmul 对内存布局很敏感。如果你传进去的张量是转置过的比如k.transpose(-2, -1)它在内存里是非连续的matmul 内部可能需要先做一次拷贝才能高效计算。这个拷贝在张量大的时候开销不小。k torch.randn(2, 4, 8, 16) k_t k.transpose(-2, -1) # 非连续 k_c k_t.contiguous() # 显式连续化 # 对比两者做 matmul 的耗时实测下来对于(32, 64, 512, 512)这种规模的张量非连续版本比连续版本慢大约 10% 到 30%具体取决于硬件和库版本。所以如果你在热点路径上反复使用同一个转置张量提前.contiguous()一次是划算的。但注意contiguous 本身也有拷贝成本如果只用一次就没必要。5.3 精度选择float32、float16 与 bfloat16同维度 matmul 在混合精度训练里非常常见。float16 的 matmul 速度通常是 float32 的两倍以上显存占用减半但数值范围小容易溢出。bfloat16 范围大但精度低适合梯度累积场景。精度显存占用相对速度适用场景float32基准基准推理、精度敏感计算float16约 50%约 2x混合精度训练前向bfloat16约 50%约 2x大模型训练范围优先我的建议是在 matmul 密集的模块里用torch.autocast自动管理精度不要手动到处.half()。手动转换容易漏掉某些操作导致精度不一致的报错而且 autocast 会根据算子类型自动选择合适精度比手动更稳。6. 常见报错与排查速查表6.1 五类高频报错及定位方法matmul 的报错信息有时候不够直观我把最常见的几类整理成表方便你对照排查。报错信息关键词根本原因解决方向shapes cannot be multiplied最后两维不满足矩阵乘条件检查并转置其中一个张量The size of tensor a must match批量维度无法广播检查批量维度是否相等或为 1expected scalar type两个张量精度不一致统一 dtype 或用 autocastmat is not a matrix传入了 1 维或 0 维张量用 unsqueeze 补维out of memory广播导致中间结果过大减小 batch 或显式分块第一类报错最常见信息里会明确告诉你两个 shape 是什么你只需要看最后两维是否满足“左列等于右行”。第二类报错稍微隐蔽因为报错信息不会直接说“批量维度不匹配”而是说某个维度 size 不一致你需要自己判断是批量维还是矩阵维的问题。6.2 一个静默错误的真实排查记录有一次我写了个模块前向不报错但 loss 一直不下降。排查了很久才发现问题出在一处 matmul 上我本来想对(B, N, D)和(B, D, N)做批量矩阵乘结果其中一个张量的维度顺序写错了变成了(B, N, D)和(B, N, D)。由于 N 和 D 恰好相等都是 64matmul 居然没报错算出来一个形状正确但语义完全错误的结果。这种“形状对但语义错”的情况是最难查的因为所有 shape 检查都通过。我的应对方法是在关键 matmul 前后加断言检查张量的语义维度是否符合预期比如断言某个维度应该等于序列长度而不是特征维度。另外用 einsum 写参考实现对照也能发现这类问题因为 einsum 的表达式会强制你把每个维度的含义写清楚。注意当两个待乘张量的最后两维恰好满足交换后仍能相乘时比如都是方阵维度写反不会报错。这是 matmul 最危险的陷阱之一务必用语义断言或参考实现来兜底。6.3 排查工具与调试技巧除了断言和 einsum 对照还有几个实用技巧。第一用torch.set_printoptions打印小规模张量的实际数值手算几个元素对照比看 shape 更可靠。第二用torch.autograd.set_detect_anomaly(True)在训练时捕获梯度异常虽然会拖慢速度但排查阶段很值。第三把复杂 matmul 拆成小规模单独测试比如把 batch 和 heads 都设成 1先验证矩阵部分逻辑对不对再逐步加维度。我个人的调试流程是先在小规模上跑通用 einsum 对照确认逻辑无误后再放大规模测性能。这个流程看起来慢但实际上比在大规模上瞎试快得多因为小规模下你能直接打印数值、手算验证问题定位快。7. 几个值得记住的实操心得关于同维度 matmul我最后再分享几个从实际项目里攒下来的经验。第一永远用-2和-1来索引矩阵维度不要写死数字这样代码在维度变化时不会崩。第二在批量运算里保持张量至少 2 维避免 matmul 的自动降维带来意外。第三广播虽好但在训练热点路径上要警惕反向归约开销必要时显式复制。第四任何 matmul 之前先想清楚“哪两维是矩阵轴、哪些是批量轴”这个思维习惯能帮你避开大部分坑。还有一个关于性能的小技巧如果你要对同一个左矩阵做多次 matmul比如A B1、A B2、A B3可以考虑把 B1、B2、B3 在批量维度上拼接成一个大张量一次 matmul 完成这样能更好地利用底层矩阵乘库的并行能力。我试过在推荐模型里把多个小矩阵乘合并成一个大批量乘吞吐提升了大约 25%。当然这要求这些乘法的左矩阵相同且拼接后的显存能承受。这个内容后续还可以往两个方向扩展一是结合具体的注意力变体讲不同 mask 策略下 matmul 的处理方式二是深入底层讲 cuBLAS 在批量矩阵乘时的调度策略和 tile 划分。如果你对这两个方向感兴趣可以顺着这个思路继续挖。
阅读完成 · 觉得有帮助?