注意力 · CausalSelfAttention 逐行拆解
一行不跳地读完 nanoGPT 的因果自注意力——qkv 合并投影、多头拆分、缩放点积、因果掩码、Flash Attention 与手写版的对照,把 GPT 里最关键也最容易被示意图糊弄过去的部分彻底讲透。
注意力 · CausalSelfAttention 逐行拆解
到了。这是整个 GPT 里最关键的一块,也是最容易被「一张示意图」糊弄过去的一块。这一章我们一行都不跳,把 CausalSelfAttention 从头到尾啃下来。
先用一句话说清它要干的事:
在预测「下一个 token」时,当前位置该重点参考前文里的哪些位置?注意力就是算这个「参考权重」、并按权重把前文信息汇总过来的机制。
举个例子:读到「小明把钥匙忘在了车里,他回去拿」,要理解「他」指谁,模型得把注意力大部分投给「小明」。注意力机制,就是让每个位置学会「该回头看谁、看多重」。
__init__:先把零件备齐
class CausalSelfAttention(nn.Module):
def __init__(self, config):
super().__init__()
assert config.n_embd % config.n_head == 0
# key, query, value projections for all heads, but in a batch
self.c_attn = nn.Linear(config.n_embd, 3 * config.n_embd, bias=config.bias)
# output projection
self.c_proj = nn.Linear(config.n_embd, config.n_embd, bias=config.bias)
# regularization
self.attn_dropout = nn.Dropout(config.dropout)
self.resid_dropout = nn.Dropout(config.dropout)
self.n_head = config.n_head
self.n_embd = config.n_embd
self.dropout = config.dropout
# flash attention make GPU go brrrrr but support is only in PyTorch >= 2.0
self.flash = hasattr(torch.nn.functional, 'scaled_dot_product_attention')
if not self.flash:
print("WARNING: using slow attention. Flash Attention requires PyTorch >= 2.0")
# causal mask to ensure that attention is only applied to the left in the input sequence
self.register_buffer("bias", torch.tril(torch.ones(config.block_size, config.block_size))
.view(1, 1, config.block_size, config.block_size))assert config.n_embd % config.n_head == 0:嵌入维度必须能被头数整除。因为一会儿要把n_embd维平均切成n_head份,每头分n_embd / n_head维,除不尽就没法切。self.c_attn = nn.Linear(n_embd, 3 * n_embd):一个矩阵,一次算出 Q、K、V 三样。你可能以为要三个投影,这里却把它们拼成一个宽三倍的线性层——一次矩阵乘法比三次快,算完再切开。这是很典型的工程优化。self.c_proj = nn.Linear(n_embd, n_embd):注意力算完后的输出投影,把汇总来的信息再过一层线性变换。- 两个
Dropout:attn_dropout作用在注意力权重上,resid_dropout作用在输出上,都是抗过拟合用(默认dropout=0时是空操作)。 self.flash = hasattr(...):检查当前 PyTorch 有没有内置的scaled_dot_product_attention(即 Flash Attention,PyTorch ≥ 2.0 才有)。有就用它,快得多。- 只有没有 Flash 时,才用
register_buffer造一个因果掩码bias——这就是让模型「只能看过去」的关键道具,下面细讲。
Q、K、V 是什么? 一个常用的类比:每个 token 都发出一个「查询 Query(我想找什么)」、亮出一个「钥匙 Key(我是什么)」、并携带一份「内容 Value(我能提供什么信息)」。某个位置的 Query 去和所有位置的 Key 比对,匹配度高的,就多取一点那个位置的 Value。注意力,本质就是这样一场「按匹配度加权取信息」的过程。
forward:数据真正流动起来
前向传播是这一章的核心,我们切成四步。开头先记住三个维度符号:
def forward(self, x):
B, T, C = x.size() # batch size, sequence length, embedding dimensionality (n_embd)B:batch,一批有几个序列。T:time / 序列长度,当前这段有几个 token。C:channel,就是n_embd,每个 token 向量多长。
后面所有 reshape,盯住这三个字母就不会晕。
第一步:一次算出 Q、K、V,并切成多头
# calculate query, key, values for all heads in batch and move head forward to be the batch dim
q, k, v = self.c_attn(x).split(self.n_embd, dim=2)
k = k.view(B, T, self.n_head, C // self.n_head).transpose(1, 2) # (B, nh, T, hs)
q = q.view(B, T, self.n_head, C // self.n_head).transpose(1, 2) # (B, nh, T, hs)
v = v.view(B, T, self.n_head, C // self.n_head).transpose(1, 2) # (B, nh, T, hs)self.c_attn(x):把(B, T, C)的输入过那个「宽三倍」的线性层,得到(B, T, 3C)。.split(self.n_embd, dim=2):沿最后一维切成三段,每段C宽,正好就是q、k、v,各自(B, T, C)。这就是前面「一次算完再切开」的兑现。- 接下来三行长得一样,做的是拆多头。以
q为例:.view(B, T, n_head, C // n_head):把最后那C维劈成n_head份,每份hs = C // n_head维(head size)。形状变成(B, T, nh, hs)。.transpose(1, 2):把T和nh两个维度对调,变成(B, nh, T, hs)。
为什么要 transpose?这样一来,nh(头)就挪到了和 B(batch)相邻的前排。后面做批量矩阵乘法时,PyTorch 会把 (B, nh) 当成「一批批」来并行处理——等于让 n_head 个头各算各的注意力,互不干扰,还全程并行。
为什么要「多头」? 一个头只能学一种「该看谁」的模式。多头就是让模型同时开好几路注意力:也许一头专门盯语法搭配、一头盯指代关系、一头盯远处的主题词……各看各的,最后拼起来。把一个大注意力拆成几个小注意力并行,表达力更丰富,计算量还不变。
第二步:算注意力——两条路
nanoGPT 给了两套实现:有 Flash 走快的,没有就手写。两者数学上等价,先看手写版最能看清原理:
# causal self-attention; Self-attend: (B, nh, T, hs) x (B, nh, hs, T) -> (B, nh, T, T)
if self.flash:
# efficient attention using Flash Attention CUDA kernels
y = torch.nn.functional.scaled_dot_product_attention(q, k, v, attn_mask=None, dropout_p=self.dropout if self.training else 0, is_causal=True)
else:
# manual implementation of attention
att = (q @ k.transpose(-2, -1)) * (1.0 / math.sqrt(k.size(-1)))
att = att.masked_fill(self.bias[:,:,:T,:T] == 0, float('-inf'))
att = F.softmax(att, dim=-1)
att = self.attn_dropout(att)
y = att @ v # (B, nh, T, T) x (B, nh, T, hs) -> (B, nh, T, hs)手写版这四行,正是那个著名公式的代码化:
逐行对上:
att = (q @ k.transpose(-2, -1)) * (1.0 / math.sqrt(k.size(-1)))—— 这是 。q @ k^T:每个位置的 Query 和所有位置的 Key 做点积,得到一张(T, T)的相似度表——第i行第j列,就是「位置i该有多关注位置j」。- 除以
√d_k(d_k就是hs):缩放。点积会随维度变大而数值膨胀,不压一压,softmax 会被推到极端、梯度消失。这个√d_k就是「缩放点积注意力」里的「缩放」。
att = att.masked_fill(self.bias[:,:,:T,:T] == 0, float('-inf'))—— 这是加上掩码 ,也是「Causal(因果)」这个词的来历,下一节专门讲。att = F.softmax(att, dim=-1)—— 把每一行的分数归一化成概率(每行加起来为 1)。现在第i行就是「位置i把注意力如何分配给各位置」的权重。att = self.attn_dropout(att)—— 训练时随机丢弃一些权重,防过拟合。y = att @ v—— 拿权重去加权汇总 Value。位置i的输出,就是所有位置 Value 按第i行权重的加权和。至此,每个位置都「按需」把前文信息收集到了自己身上。
Flash 版那一行 scaled_dot_product_attention(..., is_causal=True) 做的是完全一样的数学,只是把这几步融进一个高度优化的 CUDA 内核里,不在显存里实际造出那张 (T, T) 的大表,又快又省显存。is_causal=True 就等价于下面要讲的掩码。
第三步:因果掩码——「只能看过去」的那道法术
这是整章的题眼。语言模型生成时是从左往右一个个吐字的,预测第 i 个 token 时,它只能看到第 0 到 i-1 个,绝不能偷看第 i 个之后的——否则就是「抄答案」,训练出的模型一到真实生成(没有未来可抄)就废。
「不许看未来」这条铁律,在代码里就是那个 bias 缓冲:
self.register_buffer("bias", torch.tril(torch.ones(config.block_size, config.block_size))
.view(1, 1, config.block_size, config.block_size))torch.ones(block_size, block_size):一张全是 1 的方阵。torch.tril(...):取下三角(triangular-lower),对角线及以下保留 1,上方全变 0。对T=4来说长这样:
看j=0 j=1 j=2 j=3
位置i=0 [ 1 0 0 0 ]
位置i=1 [ 1 1 0 0 ]
位置i=2 [ 1 1 1 0 ]
位置i=3 [ 1 1 1 1 ]第 i 行只有第 0..i 列是 1——正好就是「位置 i 只允许看位置 0 到 i」。
再回看那行 masked_fill:
att = att.masked_fill(self.bias[:,:,:T,:T] == 0, float('-inf'))凡是掩码里为 0 的位置(即「未来」),把相似度分数强行填成 负无穷。这样接下来 softmax 一算,e^{-∞} = 0,这些未来位置的权重精确地变成 0,一点信息都汇总不过来。一行 masked_fill,就把「不许看未来」这条物理定律焊死进了模型。
这正是 GPT(decoder-only)和 BERT(encoder)的分水岭:BERT 没有这道下三角掩码,每个位置能同时看左右两边,适合「理解」;GPT 加了它,只能从左看到右,才能一个接一个地「生成」。一张下三角矩阵,决定了模型是「阅读理解型」还是「写作接龙型」。
第四步:合并多头,收尾输出
y = y.transpose(1, 2).contiguous().view(B, T, C) # re-assemble all head outputs side by side
# output projection
y = self.resid_dropout(self.c_proj(y))
return yy此刻是(B, nh, T, hs)——每个头各算出了自己那份结果。.transpose(1, 2):把nh和T换回来,变(B, T, nh, hs)。.contiguous().view(B, T, C):把nh个头的结果并排拼回成一个C维向量(nh * hs = C)。contiguous()是因为transpose只改了「视图」没真挪内存,view前得先把内存排整齐。几个头「分头侦查」得到的情报,在这里汇总成一份。self.c_proj(y):过输出投影,把拼起来的多头结果再融合一下。self.resid_dropout(...):最后再来一道 dropout,返回。
输出 y 的形状,又变回了进来时的 (B, T, C)——尺寸不变,但每个位置的向量,已经揉进了它该关注的前文信息。这也是为什么 Block 里能直接 x + self.attn(...):进出同形,才好做残差相加。
把这一章串起来
CausalSelfAttention 做的事,一句话:
每个位置,拿自己的 Query 去和「自己及之前」所有位置的 Key 比对,算出一组注意力权重(未来位置被掩码强制清零),再按权重把那些位置的 Value 加权汇总到自己身上。多个头并行地做这件事,最后拼合、投影输出。
回头看代码,它其实只有干净利落的十来行。但就是这十来行,让 GPT 拥有了「联系上下文」的能力——它是「大模型能读懂长文」这件事的物理基础。
至此,五个零件里最硬的一块啃完了。剩下的 LayerNorm、MLP、Block 都简单得多——下一章我们一口气把它们收拾掉,你会发现读完注意力再看它们,轻松得像饭后甜点。👉 其余三个零件:LayerNorm、MLP、Block