Mini-GPT:从零手写 Transformer 在 NPU 上训练
本文从零实现一个 GPT-2 风格的 decoder-only Transformer,在单张 Ascend 910B3 NPU 上完成训练和文本生成。目标不是做一个可用的 LLM,而是通过手写每一行代码来理解 Transformer 的内部机制——从 self-attention 的数学公式,到 causal mask 为什么要用上三角矩阵,再到 AdamW 优化器为什么比 SGD 更好。模型 ~11M 参数,字符级编码,2000 次迭代训练耗时 43 秒。
全文分两条线交织展开:理论线解释每个组件”为什么这样设计”(背景、动机、数学过程),实践线展示每个组件”怎么用代码实现”(PyTorch 代码、NPU 训练技巧、生成策略)。建议阅读顺序:先通读 §2(Transformer 核心机制),再对照 train_gpt.py 源码看 §3(模型架构),最后结合训练结果看 §7(loss 曲线与生成效果分析)。
1. 背景
在开始写代码之前,先厘清三个问题:为什么要手写而不用现成框架、语言模型究竟在做什么任务、字符级编码和子词编码各有什么取舍。这三个问题决定了后续所有设计决策的出发点。
1.1 为什么要手写
前面 7 个 phase 都是”用框架跑模型”——调用 torchvision.models.resnet50()、SentenceTransformer(...)、torch.nn.Conv2d(...)。这些方式能快速验证 NPU 是否可用,但跳过了一个关键环节:理解模型内部的每一行代码在做什么。
手写 GPT 的目的不是做一个可用的 LLM,而是回答以下问题:
- Self-attention 中的 Q、K、V 到底是什么?它们是怎样从输入变换而来的?
- “Scaled dot-product attention” 为什么要除以 √dₖ?
- Causal mask 为什么是上三角矩阵?不用 mask 会怎样?
- Multi-head 到底”多”在哪里?每个 head 在学什么?
- Position embedding 为什么需要?去掉会有什么后果?
- LayerNorm 放在 Attention 之前还是之后?为什么 GPT-2 选择”之前”?
- 训练时 forward 和 backward 各干了什么?loss 为什么能下降?
当我们亲手写出 Q @ K^T / sqrt(d_k)、masked_fill(-inf)、F.softmax(att, dim=-1) 时,对 Transformer 的理解会从”知道原理”变成”能写出来”。这两者之间有本质区别。
1.2 什么是语言模型
语言模型的任务很简单:给定前面的文本,预测下一个 token。
输入: "NPU 是华为"
目标: 预测下一个字符应该是什么?
模型计算: P(下个字符 | "NPU 是华为")
→ "昇" (概率 0.23), "的" (概率 0.15), "一" (概率 0.08), ...
→ 正确答案最可能是 "昇"
→ 模型输出 "昇" 的概率越高,loss 越低
训练过程就是不断把文本切成 (输入, 目标) 对,让模型反复练习”猜下一个字”。猜得越准,loss 越低。训练完成后,给一个开头(prompt),让模型一个字一个字地往后”续写”,就得到了文本生成。
1.3 为什么是字符级
通常 LLM 使用子词(subword)编码:
BPE (GPT-2): "NPU 加速计算" → [374, 249, 11865, 287] (4 tokens)
字符级: "NPU 加速计算" → [N, P, U, , 加, 速, 计, 算] (8 tokens)
字符级编码有两个教育优势:
- 无需外部依赖:不用下载 tokenizer 配置文件,代码中一个 Python dict 就搞定。子词编码需要 BPE 合并规则文件(通常几 MB)、预处理脚本,增加了理解负担
- 更直观:每个字符就是一个 token,encode/decode 完全透明。我们可以直接”看到”模型输入了什么、输出了什么,不需要在 token ID 和文本之间来回查表
代价是序列更长——同样的 block_size=128 下,字符级模型只能”看到”约 128 个字符(2-3 句话),而 BPE 模型可以看到约 2-3 倍的内容。但对于学习目的,这完全不是问题——我们追求的是”理解”,不是”好用”。
2. Transformer 核心机制
在展示代码之前,先理解每个组件在数学上做了什么。这是手写实现的理论基础。
2.1 Self-Attention:让每个 token “看到”其他 token
Self-attention 的核心思想是:序列中的每个位置都可以直接访问所有其他位置的信息。这是 Transformer 区别于 RNN 的根本特征——RNN 需要一步步传递信息(token1→token2→token3),而 Transformer 中 token1 可以直接”看到”token100。
数学过程:
输入: X [B, T, C] (batch_size, seq_len, n_embd)
Step 1: 投影到 Q、K、V
Q = X @ W_Q Query: "我在找什么?"
K = X @ W_K Key: "我是什么?"
V = X @ W_V Value: "我的内容是什么?"
Step 2: 计算注意力分数
scores = Q @ K^T / √dₖ [B, T, T]
矩阵中 (i, j) 位置表示 token_i 对 token_j 的"关注程度"
Step 3: Softmax 归一化
weights = softmax(scores, dim=-1)
每行加起来 = 1,每个值表示"应该花多少注意力在对应 token 上"
Step 4: 加权求和
output = weights @ V [B, T, C]
每个位置的输出 = 所有位置 V 的加权平均
为什么要除以 √dₖ?
当 dₖ(每个 head 的维度)较大时,Q·Kᵀ 的点积值会很大(因为很多项相加)。大值送入 softmax 后,梯度会变得非常小(softmax 饱和区),训练几乎停滞。除以 √dₖ 将方差控制在 1 左右,保持在 softmax 的敏感区域。这不是理论猜想,而是原始 Transformer 论文的实验发现——不加这个缩放因子,大模型根本训不动。
Causal Mask:让模型不能”偷看未来”:
语言模型的任务是预测下一个 token。如果模型能看到未来的 token,它就直接”抄答案”了,永远学不会预测。Causal mask 是一个上三角为 -∞ 的矩阵:
t0 t1 t2 t3
t0 [ 0 -∞ -∞ -∞ ] ← t0 只能看到自己
t1 [ 0 0 -∞ -∞ ] ← t1 能看到 t0, t1
t2 [ 0 0 0 -∞ ] ← t2 能看到 t0, t1, t2
t3 [ 0 0 0 0 ] ← t3 能看到所有前面的 token
softmax(-∞) = 0 → 未来位置的注意力权重为 0,模型无法利用未来信息
2.2 Multi-Head:多个”视角”同时关注
单头注意力的局限性:每个 token 只能以一种方式聚合上下文。但实际语言中,一个词可能需要同时关注”语法搭配”(下一个词是什么时态?)和”语义关联”(主语是谁?)。
Multi-head 的做法是:将 Q/K/V 拆成多个更小的 head,每个 head 独立计算注意力,最后拼起来。
单头: Q/K/V [T, 384] → Attention → [T, 384]
多头: Q/K/V [T, 384] → split 6 heads → 6 × [T, 64] → 6 个独立 Attention → concat → [T, 384]
每个 head 只处理 1/6 的维度(64 而不是 384),但由于它们是独立计算的,不同 head 可以学会关注不同的模式:
- Head 1:关注相邻词的语法关系
- Head 2:关注远距离的主语-谓语搭配
- Head 3:关注标点符号和句子边界
实际训练中,head 的分工不会这么清晰,往往是混合的。但多个 head 确实提供了比单头更丰富的表示能力。
2.3 Feed-Forward Network (FFN):对每个位置独立做非线性变换
Attention 负责”沟通”——让不同位置的 token 交换信息。但它的运算是完全线性的(矩阵乘法 + 加权求和),需要 FFN 引入非线性。
FFN(x) = GELU(x @ W1) @ W2
W1: [n_embd, 4*n_embd] → 先升维 4 倍 (384→1536)
W2: [4*n_embd, n_embd] → 再降回来 (1536→384)
为什么中间维度是 4 倍?这是一个工程上的经验值(原始 Transformer 论文的选择)。更大的中间维度意味着更强的表示能力(可以在高维空间中做更复杂的变换),代价是更多的参数和计算量。4 倍是 GPT-2 所有尺寸都保持的比例。
FFN 对序列中的每个位置独立做相同的变换——它不管 token 之间的顺序关系。顺序关系完全由 Attention 和 Position Embedding 处理。
2.4 Residual Connection + LayerNorm:让深层网络可以训练
标准的 Transformer Block:
x = x + Attention(LayerNorm(x)) ← 残差:输入直接加回输出
x = x + FFN(LayerNorm(x))
100 层的网络,梯度要从第 100 层传到第 1 层。没有残差连接时,梯度经过 100 次乘法后会指数级衰减(梯度消失)。残差连接的 "x + ..." 提供了一条"高速公路",梯度可以直接从第 100 层流到第 1 层。
LayerNorm 的作用:将每层的输入标准化为均值 0、方差 1 的分布。
- 没有 LayerNorm:每层的输出分布不断漂移,深层网络很难收敛
- 有 LayerNorm:每层都面对"干净"的输入,训练更稳定
Pre-norm vs Post-norm: GPT-2 使用 pre-norm(先 LayerNorm 再 Attention/FFN),而不是 post-norm(先 Attention/FFN 再 LayerNorm)。pre-norm 的梯度流更顺畅(残差路径上没有 LayerNorm 挡路),训练更稳定,尤其适合深层网络。代价是最终输出可能需要额外一个 LayerNorm(ln_f)。
2.5 Position Embedding:让模型知道顺序
Attention 机制本身没有顺序概念——它对所有位置一视同仁。”我 爱 你”和”你 爱 我”在 Attention 看来只是 token 不同,不知道谁在前谁在后。
Position embedding 的解决方案:给每个位置一个唯一的向量,直接加到 token embedding 上。
位置 0: [0.01, -0.03, 0.02, ...] (384 维的向量,可训练)
位置 1: [0.02, 0.01, -0.04, ...]
位置 2: [-0.01, 0.03, 0.01, ...]
...
输入 = TokenEmbedding("NPU") + PositionEmbedding(0)
= 这个词的语义向量 + 它在第 0 个位置的向量
这样,同一个词在不同位置会有不同的表示,模型可以学会”第一个词和第二个词的语法角色不同”。
3. 模型架构
有了理论基础后,本节看具体实现——先整体结构(数据从输入到输出的流向),再参数配置(每个超参数的选择理由),最后参数量拆解(每一层到底占了多少参数)。建议对照 train_gpt.py 源码阅读。
3.1 整体结构
Input Tokens [B, T] (batch=32, seq_len=128)
│
├── Token Embedding [855, 384] ← 每个字符的语义向量
├── Position Embedding [128, 384] ← 每个位置的位置向量
│ └── x = tok_emb + pos_emb + Dropout
│
▼
┌──────────────────────────────────────────┐
│ TransformerBlock × 6 │ (可以调 n_layer)
│ ┌──────────────────────────────────────┐│
│ │ 1. x = x + Attention(LayerNorm(x)) ││ LN → Attention → Residual
│ │ 2. x = x + FFN(LayerNorm(x)) ││ LN → FFN → Residual
│ └──────────────────────────────────────┘│
│ ... 重复 6 次 ... │
└──────────────────────────────────────────┘
│
▼
LayerNorm → Linear [384, 855] → logits [B, 128, 855]
│ ↑
└───────────────────────────────────┘
每个位置输出 855 个分数,对应 855 个字符的概率
loss = CrossEntropyLoss(logits, targets)
= -log(P(正确字符))
初始: -ln(1/855) ≈ 6.75
训练后: loss → 0.14 表示模型对正确字符非常有信心
3.2 参数配置
Transformer 的超参数之间有一个设计约束:n_embd 必须能被 n_head 整除,因为每个 head 的维度 = n_embd / n_head。常见的做法是选 2 的幂次(64, 128, 256),这样在 NPU 上的内存对齐最友好。以下参数是以”11M 参数、单卡训练、30 分钟内收敛”为目标选定的:
| 参数 | 值 | 为什么选这个值 |
|---|---|---|
| vocab_size | 动态(训练集唯一字符数) | 字符级编码,无需预设词表大小 |
| block_size | 128 | 中文约 128 字符 = 2-3 句话,够看到基本的上下文 |
| n_layer | 6 | 6 层足以学习基本的语言模式,不会太浅也不会太深 |
| n_head | 6 | 每头 64 维 (384/6),是 2 的幂,硬件友好 |
| n_embd | 384 | 6×64=384,总参数量 ~11M,单卡训练合适 |
| batch_size | 32 | 32×128 = 4096 tokens/batch,NPU 利用率高 |
| lr | 3e-4 | AdamW 常用学习率,不过大也不太小 |
| dropout | 0.1 | 轻微正则化,防止过拟合(对于小数据尤其重要) |
这些参数都可以通过命令行覆盖(--n-layer 8 --n-embd 512),方便快速实验不同配置。
3.3 参数量拆解
Token Embedding: 855 × 384 = 328,320
Position Embedding: 128 × 384 = 49,152
每个 Block:
CausalSelfAttention:
c_attn (QKV): 384 × (3×384) = 442,368
c_proj: 384 × 384 = 147,456
FFN:
fc1: 384 × (4×384) = 589,824
fc2: (4×384) × 384 = 589,824
LayerNorm×2: 384 × 4 = 1,536
─────────────────────────────────────────
每个 Block 合计: 1,771,008
6 个 Block: 6 × 1,771,008 = 10,626,048
Final LayerNorm: 768
LM Head: (共享 Token Embedding 权重, 0 额外参数)
───────────────────────────────────────────
总计: ≈ 11,004,288 (~11M)
可以看到,FFN 是参数量的绝对主力(每个 block 的 1.77M 参数中,FFN 占了 1.18M,约 67%)。这也是为什么扩大 FFN 的中间维度(4×→8×)会比增加层数更显著地增加参数量。
4. 训练过程
数据准备好、模型定义好后,训练就是把这两者对接起来:反复取 batch → forward 算 loss → backward 算梯度 → optimizer 更新参数。本节拆解这个循环中的每一步。
4.1 数据准备
字符级编码的流程很简单,在代码中由 CharTokenizer 类封装(fit() 建立映射、encode() 编码、decode() 还原):
# CharTokenizer.fit(text) — 建立字符↔ID 映射
chars = sorted(set(text)) # 855 个唯一字符
char_to_id = {c: i for i, c in enumerate(chars)}
# CharTokenizer.encode(text) — 编码全部文本
data = [char_to_id[c] for c in text] # 57,013 个整数
4.2 训练循环
每个 iteration 做了什么:
1. get_batch():
随机选取 32 个起点,各取 128 个连续 token 作为输入 x
对应的 y = 每个输入右移 1 位(语言模型的标准做法:
用 token[0:127] 预测 token[1:128])
2. forward():
logits = model(x) # [32, 128, 855]
loss = CrossEntropy(logits, y) # 比较预测和真实值
3. backward():
loss.backward() # 反向传播,计算所有参数的梯度
4. optimizer.step():
用梯度更新参数,使 loss 减小
AdamW 的更新规则比普通 SGD 更复杂,引入了动量和自适应学习率
4.3 Loss 函数
Cross-Entropy Loss,语言模型的标准选择:
对每个位置 i 和 batch 中的每个样本 b:
loss_i,b = -log(P_model(正确答案_i,b))
例如: 正确答案是字符 '昇' (ID=234)
模型预测概率: '昇'=0.23, '的'=0.15, ...
loss = -log(0.23) = 1.47
如果模型预测概率提升到 0.9:
loss = -log(0.9) = 0.105
loss 越低 = 模型越有信心预测正确
初始随机: loss ≈ -ln(1/855) = 6.75
4.4 优化器:AdamW
SGD 只有一个全局学习率。AdamW 给每个参数独立的学习率,基于该参数的历史梯度:
- Momentum(动量):如果某个参数一直往同一个方向更新,就加速它——像滚雪球
- Adaptive LR(自适应学习率):如果某个参数梯度很大(影响大),就减小它的学习率(谨慎更新);梯度小则增大学习率
- Weight Decay(权重衰减):每步将所有参数向 0 拉一点点(0.01 倍),防止参数值过大导致过拟合
AdamW 是 Adam 的改进版,把 weight decay 从自适应学习率中解耦出来——一个小但重要的修正,使训练更稳定。
5. 文本生成
训练完成后,如何让模型”写”出新文本?
5.1 自回归生成
while len(output) < max_tokens:
logits = model(current_sequence) # forward pass
next_token_logits = logits[-1] # 只取最后一个位置的预测
probs = softmax(next_token_logits) # 转为概率
next_token = sample(probs) # 按概率采样
current_sequence.append(next_token) # 拼回去,下一轮继续
每一步都依赖之前生成的所有 token——这就是”自回归”(autoregressive)。
5.2 Temperature:控制”创造性”
probs = softmax(logits / temperature)
temperature = 0.2: 分布更尖锐 → 高概率词更高 → 输出保守、重复
temperature = 0.8: 分布适中 → 保留一定随机性 → 输出合理但有变化
temperature = 2.0: 分布更平坦 → 低概率词被放大 → 输出随机、不连贯
temperature 不会改变哪个 token 得分最高,只改变概率分布的”陡峭程度”。GPT-2 论文中发现 temperature=0.8-1.0 是较好的默认范围。
5.3 Top-K Sampling:截断低概率词
如果直接按完整 855 个词的概率采样,那些概率极低的 token(比如生僻字、标点)偶尔被选中,会破坏生成质量。Top-K 的做法是:
1. 选出概率最高的 K 个 token(如 K=40)
2. 将其余 token 的概率设为 0
3. 只在 Top-K 中重新归一化并采样
K 越小,生成越保守;K 越大,生成越多样。K=40 是 GPT-2 论文中使用的默认值,在我们的实验中也表现良好。
6. NPU 训练的特殊考虑
同样的 PyTorch 代码在 NPU 和 GPU 上运行,行为有三个值得注意的差异:首次运行的编译延迟、小模型的内存占用特征、以及为什么本实验不开启 AMP。
6.1 图编译延迟
NPU 第一次执行模型时,CANN 的图编译器会对计算图进行优化(算子融合、内存复用、数据布局转换)。这会导致第一个 iteration 耗时远大于后续:
iter 1: ~2000ms (含图编译)
iter 2+: ~22ms (正常速度)
我们的训练脚本没有显式 warmup,但第一个 iter 的实际耗时被后续 iter 平均了。如果用 profiler 观测,第一个 iter 的 trace 会包含大量编译相关的 kernel。需要注意:loss 值通过 estimate_loss() 在 model.eval() 模式下独立计算,不受图编译延迟影响——iter=1 的 loss=5.43 是准确的随机初始状态评估。
6.2 内存占用
11M 参数 × 4 bytes (FP32) = 44 MB (模型权重)
+ 优化器状态 (AdamW 需要 2 个 momentum buffer): ~88 MB
+ 中间激活 (batch=32, seq=128): ~50 MB
+ CANN 运行时开销: ~50 MB
─────────────────────────────────────────────
总计: ~230 MB HBM
远低于 910B3 的 64 GB HBM,资源利用率很低——这是小模型的典型特征。实际训练大模型时,HBM 带宽利用率才能真正体现 NPU 的价值。
6.3 为什么不用 AMP
通常训练会开启 AMP(FP16 混合精度)提升吞吐。但在本实验中:
- 模型太小(11M),AMP 的加速效果不明显
- 字符级 vocab(855)太小,FP16 的精度优势无法体现
- 教学目的:FP32 训练更简单,不需要解释 loss scaling 和 gradient overflow
7. 训练结果
以下数据来自在 NPU 7 上实际运行 train_gpt.py 的输出。训练语料为项目中的 7 篇 Ascend 学习文档——选择这些文档而非通用语料,是为了验证模型能否学会其中的技术术语和文档格式。
7.1 实验配置
| 参数 | 值 |
|---|---|
| 训练语料 | 7 篇 Ascend 学习文档,57,013 字符 |
| 字符词表大小 | 855(含中英文、数字、标点、Markdown 符号) |
| 参数量 | 11.00M |
| block_size / n_layer / n_head / n_embd | 128 / 6 / 6 / 384 |
| batch_size / lr / max_iters | 32 / 3e-4 (AdamW) / 2000 |
| 硬件 | Ascend 910B3 × 1 (NPU 7) |
7.2 Loss 曲线
| iter | loss | 阶段分析 |
|---|---|---|
| 1 | 5.43 | 初始随机权重。随机猜测 855 个字符,理论 baseline = -ln(1/855) ≈ 6.75。模型还没学到任何东西,但权重初始化让它比纯随机稍好一点 |
| 200 | 2.52 | 模型开始学会最频繁的字符组合(空格、换行、常见标点)。这些模式简单且出现频率高,最快被掌握 |
| 400 | 1.56 | 开始学习常见词和短语(”NPU”“CANN”“PyTorch”)。这些词的字符序列是固定的,模型只需记忆 |
| 600 | 0.87 | 句子级模式开始涌现。Markdown 的标题标记和表格分隔符等格式字符的使用方式被学会 |
| 800 | 0.47 | 开始记忆训练数据中的具体段落。loss 快速下降但泛化能力下降 |
| 1000 | 0.28 | 已进入过拟合区间。loss < 1 通常意味着模型在”背诵”训练集 |
| 2000 | 0.14 | 严重过拟合——57K 字符 vs 11M 参数,参数数量是数据量的 ~200 倍。模型几乎记住了整个训练集 |
总训练时间:43 秒。 2000 iters,平均每 iter ~22ms(forward + backward + optimizer step 全部在 NPU 上完成)。
7.3 为什么过拟合是预期结果
数据量和模型规模之间的健康比例通常要求 训练 token 数 » 参数量。例如 GPT-3 的训练数据约 300B tokens,参数量 175B,比例约 1.7:1。
我们的情况:
57,013 tokens / 11,000,000 params ≈ 0.005:1
每个参数平均只见过 0.005 个 token——这是严重的”数据不足”。打个比方:这就像一个学生背了 57,000 个字的课文,但脑容量能记 1100 万个字的细节——他不需要理解课文意思,直接逐字背诵就行。模型也是如此:过多的参数给了它”死记硬背”的能力,而不是被迫学习可以泛化的语言规则。
要改善过拟合,需要:
- 增加数据量:≥ 10MB 文本(约 1000 万字符),比例提升到 ~1:1
- 减小模型:n_layer=4, n_embd=256 → ~3M 参数,对 57K tokens 仍然偏多但会改善
- 增大 dropout:0.1→0.3,更强的正则化
- early stopping:在 loss ~1.5 时停止训练,得到更有泛化能力的模型
7.4 生成效果与分析
Prompt: NPU 是
生成结果:
NPU 是华为昇腾系列 AI 处理器,采用 **达芬奇 (Da Vinci)**:设计算架构,
7 nm 制程。本服务器配备 8 张 910B3 卡,通过 HCCS (Huawei Cache
Coherence System) 全互联。
关键硬件参数 (实测数据):
| 参数 | 值
分析:
这个生成结果虽然是”背诵”训练数据,但展现了一些有趣的行为:
- 正确组织了”NPU 是…AI 处理器”的定义句式
- 准确使用了”达芬奇(Da Vinci)”的技术术语,并保留了 Markdown 强调标记
** - “7 nm 制程”是训练数据中确实存在的硬件参数
HCCS (Huawei Cache Coherence System)完整保留了缩写+全称的格式- 自动生成了 Markdown 表格的表头,格式正确
不足之处:
- 表格没有完整填充数据(
| 参数 | 值后直接截断) - “设计算架构”多了一个”计”字(生成的小瑕疵)
- 完全在训练数据的知识范围内,没有”创造性”——这是过拟合的直接体现
换一个训练数据中不常见的 prompt:
Prompt: MindSpore
生成结果:
MindSpore |
| ------------ | -------------------------------------------------------------------
这是因为 “MindSpore” 在训练数据中主要出现在对比表格中(MindSpore vs PyTorch),模型学会了:当看到 “MindSpore” 时,接下来应该画表格。但由于过拟合,它只会画表格,不会生成关于 MindSpore 的描述性文字。
8. 代码结构
train_gpt.py 约 310 行,所有组件放在一个文件中,方便对照理论章节阅读。以下按数据流顺序(token 从输入到输出经过的路径)列出各模块:
10_mini_gpt/
└── train_gpt.py # Mini-GPT 完整实现(~310 行)
├── CharTokenizer — 字符↔ID 映射 (encode/decode)
├── CausalSelfAttention — 多头 causal self-attention
│ ├── Q/K/V 合并投影 (一次 Linear 替代三次)
│ ├── Split/Merge heads (reshape + transpose)
│ ├── Scaled dot-product (Q·Kᵀ / √dₖ + causal mask)
│ └── Output projection (c_proj + dropout)
├── TransformerBlock — Attention + FFN + 残差
│ ├── pre-norm LayerNorm (GPT-2 风格)
│ ├── FFN (4× expand) (GELU 激活)
│ └── 2 个残差连接 (梯度高速公路)
├── MiniGPT — 完整模型组装
│ ├── Token + Position Embedding
│ ├── N × TransformerBlock
│ ├── Final LayerNorm + LM Head
│ ├── Weight Tying (LM head 共享 token embedding 权重)
│ └── generate() (自回归 + top-k sampling)
├── Trainer — 训练循环
│ ├── get_batch() (随机采样 x/y 对)
│ ├── estimate_loss() (多 batch 平均 loss)
│ ├── train() (主循环 + 进度打印)
│ └── save_checkpoint() (模型 + tokenizer + 配置)
└── main() — CLI (train / generate 两种模式)
9. 与之前 phase 的联系
本 phase 的手写 Transformer 不是孤立的工作——它的每个组件都可以在之前的实验中找到对应的基础:
| Phase | 关联 |
|---|---|
| Phase 1 (Hello NPU) | 矩阵乘法是 Attention 的计算核心。Q·Kᵀ 本质就是两个矩阵的乘法——和 torch.matmul(A, B) 完全一样 |
| Phase 3 (ResNet-50) | 训练循环(forward→loss→backward→optimizer.step)的模式完全相同,只是模型从 CNN 换成了 Transformer |
| Phase 7 (Profiling) | 可以对本脚本做 profiling,观察 Attention(矩阵乘法 bound)和 FFN(也是矩阵乘法 bound)各占多少 NPU 时间 |
| Phase 6 (RAG) | RAG 中的 embedding 模型是 BERT(encoder-only),而 GPT 是 decoder-only。两者的 Attention 机制几乎一样,区别在于 BERT 是双向、GPT 是单向(causal) |
10. 后续扩展
本实验的 Mini-GPT 是一个刻意简化的起点——11M 参数、57K 字符、字符级编码,每一项都在”压制”模型的能力,以便把焦点放在理解而非性能上。在此基础上,后续可以按以下优先级逐步放开约束:
建议顺序:先换子词编码(立即改善”视野”),再增大数据(让模型真正学会语言规则),然后上 AMP 和多卡(把训练速度提上来),最后尝试 FlashAttention 等进阶优化。
| 方向 | 做法 | 预期效果 |
|---|---|---|
| 更大的数据 | 中文维基百科(~1.5GB 文本) | loss 不会降到 0.1,但生成质量显著提升 |
| 子词编码 | BPE tokenizer,vocab=5000 | 序列长度缩短 ~2×,同样 block_size 下”视野”翻倍 |
| AMP 混合精度 | torch.npu.amp.autocast() |
吞吐提升 1.5-2× |
| 多卡 DDP | 8 张 NPU 数据并行 | 等效 batch_size 增大 8×,大模型训练成为可能 |
| FlashAttention | 减少 HBM 读写 | 对长序列(block_size ≥ 512)加速明显 |
| 学习率调度 | Cosine decay / warmup | 训练更稳定,最终 loss 更低 |