Building an LLM Inference Engine from ScratchPart IV / Module 12
Part IV · Building xinfer

Module 12:Phase 3 — 组装 Qwen2 模型

从算子实现进入完整模型实现:解析 Qwen2 权重命名,建立 CPU reference forward 与 resident GPU forward,并处理 DirectML/D3D12 调试问题。

学习目标

  • 掌握 Qwen2 safetensors 的 tensor-name convention,并能从名字找到每层权重。
  • 实现完整 CPU forward,作为 whole-network correctness oracle。
  • 实现 resident GPU forward:所有权重上传一次并留在 GPU 上。
  • 理解如何用 D3D12 debug layer 与 device-removed reason 调试 GPU hang。
  • 理解为什么 DirectML GEMM fault 被 custom HLSL matmul 替代。
  • 完成 Lab 12:synthetic checkpoint 的 GPU logits 与 CPU reference 匹配。

12.1权重加载与 tensor-name 约定

Phase 3 的输入从单个 kernel 扩展为一组 HuggingFace Qwen2 safetensors。完整 forward 首先依赖稳定的权重寻址:同一层的 norm、attention 与 MLP 张量必须映射到确定的结构字段,否则后续算子即使各自正确,整网也会出错。

crates\xinfer-model\src\names.rs 将 HuggingFace 名称集中封装。第 ii 层的 12 个 layer tensor 按如下规范命名:

model.layers.{i}.input_layernorm.weight
model.layers.{i}.self_attn.q_proj.weight
model.layers.{i}.self_attn.q_proj.bias
model.layers.{i}.self_attn.k_proj.weight
model.layers.{i}.self_attn.k_proj.bias
model.layers.{i}.self_attn.v_proj.weight
model.layers.{i}.self_attn.v_proj.bias
model.layers.{i}.self_attn.o_proj.weight
model.layers.{i}.post_attention_layernorm.weight
model.layers.{i}.mlp.gate_proj.weight
model.layers.{i}.mlp.up_proj.weight
model.layers.{i}.mlp.down_proj.weight

另外还有全局权重:

model.embed_tokens.weight
model.norm.weight
lm_head.weight        # 若 tie_word_embeddings=false 才单独存在

gpu_model.rs 中的 LayerWeights 按同一顺序保存这些张量:input_ln, q_w, q_b, k_w, k_b, v_w, v_b, o_w, post_ln, gate_w, up_w, down_w。其中 q/k/v/o 与 gate/up/down 七个矩阵在 GPU 侧以 f16 存放,bias 与 norm weight 保持 f32。lm_head.weighttie_word_embeddings=true 或文件缺失时回退到 model.embed_tokens.weight,这一点与 CPU reference 保持一致。

safetensors names model.layers.0.self_attn.q_proj.weight model.layers.0.self_attn.k_proj.weight model.layers.0.self_attn.v_proj.bias model.layers.0.mlp.gate_proj.weight ... LayerWeights q_w, k_w, v_b, ... GPU buffers DmlBuffer names.rs 生成规范字符串
图 12-1:权重名字先映射到结构化字段,再上传成 GPU buffer。

12.2CPU reference forward:整个网络的 oracle

CPU reference forward 位于 crates\xinfer-model\src\cpu_model.rs。它直接读取 ModelWeights 中的 f32 数据,按照 Qwen2 decoder-only 结构执行 embedding、逐层 attention/MLP、final RMSNorm 与 LM head。对输入 token 序列 x1,,xSx_1,\ldots,x_S,计算可概括为:

X0=E[x1,,xS]X_0 = E[x_1,\ldots,x_S]
X+1=DecoderLayer(X),=0,,L1X_{\ell+1} = \operatorname{DecoderLayer}_\ell(X_\ell), \qquad \ell=0,\ldots,L-1
Z=RMSNorm(XL)WlmZ = \operatorname{RMSNorm}(X_L)W_{\text{lm}}^\top

CPU reference 不追求速度,它的作用是提供 whole-network correctness oracle。单 kernel parity 只能证明某个局部算子满足给定输入输出关系;完整模型还可能在 tensor name、矩阵转置、bias、RoPE 绝对位置、GQA head 映射或残差连接上出错。整网 logits 对比把这些组装错误纳入同一验收口径。

