Capture the closed-loop run: train (loss 10.84->3.59) -> export (47 tensors, BF16) -> xserv dump-logits + greedy. Top-1 + top-11 token order identical, logits within ~1e-2 (BF16-vs-f32 drift), greedy generation token-for-token identical across two prompts. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
177 lines
11 KiB
Markdown
177 lines
11 KiB
Markdown
# Phase T9: Export to xserv — Design Document
|
||
|
||
## Goal
|
||
|
||
闭环 xtrain ↔ xserv:把 xtrain 练出的权重导成 **xserv 的 Qwen3 loader 能直接加载并服务**的格式
|
||
(HF 命名的 `config.json` + `model.safetensors` + 复用的 gpt2 `tokenizer.json`),让推理侧 xserv
|
||
跑出**与 xtrain 自身一致**的生成结果。
|
||
|
||
验收信号:**同一份权重 + 同一 prompt,xserv 的贪心生成 token 序列对住 xtrain 的贪心生成**
|
||
(logits 在浮点容差内吻合)。这是整条 P0→P6 学习链的收口——训练栈练出来的东西,推理栈真的能用。
|
||
|
||
> 范围与诚实边界:**不改 xserv**。xserv 是独立项目,T9 只调整 xtrain 的导出去适配 xserv 既有 loader。
|
||
> 架构差异中只有一项是「结构性」的(QK-norm,见下),处理方式见 **Key Design Decision 1**。
|
||
|
||
## 关键第一步:架构 diff(xtrain TinyTransformer vs xserv qwen3.rs)
|
||
|
||
逐 op 读 `crates/xtrain-model/src/model.rs` 对 `~/projects/xserv/crates/xserv-model/src/qwen3.rs`
|
||
(forward 路径 `forward_with_cache`,即 `dump-logits` 走的 prefill):
|
||
|
||
| 维度 | xtrain TinyTransformer | xserv Qwen3 | 是否兼容 / 处理 |
|
||
|------|------------------------|-------------|----------------|
|
||
| RMSNorm 公式 | `x*rsqrt(mean(x²)+eps)*γ`(无均值减) | 同 | ✅ 一致 |
|
||
| RMSNorm eps | `cfg.eps`(tiny=1e-5) | `rms_norm_eps`(config.json 提供) | ✅ 导出 eps 到 config |
|
||
| **QK-norm** | **T9 前没有** | **强制** per-head RMSNorm(Q,K)(`q_norm`/`k_norm`,shape `[head_dim]`) | **结构性差异** → 见 Decision 1 |
|
||
| RoPE 约定 | rotate_half,`freq=θ^(-2i/hd)`,pos=行号 | rotate_half,cos/sin cache `freq=1/θ^(2i/hd)`,pos=token idx | ✅ **逐式一致**(见 rope.cu)|
|
||
| RoPE θ | `cfg.rope_theta`(10000) | `rope_theta`(config 提供,默认 1e6) | ✅ 导出 θ 到 config |
|
||
| Attention scale | `1/√head_dim` | `1/√head_dim`(attention.rs / flash) | ✅ 一致 |
|
||
| Causal mask | 加性 `-1e9` 上三角 | causal flag(online softmax) | ✅ 同义 |
|
||
| GQA | MHA(无 kv 分组) | 支持 GQA(`num_kv_heads`) | ✅ 设 `num_key_value_heads = num_attention_heads`(退化为 MHA)|
|
||
| SwiGLU | `down(silu(gate)∘up)`,gate/up 独立 proj | 同(融合存 `gate_up_proj`,loader 内部 cat)| ✅ 一致,导出仍分开 gate/up(loader 自己 cat)|
|
||
| 偏置 | 无 | `attention_bias=false`,不读 bias | ✅ 一致 |
|
||
| final norm | `final_norm` 后接 lm_head | `model.norm` 后接 lm_head | ✅ 一致 |
|
||
| embedding tying | 独立 `lm_head` | 独立(`tie_word_embeddings=false`)| ✅ 一致 |
|
||
| 2D 权重 layout | `[in,out]`,算 `x@W` | `[out,in]`,算 `x@Wᵀ`(loader `.transpose(0,1)`)| ⚠️ 导出须转置 |
|
||
| **dtype** | **f32**(训练 + 自身推理) | **BF16 only**(kernel 全 assert BF16)| ⚠️ 导出转 BF16;数值上不可能 bit-exact,见 Decision 2 |
|
||
| vocab | gpt2 BPE,50257 | config 提供 | ✅ 导出 vocab |
|
||
|
||
**结论**:除 QK-norm 一项外,其余差异都是机械的(命名 / 转置 / dtype / 退化 GQA)。QK-norm 是
|
||
xserv 对 Qwen3 的**强制**步骤(`head_rmsnorm(q,q_norm)` / `head_rmsnorm(k,k_norm)` 无条件执行,
|
||
且 γ=1 也不是恒等——它仍按每个 head 向量的 RMS 做归一),xtrain 训练时从未施加 → 若不处理,
|
||
xserv 的前向数学与 xtrain 不同,闭环不可能成立。
|
||
|
||
## Key Design Decisions
|
||
|
||
### Decision 1:给 xtrain 加 per-head QK-norm(而不是伪造 match,也不是停在 blocker)
|
||
|
||
逃生舱里给了三条路:① 给 xtrain 补 QK-norm 重训以对齐 Qwen3;② 改 xserv(被禁止);
|
||
③ 当作 blocker 报告。选 ①——因为它**真的能闭环**且改动**外科手术级**:
|
||
|
||
- xserv 的顺序是 `reshape → head_rmsnorm → transpose_for_rope → rope`,即 **QK-norm 在 RoPE 之前**,
|
||
作用在每个 `[head_dim]` 的 head 向量上。
|
||
- xtrain 复用既有的 2D `rms_norm` op:把 `[seq,nh,hd]` reshape 成 `[seq*nh, hd]`,用 `[hd]` 的 γ
|
||
做 rms_norm,再 reshape 回去,**插在 reshape 与 rope 之间**——与 xserv 逐步对齐。autograd 全自动
|
||
(rms_norm/reshape 都已有 backward),优化器/checkpoint/DDP 都按 `params()` 泛化迭代,自动兼容。
|
||
- 每 block 新增 `q_norm`/`k_norm` 两个 `[head_dim]` leaf;`params()` 顺序在 `wv` 之后、`wo` 之前插入
|
||
`q_norm,k_norm`;`num_params()` 加 `2*head_dim/layer`;PyTorch 对拍参考(parity.py / adamw_parity.py)
|
||
同步加 QK-norm,名单同步——改完仍全套自洽。
|
||
|
||
这样导出的权重是**真·Qwen3 兼容**的(训练时就带 QK-norm),不是凑出来的假象。
|
||
|
||
### Decision 2:BF16 是 xserv 的硬约束 → 闭环判据用「贪心 token 一致」而非 bit-exact
|
||
|
||
xserv 的 Qwen3 前向 **只支持 BF16**(embedding/rmsnorm/gemm/silu/rope kernel 全部
|
||
`assert_eq!(dtype, BF16)`,`dump-logits` 也按 bf16 读 logits)。xtrain 是 f32。所以:
|
||
|
||
- 导出时把所有权重 `bf16::from_f32` 转成 BF16 写入 safetensors。
|
||
- **数值上不可能 bit-exact**:BF16 仅 8 位尾数,权重舍入 + 前向全程 BF16 累加,相对 xtrain 的 f32
|
||
会有 ~1e-2 量级的 logits 漂移。因此**闭环判据定为「同 prompt 下贪心 argmax token 序列一致」**
|
||
(+ 报告 logits 的 top-1/分布吻合度),这是 BF16 推理对 f32 训练能给出的最强、且诚实的判据。
|
||
|
||
### Decision 3:导出复用 train.rs 的 config + checkpoint,零猜测
|
||
|
||
导出器 `bin/export_safetensors.rs` 用与 `bin/train.rs` **完全相同**的 `Config`(gpt2 vocab、
|
||
`n_layers=4`、`Config::tiny()` 其余字段)建空模型 → `checkpoint::load_into` 灌入训练权重 →
|
||
按 `params()` 稳定序映射。tokenizer.json 直接 copy 进导出目录,两侧用同一份 BPE。
|
||
|
||
## 张量名 + layout 映射表
|
||
|
||
xtrain `params()` 序(T9 后):
|
||
`embed[vocab,dim]` → 每 block `[attn_norm, wq, wk, wv, q_norm, k_norm, wo, ffn_norm, w_gate, w_up, w_down]`
|
||
→ `final_norm[dim]` → `lm_head[dim,vocab]`。
|
||
|
||
| xtrain 参数 | shape | → HF Qwen3 名 | HF shape | 操作 |
|
||
|-------------|-------|---------------|----------|------|
|
||
| `embed` | `[vocab,dim]` | `model.embed_tokens.weight` | `[vocab,dim]` | keep(行索引两侧同)|
|
||
| `attn_norm` | `[dim]` | `model.layers.{i}.input_layernorm.weight` | `[dim]` | keep |
|
||
| `wq` | `[dim,dim]` | `…self_attn.q_proj.weight` | `[dim,dim]` | **transpose** |
|
||
| `wk` | `[dim,dim]` | `…self_attn.k_proj.weight` | `[dim,dim]` | **transpose** |
|
||
| `wv` | `[dim,dim]` | `…self_attn.v_proj.weight` | `[dim,dim]` | **transpose** |
|
||
| `q_norm` | `[head_dim]` | `…self_attn.q_norm.weight` | `[head_dim]` | keep |
|
||
| `k_norm` | `[head_dim]` | `…self_attn.k_norm.weight` | `[head_dim]` | keep |
|
||
| `wo` | `[dim,dim]` | `…self_attn.o_proj.weight` | `[dim,dim]` | **transpose** |
|
||
| `ffn_norm` | `[dim]` | `…post_attention_layernorm.weight` | `[dim]` | keep |
|
||
| `w_gate` | `[dim,ffn]` | `…mlp.gate_proj.weight` | `[ffn,dim]` | **transpose** |
|
||
| `w_up` | `[dim,ffn]` | `…mlp.up_proj.weight` | `[ffn,dim]` | **transpose** |
|
||
| `w_down` | `[ffn,dim]` | `…mlp.down_proj.weight` | `[dim,ffn]` | **transpose** |
|
||
| `final_norm` | `[dim]` | `model.norm.weight` | `[dim]` | keep |
|
||
| `lm_head` | `[dim,vocab]` | `lm_head.weight` | `[vocab,dim]` | **transpose** |
|
||
|
||
全部以 **BF16** dtype 写入。config.json 字段:`architectures=["Qwen3ForCausalLM"]`、`model_type="qwen3"`、
|
||
`vocab_size`、`hidden_size=dim`、`intermediate_size=ffn`、`num_hidden_layers`、`num_attention_heads`、
|
||
`num_key_value_heads=num_attention_heads`、`head_dim`、`rms_norm_eps=eps`、`rope_theta`、
|
||
`tie_word_embeddings=false`、`attention_bias=false`、`hidden_act="silu"`。
|
||
|
||
## Module Layout
|
||
|
||
```
|
||
crates/xtrain-model/src/model.rs # +q_norm/k_norm leaf;attention 插 per-head QK-norm;params() 序更新
|
||
crates/xtrain-model/src/config.rs # num_params() 计入 QK-norm γ
|
||
crates/xtrain-train/src/bin/export_safetensors.rs # 导出器(本 Phase 核心实现)
|
||
crates/xtrain-train/Cargo.toml # +half, +safetensors="0.5"(对齐 xserv), +bin
|
||
crates/xtrain-model/tests/parity{.py,_dump.rs} # PyTorch 对拍同步加 QK-norm
|
||
crates/xtrain-train/tests/adamw_parity{.py,_dump.rs}# 同上
|
||
```
|
||
|
||
## 验证方法
|
||
|
||
1. **本地**:`cargo check --workspace` + `cargo fmt --all -- --check` 过(导出器 GPU 体 gated 在
|
||
`not(no_cuda)`,host 侧只 check)。
|
||
2. **dash5(闭环)**:
|
||
```bash
|
||
export PATH=/usr/local/cuda/bin:/opt/wjh/.cargo/bin:$PATH
|
||
# ① 训练一个小模型 → checkpoint(或复用已训的)
|
||
cargo run -p xtrain-train --release --bin train -- \
|
||
/opt/wjh/models/gpt2/tokenizer.json data/tinystories-valid-3mb.txt \
|
||
<steps> /tmp/xtrain_tinystories.ckpt
|
||
# ② 导出
|
||
cargo run -p xtrain-train --release --bin export_safetensors -- \
|
||
/tmp/xtrain_tinystories.ckpt /opt/wjh/models/gpt2/tokenizer.json /tmp/xtrain_export
|
||
# ③ xserv 加载 + dump logits(同 prompt)
|
||
# (在 /opt/wjh/projects/xserv) cargo run -p xserv-model --release --bin dump-logits -- /tmp/xtrain_export "<prompt>"
|
||
# ④ 对拍:xserv 贪心 token 序列 vs xtrain 自身贪心(sample.rs generate, temp=0)
|
||
```
|
||
判据:**贪心 token 序列一致**(BF16 推理 vs f32 训练,logits top-1 吻合;分布在 BF16 容差内)。
|
||
|
||
## 验证结果(dash5 实跑,capture)
|
||
|
||
**训练**(CUDA_VISIBLE_DEVICES=0,1200 步,gpt2 vocab,dim 32 / 4 层 / 2 头 / ffn 64,~5M 参,8700 tok/s):
|
||
`loss 10.84 → 3.59`,贪心采样输出连贯英文(QK-norm 加入后训练/采样无回归)。
|
||
|
||
**导出**:`export: 47 tensors`(embed + 4×11 + final_norm + lm_head),写出 config.json(见上)+
|
||
model.safetensors(BF16,6.5 MB)+ tokenizer.json。
|
||
|
||
**① logits 数值对拍**(同 prompt `"Once upon a time"`,token ids `[7454, 2402, 257, 640]`):
|
||
|
||
| rank | xtrain (f32) | xserv (BF16) | token |
|
||
|------|--------------|--------------|-------|
|
||
| 0 | 11.7711 | 11.7500 | `,` |
|
||
| 1 | 10.4724 | 10.5000 | ` there` |
|
||
| 2 | 6.6288 | 6.6562 | ` upon` |
|
||
| 3 | 6.5125 | 6.5000 | ` to` |
|
||
| … | … | … | … |
|
||
| 10 | 5.3614 | 5.3438 | ` she` |
|
||
|
||
top-1 一致(`,`,id 11);top-11 token 排序完全一致;logit 绝对差 ~1e-2(~0.2–0.9% 相对),
|
||
正是 **BF16 推理 vs f32 训练** 的预期舍入漂移,无结构性误差。
|
||
|
||
**② 贪心生成逐 token 一致**(xserv `xserv-cli --max-tokens 40` vs xtrain `sample.rs generate temp=0`):
|
||
|
||
```
|
||
prompt "Once upon a time":
|
||
xtrain: Once upon a time, there was a little girl named Lily. Timmy loved to play
|
||
with her mommy. One day, Timmy's mommy's mommy's mommy. "I'm sorry, I
|
||
xserv: Once upon a time, there was a little girl named Lily. Timmy loved to play
|
||
with her mommy. One day, Timmy's mommy's mommy's mommy. "I'm sorry, I ← 逐 token 相同
|
||
|
||
prompt "One day":
|
||
两侧均: One day, Timmy's mommy's mommy's mommy. "I'm sorry, I can't be careful and
|
||
be careful. I'm sorry, I can't have a good time. ← 逐 token 相同
|
||
```
|
||
|
||
**结论:闭环成立**。xtrain 练出的权重,经导出后由 xserv 加载并服务,贪心生成与 xtrain 自身**逐 token 一致**,
|
||
logits 在 BF16 容差内吻合。整条 P0→P6 学习链收口。
|
||
|
||
> 注:xtrain 采样每步重跑全量 forward(无 KV cache),xserv 走 KV-cache prefill+decode;两者都是对同一
|
||
> logits 的 greedy argmax,故序列一致。BF16 漂移未在 40 步内造成任何 argmax 翻转。
|