Files
xtrain/docs/08-export-xserv.md
Gahow Wang 8981cf7982 docs: T9 verification results (xserv == xtrain, dash5)
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>
2026-06-15 17:37:46 +08:00

11 KiB
Raw Blame History

Phase T9: Export to xserv — Design Document

Goal

闭环 xtrain ↔ xserv把 xtrain 练出的权重导成 xserv 的 Qwen3 loader 能直接加载并服务的格式 HF 命名的 config.json + model.safetensors + 复用的 gpt2 tokenizer.json),让推理侧 xserv 跑出与 xtrain 自身一致的生成结果。

验收信号:同一份权重 + 同一 promptxserv 的贪心生成 token 序列对住 xtrain 的贪心生成 logits 在浮点容差内吻合)。这是整条 P0→P6 学习链的收口——训练栈练出来的东西,推理栈真的能用。

范围与诚实边界:不改 xserv。xserv 是独立项目T9 只调整 xtrain 的导出去适配 xserv 既有 loader。 架构差异中只有一项是「结构性」的QK-norm见下处理方式见 Key Design Decision 1

关键第一步:架构 diffxtrain 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.epstiny=1e-5 rms_norm_epsconfig.json 提供) 导出 eps 到 config
QK-norm T9 前没有 强制 per-head RMSNorm(Q,K)q_norm/k_normshape [head_dim] 结构性差异 → 见 Decision 1
RoPE 约定 rotate_halffreq=θ^(-2i/hd)pos=行号 rotate_halfcos/sin cache freq=1/θ^(2i/hd)pos=token idx 逐式一致(见 rope.cu
RoPE θ cfg.rope_theta10000 rope_thetaconfig 提供,默认 1e6 导出 θ 到 config
Attention scale 1/√head_dim 1/√head_dimattention.rs / flash 一致
Causal mask 加性 -1e9 上三角 causal flagonline softmax 同义
GQA MHA无 kv 分组) 支持 GQAnum_kv_heads num_key_value_heads = num_attention_heads(退化为 MHA
SwiGLU down(silu(gate)∘up)gate/up 独立 proj 同(融合存 gate_up_projloader 内部 cat 一致,导出仍分开 gate/uploader 自己 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 onlykernel 全 assert BF16 ⚠️ 导出转 BF16数值上不可能 bit-exact见 Decision 2
vocab gpt2 BPE50257 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] leafparams() 顺序在 wv 之后、wo 之前插入 q_norm,k_normnum_params()2*head_dim/layerPyTorch 对拍参考parity.py / adamw_parity.py 同步加 QK-norm名单同步——改完仍全套自洽。

这样导出的权重是真·Qwen3 兼容的(训练时就带 QK-norm不是凑出来的假象。

Decision 2BF16 是 xserv 的硬约束 → 闭环判据用「贪心 token 一致」而非 bit-exact

xserv 的 Qwen3 前向 只支持 BF16embedding/rmsnorm/gemm/silu/rope kernel 全部 assert_eq!(dtype, BF16)dump-logits 也按 bf16 读 logits。xtrain 是 f32。所以

  • 导出时把所有权重 bf16::from_f32 转成 BF16 写入 safetensors。
  • 数值上不可能 bit-exactBF16 仅 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 完全相同Configgpt2 vocab、 n_layers=4Config::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_sizehidden_size=dimintermediate_size=ffnnum_hidden_layersnum_attention_headsnum_key_value_heads=num_attention_headshead_dimrms_norm_eps=epsrope_thetatie_word_embeddings=falseattention_bias=falsehidden_act="silu"

Module Layout

crates/xtrain-model/src/model.rs            # +q_norm/k_norm leafattention 插 per-head QK-normparams() 序更新
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闭环
    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=01200 步gpt2 vocabdim 32 / 4 层 / 2 头 / ffn 64~5M 参8700 tok/s loss 10.84 → 3.59贪心采样输出连贯英文QK-norm 加入后训练/采样无回归)。

导出export: 47 tensorsembed + 4×11 + final_norm + lm_head写出 config.json见上+ model.safetensorsBF166.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 11top-11 token 排序完全一致logit 绝对差 ~1e-2~0.20.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 cachexserv 走 KV-cache prefill+decode两者都是对同一 logits 的 greedy argmax故序列一致。BF16 漂移未在 40 步内造成任何 argmax 翻转。