数据截至 (上游 commit f1e2ace65149)
05 · 测试、剖析与 kernel 实验室
这一章讲什么: 没有 pytest、没有 autograd 的日子里,llm.c 怎么保证「C 算得和 PyTorch 一 样」、怎么找到「哪个 kernel 慢」。三件事:对拍测试(test)、性能剖析(profile)、kernel 开 发沙盒(dev/cuda),外加把它们串起来的 Makefile 和 CI。
1. 它要解决的小问题
- 改了 kernel,怎么证明结果还对?——需要一个权威答案和一套比对流程。
- 训练慢了,慢在哪?——需要把单步拆到 kernel 级看时间。
- 新 kernel 怎么从想法走到主线?——需要一条「先沙盒演进、再进库」的流水线。
2. 对拍体系:PyTorch 是出题人
2.1 出题:train_gpt2.py 写四个 .bin
仓库里的 train_gpt2.py(860 行,nanoGPT 改写)身兼两职:PyTorch 平行实现 + 测试资产生成
器。以特定参数运行时它写出(train_gpt2.py:694-698):
| 文件 | 内容 | 写出函数 |
|---|---|---|
gpt2_124M.bin | fp32 权重(magic 20240326, version 3) | write_model(train_gpt2.py:449) |
gpt2_124M_bf16.bin | bf16 权重(version 5) | 同上 |
gpt2_124M_debug_state.bin | 一小批输入 x/y + 期望 logits、loss、全部参数梯度 | write_state(train_gpt2.py:479) |
gpt2_tokenizer.bin | tokenizer 词表 | write_tokenizer(train_gpt2.py:509) |
测试的权威答案全部来自 PyTorch 的一次真实前向/反向。C 侧永远不用「重新实现期望」,只负 责追上它。
2.2 CPU 对拍:test_gpt2.c
test_gpt2.c(195 行)的第一行是个小技巧(test_gpt2.c:1-2):
#define TESTING
#include "train_gpt2.c" // train_gpt2.c 的 main 被 #ifndef TESTING 包住,测试复用全部函数
流程:读 debug_state.bin → 用同一份 x/y 前向 → 逐个比 logits(容差 1e-2)→ 比 mean loss →
用 check_tensor(test_gpt2.c:5-34,容差 2e-2)逐张量比 16 组参数梯度 → 然后真训 10
步,每步 loss 和硬编码的期望值比(test_gpt2.c:89-100):
float expected_losses[10] = { 5.270007133483887f, 4.059706687927246f, ... };
结尾打印 overall okay: 1。注意这个测试同时验证了前向、反向、AdamW、checkpoint 读取——是
全仓库性价比最高的 200 行。
2.3 GPU 对拍:test_gpt2.cu
test_gpt2.cu(390 行)结构相同,但容差设计更讲究:bf16 的 epsilon 是 0.079,判定阈值是
threshold + |ref| * epsilon 的相对+绝对混合(test_gpt2.cu:14-19)——因为 bf16 的误差
随数值大小浮动。它既测 fp32 路径也测混合精度路径(README 的 test 一节给了两条编译命令)。
2.4 CI
.github/workflows/ 三个文件:CPU 三平台构建+测试(ci.yml)、GPU 构建+测试(ci_gpu.yml)、
以及 dev/loss_checker_ci.py 的用法——从训练日志里抠出 10 步 loss 与固定值按百分比比对。
3. 剖析:把训练裁到「单层一步」喂给 ncu
profile_gpt2.cu(74 行)同样 #define TESTING 复用主线全部函数,然后做两个手脚:
model.config.num_layers = 1; // 每层 kernel 相同,profile 一层就够(profile_gpt2.cu:57-58)
...
gpt2_forward(&model, x, B, T);
gpt2_backward_and_reduce(&model, x, y, 1, 0);
gpt2_update(...);
(profile_gpt2.cu:62-66)随机整数当输入、单层、完整走一遍 forward/backward/update——这个
可执行文件就是给 Nsight Compute 准备的靶子:
# 摘自 profile_gpt2.cu 头部注释(profile_gpt2.cu:1-24)
make profile_gpt2cu NO_MULTI_GPU=1
sudo ncu --set full --import-source yes -o profile -f ./profile_gpt2cu
profile_gpt2cu.py 进一步自动化:编译 → ncu 采集 → 导出 CSV,关注的指标包括
sm__pipe_tensor_op_hmma_cycles_active(tensor core 利用率)和 DRAM 读写量
(profile_gpt2cu.py:36-44),并按「属于第几层 / classifier」归类统计。
kernel 能被归类,靠的是每个 launcher 里的 NVTX_RANGE_FN()(llmc/cuda_common.h:124)和层循
环里的 NvtxRange layer_range("Layer", l)(train_gpt2.cu:686)——剖析报告里每个 kernel 都
挂在命名范围下。
4. dev/cuda:kernel 的演进博物馆
4.1 工作法
dev/cuda/README.md 写得很直白:这是 kernel 的「scratch space」——每个文件针对一个算 子,
里面放着同一算子的多个版本,通常「复杂度递增、耗时递减」,文件头注释就是版本目录。以
dev/cuda/layernorm_forward.cu 为例(dev/cuda/layernorm_forward.cu:1-21):
| 版本 | 思路 |
|---|---|
| v1 | CPU 代码直译:并行到 (B,T),循环 C |
| v2 | 并行到全部 (B,T,C) |
| v3 | cooperative groups 块内协作 |
| v4 | 单趟求均值方差(var = mean(x²) − mean(x)²) |
| v5 | 一行一个 block |
运行 ./layernorm_forward N 时:先跑 CPU 参考 → 跑第 N 版 GPU kernel → 比对正确性 → 再扫一
组 block size 计时。最快且正确的版本最后被「拷进」llmc/——所以 llmc/ 里的函数名带着
数字后缀(layernorm_forward_kernel6 就是这里的 v6 的直系后代)。
4.2 这套流程的价值
它把「优化 kernel」拆成了可审查的小步:每一步都有 CPU 对拍兜底、有计时数据说话、有版本留
档。dev/cuda/ 共 22 个算子文件(含 attention、matmul、fused classifier、adamw、甚至
nccl_all_reduce.cu),外加 dev/cpu/matmul_forward.c 的 CPU 端 tiling 实验和
benchmark_on_modal.py 的云端跑基准入口。
4.3 dev/ 其余角落
| 位置 | 内容 |
|---|---|
dev/test/ | dataloader、outlier detector、device↔file IO 的小单测 |
dev/eval/ | 导出 HF 格式、跑 LM-eval-harness 风格评测的脚本 |
dev/unistd.h | Windows 移植层(unistd/glob 兼容) |
dev/vislog.ipynb | 解析训练日志画 loss 曲线 |
5. Makefile:自动探测 + 特性开关
Makefile(290 行)的目标与开关一览(Makefile:247-290):
| 目标 | 产物 |
|---|---|
train_gpt2 / test_gpt2 | CPU 版(clang/gcc + OpenMP) |
train_gpt2cu / test_gpt2cu | CUDA 主线(默认 bf16) |
train_gpt2fp32cu / test_gpt2fp32cu | 冻结的 fp32 教学版 |
profile_gpt2cu | 剖析靶子 |
关键开关:
USE_CUDNN=1:启用 cuDNN flash attention(编译时间从几秒涨到约一分钟,Makefile:24-25)。PRECISION=FP32:整链 fp32。NO_MULTI_GPU=1:去掉 NCCL/MPI 依赖。- GPU 自动探测:有
nvidia-smi就查询本机最低 compute capability 并生成对应--generate-code(Makefile:49-61),也可GPU_COMPUTE_CAPABILITY=80手动 指定。 -march=native等 CFLAGS 用「试编译一个空 main」探测可用性再加(Makefile:69-83)。
6. 教学资产:doc/layernorm 与冻结文件
doc/layernorm/layernorm.md:官方教程,把 LayerNorm 从 PyTorch 语义一路推到手写反向再到 C 实现,配套layernorm.py/layernorm.c。README 明确说它是「理解层实现的最佳起点」。train_gpt2_fp32.cu/test_gpt2_fp32.cu:主线在「单卡 fp32」时代的冻结快照——更简单、更 便携,README 推荐给 CUDA 初学者。
7. 关键细节与坑
- 对拍只在特定配置下严格。 debug_state 固定 B=8、T=64 的小批次(由
train_gpt2.py决定); 换超参要重新生成。容差是「调出来的工程值」,CPU 2e-2、bf16 相对 0.079——别把对拍当数学证 明。 - profile 假设各层同构。
num_layers = 1的裁法(profile_gpt2.cu:57-58)意味着 Layer 0 独有的 encoder 行为、最后一层的 lnf 融合要靠对代码的理解去脑补。 - cuDNN 默认关是因为它拖慢编译(
Makefile:24-25),而它是默认最快的 attention 路径—— 本地开发迭代和正式 跑分应该用不同开关。 - starter pack 要联网。 四个 .bin 要么
download_starter_pack.sh下载,要么先跑python train_gpt2.py生成;没有它们,测试和 demo 都无法运行(README quick start)。 - CI 的 GPU 测试依赖自托管 runner(ci_gpu.yml);fork 仓库里 GPU CI 默认不可用(inferred: 工作流按仓库环境配置)。
8. 代码地图
| 主题 | 文件路径 | 符号名 |
|---|---|---|
| CPU 对拍 | test_gpt2.c | check_tensor、main、expected_losses |
| GPU 对拍 | test_gpt2.cu | check_tensor、main |
| 出题人 | train_gpt2.py | write_model、write_state、write_tokenizer |
| 剖析靶子 | profile_gpt2.cu | main |
| 剖析自动化 | profile_gpt2cu.py | (脚本,ncu 指标采集) |
| NVTX 标签 | llmc/cuda_common.h | NvtxRange、NVTX_RANGE_FN |
| kernel 沙盒 | dev/cuda/*.cu | 每文件的 *_kernel1..N、main |
| 沙盒说明 | dev/cuda/README.md | (文档) |
| 教程 | doc/layernorm/layernorm.md | (文档) |
| 构建 | Makefile | 目标 train_gpt2cu 等;开关 USE_CUDNN、PRECISION、NO_MULTI_GPU |
| CI | .github/workflows/ci.yml、dev/loss_checker_ci.py | (配置与脚本) |