GPT输入层:Token ID、Embedding与RoPE位置编码
结合nanochat源码,说明文本被Tokenizer转成token id之后,如何进入GPT的Embedding层,并在Attention中通过RoPE注入位置信息。
- 人工智能
- 大模型
GPT输入层:Token ID、Embedding与RoPE位置编码
上一篇文本预处理已经解决了一个问题:人类语言如何被切成token,并映射成整数id。到这里为止,模型还没有真正开始“理解”文本,因为输入仍然只是一个整数矩阵:
inputs: (B, T)
targets: (B, T)
其中:
B是 batch size,一次喂给模型多少条序列。T是 sequence length,每条序列包含多少个token。- 每个元素都是一个整数,表示词表中的某个token id。
GPT输入层要做的事情,就是把这个二维整数矩阵变成三维连续向量:
(B, T) -> (B, T, C)
其中 C = n_embd,也就是每个token的隐藏向量维度。下面结合 /Volumes/PSSD/工具/nanochat 中的源码来看这一层到底怎么实现。
从Tokenizer到模型配置
在 scripts/base_train.py 中,训练脚本会先加载Tokenizer。原因很直接:模型的输入词表大小必须和Tokenizer的词表大小一致。
tokenizer = get_tokenizer()
token_bytes = get_token_bytes(device=device)
vocab_size = tokenizer.get_vocab_size()
print0(f"Vocab size: {vocab_size:,}")
拿到 vocab_size 后,nanochat再构造GPT配置:
def build_model_meta(depth):
base_dim = depth * args.aspect_ratio
model_dim = ((base_dim + args.head_dim - 1) // args.head_dim) * args.head_dim
num_heads = model_dim // args.head_dim
config = GPTConfig(
sequence_len=args.max_seq_len,
vocab_size=vocab_size,
n_layer=depth,
n_head=num_heads,
n_kv_head=num_heads,
n_embd=model_dim,
window_pattern=args.window_pattern,
)
这里有两个关键点。
第一,vocab_size 来自Tokenizer。Tokenizer有多少个token,模型的embedding表至少就要有多少行。
第二,n_embd 不是随便定的。nanochat通过 depth * aspect_ratio 得到基础宽度,再向上取整到 head_dim 的倍数。这样可以保证:
n_embd = n_head * head_dim
否则后面做多头注意力时,通道维度就无法整齐切成多个head。
nanochat/nanochat/gpt.py 里的默认配置如下:
@dataclass
class GPTConfig:
sequence_len: int = 2048
vocab_size: int = 32768
n_layer: int = 12
n_head: int = 6
n_kv_head: int = 6
n_embd: int = 768
window_pattern: str = "SSSL"
如果使用默认值,那么每个token最终会被映射成一个768维向量。
Token Embedding是什么
Embedding层本质上是一张可以训练的查表矩阵。
假设:
vocab_size = 32768
n_embd = 768
那么token embedding表的形状就是:
(32768, 768)
第 i 行就是第 i 个token对应的向量。输入token id为 123,模型就取出embedding表的第123行。
从数学上看,可以把token id理解成一个one-hot向量,再乘以embedding矩阵:
但工程上不会真的构造巨大的one-hot矩阵,而是直接查表:
embedding = nn.Embedding(vocab_size, n_embd)
x = embedding(idx)
如果:
idx.shape = (B, T)
embedding.weight.shape = (vocab_size, C)
那么输出就是:
x.shape = (B, T, C)
这就是GPT主干网络真正接收的数据形状。
nanochat里的wte
nanochat在 GPT.__init__ 中定义token embedding层:
def __init__(self, config, pad_vocab_size_to=64):
padded_vocab_size = ((config.vocab_size + pad_vocab_size_to - 1) // pad_vocab_size_to) * pad_vocab_size_to
if padded_vocab_size != config.vocab_size:
print0(f"Padding vocab_size from {config.vocab_size} to {padded_vocab_size} for efficiency")
self.transformer = nn.ModuleDict({
"wte": nn.Embedding(padded_vocab_size, config.n_embd),
"h": nn.ModuleList([Block(config, layer_idx) for layer_idx in range(config.n_layer)]),
})
self.lm_head = Linear(config.n_embd, padded_vocab_size, bias=False)
这里的 wte 就是 Word Token Embedding。
注意它用的不是原始 config.vocab_size,而是 padded_vocab_size。也就是说,如果真实词表大小不是64的倍数,nanochat会把词表行数向上补齐到64的倍数。
比如:
vocab_size = 32769
pad_vocab_size_to = 64
padded_vocab_size = 32832
这不会改变Tokenizer真实能产生的token id范围,只是让embedding表和 lm_head 的矩阵尺寸更适合GPU矩阵计算。最后输出logits时,nanochat会再切回真实词表大小:
logits = self.lm_head(x)
logits = logits[..., :self.config.vocab_size]
也就是说,padding出来的那些词表行只是工程优化,不代表模型真的多了可生成的正常token。
Embedding初始化
Embedding不是固定表,而是模型参数,会在训练中更新。
nanochat在 init_weights() 中初始化它:
torch.nn.init.normal_(self.transformer.wte.weight, mean=0.0, std=0.8)
torch.nn.init.normal_(self.lm_head.weight, mean=0.0, std=0.001)
wte 和 lm_head 是两张不同的矩阵。很多GPT实现会共享输入embedding和输出embedding的权重,也就是所谓 weight tying;但nanochat没有共享。源码开头也明确写了:
untied weights for token embedding and lm_head
因此这里有两张表:
wte: token id -> hidden vectorlm_head: hidden vector -> vocab logits
前者负责把离散token带入模型,后者负责把模型最后的隐藏状态映射回词表得分。
forward中的输入流动
真正前向传播时,idx 就是DataLoader给出的token id矩阵:
def forward(self, idx, targets=None, kv_cache=None, loss_reduction='mean'):
B, T = idx.size()
此时:
idx.shape = (B, T)
然后进入embedding层:
x = self.transformer.wte(idx)
x = x.to(COMPUTE_DTYPE)
x = norm(x)
形状变化是:
idx: (B, T)
x: (B, T, n_embd)
这里还做了两件事。
第一,x.to(COMPUTE_DTYPE) 把激活值转换成nanochat统一使用的计算精度。nanochat没有依赖 torch.amp.autocast,而是通过 COMPUTE_DTYPE 显式控制前向计算精度。A100/H100这类支持bf16的GPU上,通常会使用 bfloat16。
第二,norm(x) 会对embedding后的向量做RMSNorm:
def norm(x):
return F.rms_norm(x, (x.size(-1),))
这一步可以理解成进入Transformer主干前,先把每个token向量的尺度稳定下来。
GPT需要位置信息
到目前为止,wte 只知道“这个位置是什么token”,但并不知道“这个token在第几个位置”。
假设有两个输入:
我 爱 你
你 爱 我
如果只看token embedding,那么每个token自己的向量是固定的:
embedding("我") 不会因为它出现在第1位或第3位而自动变化
而Attention本身主要依赖向量之间的点积。没有位置信息时,模型很难区分“谁在前,谁在后”。所以Transformer必须引入位置编码。
早期Transformer会把位置向量直接加到token embedding上:
x = token_embedding + position_embedding
但是nanochat没有使用这种可学习的绝对位置embedding。源码开头直接说明:
rotary embeddings (and no positional embeddings)
也就是说,nanochat不在输入层把位置向量加到 x 上,而是在Attention内部,把位置信息注入到 Q 和 K 中。
RoPE缓存什么时候建立
RoPE需要大量 cos 和 sin 值。它们只和位置、head维度、base有关,不依赖具体输入文本,所以可以提前算好。
nanochat在 GPT.__init__ 中预计算RoPE缓存:
self.rotary_seq_len = config.sequence_len * 10
head_dim = config.n_embd // config.n_head
cos, sin = self._precompute_rotary_embeddings(self.rotary_seq_len, head_dim)
self.register_buffer("cos", cos, persistent=False)
self.register_buffer("sin", sin, persistent=False)
这里的 rotary_seq_len = config.sequence_len * 10 是一个工程取舍。比如训练序列长度是2048,那么RoPE表会预先算到20480。
这样做的好处是推理时如果上下文略长,不需要频繁重新分配缓存。坏处是如果真的超过这个长度,前向传播里会直接断言失败:
assert T <= self.cos.size(1)
register_buffer(..., persistent=False) 表示 cos 和 sin 跟着模型移动设备,但不保存进checkpoint。原因是它们不是训练参数,也可以由配置重新计算出来。
RoPE的预计算代码
核心函数是:
def _precompute_rotary_embeddings(self, seq_len, head_dim, base=100000, device=None):
if device is None:
device = self.transformer.wte.weight.device
channel_range = torch.arange(0, head_dim, 2, dtype=torch.float32, device=device)
inv_freq = 1.0 / (base ** (channel_range / head_dim))
t = torch.arange(seq_len, dtype=torch.float32, device=device)
freqs = torch.outer(t, inv_freq)
cos, sin = freqs.cos(), freqs.sin()
cos, sin = cos.to(COMPUTE_DTYPE), sin.to(COMPUTE_DTYPE)
cos, sin = cos[None, :, None, :], sin[None, :, None, :]
return cos, sin
这段代码可以拆成四步。
第一步,取偶数通道:
channel_range = torch.arange(0, head_dim, 2)
如果 head_dim = 128,那么这里得到:
[0, 2, 4, ..., 126]
长度是 head_dim / 2。
第二步,计算每个通道对应的频率:
inv_freq = 1.0 / (base ** (channel_range / head_dim))
越靠前的通道频率越高,越靠后的通道频率越低。直观上,高频通道更敏感于短距离变化,低频通道更适合表示长距离变化。
第三步,列出所有位置:
t = torch.arange(seq_len)
如果 seq_len = 20480,那么 t 就是:
[0, 1, 2, ..., 20479]
第四步,做外积:
freqs = torch.outer(t, inv_freq)
形状变成:
freqs.shape = (seq_len, head_dim / 2)
其中第 m 行、第 i 列表示:
第 m 个位置,在第 i 个旋转平面上应该旋转多少角度
最后取 cos 和 sin,并调整成方便广播的形状:
cos, sin = cos[None, :, None, :], sin[None, :, None, :]
最终:
cos.shape = (1, seq_len, 1, head_dim / 2)
sin.shape = (1, seq_len, 1, head_dim / 2)
这个形状很重要,因为Attention里的Q/K形状是:
q.shape = (B, T, n_head, head_dim)
k.shape = (B, T, n_kv_head, head_dim)
cos/sin 的第0维和第2维都是1,可以自动广播到不同batch和不同head。
forward中如何截取RoPE
前向传播时,不需要每次都用完整RoPE表,只要取当前输入长度对应的那一段:
T0 = 0 if kv_cache is None else kv_cache.get_pos()
cos_sin = self.cos[:, T0:T0+T], self.sin[:, T0:T0+T]
训练时通常没有KV Cache:
T0 = 0
所以输入长度为 T,就取位置 [0, T) 的RoPE。
推理时如果使用KV Cache,当前这次forward可能只输入一个新token,但这个token并不是第0个位置,而是整个上下文中的第 T0 个位置。因此需要:
从RoPE缓存中取 [T0, T0 + T)
这就是为什么RoPE截取要考虑 kv_cache.get_pos()。
RoPE在哪里真正作用
Embedding阶段只得到 x:
x.shape = (B, T, C)
RoPE不是直接加到 x 上,而是在Attention内部,先生成Q/K/V:
q = self.c_q(x).view(B, T, self.n_head, self.head_dim)
k = self.c_k(x).view(B, T, self.n_kv_head, self.head_dim)
v = self.c_v(x).view(B, T, self.n_kv_head, self.head_dim)
然后只对 q 和 k 做旋转:
cos, sin = cos_sin
q, k = apply_rotary_emb(q, cos, sin), apply_rotary_emb(k, cos, sin)
注意:v 不做RoPE。
原因是Attention的匹配关系来自:
位置应该影响“当前query应该看哪些key”,所以注入到Q和K里。V是被加权汇总的内容本体,通常不需要旋转。
apply_rotary_emb怎么旋转
nanochat的RoPE应用函数如下:
def apply_rotary_emb(x, cos, sin):
assert x.ndim == 4
d = x.shape[3] // 2
x1, x2 = x[..., :d], x[..., d:]
y1 = x1 * cos + x2 * sin
y2 = x1 * (-sin) + x2 * cos
return torch.cat([y1, y2], 3)
输入 x 的形状是:
(B, T, H, head_dim)
先把最后一维切成两半:
x1: (B, T, H, head_dim / 2)
x2: (B, T, H, head_dim / 2)
然后把 x1 和 x2 看成一组组二维平面上的坐标。第一个半区的第 i 个通道,和第二个半区的第 i 个通道组成一个二维向量:
(x1_i, x2_i)
RoPE对这个二维向量做旋转:
对应代码就是:
y1 = x1 * cos + x2 * sin
y2 = x1 * (-sin) + x2 * cos
最后再拼回原来的 head_dim:
return torch.cat([y1, y2], 3)
所以RoPE前后形状不变:
(B, T, H, head_dim) -> (B, T, H, head_dim)
它改变的是向量内容,不改变张量尺寸。
一个小例子:形状如何流动
假设:
B = 2
T = 8
vocab_size = 32768
n_embd = 768
n_head = 6
head_dim = 128
输入token id:
idx.shape = (2, 8)
查embedding表:
wte.weight.shape = (32768, 768)
x.shape = (2, 8, 768)
进入Attention后:
q.shape = (2, 8, 6, 128)
k.shape = (2, 8, 6, 128)
v.shape = (2, 8, 6, 128)
RoPE缓存切片:
cos.shape = (1, 8, 1, 64)
sin.shape = (1, 8, 1, 64)
应用RoPE时,Q/K的最后一维被切成两个64维:
q1, q2: (2, 8, 6, 64)
旋转后再拼回:
q_rot.shape = (2, 8, 6, 128)
k_rot.shape = (2, 8, 6, 128)
所以这一段完整流程是:
token ids
-> wte查表
-> RMSNorm
-> Q/K/V线性映射
-> 对Q/K注入RoPE
-> 进入Attention打分
为什么不是在Embedding里加位置向量
传统绝对位置编码通常是:
x = token_embedding + position_embedding
这种做法简单,但位置被混进了残差流的每一层,模型需要自己从绝对位置向量中学出相对距离关系。
RoPE的思路不一样:它直接改造Attention中的点积。
如果第 m 个位置的query旋转 mθ,第 n 个位置的key旋转 nθ,那么它们做内积时,会自然出现和相对距离有关的项:
因此RoPE更适合表达“当前位置和被关注位置之间相隔多远”。这也是它在现代GPT类模型中非常常见的原因。
和文本预处理的衔接
把上一篇和这一篇连起来,GPT输入阶段的完整路径就是:
原始文本
-> BPE Tokenizer
-> token ids
-> DataLoader打包成 inputs / targets
-> inputs: (B, T)
-> wte查表
-> x: (B, T, C)
-> Attention内部对Q/K应用RoPE
到这里,模型已经完成了两件基础工作:
- 把离散token变成连续向量。
- 给Attention注入顺序和相对位置信息。
下一步就可以正式进入Transformer Block,继续看一个block内部如何通过Attention、MLP、残差连接和归一化不断更新这些token向量。
尺素
0 封尺素
正在取尺素...