在实际深度学习项目中,真正决定一个模型能不能学好序列数据的关键点之一,是它如何处理元素之间的依赖关系。Transformer 之所以在自然语言处理、语音、图像甚至时间序列预测中全面替代传统 RNN,核心就是它把“注意力机制”用到了极致,而自注意力又是整套 Transformer 架构的地基。本文围绕“自注意力”展开,先讲清楚它解决什么问题,再拆解数学过程,然后分别用 NumPy 和 PyTorch 实现一个最小可运行模块,最后补充工程中的常见坑、排查路径和优化方向。读者只需要具备基础 Python 和少量深度学习知识,学完就可以在手写代码、源码阅读和模型调试中快速定位问题。
1. 先理解自注意力为什么是 Transformer 的转折点
1.1 序列建模的旧问题:RNN 和 CNN 的边界在哪里
在 Transformer 出现之前,处理序列数据最常用的结构是 RNN(循环神经网络)和 CNN(卷积神经网络)。
RNN 的特点是按时间步依次处理输入。每个时间步都会维护一个隐藏状态,把之前的信息传递下去。这种顺序结构天然适合句子这类时序数据,但短板也很明显:信息要经过很多步才能从序列头部传到尾部。随着序列变长,早期信息要么被遗忘,要么在反向传播时出现梯度消失或梯度爆炸,所以 RNN 很难建模真正长距离的依赖关系。即使后来有了 LSTM 和 GRU,也只是缓解问题,没有彻底解决。
CNN 的思路不同。它通过卷积核在局部窗口内提取特征,可以通过堆叠层数扩大感受野。问题是感受野扩大是间接的,如果要让两个相隔很远的 token 相互作用,必须经过很多层卷积。而且 CNN 更擅长处理局部模式,对全局依赖的建模不够直接。
这两种方案本质上都是“通过有限的信息传递路径,间接建立依赖”。自注意力机制改变了这一点:它让序列中的任意两个位置直接计算相关性,不需要经过中间节点。
1.2 自注意力的核心思想一句话讲清楚
自注意力(Self-Attention)做的事情可以概括成一句话:对于输入序列中的每一个元素,通过计算它与其他所有元素的相关性,来决定应该从其他元素那里聚合多少信息。
这里的“自”字含义是,Query、Key、Value 全部来自同一个输入序列,而不是像传统 Attention 那样,Query 来自一个序列、Key 和 Value 来自另一个序列。在机器翻译中,传统注意力机制让解码器在生成每个词时去“看”编码器输出的所有词;而自注意力让句子内部的每个词去“看”句子内部所有词,从而建模词与词之间的语义关系。
以“银行无法兑付”这句话为例。要理解“银行”在这里是金融机构还是河岸,模型需要结合“兑付”这个上下文。自注意力在计算“银行”的新表示时,会给“兑付”分配较高的注意力权重,从而把相关语义融合进来。
1.3 一个关键的直觉:全局交互取代顺序传递
自注意力最重要的特性是全局感受野。输入序列长度为 n,自注意力一次计算就能让每个位置都和其他 n-1 个位置交互,计算复杂度是 O(n^2),但信息传递路径是 O(1) 级别的直接连接。
这也解释了为什么 Transformer 训练效率高:RNN 必须等前一个时间步算完才能算后一个时间步,无法并行;自注意力的所有位置在计算时相互独立,可以用矩阵运算一次性完成,非常适合 GPU 并行加速。
有一点需要特别说明:自注意力本身不感知位置顺序。在“我打你”和“你打我”中,词的集合一样,自注意力计算出的每一项如果不加位置信息,就会输出相同的表示。所以 Transformer 必须额外加入位置编码,这一点后面会展开讲。
2. 自注意力的数学过程:从初识 Q/K/V 到缩放点积公式
2.1 输入表示与三个投影矩阵
自注意力的输入通常用 X 表示,形状是 [seq_len, d_model]。seq_len 是序列长度,d_model 是每个 token 的特征维度,也就是 Embedding 维度。
为了计算注意力,需要先通过三个可学习的线性投影矩阵,把 X 映射成三个不同的向量序列:
- Query(查询向量):代表当前位置“想找什么”,由 W_Q 投影得到。
- Key(键向量):代表当前位置“能提供什么”,由 W_K 投影得到。
- Value(值向量):代表当前位置“实际携带的内容”,由 W_V 投影得到。
投影可以用 PyTorch 中的 nn.Linear 实现,也可以写成矩阵乘法:
Q = X W_Q K = X W_K V = X W_V
投影后,Q、K、V 通常具有比 d_model 更小的维度,记为 d_k 和 d_v。这样做的目的之一是控制计算量。多头注意力中,每个头的 d_k 往往是 d_model 除以头数。
举一个直观类比。Query 像搜索引擎里输入的检索式,Key 像每个网页的标题和摘要,Value 像网页正文。搜索引擎先计算 Query 和 Key 的相似度,再用相似度作为权重去加权融合 Value 的内容。自注意力就是序列内部自己做了一次这样的检索。
2.2 缩放点积注意力公式与为什么要缩放
计算过程用公式表达是:
Attention(Q, K, V) = softmax(QK^T / sqrt(d_k)) V
拆开来看:
- Q 和 K 的转置做矩阵乘法,得到形状为 [seq_len, seq_len] 的注意力分数矩阵。第 i 行第 j 列的值表示第 i 个位置的 Query 与第 j 个位置的 Key 的相关程度。
- 除以 sqrt(d_k) 进行缩放。
- 在最后一个维度上做 softmax,让每行所有分数变成和为 1 的概率分布。
- 用 softmax 的结果作为权重,对 V 做加权求和,得到输出。
这里最容易被忽略的是为什么除以 sqrt(d_k)。如果两个独立随机变量均值都为 0、方差为 1,那么它们做点积以后,方差会增大到 d_k。也就是说,维度越高,点积值的绝对值越大。当点积值过大时,softmax 函数会进入梯度很小的饱和区域,导致训练时梯度消失。除以 sqrt(d_k) 可以把点积方差重新拉回到 1 附近,让 softmax 的输入保持在梯度合理的区间。这个细节在面试和源码阅读中经常被问到,实际模型训练时也直接影响稳定性。
2.3 掩码:padding mask 和因果 mask
在实际实现中,很少有场景直接把完整的注意力分数交给 softmax,通常要处理两类掩码。
第一类是 Padding Mask。一个 batch 里多个句子长度不一样,短的句子要用填充符号补齐到一样长。这些填充位置不是真实 token,不应该参与注意力计算。做法是在计算 softmax 之前,把填充位置的注意力分数设置为一个非常大的负数,比如 -1e9 或 float('-inf')。经过 softmax 后,这些位置的权重会趋近于 0,相当于模型完全忽略它们。
第二类是因果 Mask(Causal Mask),也叫自回归 Mask。它用于从左到右生成的场景,比如 GPT。在预测第 i 个 token 时,模型不能看到第 i 个 token 之后的未来信息。做法是构造一个下三角矩阵,让第 i 行只能看到前 i 列,其余位置掩码为负无穷。这样每个位置在计算注意力时,只会使用当前位置和它之前的位置。
两种掩码可以叠加使用。常见做法是先把 Padding Mask 和因果 Mask 合并,再一次性应用到注意力分数上。注意掩码必须应用在 softmax 之前,而不是之后。如果在 softmax 之后把某些位置置 0,虽然权重看起来是对的,但 softmax 的和就不再是 1,会破坏概率分布的意义。
2.4 多头注意力为什么比单头更有效
单头注意力只能学习一种“相关性”模式。但真实语言中的相关性模式很多:有的词之间是语法关系,有的词之间是语义指代,有的词之间是位置邻近关系。如果只用一组 Q/K/V,所有关系都会被压缩到同一种投影空间里,建模能力受限。
多头注意力(Multi-Head Attention)的做法是并行使用多组 W_Q、W_K、W_V,每组称为一个头。每个头可以关注不同的关系模式。计算完所有头之后,把所有头的输出拼接起来,再经过一个线性投影,得到最终输出。
有了多头之后,模型的实际观察是:不同头确实会关注不同类型的词。比如有的头倾向于关注句法依赖,有的头倾向于关注指代关系。多头机制提升了模型的表达能力,也是 Transformer 的成功因素之一。
3. 用手写代码跑通一个最小自注意力模块
3.1 环境准备:NumPy 和 PyTorch 两种方式
为了兼顾原理理解和工程复现,这里分别给出 NumPy 和 PyTorch 两个版本。
NumPy 版本适合用来理解计算过程,因为它把每一步都摊开了,可以非常直观地打印中间结果。PyTorch 版本适合实际嵌入到神经网络中使用,因为它带有自动求导,可以参与反向传播训练。
环境建议:
python --version pip install numpy pip install torch如果只是在本地跑通最小示例,CPU 即可,不需要 GPU。PyTorch 安装时注意根据自己的操作系统和 Python 版本选择对应命令。原始材料没有给出具体版本,落地前建议先确认本机 CUDA 环境和 PyTorch 版本兼容性。
3.2 用 NumPy 实现单头自注意力
下面这段代码实现单头自注意力,输入形状是 [seq_len, d_model]。为了便于查看结果,没有添加 batch 维度,核心逻辑一目了然。
import numpy as np def softmax(x, axis=-1): # 减去最大值是为了数值稳定,避免 exp 溢出 x_max = np.max(x, axis=axis, keepdims=True) exp_x = np.exp(x - x_max) return exp_x / np.sum(exp_x, axis=axis, keepdims=True) def self_attention_numpy(X, W_Q, W_K, W_V, d_k=None): """ X: [seq_len, d_model] W_Q: [d_model, d_k] W_K: [d_model, d_k] W_V: [d_model, d_v] """ seq_len, d_model = X.shape d_k = d_k or W_Q.shape[1] Q = X @ W_Q # [seq_len, d_k] K = X @ W_K # [seq_len, d_k] V = X @ W_V # [seq_len, d_v] scores = Q @ K.T # [seq_len, seq_len] scores = scores / np.sqrt(d_k) attention = softmax(scores, axis=-1) output = attention @ V # [seq_len, d_v] return output, attention # 构造一个小例子 np.random.seed(0) seq_len, d_model, d_k, d_v = 4, 8, 4, 4 X = np.random.randn(seq_len, d_model) W_Q = np.random.randn(d_model, d_k) W_K = np.random.randn(d_model, d_k) W_V = np.random.randn(d_model, d_v) output, attention = self_attention_numpy(X, W_Q, W_K, W_V) print("attention shape:", attention.shape) print("output shape:", output.shape) print("attention row sums:", attention.sum(axis=-1))运行后会看到注意力矩阵是 4x4,每一行相加都等于 1。这说明 softmax 后的权重确实是一个概率分布。如果用随机初始化的权重计算,注意力权重会接近均匀分布,这是正常现象。只有经过训练,权重才会有明显的倾向性。
3.3 使用 PyTorch 实现可训练的 SelfAttention 层
PyTorch 版本更适合集成到模型里。这里把它封装成一个 nn.Module,forward 中同时支持可选的 mask 参数。
import math import torch import torch.nn as nn import torch.nn.functional as F class SelfAttention(nn.Module): def __init__(self, d_model, d_k, d_v): super().__init__() self.d_k = d_k self.d_v = d_v self.W_Q = nn.Linear(d_model, d_k) self.W_K = nn.Linear(d_model, d_k) self.W_V = nn.Linear(d_model, d_v) def forward(self, x, mask=None): """ x: [batch_size, seq_len, d_model] mask: [batch_size, seq_len] 或 [batch_size, seq_len, seq_len] """ batch_size, seq_len, _ = x.shape Q = self.W_Q(x) # [batch, seq_len, d_k] K = self.W_K(x) V = self.W_V(x) scores = torch.matmul(Q, K.transpose(-2, -1)) / math.sqrt(self.d_k) # scores: [batch, seq_len, seq_len] if mask is not None: # 假设传入的是 padding mask,形状 [batch, seq_len] # 将形状扩展成 [batch, 1, seq_len] if mask.dim() == 2: mask = mask.unsqueeze(1) scores = scores.masked_fill(mask == 0, float("-inf")) attention = F.softmax(scores, dim=-1) output = torch.matmul(attention, V) return output, attention这里有一个常见的细节:使用masked_fill时,要确保掩码的形状能够广播到 scores 的形状。如果 mask 是 padding mask,应先把 [batch, seq_len] 变成 [batch, 1, seq_len],然后通过广播让每一行都使用同一个掩码。如果掩码位置写反,比如把有效位置置 0、无效位置置 1,模型就会完全学不到有效信息。
3.4 运行示例与结果检查
构造一个 batch 为 2、序列长度不同但已经补齐的输入,运行上面的模块。
torch.manual_seed(42) batch_size, seq_len, d_model, d_k, d_v = 2, 5, 16, 8, 8 x = torch.randn(batch_size, seq_len, d_model) mask = torch.ones(batch_size, seq_len, dtype=torch.long) mask[1, 3:] = 0 # 第 2 个样本后两个位置是 padding model = SelfAttention(d_model, d_k, d_v) output, attention = model(x, mask) print("output shape:", output.shape) print("attention shape:", attention.shape) print("sample attention matrix:") print(attention[0])预期的输出形状为 output: [2, 5, 8],attention: [2, 5, 5]。其中第一个样本没有 padding,所以注意力矩阵每行和为 1。第二个样本由于第 3、4 个位置被掩码,softmax 后这些位置的权重会接近 0,其他位置权重相对变大。
验证掩码是否生效,可以打印第二行样本的注意力矩阵:
print(attention[1])如果掩码正确,第 1 行(真实 token)对第 3、4 列(padding)的注意力权重应该非常接近 0。如果看到这些位置的权重不是 0,说明掩码没有在 softmax 之前生效,需要检查 mask 的维度和填充方式。
注意:验证一个自注意力实现是否正确,不能只看输出形状。还要检查注意力矩阵行和是否为 1,检查被掩码位置的权重是否接近 0,以及随机初始化时注意力是否大致均匀。这些检查能快速定位大多数维度或掩码错误。
4. 自注意力在 Transformer 中的位置与工程实现细节
4.1 从自注意力到 Transformer 编码器
Transformer 编码器不是简单放一个自注意力模块,而是把它和若干辅助模块组合成一个完整子层。一个标准编码器层由以下部分组成:
- 多头自注意力子层
- 残差连接
- 层归一化
- 前馈神经网络子层(FFN)
- 第二个残差连接和层归一化
每个 token 先经过多头自注意力,得到融合全局信息的表示;然后再经过一个两层线性变换加激活函数的前馈网络,进一步增强非线性表达能力。每个子层之后加上残差连接和层归一化,作用是缓解深层网络训练中的梯度传播问题,并稳定每一层的输出分布。
如果用 PyTorch 的 nn.TransformerEncoderLayer,一个编码器层的配置非常简洁:
import torch.nn as nn encoder_layer = nn.TransformerEncoderLayer( d_model=512, nhead=8, dim_feedforward=2048, dropout=0.1, activation="relu", batch_first=True )实际训练中,nhead 必须能被 d_model 整除。因为每个头的维度是 d_model / nhead,如果除不尽,在多头拼接时会引发维度错误。
4.2 位置编码:自注意力天然不感知顺序
前面提到,自注意力是排列等变的。如果输入 X 的顺序改变,输出也只是对应行互换,这导致模型无法区分“我打你”和“你打我”。因此必须在输入中加入位置信息。
常见方案有两种:
- 正余弦位置编码(Sinusoidal Positional Encoding):使用不同频率的正弦和余弦函数生成固定编码。优点是编码是确定性的,并且可以外推到训练时没有见过的序列长度。
- 可学习位置编码(Learned Positional Encoding):把位置索引映射成一组可训练参数,随模型一起训练。BERT 和 GPT 早期常使用这种方式。缺点是最长序列长度在训练前就要确定,超过这个长度需要处理。
正余弦位置编码的公式如下:
PE(pos, 2i) = sin(pos / 10000^(2i / d_model)) PE(pos, 2i+1) = cos(pos / 10000^(2i / d_model))
其中 pos 是位置索引,i 是维度下标。这个编码会被加到 token embedding 上,而不是拼接。为什么可以直接相加?因为模型在训练时会把位置信息和 token 信息一起调整,相加后仍然可以通过后续变换恢复出必要的区分度。相比拼接,相加不增加维度,计算更省。
实际工程中,如果直接使用nn.Transformer,位置编码需要手动加到输入上。很多开源模型已经内置,但了解实现仍然重要,因为换成长序列推理时,外推策略会影响效果。
4.3 层归一化、残差连接与 FFN 的作用
层归一化(Layer Normalization)在 NLP 任务中比批量归一化更常用。原因是文本序列长度不定,batch 维度上统计均值方差会受到 padding 影响,而且训练和推理时 batch 大小可能不一致。层归一化对每个样本的每个 token 的特征维度做归一化,不依赖 batch 里其他样本。
残差连接让梯度的传递路径更短。如果没有残差连接,随着层数加深,输入信息在多层变换中可能被削弱或丢失。加上残差后,每个子层学习的内容可以理解为对输入的“增量更新”,优化难度明显下降。
FFN 通常用两个线性层夹一个激活函数实现:
FFN(x) = max(0, x W1 + b1) W2 + b2
第一层把维度从 d_model 放大到 dim_feedforward,比如从 512 放大到 2048;第二层再投影回 d_model。这个设计的目的是把注意力聚焦之后的结果做一次非线性变换,增强模型的表达能力。不同 token 在 FFN 中是逐个独立计算的,所以 FFN 没有跨 token 交互。
4.4 学习环境与生产环境的实现差异
在本地学习或原型验证时,直接使用nn.MultiheadAttention或nn.TransformerEncoderLayer足够。代码短,逻辑清晰,也能跑通大部分实验。
但在生产环境,尤其是大模型推理场景,直接套这些高层 API 往往不够。需要额外考虑以下问题:
| 对比维度 | 学习环境 | 生产环境 |
|---|---|---|
| 注意力计算 | 标准矩阵乘法 | FlashAttention 等高效实现 |
| 精度 | FP32 或 FP16 | FP16/BF16/INT8 量化 |
| 推理方式 | 每次输入完整序列 | 自回归生成时使用 KV Cache |
| 序列长度 | 训练时固定 | 可能出现长序列外推 |
| 中间结果 | 保留 Attention 矩阵便于调试 | 尽量不保存,节省显存 |
| 框架 API | 高层 nn.Transformer 模块 | 手写内核或使用优化算子库 |
KV Cache 是自注意力推理时最典型的优化手段。生成第 n 个 token 时,前 n-1 个 token 的 Key 和 Value 计算一次后可以缓存下来,不需要每步重复计算。这个优化把自注意力推理复杂度从 O(n^3) 降为 O(n^2),在生成场景中非常关键。
学习环境可以直接保存 attention 矩阵做可视化,生产环境则很少保存。因为 attention 矩阵形状是 [batch, heads, seq_len, seq_len],序列长度越长,显存占用越大,而且它对最终下游任务不一定有直接价值。
5. 自注意力实战中的常见问题与排查路径
5.1 忘记除以 sqrt(d_k),导致梯度不稳定
错误现象:训练初期 loss 一直不降,或者 loss 很快变成 NaN。把注意力分数打印出来,发现数值非常大,softmax 输出接近 one-hot,很多位置梯度接近 0。
根本原因:点积结果随 d_k 增大而增大,softmax 进入饱和区。这种写法在低维时可能不明显,但 d_model 和 d_k 越大,问题越严重。
排查方式:在代码里打印 scores 的均值、方差,或者观察梯度范数。如果 scores 的方差明显大于 1,就要检查是否缺少缩放。
解决方式:在点积后除以 sqrt(d_k),并确认 d_k 使用的是投影后的维度,而不是 d_model。
5.2 Mask 位置错误导致信息泄漏或全部被遮蔽
错误现象:即使训练损失很低,生成文本时却出现重复、逻辑混乱,或者模型似乎“看到了未来”。另一种情况是模型输出很差,attention 矩阵全为 0 或 NaN。
常见原因:
- 把 mask 应用在 softmax 之后,直接赋 0。
- mask 形状不对,广播到 scores 时没有覆盖到所有 batch 和所有 head。
- padding mask 与 causal mask 叠加时没有取并集,导致未来位置仍然可见。
- 布尔值或 0/1 的方向用反,把有效位置遮住了。
排查方式:打印 mask 形状与 scores 形状,检查广播关系;单步调试查看masked_fill之后是否有大量-inf;打印 softmax 后的 attention,确认被 mask 位置的权重是否为 0。
解决方式:先确定 mask 类型,padding mask 和 causal mask 分开构造,再合并。在送入masked_fill前,统一转换为布尔张量或 0/1 张量,并明确 1 表示保留、0 表示遮蔽。
5.3 Q/K/V 的维度与 batch 维度不匹配
错误现象:代码运行时报矩阵乘法维度错误,或者输出 shape 与输入不一致。
常见原因:
- 在多头实现中,把
d_model当成d_k / d_v使用。 batch_first和seq_first不一致。nn.Linear的输入维度写错。- 多头拼接时,
permute和contiguous没有调用,导致view报错。
排查方式:从输入开始逐行打印各阶段 shape,确认 Q/K/V 的形状都是[batch, seq_len, d_k];打印 attention 的形状是[batch, heads, seq_len, seq_len];最后的输出应能还原回[batch, seq_len, d_model]。
解决方式:在 forward 开头记录输入维度,结束时断言输出大小一致。使用torch的维度注释或einops库重排,减少手工 reshape 出错的概率。
5.4 注意力矩阵趋同或完全均匀
错误现象:训练过程中 attention 矩阵每个位置都差不多,或者多个头的行为完全相同。
可能原因:
- 随机初始化时,注意力均匀是正常的。
- 训练后期仍然均匀,可能是学习率太小,模型没有学到有效交互。
- 多个头趋同,可能是投影矩阵初始化方式导致所有头从相同状态开始,或者模型能力不足,只用其中一个头就足够拟合。
- 输入本身没有区分度,比如 padding 比例过高。
排查方式:可视化 attention 矩阵,观察是否有明显的对角 dominance 或头间差异。检查训练 loss 是否持续下降。检查输入中 padding token 是否参与了计算。
解决方式:如果是初始化问题,可以重新设置不同头的初始化;如果 pad 比例过高,优化 batch 构造;如果模型表达能力不够,增大 d_model 或增加层数前,先确认数据质量和训练配置。
以下排查表可以在实际项目里直接参考:
| 问题现象 | 常见原因 | 检查方式 | 处理建议 |
|---|---|---|---|
| loss 不降或 NaN | 注意力分数未缩放 | 打印 scores 方差 | 除以 sqrt(d_k) |
| 模型预测看到未来 | causal mask 失效 | 打印 mask 矩阵 | 用下三角掩码并叠加 padding mask |
| 输出 shape 报错 | d_k/d_v 与 d_model 混淆 | 打印所有中间 shape | 统一维度定义,增加断言 |
| attention 全为 0 | mask 方向写反 | 检查 masked_fill 参数 | 确认 1 表示保留、0 表示遮蔽 |
| 多头行为趋同 | 初始化或表达能力不足 | 可视化多头 attention | 调整初始化或增加容量 |
6. 最佳实践与扩展方向
6.1 可复用的自注意力实现检查清单
每次写完自注意力模块,建议按下面的清单过一遍:
- 输入形状是
[batch, seq_len, d_model],投影后 Q/K/V 维度是d_k/d_v。 - 注意力公式中是否除以
sqrt(d_k)。 - mask 是否在 softmax 之前应用。
- padding mask 和 causal mask 是否合并正确。
- 输出形状是否与期望一致。
- 随机初始化时,attention 矩阵每行和是否为 1。
- 是否在训练第一步用一个小 batch 做冒烟测试,而不是直接开始大模型训练。
- 是否检查过 GPU 显存占用,序列长度超出预期时是否有 OOM 风险。
- 生产环境是否已经考虑 KV Cache、混合精度和算子优化。
这个清单既适合新手排除基础错误,也适合在代码 review 时用来确认实现质量。
6.2 从单头自注意力到多头,再到完整 Transformer
理解单头自注意力之后,建议按这个顺序扩展学习:
- 实现 Padding Mask 和 Causal Mask,并理解 mask 的广播机制。
- 把单头改成多头,掌握
reshape、permute、transpose和contiguous的组合用法。 - 加上位置编码和残差连接,搭建一个编码器层。
- 用一个小型中英文翻译数据集或文本分类任务跑通训练流程。
- 阅读 PyTorch 官方
nn.MultiheadAttention源码,对比自己的实现。 - 最后阅读 GPT 或 BERT 的开源实现,理解自注意力在完整预训练模型中的应用。
不建议一开始就直接手写一个“缝合怪” Transformer。先把自注意力这一个点吃透,后续看官方源码会轻松很多。
6.3 性能优化与显存控制方向
自注意力的复杂度是 O(n^2),序列长度翻倍,显存占用大约翻四倍。实际工程中主要从四个方向优化:
- 算子优化:使用 FlashAttention,它通过分块计算和 Kernel 融合减少显存读写,能够显著降低长序列训练成本。
- 精度优化:FP16/BF16 混合精度训练可以降低显存占用并提升速度,但要注意数值稳定性。FP16 容易溢出的场景,BF16 通常更稳。
- 推理优化:自回归生成时使用 KV Cache,避免重复计算历史 token。
- 结构优化:当序列很长时,考虑稀疏注意力、窗口注意力、线性注意力等变体。它们牺牲部分全局建模能力,换取更低复杂度。
优化之前先做 profiling。不要盲目替换算子。如果序列长度在 512 以内,标准实现配合混合精度通常已经够用;长度超过 2048 之后,FlashAttention 或稀疏注意力才更能体现收益。
6.4 给新手的练习建议
如果只做一件事来巩固自注意力,建议用 NumPy 实现一个带 mask 的因果自注意力,然后用随机输入和 PyTorch 版本做对比,确保输出一致。之后再用一个小文本分类任务跑通一个编码器模型,观察 loss 变化。
这个练习能覆盖的坑足够多:矩阵乘法维度、softmax 稳定性、mask 方向、padding 处理、多头重排、训练稳定性。把这些坑踩一遍,再去看大模型源码,就不会觉得注意力机制只是“一堆矩阵乘法”了。自注意力的核心思想虽然简单,但工程实现中的细节决定了模型能不能稳定训练、能不能高效推理。理解原理后,要继续在源码阅读和实际训练中积累经验,才能把这一块真正掌握。