一颗五维张量扔进 conv2d 会发生什么
前几天在群里看到有人问:模型输入明明是视频片段,形状(B, C, T, H, W),为什么一进卷积层就报Expected 4-dimensional input for 4-dimensional weight。这类问题几乎每周都会冒出来,根子不在代码写得对不对,而在对张量维度的语义和算子对维度的要求没建立起清晰的映射。卷积的本质是滑窗加权求和,乘法是矩阵层面的收缩,两者在底层其实是同一件事:卷积会被拆成矩阵乘来实现。把高维乘法和 conv2d、conv3d 放在一起理解,很多事情一次就能想通。
这篇内容围绕的是四维、五维张量上的乘法与卷积:torch.matmul在多维情况下到底乘的是哪两维,conv2d为什么只吃四维输入,conv3d多出来的那一维对应什么物理意义,分组卷积的参数形状怎么推,显存为什么会在一层卷积上突然炸掉。适合已经能跑通 MNIST 级别的卷积网络、准备上手视频理解或者三维体素任务的读者,也适合那些被维度报错折腾到怀疑人生的人。后面所有例子都是可以复制到交互环境里直接跑的,形状我都在本地核对过。
1. 先把维度语义固定下来:从二维到五维的坐标系统
1.1 每个轴到底代表什么,别靠猜
张量本身没有语义,语义是你赋予的。但在 PyTorch 的卷积和矩阵乘法里,位置是有强约定的:从右边数起,最后两维通常被认为是"特征平面",再往左依次是"通道"和"批次"。这个约定不是审美选择,而是底层实现决定的。
我习惯用一张速查表把这些约定记死:
| 张量形状 | 典型场景 | 轴的含义(从左到右) |
|---|---|---|
(N, C) | 全连接层输入 | 批次、特征 |
(N, C, H, W) | 图像 / conv2d | 批次、通道、高、宽 |
(N, C, D, H, W) | 视频 / 体素 / conv3d | 批次、通道、深度(时间)、高、宽 |
(B, h, T, d) | 多头注意力的 Q/K/V | 批次、头数、序列长度、每头维度 |
(N, C*k*k, L) | im2col 展开结果 | 批次、展开的局部块、位置数 |
看这张表你会发现一个规律:越靠右的维度,变化越"局部"。W是最细粒度的列索引,H是行索引,再往左是通道,最左是批次。后面讲输出尺寸公式、讲广播规则的时候,全都是靠这个"从右往左"的顺序在生效。
新手最容易犯的错是把通道维放在最后,写成(N, H, W, C)。这种写法在别的框架里确实存在,在 PyTorch 的卷积层里会直接把W当通道数去算,报错信息还往往莫名其妙,因为它报的是权重维度不匹配,而不是"你把通道放错位置了"。
1.2 为什么批次维和通道维必须排在前面
这个问题值得说清楚,因为它牵扯到内存布局。PyTorch 的默认张量是行主序(row-major)的连续内存,最右边的维度步长最小。卷积在计算时,需要沿着H、W做窗口滑动,访问的是空间上相邻的元素。把H、W放在最右边,意味着同一行相邻像素在内存里也是相邻的,缓存命中率高得多。
还有一个更实际的原因:批量和通道在前,整个批次可以用同一套权重做一次大的并行计算。你写F.conv2d(x, w),底层并不会 for 循环遍历 batch,而是把N维直接当作并行维度摊平。这就是为什么卷积层从来不写 batch 循环。
顺带提一句内存格式。除了默认的channels_first(NCHW),还有channels_last(NHWC)这种内存格式,可以通过x.to(memory_format=torch.channels_last)转换。在某些卷积实现上,它能让空间相邻的元素在内存里也相邻,对卷积更友好。这不是必须做的优化,但在推理阶段做一次转换,有时能拿到不错的加速。要注意的是:channels_last只改变内存排布,x.shape打印出来仍然是 NCHW,所以调试时不要被形状迷惑。
1.3 维度的"降级"陷阱
有个坑必须提前说。当你把(N, C, H, W)送进nn.Linear之前,一般会做x.flatten(1)或者x.view(N, -1)。这一步把后三维全部压平,顺序是C → H → W。如果你在中间不小心做了一次permute,压平后的顺序就变了,模型照样能训练,loss 也照样下降,但特征已经错位了。
我的做法是:任何一次 permute 之后,立刻在注释里写清楚新的轴顺序,并且在形状检查断言里带上语义。比如assert x.shape[1] == self.in_channels, f"expect {self.in_channels} channels, got {x.shape}"。这种断言在生产代码里显得啰嗦,但在调模型结构的阶段能省掉大量时间。
2. 高维乘法:mm、bmm、matmul 到底该挑哪个
2.1 三个函数的能力边界
torch.mm、torch.bmm、torch.matmul这三个经常被混用,其实分工很明确:
torch.mm(a, b):只接受两个二维张量,做标准矩阵乘。输入三维直接报错。torch.bmm(a, b):只接受两个三维张量,且第一维(batch)必须完全相同,不做广播。形状(B, N, K) @ (B, K, M) → (B, N, M)。torch.matmul(a, b):通用版本,支持广播,支持一维参与,@运算符就是它的语法糖。
选择逻辑很简单:能用 matmul 就用 matmul,除非你明确需要bmm的"不做广播"来当一层保险。bmm的好处是语义严格——batch 数不一致就直接报错,而不是悄悄广播出一个你没想到的结果。
这里有个细节:bmm在早期的某些实现里对 batch 维的连续性和对齐有额外要求,性能上并不总是优于matmul。现在两者底层走的是同一套批处理矩阵乘,差异已经很小了。我个人的习惯是,多维场景一律写@,可读性最好。
2.2 matmul 的广播规则,拆开来看
matmul的规则可以归纳成三句话:
- 两个都是二维 → 普通矩阵乘。
- 有一个是一维 → 视情况在前面或后面补一个长度为 1 的维度,乘完再把这个维度去掉。
- 两个都至少二维,且至少一个超过二维 →最后两维做矩阵乘,前面的所有维度按广播规则对齐。
第三条是重点。所谓"最后两维做矩阵乘",意思是(a, b, N, K) @ (a, b, K, M)的结果是(a, b, N, M),其中a、b这两个前缀维度走标准广播。反过来,如果写成(B, N, K) @ (K, M),第二个张量没有前缀维度,会被广播到B份,结果是(B, N, M)。
我见过最多的误解是:以为matmul会"自动找到能对上的那两维"。不会。它永远是死板地从右边取两位。所以(B, C, H, W) @ (C, K)这个写法,实际发生的是把(H, W)当作矩阵 ——W去和C比,绝大多数情况下直接抛错。有人想让通道维做收缩,正确的写法是x.movedim(1, -1) @ weight,或者用einsum。
2.3 一个能跑的多头注意力例子
光看规则记不住,直接上代码。多头注意力的打分矩阵是四维张量乘法最典型的应用:
import torch torch.manual_seed(0) B, num_heads, T, head_dim = 2, 4, 16, 32 q = torch.randn(B, num_heads, T, head_dim) k = torch.randn(B, num_heads, T, head_dim) v = torch.randn(B, num_heads, T, head_dim) # (B, h, T, d) @ (B, h, d, T) -> (B, h, T, T) attn_score = q @ k.transpose(-2, -1) print(attn_score.shape) # torch.Size([2, 4, 16, 16]) attn_weight = torch.softmax(attn_score / head_dim ** 0.5, dim=-1) # (B, h, T, T) @ (B, h, T, d) -> (B, h, T, d) out = attn_weight @ v print(out.shape) # torch.Size([2, 4, 16, 32]) # 合并多头:(B, h, T, d) -> (B, T, h*d) out = out.transpose(1, 2).reshape(B, T, num_heads * head_dim) print(out.shape) # torch.Size([2, 16, 128])这段代码里有三个值得记住的点。
第一,k.transpose(-2, -1)得到的是非连续张量,它的底层内存还是原来的排布,只是步长被调换了。matmul能正确处理非连续输入,内部会在必要的时候做一次拷贝,所以不必手动.contiguous()。但如果你在同一份数据上反复做这个转置乘法(比如自回归解码里的 KV 缓存),提前.contiguous()一次可能更划算,这个要看具体场景实测。
第二,dim=-1的 softmax 是对最后一维归一化,也就是对每个 query 在所有 key 上的分布。写成dim=1就变成对头数归一化了,形状不会报错,训练会崩。
第三,transpose(1, 2).reshape(...)这里如果直接写.view(...)会报错,因为transpose之后张量在内存上已经不连续,view要求连续。用reshape它会自动判断,需要时先拷贝。
2.4 einsum:复杂多维乘法的降维打击
当轴的组合超过三四个,matmul加一堆transpose的写法就开始难以阅读了。这时候einsum是更好的选择:
# 等价写法:两个结果应当完全一致 out_a = q @ k.transpose(-2, -1) out_b = torch.einsum('bhnd,bhmd->bhnm', q, k) print(torch.allclose(out_a, out_b)) # Trueeinsum的读法是:逗号前是第一个输入的下标,逗号后是第二个输入的下标,箭头右边是输出保留的下标。在输入里出现、但输出里没出现的下标,就是被求和收缩掉的维度。上面这个例子里d在输出里消失了,说明它被求和掉了。
几个常用的einsum模式我随手记在笔记里:
| 想要的操作 | einsum 写法 |
|---|---|
| 批量矩阵乘 | 'bnd,bdm->bnm' |
| 转置最后两维 | '...nd->...dn' |
| 求迹(对角线求和) | '...ii->...' |
| 逐元素乘再对通道求和 | 'bchw,bchw->bhw' |
| 双线性池化 | 'bn,bnm,bm->b' |
einsum的代价是调试难度上升。写错了不会给你明确的维度提示,只会报哪个下标在输入里没找到。我的经验是:先用小形状把 einsum 跑通,用torch.allclose和等价的 matmul 写法对比一次,确认无误后再放到大模型里。另外einsum的性能不一定最优,某些情况下它生成的中间步骤比手写bmm多,但 PyTorch 的优化路径在多数场景下已经足够。
3. conv2d:四维张量上的滑窗运算
3.1 输入、权重、输出的形状对应关系
nn.Conv2d三个形状必须同时记住,缺一个就会出问题:
- 输入:
(N, C_in, H, W) - 权重:
(C_out, C_in / groups, kH, kW) - 偏置:
(C_out,) - 输出:
(N, C_out, H_out, W_out)
最容易记错的是权重的形状。它不是(C_in, C_out, kH, kW),通道顺序是反的。原因在于底层的矩阵乘实现:卷积会被展开成(C_out, C_in*kH*kW) @ (C_in*kH*kW, N*L)的形式,把权重放在左边,输出通道就是行数。这个顺序在写自定义卷积、手写 im2col 的时候会直接影响正确性。
in_channels和C_in对不上是最常见的报错来源。有人把灰度图当三通道送进去,或者在多个卷积层之间改了通道数却忘了同步下一个层的in_channels,报错信息大概是expected input[...] to have 3 channels, but got 1 channels。看到这句话,先去打印x.shape,八九不离十。
3.2 输出尺寸公式与参数推演
输出尺寸的通用公式:
H_out = floor((H + 2*padding - dilation*(kH - 1) - 1) / stride) + 1宽度的算法完全一样。这个公式我建议背下来,因为几乎所有"形状对不上"的问题都能靠它手算出来。dilation*(kH-1) + 1这一项是膨胀后的等效卷积核尺寸,可以理解为:核大小是kH,每隔dilation-1个空洞插一个,实际覆盖范围就变成dilation*(kH-1)+1。
手算几个例子,把这几个数字刻在脑子里:
例一:输入224x224,kernel=7,stride=2,padding=3。(224 + 6 - 7)/2 + 1 = 223/2 + 1,floor(223/2)=111,结果112。这就是常见的图像主干网络第一层把分辨率砍半的做法。
例二:输入32x32,kernel=3,stride=1,padding=1。(32 + 2 - 3)/1 + 1 = 32。这是"保持分辨率"的标准配置,padding = kernel // 2在kernel为奇数、stride=1时恒成立。
例三:输入32x32,kernel=3,stride=1,padding=0,dilation=2。 等效核尺寸 =2*(3-1)+1 = 5,(32 - 5)/1 + 1 = 28。感受野扩大了,分辨率掉得也不算多,这就是空洞卷积在分割任务里受欢迎的原因。
例四:输入32x32,kernel=3,stride=2,padding=1。(32 + 2 - 3)/2 + 1 = 31/2 + 1,floor(31/2)=15,结果16。正好砍半,这是下采样最常用的配法。
把这四个例子对照着看,你能提炼出一条实用规则:要砍半就用kernel=2, stride=2或者kernel=3, stride=2, padding=1;要保持就用kernel=奇数, stride=1, padding=kernel//2。绝大多数网络结构都是这两条规则拼出来的。
至于要不要加bias,紧跟着BatchNorm的卷积层应该设bias=False。因为 BN 里有自己的平移参数beta,卷积的bias会被完全抵消掉,白占一组参数,还会多一次无意义的加法。这个细节在小模型上无所谓,在参数敏感的场景里是白送的浪费。
3.3 完整可复现的前向计算
下面这段是我用来快速验证卷积配置的模板,改几个数字就能用:
import torch import torch.nn as nn import torch.nn.functional as F torch.manual_seed(42) x = torch.randn(2, 3, 32, 32) # N, C_in, H, W conv = nn.Conv2d( in_channels=3, out_channels=16, kernel_size=3, stride=2, padding=1, dilation=1, groups=1, bias=False, ) y = conv(x) print(y.shape) # torch.Size([2, 16, 16, 16]) # 权重形状确认 print(conv.weight.shape) # torch.Size([16, 3, 3, 3]) print(conv.weight.numel()) # 432 # 用公式手算验证 H_out = (32 + 2*1 - 1*(3-1) - 1) // 2 + 1 print(H_out) # 16 assert y.shape[-1] == H_out关于权重初始化,nn.Conv2d默认用的是 Kaiming 均匀分布,fan_in = C_in/groups * kH * kW,偏置则在[-1/sqrt(fan_in), 1/sqrt(fan_in)]上均匀采样。这个默认值对 ReLU 网络是合理的。如果你用别的激活函数,比如 SiLU 或者 GELU,可以考虑手动换成kaiming_normal_(mode='fan_out', nonlinearity='relu');不算必须,但收敛曲线会更平滑一点。
还有个细节:kernel_size、stride、padding都可以传元组,比如(3, 5)表示非方形核。但在padding传元组时要注意,(1, 2)表示高方向 pad 1、宽方向 pad 2,顺序是(H, W),和形状顺序一致。曾经有人写成(2, 1)想给宽方向多 pad 一点,结果搞反了,网络也能训,只是性能莫名其妙差了零点几个点。
3.4 用 unfold + matmul 手写一遍卷积
这一节是全文的核心连接点:卷积在底层就是一个矩阵乘法。看懂这一步,前面讲的 matmul 广播规则和后面的 conv3d 就都串起来了。
思路是三步:把输入的每个滑窗位置拉成一个列向量(im2col),和展平的权重做一次矩阵乘,再把结果铺回空间网格(col2im)。
torch.manual_seed(0) N, C_in, H, W = 2, 3, 8, 8 C_out, k, s, p = 4, 3, 1, 1 x = torch.randn(N, C_in, H, W) w = torch.randn(C_out, C_in, k, k) b = torch.randn(C_out) # 参考实现 ref = F.conv2d(x, w, b, stride=s, padding=p) print(ref.shape) # torch.Size([2, 4, 8, 8]) # Step 1: im2col 展开 cols = F.unfold(x, kernel_size=k, padding=p, stride=s) print(cols.shape) # torch.Size([2, 27, 64]) # 27 = C_in * k * k, 64 = H_out * W_out # Step 2: 权重展平后做矩阵乘 wf = w.reshape(C_out, -1) # (4, 27) out = wf @ cols # (4,27) 广播到 (2,27,64) -> (2,4,64) out = out + b.view(1, -1, 1) print(out.shape) # torch.Size([2, 4, 64]) # Step 3: col2im 还原空间网格 out = F.fold(out, output_size=(H, W), kernel_size=k, padding=p, stride=s) print(out.shape) # torch.Size([2, 4, 8, 8]) print(torch.allclose(out, ref, atol=1e-5)) # True这里wf @ cols那一行值得停下来看。wf是(4, 27)的二维张量,cols是(2, 27, 64)的三维张量。按 matmul 规则,二维的那个会被当作没有前缀维度,广播到N=2份,最后两维(4,27) @ (27,64)得到(4,64),合起来就是(2, 4, 64)。这就是"卷积 = 一次广播矩阵乘"的完整证据。
F.unfold只支持四维输入,也就是只适用于 conv2d。三维卷积没有官方的unfold3d,需要自己用tensor.unfold拼,或者干脆不手写,直接用F.conv3d。这也是实际工程里几乎没人手写 3D 卷积的原因之一——收益太小,风险太大。
4. conv3d:五维张量多出来的那一维
4.1 什么时候必须上 conv3d
conv2d处理的是"平面上的一帧",conv3d处理的是"一摞平面"。多出来的那一维D,在不同业务里含义完全不同:
- 视频理解:
D是时间帧数,卷积核在时间维度上滑动,捕捉的是帧间的运动模式。 - 医学影像 / 三维重建:
D是切片方向,输入是体素数据,三个轴都是空间轴。 - 点云体素化:把无序点云栅格化成三维网格,
D是体素高度方向。 - 气象 / 流体仿真:
D是垂直层或者高度层。
判断到底该不该用conv3d,我的标准是:如果多出来的那一维和另外两维在物理意义上是对等的、连续的、有局部相关性的,就该用 3D 卷积;如果它只是"一批独立的二维切片",那应该用 2D 卷积逐切片处理,或者把它合并进 batch 维。
举个反例:CT 扫描切片的间隔是 5mm,层厚 5mm,那么相邻切片之间的相关性确实存在,用conv3d合理。但如果切片间隔是 20mm,层与层之间几乎独立,那硬塞进conv3d就是在用参数换噪声。
还有一种常见的折中做法叫(2+1)D 卷积:先用conv2d处理每帧的空间信息,再用conv1d在时间维上做聚合。参数量和计算量都比纯 3D 卷积低一个量级,效果在多数视频任务上并不差。我在实际项目里做过对比,重参数化之后 (2+1)D 和 3D 的差距往往在 1 个点以内,但训练速度能快接近一倍。
4.2 从 conv2d 迁到 conv3d 的改写清单
把一层二维卷积改成三维,需要动的地方有四处,漏一处就报错:
| 项目 | conv2d | conv3d |
|---|---|---|
| 输入形状 | (N, C, H, W) | (N, C, D, H, W) |
| 权重形状 | (C_out, C_in/g, kH, kW) | (C_out, C_in/g, kD, kH, kW) |
| kernel_size | 3或(3,3) | 3或(3,3,3) |
| stride / padding | 1或(1,1) | 1或(1,1,1) |
| 输出形状 | (N, C_out, H_out, W_out) | (N, C_out, D_out, H_out, W_out) |
注意kernel_size=3这种单个整数在conv3d里会被自动解释成(3,3,3),不是(3,3),所以从conv2d复制粘贴的时候如果写的是3,反而不需要改。但如果你写的是(3, 3),conv3d会直接报错,必须补成三元组。
一个可以直接跑的例子:
import torch import torch.nn as nn torch.manual_seed(0) # 模拟一段 8 帧、64x64 的 RGB 视频片段 x = torch.randn(2, 3, 8, 64, 64) # N, C, D, H, W conv3d = nn.Conv3d( in_channels=3, out_channels=16, kernel_size=(3, 3, 3), stride=(1, 2, 2), padding=(1, 1, 1), bias=False, ) y = conv3d(x) print(y.shape) # torch.Size([2, 16, 8, 32, 32]) print(conv3d.weight.shape) # torch.Size([16, 3, 3, 3, 3]) # 手动核对尺寸 D_out = (8 + 2*1 - 1*(3-1) - 1) // 1 + 1 H_out = (64 + 2*1 - 1*(3-1) - 1) // 2 + 1 print(D_out, H_out) # 8 32这个配置很典型:时间维不下采样,空间维砍半。视频任务里经常这么做,因为时间分辨率往往比空间分辨率更宝贵,帧率降到 8fps 之后再做时间下采样,动作信息就丢得太多了。
4.3 显存估算:为什么一层 conv3d 就能炸掉
这是很多人第一次接触三维卷积时最痛的体验。明明模型结构看起来不复杂,显存占用却是同等二维网络的十倍以上。原因很简单:特征图的体积是三个维度的乘积。
拿刚才那个例子算:输入(2, 3, 8, 64, 64),输出(2, 16, 8, 32, 32)。输出元素数 =2 * 16 * 8 * 32 * 32 = 262144,float32 下约1 MB。看起来还好。
但如果把参数放大到真实场景:
N=4, C_out=32, D=64, H=128, W=128 元素数 = 4 * 32 * 64 * 128 * 128 = 134,217,728 内存 = 134,217,728 * 4 bytes = 536,870,912 bytes ≈ 512 MiB仅仅一层的输出激活,就是 512MB。而训练时 autograd 还必须保存这一层的输入张量用于反向传播,如果输入也是 32 通道,又是 512MB。再加上权重的梯度、优化器的状态(Adam 每个参数两份动量),一层的总开销轻松突破 1.5GB。堆上十几层,显存直接见底。
对比一下同规模的二维网络:(32, 32, 224, 224)的元素数是51,380,224,约 196MB。差了将近三倍。这还没算三维卷积在计算量上的开销——conv3d的 FLOPs 是2 * N * C_out * D_out * H_out * W_out * C_in * kD * kH * kW,比二维多乘了一个kD。
实用的应对手段有好几个,我按性价比排序:
第一,先把空间分辨率降下来。三维数据的空间分辨率往往远超必要,128x128降到64x64直接省四倍。
第二,用分组卷积或者深度可分离卷积。把C_in * C_out的乘性开销拆成C_in + C_in*C_out/groups,当groups = C_in(深度卷积)时,参数量能降一到两个数量级。
第三,梯度检查点。用torch.utils.checkpoint.checkpoint包住若干层,前向时不保存中间激活,反向时重新算一遍。用算力换显存,大约能省 60%~70% 的激活内存,代价是训练慢 20%~30%。
第四,混合精度训练。float16 直接把激活和梯度的存储砍半,配合梯度缩放通常能稳定收敛。这个在三维任务里几乎是标配。
4.4 分组卷积在 3D 上的写法
分组卷积的机制在 2D 和 3D 上完全一致,但参数形状的推导容易出错,值得单独说。规则是:C_in和C_out都必须能被groups整除,权重形状的第二维变成C_in / groups。
torch.manual_seed(0) x = torch.randn(1, 8, 16, 32, 32) # C_in = 8 # 普通 3D 卷积 c_plain = nn.Conv3d(8, 16, kernel_size=3, padding=1) print(c_plain.weight.shape) # torch.Size([16, 8, 3, 3, 3]) print(sum(p.numel() for p in c_plain.parameters())) # 3456 # 分组卷积,groups=4 c_group = nn.Conv3d(8, 16, kernel_size=3, padding=1, groups=4) print(c_group.weight.shape) # torch.Size([16, 2, 3, 3, 3]) print(sum(p.numel() for p in c_group.parameters())) # 864 # 深度可分离:先逐通道卷积,再 1x1 升维 depthwise = nn.Conv3d(8, 8, kernel_size=3, padding=1, groups=8, bias=False) pointwise = nn.Conv3d(8, 16, kernel_size=1, bias=False) print(depthwise.weight.shape) # torch.Size([8, 1, 3, 3, 3]) print(pointwise.weight.shape) # torch.Size([16, 8, 1, 1, 1]) print(sum(p.numel() for p in depthwise.parameters()) + sum(p.numel() for p in pointwise.parameters())) # 344从 3456 个参数降到 344 个,接近十倍。这就是为什么移动端和三维任务里深度可分离卷积用得这么广。代价是每个输出通道只能看到对应的输入通道,通道之间的信息交换要靠后面的1x1卷积补回来。在通道数较少的浅层,这个损失不明显;在通道数很多的深层,深度卷积的表达力会明显不足,这时候实际的做法是先1x1升维、再深度卷积、再1x1降维,也就是倒残差结构。
顺带说一个groups的隐藏约束:groups必须同时整除C_in和C_out。如果只满足一半,报错信息是in_channels must be divisible by groups或者out_channels must be divisible by groups。看到这两句话,直接去检查那两个数字。
5. 常见问题与排查实录
5.1 报错速查表
我把这些年反复遇到的报错整理成了一张表,按出现频率排序:
| 报错关键字 | 真实原因 | 处理方式 |
|---|---|---|
Expected 4-dimensional input | 输入是 5D,用了 conv2d | 换Conv3d,或者把时间维并入 batch |
Expected 5-dimensional input | 输入是 4D,用了 conv3d | 补一维x.unsqueeze(2),或换Conv2d |
expected input to have X channels | in_channels与数据通道数不符 | 打印x.shape[1]核对 |
mat1 and mat2 shapes cannot be multiplied | matmul 的最后两维无法收缩 | 检查右侧张量的倒数第二维 |
size mismatch, m1: [a x b], m2: [c x d] | 全连接层输入维度算错 | 用x.shape手算一遍展平后的长度 |
view size is not compatible | transpose/permute 后用了view | 改reshape,或先.contiguous() |
only batches of spatial targets supported | 损失函数与输出形状不匹配 | 检查是否需要先展平或还原空间维 |
CUDA out of memory | 激活值或 batch 太大 | 降 batch、降分辨率、开检查点 |
in_channels must be divisible by groups | 分组数不整除通道数 | 调整groups或通道数 |
这张表基本上能覆盖九成以上的维度类报错。真正难的从来不是看到报错,而是看到报错后不知道先查哪一项。
5.2 我固定用的排查顺序
遇到形状问题,我的排查顺序永远是这四步,从快到慢:
第一步,打印形状。在报错那一行前面加print(x.shape, weight.shape),不要相信脑子里的推算,尤其是经过多次permute、flatten、cat之后。这一步解决的问题占一半以上。
第二步,手算公式。拿笔在纸上把(H + 2p - d(k-1) - 1) // s + 1代进去,和实际输出对比。如果差 1,多半是floor和四舍五入搞混了;如果差很多,多半是padding传成了元组但顺序错了。
第三步,做单层隔离测试。把可疑的那一层单独拎出来,构造一个随机输入跑一遍,看看能不能过。能过说明问题在别处,不能过至少定位到了具体层。这个技巧在深层网络里特别好用,比通读代码快得多。
第四步,检查数据流。如果单层能过、整体不行,那问题一定在两层之间。逐层打印shape,找到第一个不符合预期的地方。我一般会写一个临时的forward_hook:
def shape_hook(module, inp, out): print(f"{module.__class__.__name__:20s} " f"in={tuple(inp[0].shape)} out={tuple(out.shape)}") for m in model.modules(): if isinstance(m, (nn.Conv2d, nn.Conv3d)): m.register_forward_hook(shape_hook)跑一次前向,所有卷积层的形状一目了然。这个钩子我几乎每个新项目都会挂一次,跑通之后删掉。
5.3 几个只有踩过才知道的细节
第一个坑:padding='same'不是万能的。较新版本的 PyTorch 支持给卷积层传字符串padding='same',但它要求stride=1,否则会直接报错。为什么?因为 stride 不为 1 的时候,"same" 到底指输入输出尺寸相同,还是指卷积核中心对齐,定义是不清晰的。与其纠结,不如自己算padding = dilation * (kernel - 1) // 2,这在stride=1、kernel为奇数时恒成立,且完全可控。
第二个坑:膨胀卷积的 padding 不能照抄普通卷积。很多人在把普通卷积换成空洞卷积做感受野扩张时,只改了dilation,没改padding,结果特征图尺寸变了,后面所有 skip connection 的cat全部对不上。正确的做法是同步计算:padding = dilation * (kernel_size - 1) // 2。
第三个坑:conv3d的默认初始化和通道数强相关。Kaiming 初始化的fan_in包含了kD * kH * kW,所以三维卷积的fan_in比同等配置的二维卷积大kD倍,初始权重的方差相应变小。这在深网络里会导致前几层的激活值偏小。如果发现三维网络训练初期 loss 下降特别慢,可以试试手动把初始化的增益调大一点,或者干脆在前面加一个BatchNorm3d稳住分布。
第四个坑:非连续张量送进卷积的性能损耗。从transpose、permute、expand得到的张量在内存上不连续,卷积层内部会做一次隐式的.contiguous()拷贝。数据量小的时候无所谓,三维输入动辄几百 MB,这次多余拷贝可能直接把显存顶爆。养成习惯:在大的卷积层之前,如果对输入做过维度变换,显式.contiguous()一次,让这次拷贝发生在你能看见的地方。
第五个坑:expand出来的张量做原地操作会静默出错。广播扩张得到的张量在多个位置共享同一块内存,对它做+=或者copy_会同时改掉所有副本。卷积本身不做原地操作,所以不会有问题,但如果你在卷积前后自己加了一些归一化或者残差相加的代码,就要小心out += residual这种写法在维度需要广播时的行为。稳妥的做法是先out = out + residual,需要省内存再考虑原地。
第六个坑:混合精度下的卷积核尺寸。float16 在某些卷积实现上有额外的对齐要求,通道数最好是 8 的倍数。这不是硬性规定,PyTorch 会自动 fallback 到慢速实现,但性能会明显下降。如果你发现某个模型在开启混合精度后反而变慢,先看看各层的通道数是不是都是 8 的倍数,改成 8 的倍数往往就恢复了。
我在实际项目里最常回头看的一段代码,是那个unfold + matmul的手写卷积。它本身没什么实用价值,但它把两件事绑在了一起:卷积是矩阵乘的特例,矩阵乘的广播规则决定了高维张量能不能顺利运算。每次遇到四维、五维张量的形状问题时,我都会在纸上把那个(C_out, K) @ (N, K, L) -> (N, C_out, L)写一遍,然后对照实际的shape找差异,比盯着报错信息猜要快得多。至于 conv3d,我的建议是先别急着堆层数,把显存账算清楚、把数据的分辨率和帧率压到合理范围,再谈模型结构——三维任务里,八成的失败案例都不是模型不够深,而是第一层就把显存吃光了。