深度学习的所有复杂操作,最终都可以追溯到对单个数值的加减乘除。就像再宏伟的建筑也是由一块块砖砌成的——本模块就是带你认识每一块"砖"。
📓 配套 Jupyter Notebook: module-01-notebook.ipynb — 40 个 cell,边读边跑,代码验证每一个概念。建议先读本文档建立认知框架,再打开 Notebook 逐 cell 运行。
- 说出 7 种 BinaryOp 及其在 LLM 中的具体应用场景
- 理解广播 (Broadcasting) 的规则,一眼判断两个张量能否广播
- 理解梯度与反向传播的基本原理——深度学习为什么能"学习"
- 画出 ReLU / GELU / Swish 的函数图像,讲出它们的故事
- 对比 Sigmoid 和 Softmax——一个做"独立判断",一个做"联合决策"
- 解释五种归一化的核心差异——为什么现代 LLM 集体投奔 RMSNorm
- 判断一个算子是 memory-bound 还是 compute-bound(背下 roofline 拐点!)
- 理解 SIMD 为什么能让逐元素算子"一次处理 16 个"
在深入到具体算子之前,必须先理解整个深度学习的"发动机"是怎么转起来的。如果只记住一件事,记住这个:模型通过梯度来学习,梯度告诉每个参数该往哪个方向调整。
深度学习的"学习"其实就是一个不断试错、不断修正的过程:
1. 前向传播: 输入 x → 模型(参数W) → 预测 y_pred
2. 计算损失: Loss = 衡量(y_pred, 真实值 y_true) — "你错得有多离谱?"
3. 反向传播: 计算 Loss 对每个参数的梯度 — "每个参数对错误负多大责任?"
4. 更新参数: W_new = W_old − lr × 梯度 — "朝减少错误的方向调一调"
5. 重复 1-4: 直到 Loss 足够小
你站在一座山上(山的高度 = 损失函数的值,越低越好)
你想下山(减少损失)
梯度告诉你:往哪个方向走,下山最快
梯度 > 0: "往这边走高度会增加" → 反方向走!
梯度 < 0: "往这边走高度会降低" → 继续走!
梯度 ≈ 0: "这儿是平的" → 不知道该往哪走(梯度消失!)
数学上:对于函数 f(x₁, x₂, ..., xₙ),梯度是一个向量:
∇f = [∂f/∂x₁, ∂f/∂x₂, ..., ∂f/∂xₙ]
每个分量 ∂f/∂xᵢ 回答一个问题:
"如果我把 xᵢ 调大 0.0001,f 会变大还是变小?变多少?"
一个具体到不能再具体的例子:
假设 Loss = (预测值 − 3)²,而预测值 = w × 2
即 Loss = (2w − 3)²
∂Loss/∂w = 2(2w−3) × 2 = 4(2w−3)
如果 w=0: Loss=9, 梯度=4(0−3)=−12
→ 梯度为负 → 增大 w 能减小 Loss
→ w = 0 − 0.01×(−12) = 0.12 ← 果然变大了!
如果 w=3: Loss=9, 梯度=4(6−3)=12
→ 梯度为正 → 减小 w 能减小 Loss
→ w = 3 − 0.01×12 = 2.88 ← 果然变小了!
如果 w=1.5: Loss=0, 梯度=4(3−3)=0
→ 梯度为零 → 已经到最低点了!
💡 关键直觉:梯度不告诉你"最小值在哪",只告诉你"现在该往哪走一步"。所以需要反复迭代——每一步都根据当前位置重新计算梯度。
神经网络是一层一层叠起来的:y = f₃(f₂(f₁(x)))。最后一层的 Loss 怎么影响第一层的参数?靠链式法则:
∂Loss/∂W₁ = ∂Loss/∂y × ∂y/∂h₂ × ∂h₂/∂h₁ × ∂h₁/∂W₁
└────── 梯度从输出层"接力"传回输入层 ──────┘
反向传播 (Backpropagation) 的本质:
1. 前向: 从输入算到输出,每层记下中间结果(为反向做准备)
2. 反向: 从输出往回传梯度,每层用链式法则乘上自己的导数(雅可比矩阵)
为什么叫"反向传播"? 因为梯度的计算方向和数据的前向流动方向相反:
前向: x → W₁ → h₁ → W₂ → h₂ → W₃ → y → Loss
反向: Loss → ∂L/∂y → ∂L/∂W₃ → ∂L/∂h₂ → ∂L/∂W₂ → ∂L/∂h₁ → ∂L/∂W₁
└─────────────── 梯度往回传 ──────────────────────┘
每层的梯度都要乘上激活函数的导数。导数 = 0 → 梯度在此中断:
反向传播经过 ReLU:
grad × ReLU'(x)
ReLU'(−5) = 0 ← 断了!前面的层收不到任何梯度!
ReLU'(3) = 1 ← 正常通过
反向传播经过 Swish:
grad × Swish'(x)
Swish'(−5) ≈ 0.007 ← 很小,但不为 0 → 信号微弱但没断!
Swish'(3) ≈ 0.95 ← 几乎全通过
这就是为什么:
- ReLU 可能"杀死"神经元:一旦某个神经元对所有输入都输出负值,梯度永远为 0,参数再也无法更新
- Swish/GELU 更稳定:即使 x<0,也保留微弱梯度,"死"不掉
- 这也是为什么激活函数的设计如此重要——它不只是"加非线性",它决定了梯度能否顺畅流动
SGD (最简单的更新规则):
W_new = W_old − learning_rate × ∇W
└─ 学习率,控制"步子多大"
为什么需要 learning_rate(学习率)?
梯度只告诉你"当前位置的坡度"——
你站在半山腰,只看到周围几米的地形,看不到整座山。
学习率太大 → 可能直接跳过最低点 → 来回震荡
学习率太小 → 走太慢 → 训练到天荒地老
学习率合适 → 平稳快速收敛
这是深度学习最重要的超参数,没有之一。
两个张量逐元素做运算——这是所有复杂操作的最小可分解单元。
| 操作 | 公式 | 在 LLM 中干啥 |
|---|---|---|
| Add | a + b | 残差连接("跳过几层直接加过来")、偏置加法 |
| Sub | a − b | 残差计算("和原来差了多少") |
| Mul | a × b | 门控的灵魂——SwiGLU 的 gate×up、注意力权重×Value |
| Div | a / b | 归一化中的除法、缩放 |
| Pow | a^b | RoPE 的频率计算 |
| Max | max(a, b) | 最大池化 |
| Min | min(a, b) | 截断、Clip |
💡 Mul 是门控机制的基石。从 LSTM 的遗忘门 (
f_t ⊙ c_{t-1}) 到 SwiGLU 的Swish(gate) ⊙ up,所有"选择性放行"的操作都建立在逐元素乘法之上。一个操作数充当门控信号(0~1 范围),另一个充当被控信息。理解了这一点,GLU 家族的所有变体就不神秘了——它们只是在"门控信号的生成方式"上做文章。
两个张量形状不完全一样时,框架会自动"扩展"较小的张量。这件事你天天在用,但你真的理解规则吗?
规则只有一句话:从最后一个维度向前比较,每个维度要么相等,要么其中一个为 1。
张量 A: [B, C, 1, 1] ← C 个标量,每个在 (1,1) 的空间位置上
张量 B: [B, 1, H, W] ← 一张 H×W 的图,通道维度是 1
广播后: [B, C, H, W] ← 标量被"涂满"整张图,图被"复制"到每个通道
逐维度检查:
轴0: B vs B → 相等 ✓
轴1: C vs 1 → 1 扩展为 C ✓ (A 的每个标量被复制 H×W 次)
轴2: 1 vs H → 1 扩展为 H ✓ (B 的每行被复制 C 次)
轴3: 1 vs W → 1 扩展为 W ✓
更多实例——练到一眼判断:
✅ 能广播的:
[3, 1, 4] + [1, 5, 4] → [3, 5, 4] 轴0:3vs1→3 ✓ 轴1:1vs5→5 ✓ 轴2:4vs4→4 ✓
[2, 3] + [3] → [2, 3] 从最后对齐: 3vs3→3, 前面2直接保留
[5, 1] + [1, 6] → [5, 6] 轴0:5vs1→5 ✓ 轴1:1vs6→6 ✓
[2, 3, 1] + [1, 1, 5] → [2, 3, 5] 轴0:2vs1→2 ✓ 轴1:3vs1→3 ✓ 轴2:1vs5→5 ✓
❌ 不能广播的:
[3, 4] + [2, 4] → ❌ 轴0: 3 vs 2, 都不为1
[3, 4] + [3, 5] → ❌ 轴1: 4 vs 5, 都不为1
[2, 3, 4] + [5, 4] → ❌ 从最后对齐: 3 vs 5, 都不为1
💡 记忆技巧: 从右往左逐维对齐,像做竖式加法:
[2, 3, 1, 5]
[1, 1, 5] ← 5=5匹配, 1vs1可扩, 3vs1可扩 → ✅
[2, 3, 4]
[5, 4] ← 4=4匹配, 但3vs5都不为1 → ❌
广播在 LLM 中的应用:
场景 1: LayerNorm 的 gamma 参数
x: [batch, seq_len, hidden_dim]
gamma (γ): [hidden_dim]
→ 自动广播为 [1, 1, hidden_dim] → 每个 hidden 维度的 γ "涂满"整个 batch 和 seq
场景 2: 注意力掩码
mask: [seq_len, seq_len]
→ 需要广播到: [batch, num_heads, seq_len, seq_len]
→ 在前两个维度自动扩展,无需手动复制
场景 3: 位置编码
pos_embed: [seq_len, hidden_dim]
token_embed: [batch, seq_len, hidden_dim]
→ 位置编码在 batch 维自动扩展后相加
💡 在深度学习中,BN 的 γ/β(形状 [C])广播到 [B,C,H,W]、注意力掩码([seq,seq])广播到 [batch,heads,seq,seq]——这些都是广播在默默工作。你不需要手动
expand,框架帮你做了。
一元运算(每个元素独立变换):
| 操作 | 公式 | 在哪出现 |
|---|---|---|
| Exp | eˣ | Softmax 的核心——把任意实数变成正数 |
| Sqrt | √x | RMSNorm 的均方根 |
| Log | ln(x) | 交叉熵损失——"惩罚"错误的高置信度预测 |
| Floor/Round | ⌊x⌋ / round(x) | 量化取整——FP32→INT8 的关键步骤 |
归约运算(多个值合并为一个):
| 操作 | 公式 | 本质 |
|---|---|---|
| Sum | Σxᵢ | Softmax 的分母——把所有 exp 值加起来 |
| Mean | Σxᵢ/n | GlobalAvgPool——把整张特征图压缩为一个数 |
| Max | max(xᵢ) | Softmax 的数值稳定技巧——先减 max 再 exp |
| L2Norm | √(Σxᵢ²) | 权重衰减——让权重不要太大 |
💡 归约是"从多到少"的核心操作。在 GPU 上做归约比 CPU 复杂——归约要求线程间通信,GPU 的几千个线程并不擅长互相等待。高效的 GPU 归约用的是树形归约(tree reduction),每轮两两合并,O(log n) 轮完成而非 O(n)。
这是神经网络参数化的核心——80%+ 的计算量都在这里。
MatMul: C = A × B 最通用的矩阵乘法 [M,K] × [K,N] → [M,N]
GEMM: C = α·A·B + β·C 加上了缩放和累加——更灵活
InnerProduct: y = Wx + b GEMM 的特例——封装了权重和偏置
三者的关系: InnerProduct ⊂ GEMM ⊂ MatMul
几乎每一个带可学习参数的操作最终都落到矩阵乘法上——卷积用 im2col 变成 MatMul、Attention 的 QKV 投影是 MatMul、MLP 那三层是 MatMul、最后的 Projection 也是 MatMul。
# 矩阵乘法: [M,K] × [K,N] → [M,N]
torch.einsum('ij,jk->ik', A, B)
# Attention Q·K^T: [B,H,Q,D] × [B,H,K,D] → [B,H,Q,K]
torch.einsum('bhqd,bhkd->bhqk', Q, K)
# 逐通道缩放: [B,C,1,1] × [B,C,H,W] → [B,C,H,W]
torch.einsum('bc,bchw->bchw', scale, x)规则:消去重复字母 → 保留未出现字母。'ij,jk->ik' 中 j 重复了→消去,i 和 k 保留→输出 ik。
如果没有激活函数,无论多少层,从输入到输出永远只是一个线性变换——W_n(...(W_1·x)) = W_effective·x。100 层 = 1 层。激活函数的非线性打破了这种"层数的幻象"。
反向传播角度(联系 0.4 节):激活函数的导数 = 梯度的"中继器"。ReLU 的中继器在 x<0 时直接断电;Swish/GELU 的中继器永远有微弱信号通过。这就是"死亡 ReLU"的根源——好的激活函数必须在大部分输入范围上有非零梯度。
ReLU (2012): max(0, x) ← 硬截断, 计算最快, 但有死亡区
↓
LeakyReLU (2015): x>0 ? x : 0.01x ← "给负值区开一条小缝"
↓
GELU (2018): x·Φ(x) ← "概率门控", BERT/GPT 标准
↓
Swish/SiLU (2017): x·σ(x) ← "自门控", LLaMA/Qwen 标准
↓
SwiGLU (2022): Swish(xW₁) ⊙ (xW₂) ← "显式门控 MLP", 现代 LLM 标配
ReLU(x) = max(0, x)
导数: x>0 → 1; x≤0 → 0
优点: 一个 max 操作搞定,快到不能再快
缺点: x≤0 时梯度为 0 → 神经元可能"永久死亡"
直觉: 像一个只有"开"和"关"两档的电源开关。
x>0: 通电,信息畅通
x≤0: 断电,信息归零,梯度也归零
GELU(x) = x · Φ(x) (Φ = 标准正态 CDF,近似: 0.5x(1+tanh(√(2/π)(x+0.044715x³))))
直觉: 不像 ReLU 的问"x>0?(是/否)",
GELU 问的是"x>0 的概率有多大?"
x=−2: Φ(−2)≈0.02 → GELU≈−0.04 "基本确定是错的,几乎全关"
x=0: Φ(0)=0.5 → GELU=0 "不确定,对半分"
x=2: Φ(2)≈0.98 → GELU≈1.96 "很确定是对的,几乎全过"
这是一种"概率门控"——模型根据对输入的"信心"来决定放行多少。
Swish(x) = x · σ(x) = x / (1 + e⁻ˣ)
直觉: x 同时扮演"信息"和"自己的门控信号"两个角色
x=5: σ(5)≈0.99 → Swish≈4.95 "有话说,大声说"
x=0.5: σ(0.5)≈0.62 → Swish≈0.31 "不太确定,小声点"
x=−3: σ(−3)≈0.05 → Swish≈−0.14 "不对,基本闭嘴"
与 GELU 几乎完全等价(β=1 时),但计算更简单——Sigmoid 比正态 CDF 好算。
代表: LLaMA, Qwen, Mistral——所有主流开源 LLM。
SwiGLU(x) = Swish(x·W_gate) ⊙ (x·W_up) · W_down
为什么中间维度是 3072 (3×1024) 而非 4096 (4×1024)?
标准 ReLU-FFN: W1(1024×4096) + W2(4096×1024) = 2×4=8 个"参数块"
SwiGLU: gate(1024×3072) + up(1024×3072) + down(3072×1024) = 3×3=9 块
→ 总参数量接近,但 SwiGLU 多了一层门控 → 精度显著更好
直觉: gate 分支产生"放行多少"的软门控,up 分支携带信息本身
gate⊙up = "对 up 的每个维度,根据置信度选择性表达"
| 对比 | Sigmoid | Softmax |
|---|---|---|
| 决策方式 | 逐元素独立判断 | 整个向量联合决策 |
| 多分类 | 各类独立(可以有多个高概率) | 互斥选择(概率和=1,只能选一个) |
| 典型场景 | 二分类、LSTM 门控 | 多分类、注意力权重 |
| 输出范围 | 每个值独立在 (0,1) | 所有值加起来 = 1 |
💡 一句话:Sigmoid 是"这图里有猫吗?有狗吗?"(可以同时 yes)。Softmax 是"这图是猫还是狗还是鸟?"(只能选一个)。
| 场景 | 推荐 | 原因 |
|---|---|---|
| CNN 分类 | ReLU | 简单有效,计算快 |
| Transformer (BERT系) | GELU | 平滑门控,训练稳定 |
| 现代 LLM MLP | SwiGLU | 门控+平滑,当前最佳 |
| 移动端部署 | HardSwish | 无 exp 运算,省计算 |
| LSTM/GRU 门控 | Sigmoid | 输出天然在 (0,1) |
训练深层网络时,前面层的参数一更新,后面层接收到的输入分布就变了——就像射击训练中靶子在不停移动,射手需要不断重新适应。这导致:学习率要更小(慢)、初始化要更谨慎(烦)、深层容易梯度消失(痛)。
归一化相当于把靶子钉死在同一个位置——每层输入被拉到均值 0、方差 1 附近。
N (batch) C (channel) H,W (spatial) 适用场景
BatchNorm: ────────── ✗ 做归一化 ✗ 做归一化 CNN (大batch)
LayerNorm: ✗ 每个独立 ─────────── ─────────── Transformer
RMSNorm: ✗ 每个独立 ─────────── ─────────── 现代 LLM
GroupNorm: ✗ 每个独立 分组内做 ─────────── 扩散模型/小batch
InstanceN: ✗ 每个独立 每通道独立 ─────────── 风格迁移
训练: y = (x − μ_batch) / √(σ²_batch + ε) · γ + β
推理: y = (x − running_mean) / √(running_var + ε) · γ + β
↑ 推理时用训练累积的统计量,不依赖 batch
致命弱点: batch size 小了统计量不准 → 效果差
Transformer 通常 batch 不大 → BatchNorm 不适用
推理时的魔法:Conv-BN 融合
BN 在推理时退化为线性函数(μ 和 σ 是常数):
BN(x) = γ·(x−μ)/√(σ²+ε) + β = (γ/√(σ²+ε))·x + (β − γ·μ/√(σ²+ε))
= α·x + b' ← 一个简单的线性变换!
融合进卷积: W' = α·W, b_new = α·b + b'
→ 模型加载时一次性算好,推理时完全零 BN 开销
LayerNorm: y = (x − mean(x)) / √(var(x) + ε) · γ + β
RMSNorm: y = x / √(mean(x²) + ε) · γ
精简了什么?
- 去掉均值减法 (x−mean) → 省一次归约
- 去掉偏置项 β → 省一半参数
代价?精度损失 < 0.1%,几乎可以忽略。
为什么 LLaMA/Qwen/Mistral 全选 RMSNorm?
28 层模型: 每层省 1 次归约 + 1 次减法 + 1 次加法 + 一半参数
→ 累计省 ~30% 归一化计算量 + 50% 归一化参数量
→ 在万亿 token 训练中,这省的时间和算力是天文数字
为什么 [1024,1024] 的 MatMul (8.6G FLOPs) 可能比 [1024,1024] 的 Add (1M FLOPs) 快?
Add:
计算: 1M 次加法 ≈ 0.001ms
内存: 读 4MB + 写 4MB ≈ 0.1ms
→ 99% 时间在等内存!瓶颈在带宽。
MatMul:
计算: 8.6G FLOPs ≈ 8ms
内存: 读 16MB + 写 4MB ≈ 0.3ms
→ 96% 时间在计算。瓶颈在算力。
结论: 算得慢不可怕,等得慢才可怕。
许多"轻量"算子(Add/ReLU/Norm)反而是最慢的——
因为它们几乎不做计算,纯在搬数据。
算力天花板 (TFLOPS)
┃
计算密集区 ┃ ← MatMul, 大Conv, Prefill
╲ ┃
╲ ┃
─────────╲────────┃────── ← 内存带宽天花板
╲ ┃
内存密集区 ╲ ┃ ← Add, ReLU, RMSNorm, Decode
╲ ┃
────┸────→ 计算强度 (FLOPs/Byte)
计算强度 = 总FLOPs / 总内存访问字节数
A100 roofline 拐点: ~150 FLOPs/Byte
< 150: memory-bound → 优化布局/融合/压缩
> 150: compute-bound → 优化算法/更大batch/TensorCore
Qwen3 decode GEMM (batch=1): ~0.5 FLOPs/Byte → 严重 memory-bound!
Qwen3 prefill GEMM (batch=512): ~150 FLOPs/Byte → compute-bound!
标量: for i in range(N): c[i] = a[i] + b[i]
SIMD: c[0:16] = a[0:16] + b[0:16] ← 一条指令同时处理 16 个数!
不同架构的 SIMD 宽度:
ARM NEON: 4 个 FP32/指令
x86 AVX2: 8 个 FP32/指令
x86 AVX-512: 16 个 FP32/指令
CUDA warp: 32 线程同时执行
ncnn 的做法: 每个算子写 4 个版本
- 通用 C++ (fallback,任何 CPU 都能跑)
- ARM NEON (手机,4路并行)
- x86 AVX2 (PC,8路并行)
- Vulkan shader (跨平台 GPU)
CPU 内存层级 (从近到远,从快到慢):
L1 Cache: ~1ns, 32KB/core — 寄存器级速度
L2 Cache: ~4ns, 256KB/core — 核心专属
L3 Cache: ~12ns, 16MB/共享 — 所有核心共享
DDR RAM: ~100ns, 16GB+ — 主内存
SSD: ~100μs, 512GB+ — 磁盘(慢 10 万倍!)
关键数据:
从 RAM 读一个数的时间 ≈ 做 100 次 FP32 乘法的时间
从 SSD 读一个数的时间 ≈ 做 100,000 次 FP32 乘法的时间
→ "少搬数据"比"少做计算"更重要(对于 memory-bound 算子)
→ Tiling(分块)的本质:让数据在更快的层级中被反复使用
SGD: W = W − lr·∇W (最简单)
Momentum: v = μ·v + ∇W; W = W − lr·v (累积历史梯度,减少震荡)
Adam: m = β₁·m + (1−β₁)·∇W (一阶矩估计)
v = β₂·v + (1−β₂)·∇W² (二阶矩估计)
W = W − lr·m̂/(√v̂+ε) (自适应学习率)
AdamW: 同 Adam,但权重衰减从梯度中解耦 (LLM 训练标准)
现代 LLM 训练标配: Warmup + Cosine
lr
^ ╱╲
│ ╱ ╲ ← 余弦曲线
│ ╱ ╲
│ ╱ ╲
│ ╱ ╲
└───╱──────────╲───→ steps
warmup cosine decay
Warmup (前 2000~5000 steps):
线性从小 lr 增长 → 防止初期梯度爆炸
Cosine Decay:
按余弦曲线平滑降到 lr_min → 精细收敛
打开 module-01-notebook.ipynb,按以下路线边读边跑:
| 练习 | 内容 | Notebook Cell | 验证目标 |
|---|---|---|---|
| 1. 手算梯度 | Loss=(wx-3)^2 5步 SGD |
§4 (Cell 25-27) | w 收敛到 1.5 |
| 2. 广播正反例 | [3,1,4]+[1,5,4] 等 |
§1.2 (Cell 8-11) | 读懂每个 shape 变化 |
| 3. 激活函数 | 6条曲线 + 负值区对比 | §2 (Cell 14-16) | ReLU=0 vs Swish=-0.14 |
| 4. 手写归一化 | BN/LN/RMSNorm vs PyTorch | §3 (Cell 22-23) | 误差 < 1e-6 |
| 5. 死亡 ReLU | 全负输入,ReLU vs Swish | §4.1 (Cell 29) | ReLU DEAD, Swish alive |
| 6. Roofline | Q投影 GEMM, B=1~512 | §5 (Cell 31-32) | B=1 memory-bound |
| 7. 优化器对比 | SGD/Momentum/Adam 路径图 | §6 (Cell 34) | Adam 最快 |
| 8. BN 融合推导 | Conv+BN 融合手算 | 纸笔练习 | W'=αW, b_new=αb+β' |
建议顺序:先读本文档建立认知 → 开 Notebook 跑对应 cell → 改参数观察变化 → 回文档巩固。
下一模块: Module 2: 构建算子