arganzheng · 2026-01-22
一个想法,怎么变成一次能跑、能算、能复现的实验?
%%{init: {"flowchart": {"wrappingWidth": 190}}}%%
flowchart TB
P1["01 Python 使用层"] --> P2["02 NumPy / Pandas / Matplotlib"]
P2 --> P3["03 PyTorch(上)"]
P3 --> P4["04 PyTorch(下)"]
P3 & P4 --> P5["05 Hugging Face"]
P4 --> P6["06 GPU 直觉与实验管理"]
P1 -. "@dataclass 配置、协议方法" .-> P3
| 篇 | 一个数字 |
|---|---|
| 01 Python | 19.4 MB 文件读成 list 要 102.9 MB,生成器 11.5 MB;8 线程 1.0×、8 进程 3.1× |
| 02 三剑客 | 30 行手写 attention 与 PyTorch 对到 2.65 × 10⁻⁷ |
| 03 PyTorch 上 | 84 万参数的小 Transformer,一分钟 PPL 128 → 9.6,首步 loss 5.07 ≈ ln 128 |
| 04 PyTorch 下 | 每个可训练参数 16 字节;8B 全量 128.5 GB / LoRA 16.7 GB / QLoRA 5.1 GB |
| 05 Hugging Face | Qwen2.5-0.5B 上 LoRA 可训练 1.78%;85% 的 token 被 mask |
| 06 GPU 直觉 | decode 每 token 搬 16 GB 权重 ÷ 3.35 TB/s ≈ 4.8 ms;ridge ≈ 295 FLOP/字节 |
这一撮语法各是一个 Python 协议,PyTorch 建在上面;GIL 下只有进程池真并行
| 语法 | 它是什么 | 对应的 PyTorch API |
|---|---|---|
__getitem__ / __len__ |
序列协议 | Dataset |
yield |
生成器 | DataLoader 的惰性取数 |
__call__ |
可调用对象 | model(x)(先跑 hooks 再 forward) |
@torch.no_grad() |
装饰器 = 上下文管理器 | 推理评测必加 |
**kwargs |
关键字透传 | model(**batch) |
@dataclass |
配置对象 | asdict(cfg) 存成 config.json |
list 峰值 102.9 MB(5.3 倍);生成器流水线 11.5 MB——与文件多大无关Pool.map 的函数要在模块顶层、全局变量在子进程里是拷贝——DataLoader worker、datasets.map(num_proc=8)、DDP 的每个 rank 都是同一套规矩_call_impl、找自己文件的最后一帧「沿哪个维度」= 轴;与参考实现对到浮点精度才算对了

