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 基础就算扎牢啦 📐✨