2879 字
约 9 分钟
15
S23-X11 · 训练与推理的机制面:Self-Attention、Multi-Head 与 RMSNorm 的工程含义

调模型时遇到的"玄学",多数能落回三个可算的量:注意力矩阵的形状、头的切分方式、归一化放在哪。这三样决定显存怎么涨、精度怎么掉、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. 自测

  1. T 从 4096 加到 8192,注意力分数矩阵的显存变成几倍?训练和推理分别受影响吗?
  2. 去掉 1/sqrt(d_head) 缩放,最先出现的可观测信号是什么?
  3. GQA 的 KV 组数从 8 降到 1,省下的显存从哪一项来?质量风险为什么在长文上更明显?
  4. RMSNorm 的 eps 放在 rsqrt 之前和之后,差别在什么输入下才会暴露?
  5. 对比学习训练过的句向量模型,混用池化方式会以什么形式失败?
  6. 想在单卡上微调 7B 模型,QLoRA 省下的是哪部分显存?alpha / r 改动会等价于改什么?
S23-X11 · 训练与推理的机制面:Self-Attention、Multi-Head 与 RMSNorm 的工程含义
https://www.clxhxhhr.top/posts/6009/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。