nanochat 学习笔记
本文档记录解读学习 Andrej Karpathy 开源项目 nanochat 的笔记。
学习的 git 代码版本是: f5a0ea4。
项目简介
nanochat 是一个用(几乎,有一点 Rust 用来训练分词器)纯 Python 实现的全栈 GPT库,包括 pretrain、midtrain、finetune、inference 等。它的目标是最简化帮助理解大型语言模型(LLM)的工作原理。
计划从以下几个方面来学习这个项目:
- 代码结构:了解项目的整体架构和各个模块的功能。
- 模型实现:理解 Transformer 模型的实现细节。
- 训练流程:学习模型的训练过程,包括数据预处理、训练循环等。
Wiki 参考: - nanochat Wiki
项目安装
我使用的机器与环境如下: - windows上的wsl2+Ubuntu 22.04 - CUDA 13.1 - RAM: 48GB - GPU: RTX 5080 16GB
1 | |
试试跑起来 1
2
3
4
5python -m nanochat.report reset
python -m nanochat.dataset -n 240
python -m scripts.tok_train --max_chars=2000000000 --vocab_size=65536
python -m scripts.tok_eval
python -m scripts.base_train --depth=4 --max_seq_len=512 --device_batch_size=1 --eval_tokens=512 --core_metric_every=-1 --total_batch_size=512 --num_iterations=20
代码结构
nanochat/:核心代码目录,包含模型实现、数据处理等模块。adamw.py:AdamW 优化器实现。checkpoint_manager.py:检查点管理器,用于保存和加载模型,优化器状态等。common.py:常用工具函数。有打印,logging,检测torch device等。core_eval.py:评价模型性能的核心函数。dataloader.py:数据加载器实现。dataset.py:数据集处理模块。engine.py:用于推理对话的引擎,发送和接收tokenexecution.py:一个沙盒执行环境gpt.py:GPT 模型的核心实现。loss_eval.py:损失评估模块。muon.py:Muon 优化器实现。report.py:报告生成模块。tokenizer.py:分词器实现。
代码实现
从前到后,按照模型本身的逻辑顺序来学习代码实现,外加训练,评估等辅助功能。
数据集与分词器
数据集相关代码在 dataset.py 和
dataloader.py 中,分词器在 tokenizer.py
中。
分词器实现
首先,tokenizer.py
实现了两个分词器类:HuggingFaceTokenizer 和
RustBPETokenizer。按照描述,两者应该都是基于 BPE(Byte Pair
Encoding)算法的分词器。只是 HuggingFaceTokenizer 使用了
Hugging Face 的 tokenizers 库,可能为了完整的兼容。而
RustBPETokenizer 是基于 tiktoken 库并用
RustBPE 训练。
1 | |
这标注了特殊的 token 列表
SPECIAL_TOKENS,以及用于预分词的正则表达式
SPLIT_PATTERN。
这里简单分析一下它的正则表达式逻辑: -
'(?i:[sdmt]|ll|ve|re):匹配常见的缩写形式,如 ’s, ’d, ’m,
’t, ll, ve, re(不区分大小写)。 -
[^\r\n\p{L}\p{N}]?+\p{L}+:匹配以字母开头的单词,前面可以有一个非字母数字字符。
- \p{N}{1,2}:匹配1到2位的数字序列,标准的 GPT-4
使用的是1到3位数字。这里做了修改以节省 token 空间。 -
?[^\s\p{L}\p{N}]++[\r\n]*:匹配非空白、非字母数字的字符,前面可以有一个空格,后面可以跟换行符。
- \s*[\r\n]:匹配换行符,前面可以有任意数量的空白字符。 -
\s+(?!\S):匹配空白字符,后面不跟非空白字符(即匹配行尾的空白)。
- \s+:匹配任意数量的空白字符。
先来看 HuggingFaceTokenizer 类:
1 | |
RustBPETokenizer 类的实现:
1 | |
数据集
数据是 fineweb_100b 数据集的一个子集,link。下载到
$BASE_DIR/base_data 目录下。 数据被切片成多个
.parquet 文件。
1 | |
parquet 文件是一个列式存储格式,这里按行组(row group)来读取,避免一次性加载过多数据。
而 DataLoader 负责读取数据并生成训练所需的一个批次的 token 序列:
1 | |
document_batches 函数流式 yields tokenized_batch_size 个文本列表+(parquet文件索引,行组索引),然后外层循环不断累积 token 直到满足一个训练批次的需求,再切分成 inputs 和 targets 返回,并附带当前的 parquet 文件和行组索引状态,方便后续恢复训练时使用。
优化器
优化器代码在 adamw.py 和 muon.py 中。
AdamW 优化器
令参数为 \(\theta_t\),学习率为 \(\alpha\),一阶矩估计为 \(m_t\),二阶矩估计为 \(v_t\),偏置修正后的一阶矩为 \(\hat{m}_t\),偏置修正后的二阶矩为 \(\hat{v}_t\),权重衰减系数为 \(\lambda\)。 \[ \begin{align*} \theta_{t+1} &= \theta_t - \alpha \frac{\hat{m}_t}{\sqrt{\hat{v}_t} + \epsilon} - \alpha \lambda \theta_t \\ m_{t+1} &= \beta_1 m_t + (1 - \beta_1) g_t\\ v_{t+1} &= \beta_2 v_t + (1 - \beta_2) g_t^2\\ \hat{m}_t &= \frac{m_t}{1 - \beta_1^t} \\ \hat{v}_t &= \frac{v_t}{1 - \beta_2^t} \\ \end{align*} \]
这里采取分布式训练的方式实现 AdamW 优化器,关键是利用了
torch.distributed.reduce_scatter_tensor与
torch.distributed.all_gather_into_tensor
来实现梯度的分布式平均和切分,从而减少每个 GPU 的内存占用。
1 | |
这里先对每个参数的梯度进行 reduce_scatter
操作,将梯度平均后切分成多个片段,每个 GPU
只保留自己负责的片段,减少内存占用。
目前有 grad_slices 列表,存储了每个参数在当前 GPU 上的梯度片段和对应的 future 对象。 接下来对每个参数组,读取AdamW 的超参数,再对每个参数进行更新:
状态存储在 self.state[p] 中,包括 step 计数器,一阶矩 exp_avg 和二阶矩 exp_avg_sq。
1 | |
Muon 优化器
咕咕咕,可先看 link。
GPT 模型实现
架构流程: 1. 输入的Token进入输入嵌入层(Token
Embeddings),输入类型: 1
2inputs: Tensor # (B, T) long tensor of token indices
outputs: Tensor # (B, T, C) float tensor of logits over vocabulary1
pos_emb: Tensor # (T, C) float tensor of positional embeddings
多层 Transformer 块(Transformer Blocks)处理嵌入,捕捉上下文关系。
输出层(Output Layer)将 Transformer 的输出映射到词汇表大小的 logits。
从 logits 计算损失(Loss Computation),用于训练。或者按照multinomial分布采样生成下一个 token,用于推理。
我们需要的块包括: - 输入嵌入层 - 预计算位置嵌入层 - Transformer 块 (多个堆叠+ 残差连接) - 层归一化 - 多头自注意力机制 (推理时缓存 K,V) - 前馈神经网络 - 输出层
表格如下
| 模块名称 | 输入维度 | 输出维度 | 核心功能 |
|---|---|---|---|
| Token Embedding | (B,T) | (B,T,C) | 将离散索引转为连续向量 |
| Pos Embedding | (T,C) | (T,C) | 提供序列位置的绝对/相对信息 |
| Transformer Block | (B,T,C) | (B,T,C) | 包含 LayerNorm -> Self-Attention -> Residual -> LayerNorm -> FFN -> Residual |
| Output Head | (B,T,C) | (B,T,V) | 投影至词汇表空间 |
优化方面:
nanochat 使用了 KV Cache 来加速推理过程中的自注意力计算,并且使用了 GQA (Grouped Query Attention)来优化多头注意力的计算效率。
对于 KV Cache 的推理,要记录当前 Token 的位置索引,并索引正确的 RoPE 位置嵌入(按绝对位置)。
对于 GQA 的实现,即保持 Q 的头数不变,但将 K 和 V 的头数减少分组,每组共享 K 和 V。
先来看 gpt.py 中注意力模块的实现
1 | |
简单的 MLP 和 Transformer Block 堆叠块
1 | |
最后是 GPT 模型的整体实现(去除部分辅助函数):
1 | |
以下是 Gemini 的总结
权重初始化:
嵌入层与输出层的极端标准差差异
wte(Token Embedding):std=1.0: Token 嵌入被初始化为标准正态分布。由于 \(wte\) 的权重通常会随后被层归一化(LayerNorm)处理,较大的初始标准差可以为模型提供丰富的初始特征表示。lm_head:std=0.001输出层使用了非常小的标准差。目的: 在训练开始时,使模型对所有词汇的预测概率趋于均匀分布。如果初始权重过大,模型会产生强烈的随机偏见,导致初始损失值(Loss)极高,增加收敛难度。均匀分布初始化与 \(\sqrt{3}\) 的数学推导代码中使用了均匀分布
uniform_(-s, s)而非正态分布,并定义了 \(s = \sqrt{3} \times \frac{1}{\sqrt{n\_embd}}\)。为什么用 Uniform: 注释提到是为了“避免离群值(outliers)”。正态分布理论上可能产生极大或极小的权重,而均匀分布的范围是严格受限的。\(\sqrt{3}\) 的来源: 对于均匀分布 \(U(-s, s)\),其方差为 \(Var = \frac{(s - (-s))^2}{12} = \frac{4s^2}{12} = \frac{s^2}{3}\)。为了让均匀分布的方差等于期望的方差 \(\sigma^2\)(即 \(1/n\_embd\)),则需要: \[ \frac{s^2}{3} = \sigma^2 \implies s = \sigma \sqrt{3} \] 这确保了无论使用哪种分布,权重的统计特性是一致的。投影层初始化为零 (
c_proj: zeros)这是一个非常关键的技巧,常见于高性能模型实现(如 GPT-2 或部分版本的 Llama):残差流的恒等映射: 在训练初始时刻,如果注意力投影c_proj为零,那么 Transformer 块的输出就等于输入(因为残差连接 \(x + 0 = x\))。逻辑: 这种做法类似于“恒等函数”初始化,让模型先学习直通的数据流动,再逐渐通过训练学习如何修改残差流中的特征。这极大地提高了极深网络的训练稳定性。旋转位置嵌入(RoPE)的预计算操作: 调用内部函数生成
cos和sin表。逻辑: 如前所述,这部分内容是确定性的数学值(频率分布),不需要学习,因此在初始化阶段一次性生成并缓存,以供推理和训练时快速索引。混合精度转换 (
bfloat16)操作: 显式将wte.weight转为bfloat16。优势: 显存节省: 词表往往很大,将其从 float32 转为 bf16 可以直接节省一半的词表显存。精度特性:bf16具有与fp32相同的指数位范围,能够有效防止训练中的溢出风险。
RoPE 位置嵌入预计算公式:
设 \(d\) 为每个注意力头的维度,\(i\) 为通道索引,\(t\) 为时间步索引,\(\theta\) 为基频(通常取 10000)。则每个位置 \(t\) 和通道 \(i\) 的旋转频率和角度为: \[ \text{freq}(t, i) = \frac{1}{\theta^{\frac{2i}{d}}} \] \[ \text{angle}(t, i) = t \times \text{freq}(t, i) = \frac{t}{\theta^{\frac{2i}{d}}} \]