ci95 = 1.96 √(p(1−p)/n):80 道题 ±10.9,5 个点的差分辨不出scores = np.einsum("btd,bsd->bts", q, k) / np.sqrt(d) # [B, T, S]
scores = np.where(mask, scores, -np.inf) # 因果 mask
p = np.exp(scores - scores.max(-1, keepdims=True)) # 减最大值再 exp
p = p / p.sum(-1, keepdims=True)
out = np.einsum("bts,bsd->btd", p, v) # [B, T, D]
(3,) + (3, 1) 悄悄变成 (3, 3)——关键处 assert 形状F.scaled_dot_product_attention 最大误差 2.65 × 10⁻⁷:float32 约 1e-6、float64 约 1e-12 才叫「对了」只有五个对象、二十行循环;Trainer 是二十行加外壳
%%{init: {"flowchart": {"wrappingWidth": 120}}}%%
flowchart TB
DS["<b>Dataset</b>"] --> DL["<b>DataLoader</b>"]
DL -- "batch" --> M["<b>nn.Module</b>"]
M -- "logits" --> L["<b>loss</b>"]
L -- "backward()" --> AG["<b>Autograd</b>"]
AG -- ".grad" --> OPT["<b>Optimizer</b>"]
OPT -- "新参数" --> M
classDef data fill:#eefaf0,stroke:#4d9a5c,color:#222
classDef model fill:#fff7e0,stroke:#c98a00,stroke-width:2px,color:#222
classDef grad fill:#eef6ff,stroke:#5b8fd6,color:#222
class DS,DL data
class M,L model
class AG,OPT grad
for step, (x, y) in enumerate(loader):
logits = model(x) # [B, T, V]
loss = F.cross_entropy(logits.view(-1, V).float(), # loss 用 fp32 算
y.view(-1), ignore_index=-100)
loss.backward() # 梯度是【累加】的
torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0)
opt.step(); sched.step()
opt.zero_grad(set_to_none=True) # 所以要清零
zero_grad 不是 bug 就是梯度累积(小 batch 模拟大 batch)no_grad:中间量等一个不会来的 backward,显存涨到 OOMlr 调度 = warmup 后 cosine 降到峰值的 1/10
loss.item() 慢:CPU 异步发 kernel,.item() 要等 GPU 队列清空autocast 慢 30 倍:精度的收益来自硬件每个可训练参数 16 字节;激活另算;第一道约束是有几张卡
| 每个可训练参数 | bf16 副本 | 梯度 | fp32 主权重 | Adam m、v | 合计 |
|---|---|---|---|---|---|
| 字节 | 2 | 2 | 4 | 4 + 4 | 16 |
| Llama-3-8B | 算法 | 显存(不含激活) |
|---|---|---|
| 全量微调 | 16 字节 × 8.03B | 128.5 GB |
| LoRA(r = 16,可训练 0.5%) | 底座 bf16 16 GB + 14 字节 × 42M | 16.7 GB |
| QLoRA(4-bit 底座) | 底座 4 GB + 同上 | 5.1 GB |
flowchart LR
W32["fp32 主权重"] -- "① autocast 转 bf16" --> W16["bf16 副本"]
W16 -- "② 矩阵乘" --> ACT["bf16 激活"]
ACT -- "③ 升回 fp32" --> LOSS["fp32 loss"]
LOSS -- "④ backward" --> G["梯度"]
G --> OPT["AdamW m、v"]
OPT -- "⑤ fp32 上加" --> W32
autocast 不把参数变成 bf16:减半的是矩阵乘的中间结果,训练状态仍是 16 字节 / 参数backward 后 → 梯度 + 状态;加了 LoRA 还 OOM → 看激活六个库各管一段,六行组装
%%{init: {"flowchart": {"wrappingWidth": 200}}}%%
flowchart TB
HUB["`**Hub**`"]
DS["`**datasets**`"]
TOK["`**tokenizers**`"]
TF["`**transformers**`"]
PEFT["`**peft**`"]
TRL["`**trl**`"]
ACC["`**accelerate**`"]
HUB --> DS & TF
DS --> TOK --> TRL
TF --> PEFT --> TRL
TRL --> ACC
classDef lib fill:#eef4fb,stroke:#5b8dc9,color:#222
classDef hub fill:#fff7e0,stroke:#c98a00,stroke-width:2px,color:#222
class DS,TOK,TF,PEFT,TRL,ACC lib
class HUB hub
safetensors = JSON 索引 + 字节区,可 mmap 只读一层
config.json:Qwen2.5-0.5B 24 层、hidden 896、词表 151936、tie_word_embeddingstokenizer.json + chat template:哪些 token 算 lossds = load_dataset("...", split="train") # datasets
tok = AutoTokenizer.from_pretrained(name) # chat template → __getitem__
model = AutoModelForCausalLM.from_pretrained(name, dtype=torch.bfloat16)
model = get_peft_model(model, LoraConfig(r=16, lora_alpha=32, ...)) # ΔW = (α/r)·BA
trainer = SFTTrainer(model, train_dataset=ds, args=SFTConfig(...)) # loss mask = ignore_index=-100
trainer.train() # 二十行加外壳
ridge ≈ 295;decode 强度 ≈ 1 是 memory-bound,batch 大才快
| 问题 | 算术 |
|---|---|
| decode 每 token 多久 | (16.06\,\text{GB} \div 3.35\,\text{TB/s} \approx 4.8\,\text{ms}) → 209 token/s;4-bit 量化字节 1/4、时间也 1/4 |
| prefill 4096 token | 2 × 8B × 4096 ≈ 66 TFLOP ÷ 989 TFLOPS ≈ 67 ms,compute-bound |
| 训练 MFU | 40–50% 算正常;反向 ≈ 2 × 前向 |
| 显存四块 | 权重 · 梯度与优化器状态 · 激活 · KV cache(131 KB / token) |
asdict(cfg) → config.json)· 数据版本(revision)· seed · 环境(pip freeze、GPU 型号与卡数)· 指标use_deterministic_algorithms;换个 seed 差 0.14——20 步训练里 seed 的影响比很多「方法改进」都大copy_、launch 开销或 GPU 空转之一| 线 | 从第一篇到第六篇 |
|---|---|
| 形状 | __getitem__ 取一条 → 轴与广播 → Tensor → 激活的形状与大小 → KV cache 与 profiler 表里每个算子 |
| 字节的账 | nbytes → dtype → 16 字节 / 参数、128.5 GB → 0.5B 上验账 141 MB → 4.8 ms / token |
| 惰性与进程 | 生成器、GIL → DataLoader worker → torchrun 每 rank 一进程 → datasets 的 streaming / num_proc |
| 从封装回到二十行 | 协议对照表 → 二十行 → 六行 SFT 的每件事落在哪一行 → OOM 归四块、慢归 profiler 表 |
| 一个数字不算结论 | ci95 与阴影带 → seed 差 0.14 → 七项记录 |
越过这些表能解释的范围(一个算子太慢、一个 OOM 调参绕不过),就是进 Infra 地图的信号。
(3,) + (3, 1) 悄悄成 (3, 3)autocast 把参数变成 bf16、显存减半——参数精度不变,训练状态仍 16 字节 / 参数