Tensor 基础:布局、算子与形状推导
想看懂卷积、池化公式却老被 NCHW
、stride
搞糊涂?本文聚焦 张量布局 → 常见算子 → 形状计算公式,用一篇梳理基础概念。
术语:算子(Operator)是什么?
- 定义:在计算图中对张量进行某种变换的“最小计算单元”,如加法、卷积、矩阵乘。深度学习框架把算子拼成计算图并自动求导。
- 省流:算子=“对张量做事”的函数积木;模型=“很多算子按顺序和拓扑连起来”。
常见算子速览(按功能分组)
- 形状与索引:Reshape/View、Squeeze/Unsqueeze、Transpose/Permute、Concat、Split/Slice、Gather/Scatter、Repeat/Tile、Broadcast
- 逐元素与激活:Add/Sub/Mul/Div、Pow、Clamp、Abs、Exp/Log、ReLU/LeakyReLU/GELU/Sigmoid/Tanh/Softplus/Swish
- 归约(Reduction):Sum/Mean/Max/Min、Argmax/Argmin、Prod、Norm(L1/L2)
- 线性代数:MatMul/GEMM、BatchMatMul(BMM)、Linear(全连接)、einsum
- 卷积族:Conv1d/2d/3d、Depthwise/Grouped、Dilated(空洞)、Transposed Conv(反卷积)、Separable Conv
- 池化:Max/Avg/Global/Adaptive Pool(1d/2d/3d)
- 归一化与正则:BatchNorm/LayerNorm/GroupNorm/InstanceNorm、Dropout
- 插值与采样:Interpolate/Upsample(nearest/bilinear)、GridSample、Pad(Zero/Reflect/Replicate)
- 概率与损失:Softmax/LogSoftmax、CrossEntropy、MSE/MAE、NLL、KLDiv、Focal Loss
- 嵌入与稀疏:Embedding/EmbeddingBag、Sparse MatMul
- 频域与信号:FFT/iFFT、STFT、Conv1d 信号处理
1️⃣ 张量与布局:从数字到多维数组
什么是张量 (Tensor)?
想象一下:
- 0D 张量 (标量):一个孤零零的数字,比如
5
。 1D 张量 (向量):一排数字,像 Python 列表
[1, 2, 3, 4]
。等等,Python 列表哪来的
shape
和dtype
?
好问题!Python 列表本身没有。我们说的张量,其实是深度学习框架(如 PyTorch)里的一个特殊对象。框架会“接收”你的 Python 列表,然后把它“包装”成一个真正的张量。1
2
3
4
5
6
7
8
9
10
11import torch
# 1. 这是一个普通的 Python 列表
data = [1, 2, 3, 4]
# 2. PyTorch 把它包装成一个 Tensor 对象
tensor_1d = torch.tensor(data)
# 3. 现在,这个对象就有 shape 和 dtype 属性了
print(tensor_1d.shape) # 输出: torch.Size([4]),表示这是一个长度为4的1D张量
print(tensor_1d.dtype) # 输出: torch.int64,框架自动推断出是整数类型✨ 所以,张量是“数据 + 描述信息(形状、类型)”的组合体。
- 2D 张量 (矩阵):一个表格,有行有列,像 Excel 工作表。
- 3D 张量:一沓叠起来的表格,比如一张彩色图片(高 × 宽 × 3个颜色通道)。
4D 张量:一堆 3D 张量,比如 一批 彩色图片。
✨ 一句话: 张量就是“多维数组”,用来装深度学习模型处理的数据。每个张量都有两个核心属性:
shape
(形状):一个元组,告诉你每个维度有多大。例如(64, 3, 224, 224)
。dtype
(数据类型):说明里面存的是什么类型的数,比如float32
(小数) 或int8
(整数)。
为何需要 NCHW 这种布局?
处理图片时,我们需要用一种标准方式来组织数据。NCHW
就是最流行的一种“打包格式”。
字母 | 含义 | 🌰 举例:一张 (3, 224, 224) 的 RGB 图片 |
---|---|---|
N | Number / Batch (数量) | 一次处理 64 张图片,N=64 |
C | Channel (通道) | 红/绿/蓝 3 个通道, C=3 |
H | Height (高度) | 图片高 224 像素, H=224 |
W | Width (宽度) | 图片宽 224 像素, W=224 |
所以,一个 (64, 3, 224, 224)
的张量,意思就是“64 张、3 通道、224×224 大小的图片”。
NCHW vs NHWC:有啥区别?
NCHW
(channels_first):(N, C, H, W)
- GPU 友好:CUDA/cuDNN 库针对这种布局做了优化,相邻通道的数据在内存里更连续,访问快。
- PyTorch/Paddle 默认。
NHWC
(channels_last):(N, H, W, C)
- CPU/TPU 友好:某些硬件访存模式更适合通道在最后。
- TensorFlow 默认。
省流:搞不清就用
NCHW
,它是 GPU 上的“高速公路”。代码里看到permute(0, 2, 3, 1)
就是在做NCHW
→NHWC
的切换。
2️⃣ 卷积 (Convolution):像用“滤镜”提取特征
想象一下你给照片加滤镜(比如“锐化”或“模糊”),卷积做的就是类似的事情。它用一个小的“滤镜”(称为 Kernel 或卷积核),在输入图片上一点点地滑动,计算出每个区域的特征值,最后汇集成一张新的“特征图”。
卷积的五个关键参数
参数 | 符号 | 作用 | 🌰 生活化比喻 |
---|---|---|---|
输入尺寸 | I |
图片的宽或高 | 一块 10x10 的大巧克力 |
Kernel | K |
滤镜/卷积核的大小 | 一个 3x3 的饼干模具 |
Padding | P |
在图片边缘填充几圈0 | 在巧克力周围加一圈奶油,防止模具出界 |
Stride | S |
滤镜每次滑动的步长 | 模具每次向右/下移动几格 |
Dilation | D |
Kernel内部元素的间距 | 模具上的图案本身有多稀疏 |
正向卷积 (Forward Conv)
这是最常见的卷积,用来缩小特征图,提取特征。
公式:
OUT = ⌊(I + 2P - D·(K-1) - 1) / S⌋ + 1
公式拆解:
D·(K-1) + 1
:这是考虑了空洞(Dilation)之后,卷积核覆盖的实际范围。如果D=1
,它就等于K
。I + 2P
:这是输入图片在两边都加上了P
圈“奶油”之后的总宽度。(I + 2P) - (D·(K-1) + 1)
:这是模具可以在奶油巧克力上滑动的“总距离”。- 除以
S
再+1
:计算在这个总距离上,以S
为步长,总共能挪动几步(结果向下取整)。
🌰 示例: 输入
I=5
, KernelK=3
, PaddingP=1
, StrideS=2
, DilationD=1
。 - 加上 padding 后的总宽度是5 + 2*1 = 7
。 - Kernel 覆盖范围是1*(3-1)+1 = 3
。 - 滑动总距离是7 - 3 = 4
。 - 能滑几步?4 / 2 = 2
步。 - 总共能摆放几次?2 + 1 = 3
次。所以输出OUT=3
。
转置卷积 (Transposed Conv)
也叫“反卷积”,但这个名字不准确。它的作用和正向卷积相反,常用来放大特征图(上采样)。
公式:
OUT = (I - 1)·S - 2P + D·(K-1) + 1
把它想象成“从特征图反推原图尺寸”的过程,或者“用一个画笔(Kernel)在小画布上画,画出一个大图”。公式的每一项都是正向卷积的“逆运算”。
3️⃣ 池化 (Pooling):给特征图“瘦身减负”
如果说卷积是“精加工提取特征”,那么池化就是“粗加工,信息压缩”。它同样用一个小窗口在图上滑动,但计算规则更简单:只取窗口内的最大值(Max Pooling)或平均值(Average Pooling)。
为什么需要池化?
- 降低计算量:特征图变小了,后续计算更快。
- 防止过拟合:保留最主要的特征,丢掉一些不重要的细节。
- 增加感受野:让后面的卷积能看到更大范围的原始信息。
Max / Avg Pooling
公式:
OUT = ⌊(I + 2P - K) / S⌋ + 1
(和卷积公式一样,只是没有 Dilation)
🌰 示例:Max Pooling 假设有一个 4x4 的输入,用一个 2x2 的窗口、步长为 2 来做最大池化:
1
2
3
4
5
6
7
8
9 输入: 窗口1: [[1, 2], 窗口2: [[3, 4],
[[1, 2, 3, 4], [5, 6]] → max=6 [7, 8]] → max=8
[5, 6, 7, 8],
[9, 1, 2, 3], 窗口3: [[9, 1], 窗口4: [[2, 3],
[4, 5, 6, 7]] [4, 5]] → max=9 [6, 7]] → max=7
输出 (2x2):
[[6, 8],
[9, 7]]
自适应池化 (Adaptive Pooling)
这是个“懒人”池化。你不需要关心 K、P、S 是多少,直接告诉它你想要多大的输出就行了。
用法:
AdaptiveMaxPool2d(output_size=7)
框架会自动计算出合适的 K 和 S,把任意大小的输入都变成
7x7
。这在连接卷积层和全连接层时特别有用。
4️⃣ 二元 / 矩阵算子形状规则
1. 逐元素 (Element-wise) 运算:加减乘除
这是最简单的运算,就像小学数学题,把两个形状完全一样的表格,对应位置的数字做加减法。
规则:两个张量的形状必须完全一样。
🌰 示例:
1
2
3
4 A = [[1, 2], B = [[5, 6], A + B = [[1+5, 2+6],
[3, 4]] [7, 8]] [3+7, 4+8]]
# 结果: [[6, 8], [10, 12]]
✨ 进阶:广播 (Broadcasting)
如果两个张量形状不完全一样,但又“兼容”,框架会自动“扩展”那个小一点的张量,让它们能够运算。这个过程就叫广播。
🌰 示例:给矩阵的每一行都加上一个向量
广播非常强大,能省去很多手动的
1
2
3
4
5
6
7 A = [[1, 2, 3], B = [10, 20, 30]
[4, 5, 6]]
# 框架会自动把 B “复制”成两行,变成 [[10, 20, 30], [10, 20, 30]]
# 然后再和 A 做逐元素相加。
A + B = [[11, 22, 33],
[14, 25, 36]]for
循环。
2. 矩阵乘法 (MatMul / BMM)
矩阵乘法不是对应位置相乘,规则要特殊一些。
规则:对于
A @ B
(在 Python 里@
是矩阵乘法的运算符),A 的 列数 必须等于 B 的 行数。 -A (n, m)
@B (m, p)
→C (n, p)
把它想象成“配对消除”:中间的
m
维配对后消失,剩下两头的n
和p
组成新形状。
🌰 示例: 一个
(2, 3)
矩阵乘以一个(3, 4)
矩阵:
A
有 2 行 3 列B
有 3 行 4 列- A 的列数 (3) == B 的行数 (3),可以相乘!
- 结果
C
的形状是(2, 4)
。
✨ 进阶:批量矩阵乘法 (BMM - Batched Matrix Multiplication)
如果想一次性做好几组独立的矩阵乘法,就可以用 BMM。
规则:
A (b, n, m)
@B (b, m, p)
→C (b, n, p)
b
是batch_size
,代表有多少组。除了b
必须相等,每一组内的n, m, p
规则和普通矩阵乘法一样。
3. 拼接 (Concatenation)
拼接就是把几个张量“粘”在一起,变成一个更大的张量。
规则:除了你要拼接的那个维度(
axis
或dim
),其他所有维度的大小都必须完全一致。
🌰 示例: 两个形状为
(2, 3)
的张量A
和B
。
沿
axis=0
(行) 拼接:像叠罗汉一样上下拼接。
1
2
3
4
5
6
7 A = [[1, 1, 1], B = [[2, 2, 2],
[1, 1, 1]] [2, 2, 2]]
# 结果形状 (4, 3)
[[1, 1, 1],
[1, 1, 1],
[2, 2, 2],
[2, 2, 2]]沿
axis=1
(列) 拼接:像火车车厢一样左右拼接。
1
2
3 # 结果形状 (2, 6)
[[1, 1, 1, 2, 2, 2],
[1, 1, 1, 2, 2, 2]]
5️⃣ Python 取整与 Z3 约束细节
为何需要 //
(向下取整)?
在计算卷积或池化输出尺寸时,公式 (I + 2P - K) / S + 1
很容易算出小数,比如 (10 - 3) / 2 = 3.5
。但像素数不可能是小数,我们必须把它变成整数。
/
(普通除法):7 / 2 = 3.5
//
(地板除法):7 // 2 = 3
(直接扔掉小数部分,往小了取)
✨ 核心: 深度学习里的形状计算,用的都是向下取整。所以你在代码里会看到
//
而不是/
。
Z3 如何理解取整?
Z3 的整数除法 /
在处理正数时,行为和 Python 的 //
一模一样。这就是为什么在给 Z3 添加规则时,我们总是先加一条:
solver.add(H_in > 0, K > 0, S > 0, ...)
这条规则相当于告诉 Z3:“别去想那些负数或者零的情况,咱们只在正数范围内玩耍。” 这样就保证了 Z3 的数学模型和框架的实际计算结果能对得上。
为何要限制大小 (< 2**31
)?
在 ops.py
里,我们还常会加一条 dim < 2**31
的约束。
生活化比喻: 这就像你让朋友猜一个数字,但你先告诉他:“这个数在 1 到 100 之间”。这会让他猜得更快,而不是天马行空地去想。
主要原因有两个:
- 现实限制:GPU 内存有限,不可能创建一个几百亿维度的张量。
2**31-1
是很多框架内部表示维度大小的上限。 - 求解效率:给 Z3 一个明确的范围,可以极大地缩小它的“搜索空间”,让它在几秒钟内就找到答案,而不是花几个小时去尝试那些不切实际的超大数字。
6️⃣ Z3:用数学公式反推合法参数
什么是 fuzzing?
- Fuzzing:自动生成大量输入,触达边界与异常路径,用于找 Bug/崩溃/未定义行为。
本项目属于“约束引导 fuzzing”:
- 先用 Z3 约束“形状与参数必须合法”;
- 再通过多次采样(哈希不等式)获取“多样但合规”的极端组合;
- 用这些组合去驱动 Deep Learning/CUDA 路径,观察异常行为与性能拐点。
Z3 是什么?
- Z3 是微软研究院开源的 SMT(Satisfiability Modulo Theories)求解器,能在“命题可满足性”的基础上,处理整数/布尔/数组/位向量等“理论”的联合约束。
- 你给出变量与约束(= 公式),它判断是否可满足,并返回一个“模型”(具体赋值)。
Z3 是一个“约束求解器”。你可以把它想象成一个超级聪明的数独程序。
工作流程:
- 你提供规则:比如“这一行数字不能重复”、“这一格必须是 5”。
- 它负责寻找答案:Z3 会自动尝试所有可能性,找出一个或多个满足你所有规则的数字组合。
在我们的场景里,规则就是算子的“形状计算公式”,答案就是一组“合法的张量形状和参数”。
1. Z3 的基本积木
1 | from z3 import Int, Solver, And, sat |
2. 用公式当规则:反推卷积参数
现在,我们把卷积的输出公式也加进去当规则:
1 | # ...接上文... |
3. 如何找到“不同的”解?
为了测试更多情况,我们需要 Z3 给出和上次不一样的答案。
方法:在找到一组解(比如
H_in = 13
)之后,往求解器里追加一条新规则:solver.add(H_in != 13)
,然后再solver.check()
,它就会去找下一组解了。
universal_hash
是做什么的? 它是一种更高级的“制造不同”的方法。有时只排除一个变量 (H_in != 13
),Z3 给出的新解可能只是其他变量稍微变了一下,不够“多样”。universal_hash
会把所有变量的值都“搅乱”一下,再添加不等约束,这样更容易驱动 Z3 去探索一个全新的、差异更大的解空间。
在 ops.py
里: 1
2
3
4
5
6
7def universal_hash(x):
if z3.is_int(x):
bv = z3.Int2BV(x, 32)
bv = (bv & 0xaaaaaaaa) ^ (bv & 0x55555555)
return z3.BV2Int(bv, 32)
if isinstance(x, int):
return (x & 0xaaaaaaaa) ^ (x & 0x55555555)
- 对 z3 的 Int 先转 32 位位向量,分别与
0xaaaaaaaa
(1010…)和0x55555555
(0101…)做按位与,再异或,最后转回 Int。 - 作用:把“具体值”在比特层面打散,得到“哈希指纹”。配合不等式使用: 这样比只写
1
2solver.add(universal_hash(sym) != universal_hash(interp.as_long()))
solver.add(sym != interp.as_long())sym != old_value
更容易跳出“局部变化”,采到“差异更大”的新解。
4. 高级规则 ForAll
:处理 Concat
对于 Concat 算子,规则是“除了拼接的那个维度,其他维度的大小都必须一样”。用 ForAll
就能优雅地表达这个规则。
ForAll([i], Implies(i != concat_dim, shape_A[i] == shape_B[i]))
翻译成人话就是: “对于所有的维度
i
,如果i
不是我们正在拼接的那个维度,那么shape_A
在i
上的大小必须等于shape_B
在i
上的大小。”
ops.py 中 Z3 的具体应用
抽象层次:
AbsCommonNN
为卷积/池化等建立符号:insize/outsize/kernelsize/stride/padding/dilation
;- 依算子写出尺寸公式并
solver.add(...)
。
典型公式:
- 卷积:
OUT == (IN + 2*P - D*(K-1) - 1) / S + 1
; - 池化(Avg/Max):
OUT == (IN + 2*P - K) / S + 1
; - Concat:用
ForAll(i, Implies(i!=dim, A[i]==B[i]))
表达“除拼接维外全等”。
- 卷积:
多解采样:
- 在
materialize()
内循环check → model → yield
; - 用
_sol_sample()
给随机选取的符号追加:1
2solver.add(universal_hash(sym) != universal_hash(interp.as_long()))
solver.add(sym != interp.as_long()) - 从而持续产出“不同解”(生成器
yield
多轮)。
- 在
工程护栏:
- 约束
>0
、<2**31
、可按需启用显存元素上限(ELEM_LIMIT
)以控规模; Solver().set(timeout=...)
防卡死;- 求解后将 z3 模型映射到各框架参数,动态构造
Conv/Pool/...
层进行实测。
- 约束
7️⃣ 三大框架参数映射:同一功能,不同叫法
PyTorch, PaddlePaddle, TensorFlow (Keras) 是三个最主流的深度学习框架。它们都有卷积、池化这些功能,但就像不同地方的方言,它们给这些功能的参数起了不同的名字。
核心参数“方言”对照表
通用概念 | PyTorch | PaddlePaddle | TensorFlow (Keras) | 🗣️ 备注 |
---|---|---|---|---|
输入通道数 | in_channels |
in_channels |
(自动推断) | Keras 根据输入形状自动识别 |
输出通道数 | out_channels |
out_channels |
filters |
TF 叫“滤波器数量” |
卷积核大小 | kernel_size |
kernel_size |
kernel_size |
这个大家都一样 |
步长 | stride |
stride |
strides |
TF 喜欢用复数形式 |
填充 | padding (int) |
padding (int/str) |
padding (str) |
这个坑最多! (见下文) |
空洞卷积 | dilation |
dilation |
dilation_rate |
TF 叫“空洞率” |
分组卷积 | groups |
groups |
groups |
基本一致 |
数据格式 | (默认 NCHW) | (默认 NCHW) | (默认 NHWC) | TF/Keras 默认通道在后! |
关键差异与代码示例
1. Padding 的大坑
- PyTorch/Paddle:
padding=1
表示在图片四周都填充 1 圈 0。 - TensorFlow/Keras:
padding
只能是字符串'valid'
(不填充) 或'same'
(自动计算填充,让输出和输入尺寸差不多)。如果你想精确控制填充几圈,必须单独用一个ZeroPadding2D
层。
1 | # PyTorch: 一步到位 |
2. 数据格式 data_format
因为 PyTorch/Paddle 默认 NCHW
(channels-first),而 Keras 默认 NHWC
(channels-last),跨框架复现代码时,一定要在 Keras 层里加上 data_format='channels_first'
来保持统一!
1 | # Keras 里保持和 PyTorch 一样的 NCHW 格式 |
特殊算子支持情况
- 转置卷积:TF/Keras 的
Conv2DTranspose
不支持groups
参数。 - 某些算子:有些高级或不常用的算子,可能只有一个或两个框架支持。
省流:写代码前,先查一下目标框架的官方文档,看好参数名叫什么、支持哪些功能。尤其是 Padding 和 data_format,是新手最容易踩的两个坑。
📝 小结
- 布局先行:牢记
NCHW
/NCDHW
与各维意义。
- 掌握卷积/池化公式,一眼能推形状。
//
取整配合 “>0” 约束,确保 SMT 结果一致。
- 参数映射时注意三框架差异,踩坑前先看文档。
省流:布局→公式→约束→映射 四步打通,你的 Tensor 基础就算扎牢啦 📐✨