tokenssame input CPU forwardsimple ops GPU forwardresident buffers Compare logitsmax abs diff Passargmax stable
图 12-2:整网 parity 测试能发现单算子测试发现不了的组装错误。

12.3Resident GPU forward:权重留在 GPU 上

GPU forward 的第一版采用 resident 模式。QwenModel::load 在加载阶段上传全部 decoder layer 权重,以及 final norm 与 LM head;推理阶段只为当前 token 序列准备 embedding activation 与中间 buffer,不重复传输权重。该模式显存占用较高,但执行路径固定,适合建立 GPU 与 CPU 的整网 parity。

以一层为例,GPU forward 的顺序与 CPU forward 一致:

  1. RMSNorm XAX_\ell\rightarrow A
  2. Q/K/V linear + bias
  3. RoPE(Q/K)
  4. GQA attention
  5. o_proj + residual add 得到 UU_\ell
  6. RMSNorm UBU_\ell\rightarrow B
  7. gate/up linear、SwiGLU、down linear
  8. residual add 得到 X+1X_{\ell+1}
为什么 resident 是第一版?

Resident 模式减少了变量:权重一经上传便不再移动,调试重点集中在计算顺序、资源状态与数值误差。只有在 resident 路径通过整网验证后,layer streaming 才有可靠的参照对象。

12.4调试 GPU hang:debug layer 与 device removed

GPU 错误通常不会以普通 Rust panic 的形式出现。常见现象包括 command queue 提交失败、readback 等待异常、进程挂起以及 device removed。D3D12 调试时至少需要两类信息:

  • **D3D12 debug layer:**输出资源状态、binding、非法 API 使用等诊断信息。
  • **GetDeviceRemovedReason:**当 device removed 时给出 HRESULT,例如 0x887A0005

DmlDevice::new(true) 会尝试启用 D3D12 debug layer 与 GPU-based validation;drain_debug_messagesID3D12InfoQueue 输出诊断消息;removed_reason 调用 GetDeviceRemovedReason 返回 HRESULT 字符串。它们使错误定位从“最终 readback 失败”前移到具体 API 使用、资源状态或 device removed 原因。

GPU failurehang / removed Debug layerAPI / state messages Removed reasonHRESULT Isolate shape/opsingle test Replace/fixcustom kernel
图 12-3:GPU hang 调试流程:打开 debug layer、查询 removed reason、隔离 shape/op、替换或修复。

12.5解决 DirectML GEMM fault:改用自写 HLSL matmul

在本项目的 RDNA4 开发环境中,DirectML GEMM operator 在部分 shape 上会触发 device removed(HRESULT 0x887A0005),例如 M=5,K=64,N=32M=5,K=64,N=32。单独隔离 shape/op 后可以确认,问题出现在特定硬件、驱动或 DirectML fallback 路径组合上。

因此模型主路径改为自写 HLSL matmul,DirectML operator wrapper 保留为 bring-up 与对照实现。后续优化集中在以下方向:

  • f16 weights:q/k/v/o 与 gate/up/down 矩阵、LM head 以 f16 存放,降低显存与带宽压力;
  • coalesced GEMV:改善权重读取与 reduction 访存模式;
  • 2D dispatch grid:覆盖 Qwen2.5 的 151936 维 LM head 输出;
  • GPU argmax:greedy decode 时只读回一个 token id,避免每 token 读回完整 logits。
图 12-4:Phase 3 的关键路径:先保证整网正确,再处理 GEMM fault 与自写 matmul。

Lab 12synthetic checkpoint:GPU logits 匹配 CPU reference

Lab 12 使用 synthetic tiny Qwen2 checkpoint 建立第一轮验收。该 checkpoint 的 hidden size、层数与词表都很小,但结构覆盖 GQA、QKV bias、SwiGLU 以及 tied/untied LM head,足以暴露模型组装错误,同时避免真实 0.5B 权重带来的加载与定位成本。

验收目标:

