调模型时遇到的"玄学",多数能落回三个可算的量:注意力矩阵的形状、头的切分方式、归一化放在哪。这三样决定显存怎么涨、精度怎么掉、batch 还能不能拉大。下面按"形状账 → 数值账 → 训练账"三段拆,公式都配代入计算。
1. 形状账:一次 forward 到底存了什么
符号钉死:B 批大小、T 序列长度、d 隐藏维度、h 头数、d_head = d / h。符号错一个,估算就差一个量级。
# 单个样本、单层、bf16(2 字节)的注意力中间量
B, T, d, h = 1, 8192, 4096, 32
d_head = d // h
qk = B * h * T * T * 2 # QK^T 分数矩阵
pv = B * h * T * T * 2 # softmax 后的概率矩阵(如果物化)
print(qk / 2**30, "GiB") # 约 2 GiB,仅一层、单样本
| 张量 | 形状 | bf16 占用(T=8192, d=4096) | 随 T 的关系 |
|---|---|---|---|
q/k/v 投影 |
[B, T, d] |
每份 64 MiB | 线性 |
注意力分数 QK^T |
[B, h, T, T] |
2.0 GiB | 平方 |
| softmax 输出 | [B, h, T, T] |
2.0 GiB(可融合) | 平方 |
| MLP 中间层 | [B, T, 4d] |
256 MiB | 线性 |
| KV cache(推理) | [B, h, T, d_head] × 2 |
每样本 512 MiB | 线性 |
这张表解释了两件事:训练的 OOM 大多来自第二、三行,推理的显存爬升来自最后一行。同一个 T,训练要平方项,推理只要线性项——所以"能推理长上下文"不等于"能训练长上下文"。
2. Self-Attention 的三步与它省不掉的缩放
Self-Attention 只有三步:投影出 Q/K/V、算分数并缩放、softmax 后加权求和 V。
import math, torch
import torch.nn.functional as F
def attention(q, k, v, causal=True):
# q: [B, h, T, d_head]
scale = 1.0 / math.sqrt(q.size(-1)) # 不是可选项
s = q @ k.transpose(-1, -2) * scale # [B, h, T, T]
if causal:
T = q.size(-2)
mask = torch.triu(torch.ones(T, T, dtype=torch.bool, device=q.device), 1)
s = s.masked_fill(mask, float("-inf"))
p = F.softmax(s, dim=-1) # 沿 key 维归一
return p @ v
scale 不是调参技巧,而是数值条件问题:点积方差随 d_head 线性增长,不除 sqrt(d_head),softmax 会饱和成 one-hot,梯度压到接近 0。去掉缩放,训练前期 loss 会长时间不动,这是必然,不是随机种子问题。
| mask 类型 | 作用位置 | 语义效果 | 典型代价 |
|---|---|---|---|
| causal(下三角) | 分数矩阵上三角置 -inf |
自回归,不能看未来 | 训练与推理都必须有 |
| padding mask | key 侧按有效长度 | 忽略补齐 token | 需与 causal 合并,顺序别反 |
| sliding window | 只保留最近 W 个 key |
局部注意力,长文省显存 | 全局信息需要额外通路 |
三种 mask 叠加时最易出的错是先 softmax 再补 mask:全 -inf 行产生 NaN 污染整批。顺序永远是 mask → softmax → 加权。
3. Multi-Head:为什么拆头而不是把维度加宽
Multi-Head 的假设很朴素:一次注意力只能学一种关注方式,那就切 h 份并行学。拆头不增加参数量——h 个头、d_head = d/h,投影总量与单头 d 维相同;买到的是表达多样性,付出的是 kernel 启动次数。
真正的工程分歧在推理侧:KV cache 该存几份。
# KV cache 显存:以 70B 级、bf16、T=8192 为例的数量级推演
layers, d, T, bytes_per = 80, 8192, 8192, 2
mha = layers * 2 * d * T * bytes_per # 每样本
mqa = layers * 2 * 128 * T * bytes_per # 只留 1 组 KV
gqa8 = layers * 2 * (d // 8) * T * bytes_per # 8 组 KV
for name, v in [("MHA", mha), ("GQA-8", gqa8), ("MQA", mqa)]:
print(f"{name}: {v / 2**30:.1f} GiB / sample")
| 变体 | KV 组数 | 相对 KV 显存 | 质量风险 | 适用 |
|---|---|---|---|---|
| MHA | h(每头一份) |
1.0× | 基线 | 训练、小模型 |
| GQA | g(1 < g < h) |
g/h |
很小 | 主流推理部署 |
| MQA | 1 | 1/h |
明显,长文更敏感 | 高吞吐、短请求 |
head_dim 也别随手改:它是 RoPE 的旋转配对单位,必须为偶数;h 调大导致 d_head 过小(如 32)时,注意力带宽不足,loss 会停在比预期更差的平台。
4. RMSNorm:去掉减均值之后,省下的是什么
LayerNorm 做两步:减均值、除标准差。RMSNorm 只做第二步,用均方根代替标准差。
def rms_norm(x, w, eps=1e-6):
# x: [..., d],w: [d]
rms = x.pow(2).mean(dim=-1, keepdim=True).add(eps).rsqrt()
return (x * rms) * w
| 维度 | LayerNorm | RMSNorm |
|---|---|---|
| 是否中心化 | 是(减均值) | 否 |
| 统计量 | 标准差 | 均方根 |
| 每 token 的归约操作 | 两次 | 一次 |
| 对 bias 项的依赖 | 需要 beta | 通常省掉 |
| 数值敏感点 | 均值抵消带来的抵消误差 | eps 与 dtype 精度 |
eps 的位置有讲究:add(eps).rsqrt() 在 x 全零时给出有限值,rsqrt().add(eps) 会炸成 inf。按具体实现核对。
另一个被忽略的点是归一化的位置。pre-norm(x + Attn(Norm(x)))让残差路径恒等,深堆叠更稳,代价是末层多一次 norm;post-norm 更难训但推理少一层。判定信号直接:post-norm 在 depth > 24 时常需要 warmup 才不炸。
5. 从 Word2Vec 到上下文向量:静态表示在哪还有位置
Word2Vec 给每个词固定向量,与上下文无关,问题不是过时,而是无法表达一词多义。今天的 token 表示上下文化,但"把一段文本压成一维向量"仍要在池化层做决定。
| 表示方式 | 词/句向量来源 | 一词多义 | 典型用途 |
|---|---|---|---|
| Word2Vec(CBOW/Skip-gram) | 查表,上下文窗口平均 | 不支持 | 词级相似、特征工程 |
| 上下文化隐状态 | 最后一层 hidden | 支持 | 分类、抽取 |
| 池化句向量 | mean / cls / last |
随模型而定 | 检索、聚类 |
| 双塔对比学习向量 | 对比目标训练 | 支持 | 大规模召回 |
做检索时池化选错的影响常大于换模型:mean 对长文本更稳,cls 对短查询更敏感,对比学习过的模型必须用训练时的池化方式,混用掉点且不报错,只表现为召回莫名变差。
6. Pre-training 与 Fine-tuning 的分工
| 阶段 | 数据形态 | 目标 | 学到什么 | 成本量级 |
|---|---|---|---|---|
| Pre-training | 无标注语料,万亿 token | 下一个 token 预测 | 语言与世界知识 | 千卡·天 |
| 监督微调(SFT) | 指令-回答对,万到百万条 | 条件生成 | 听话、格式 | 单机多卡·小时到天 |
| 偏好对齐 | 成对偏好数据 | 排序损失 | 有用/无害倾向 | 与 SFT 同量级 |
| 参数高效微调(LoRA) | 同 SFT | 低秩增量 | 领域适配 | 单卡·分钟到小时 |
判据:知识不足靠 RAG 补,行为不对靠微调改。用微调灌知识通常得到"能背不会用",且新旧知识冲突。
7. QLoRA:把显存账算清楚再动手
QLoRA = 基座量化到 4bit + 只训练低秩适配器,收益全在冻结权重的显存上。
from transformers import BitsAndBytesConfig
bnb = BitsAndBytesConfig(
load_in_4bit=True,
bnb_4bit_quant_type="nf4", # nf4 比 fp4 在正态权重上更贴
bnb_4bit_compute_dtype="bfloat16", # 计算仍用 bf16
bnb_4bit_use_double_quant=True, # 二次量化,再省约 0.4 bit/参数
)
# LoRA 侧:只对 q_proj/k_proj/v_proj/o_proj 挂适配器
lora = dict(r=16, lora_alpha=32, lora_dropout=0.05,
target_modules=["q_proj", "k_proj", "v_proj", "o_proj"])
| 方案 | 权重显存(7B 量级) | 可训练参数 | 优化器状态 | 备注 |
|---|---|---|---|---|
| 全参 bf16 | 约 14 GB | 100% | 约 84 GB(Adam) | 需要分片 |
| LoRA bf16 | 约 14 GB | 约 0.1%–1% | 很小 | 冻结权重仍占满 |
| QLoRA 4bit | 约 3.5–4 GB | 约 0.1%–1% | 很小 | 单卡可行 |
r 与 alpha 的关系要按比例看:有效缩放约等于 alpha / r。改 r 而不改 alpha,等于顺手改了学习率,这是"调 LoRA 越调越差"的常见起因。
8. Zero-shot 与 Test-time:同一个模型的不同用法
| 用法 | 是否给示例 | 是否额外算力 | 适用 | 失效信号 |
|---|---|---|---|---|
| Zero-shot | 否 | 无 | 任务定义清晰、格式简单 | 输出格式不稳 |
| Few-shot | 是(k 条) | 输入变长 | 边界模糊、需要风格对齐 | k 增大但收益饱和 |
| Test-time 扩展 | 可选 | 多倍采样/多轮自检 | 有明确判据的难题 | 票数增加但一致率不升 |
Zero-shot 的可靠性主要由任务描述的可判定性决定,不是模型大小。判据写不清,加示例只是教它模仿表面格式。
9. 排错:机制层的六种现场
| 现象 | 机制原因 | 处理 |
|---|---|---|
| loss 前几百步不动,随后突降 | 缩放缺失或 warmup 过短,softmax 饱和 | 补 1/sqrt(d_head),加线性 warmup |
| 长序列 OOM,短序列正常 | 分数矩阵平方项 | 换 flash-attention 类融合核,或切块计算 |
出现 NaN 且整批污染 |
全 -inf 行 softmax |
先 mask 后 softmax;加 -1e9 而非 -inf |
| 精度掉了但 loss 曲线正常 | 归一化 eps 与 dtype 不匹配 |
用 bf16 计算归一化,或调大 eps |
| 推理并发上不去 | KV cache 线性项吃满 | 换 GQA 模型,或降 T 上限 |
| 换池化方式后召回骤降 | 训练/推理池化不一致 | 按模型训练配置核对池化与指令前缀 |
10. 边界:机制解释不到的地方
| 问题 | 该看什么 | 为什么机制层给不出答案 |
|---|---|---|
| 事实错误 | 检索链路、数据时效 | 注意力形状与事实性无直接映射 |
| 指令不服从 | 提示结构、对齐数据 | 属于行为层,不是表示层 |
| 领域术语不懂 | 词表覆盖、领域语料 | tokenization 与预训练分布问题 |
| 输出格式不稳 | 解码约束、结构化输出 | 采样与语法约束才是主因 |
| 成本过高 | 请求路由、缓存 | 需要系统层账,不是单模型账 |
11. 自测
T从 4096 加到 8192,注意力分数矩阵的显存变成几倍?训练和推理分别受影响吗?- 去掉
1/sqrt(d_head)缩放,最先出现的可观测信号是什么? - GQA 的 KV 组数从 8 降到 1,省下的显存从哪一项来?质量风险为什么在长文上更明显?
- RMSNorm 的
eps放在rsqrt之前和之后,差别在什么输入下才会暴露? - 对比学习训练过的句向量模型,混用池化方式会以什么形式失败?
- 想在单卡上微调 7B 模型,QLoRA 省下的是哪部分显存?
alpha / r改动会等价于改什么?