maxizigpuzicpu<ε\max_i |z^{gpu}_i-z^{cpu}_i| < \varepsilon
# 关键验收命令
cargo test -p xinfer-model --test forward_parity -- --nocapture

# 期望看到类似:
gpu_forward_matches_cpu ... ok
kv_cache_decode_matches_full ... ok
streaming_matches_resident ... ok
调试顺序

如果整网 logits 不匹配,应按 layer 缩小范围:先比较 embedding 后的 X0X_0,再逐层检查 attention output、MLP output 与 residual。最终 logits 是多层误差传播后的结果,直接从它推断根因通常效率较低。

小结

本章完成从算子到完整 Qwen2 模型的过渡。核心实现包括:用 names.rs 固化 HuggingFace tensor 命名;用 CPU reference forward 作为整网 oracle;用 resident GPU forward 建立稳定的 GPU 路径;通过 D3D12 debug layer 与 GetDeviceRemovedReason 定位 device removed;在 DirectML GEMM 触发故障的 shape 上改用 shaders\linear*.hlsl 自写 matmul。Lab 12 的验收标准从单个 kernel 扩展到整网层面:GPU logits 必须与 CPU reference 匹配。

思考与练习

基础为什么要用 synthetic checkpoint 做第一轮整网测试?

synthetic(小而随机初始化的)checkpoint 让你能用极小的层数/维度快速跑通整网前向,并和 CPU 参考逐元素对比,定位组装错误(权重命名、层连接、残差、归一化顺序)。它不依赖下载几百 MB 的真实权重,跑得快、可放进 CI,且因为维度小便于手动核对。等整网 parity 通过后,再换真实 Qwen2.5-0.5B 验证端到端。

基础列出 Qwen2 第 0 层 attention 的 8 个主要权重/偏置名字。

model.layers.0.self_attn.q_proj.weight.q_proj.bias.k_proj.weight.k_proj.bias.v_proj.weight.v_proj.bias.o_proj.weight(o_proj 无 bias)。共 7 个 attention 张量;Qwen2 的特点是 q/k/v 都带 bias 而 o_proj 不带。若把同层的 input_layernorm.weight 也算上则凑足 8 个。

进阶如果忘记加 q_proj.bias,单个 matmul kernel parity 会失败吗?整网 parity 会怎样?

单个 matmul kernel 的 parity 测试只测 Y=XWY=XW^\top 本身,不涉及 bias,所以它仍会通过——bias 是在 linear 之后单独加的。但整网 parity 会失败:少了 q 偏置,Q 数值偏移,经 attention 传播后 logits 与 CPU 参考(含 bias)不一致,max abs diff 超容差。这说明算子级测试通过不代表组装正确,仍需整网 parity 兜底。

进阶解释 resident GPU forward 与 streaming forward 的正确性关系。

两者计算的是同一个数学函数,只是权重的“住处”不同:resident 把所有层权重常驻显存;streaming 把权重留在主机 RAM,按需上传到一个轮转的 GPU slot 池,并用 copy 队列与 compute 重叠。只要上传的内容、绑定和计算顺序正确,二者输出必须逐位(或在容差内)一致。xinfer 用 streaming_matches_resident 测试断言 diff=0,即把 streaming 的正确性锚定到已验证的 resident 路径上。

挑战设计一个逐层 dump 工具:输出 CPU/GPU 每层 max abs diff,并定位第一层不匹配。

思路:让 GPU forward 与 CPU 参考 forward 都在每层结束后暴露该层 hidden state。工具按层循环:① 取 CPU 该层输出 hcpuh^{cpu}_\ell 与 GPU 输出 hgpuh^{gpu}_\ell(readback);② 计算 max_abs_diff = max(abs(h_cpu - h_gpu)) 并打印 \ell 与该值;③ 设一个容差(如 1e-2,f16 路径放宽),第一处超过容差的层即“首个发散层”,立即报告并可 dump 该层输入与各子模块(norm/attn/mlp)中间量进一步缩小范围。

关键点:逐层比较能把“最终 logits 不对”定位到具体某层某算子;从第一处发散层入手,因为后续层的 diff 是被前面污染的、无独立意义。实现上给 QwenModel 加一个可选的 per-layer hook/回调即可。