nano-vllm-omni · 提示 ← M06M08 →
#7 · Track M · Qwen-Omni 模型实现 · 书 p.41

M07 · 按 stage 加载与打包权重

一个 Qwen3-Omni checkpoint 同时装着 thinker、talker、code2wav 的权重;每个 stage 进程只该打开自己的 shard、只取自己的张量,并把 q/k/v、gate/up、各 expert 打包成 kernel 友好的布局。

⏱ 45 min 难度 中 实现 mine/m07_stage_weight_loading.py 验收 ./check.sh m07 提示 A-7 · 参考解 C-7 solutions/m07_stage_weight_loading.py

题面速览

qkv    = cat([q_proj, k_proj, v_proj], dim=0)   # [(H + 2·KVH)·D, hidden]   一次 GEMM 出三者
w13    = stack_e cat([gate_e, up_e], dim=0)     # [E, 2I, hidden]   FusedMoE 的 w1/w3
w2     = stack_e down_e                         # [E, hidden, I]
router = mlp.gate.weight                        # [E, hidden]

要实现:shards_for_stage(weight_map, stage)、select_stage(state, stage, strip)、pack_layer(sd, i)、load_thinker_text(state, num_layers)。

验收要点:只挑本 stage 的 shard · select_stage 去前缀且丢弃其他 stage · 打包后的 qkv / w13 / w2 复现 HF 投影与 expert 输出 · 缺失与多余 key 都报错。

直觉与类比

合租仓库搬家

三户人家的东西存在同一个仓库、分装在若干大箱子(shard)里,每件物品贴着“户名.房间.物品”的标签(key)。门口有一张总清单(weight_map)。搬家时每户只拉装有自家东西的箱子——有的箱子混着两户的东西,两户都得拉。开箱后只拿标签以自家户名开头的物品,并把“户名.”撕掉。标签必须连同那个点一起比:thinker 和 thinker_extra 前几个字一样,但不是一家人。

钥匙串与装箱单

q、k、v 三把钥匙开同一扇门(输入都是 hidden),串到一个钥匙圈上(cat(dim=0)),开门一次动作(一次 GEMM),再按 [H·D, KVH·D, KVH·D] 分开。gate / up 同理串成 w13——顺序约定好:先 gate 后 up,串反了照样插得进去,只是开错了门。搬完拿装箱单逐项打勾(used 集合):没打勾的(多余 key)和找不到的(缺失 key)都要喊停。

框架视角 · shape trace

tiny 随机 thinker(hidden=64,H=4,KVH=2,D=16,E=6,I=32),第 0 层(实测):

HF key(去前缀后) HF 形状 打包后 发生了什么
self_attn.{q,k,v}_proj.weight [64,64]、[32,64]、[32,64] qkv [128, 64] 沿 dim 0 拼:64 + 32 + 32 = (4 + 2·2)·16;(x @ qkv.T).split([64,32,32]) 的 q 与 HF 最大差 0.0
mlp.experts.{e}.gate_proj / up_proj [32, 64] ×2 ×6 w13 [6, 64, 64] 每个 expert 内 cat,再按 e = 0..5 stack
mlp.experts.{e}.down_proj [64, 32] ×6 w2 [6, 64, 32] 按 e stack
mlp.gate.weight [6, 64] router [6, 64] 名字叫 gate,但它是 router,不是 gate_proj
norm × 4、o_proj 原样 原样 input_layernorm / post_attention_layernorm / q_norm / k_norm
层外 embed_tokens.weight、norm.weight tiny 模型共 56 个 key:2 个层外 + 2 层 × 27 个

易错点

逐步图:pack_layer 的记账

▶ H=2, KVH=1, D=2, hidden=4, E=2, I=3(每个张量填常数便于看来源)
1. 切出本层子字典

p = "layers.0."(带结尾点);sub = {去前缀后的 key: tensor},之后所有操作都在 sub 上,key 变成 self_attn.q_proj.weight 这样的短名。

sd 里同时存在 layers.1.* 也没关系。

2. 收集 expert(正则 + used)

^mlp\.experts\.(\d+)\.(gate_proj|up_proj|down_proj)\.weight$

experts = {0: {gate_proj, up_proj, down_proj}, 1: {gate_proj, up_proj, down_proj}}
used += 这 6 个 key

检查:非空、sorted(experts) == list(range(len(experts)))、每个 expert 的 proj 集合恰好是三种。

3. take 必需字段,打包
qkv [8, 4]     第 0 列 = [1, 1, 1, 1, 2, 2, 3, 3]          # q 4 行 | k 2 行 | v 2 行
w13 [2, 6, 4]  w13[:, :, 0] = [[1, 1, 1, 2, 2, 2],
                               [11, 11, 11, 12, 12, 12]]   # 每个 expert:gate 3 行 | up 3 行
w2  [2, 4, 3]  w2[:, 0, 0] = [3, 13]
router [2, 4]

(q 填 1、k 填 2、v 填 3;expert e 的 gate/up/down 填 10e+1 / 10e+2 / 10e+3)

4. 最后检查多余 key

unexpected = set(sub) - used,非空就 LoadError。

修改 pack_layer(sd, 0)
原样 成功
额外加入 expert 3(id 变成 0,1,3) LoadError(id 不连续)
删掉 mlp.experts.1.down_proj.weight LoadError(expert 1 不完整)
加入 layers.0.self_attn.rotary_emb.inv_freq LoadError(多余 key)
删掉 layers.0.self_attn.k_norm.weight LoadError(缺 key)
加入 layers.10.input_layernorm.weight 成功(那是第 10 层)

三级提示

L1I/O 示例与关键问题I/O 示例 + 关键问题 + 细节(轻度剧透)

shards_for_stage:

weight_map = {
  "thinker.model.embed_tokens.weight":      "model-00001.safetensors",
  "thinker.model.layers.0.mlp.gate.weight": "model-00002.safetensors",
  "talker.model.codec_embedding.weight":    "model-00002.safetensors",
  "talker.code_predictor.lm_head.0.weight": "model-00003.safetensors",
  "code2wav.decoder.0.conv.weight":         "model-00003.safetensors",
  "thinker.lm_head.weight":                 "model-00004.safetensors",
}
shards_for_stage(weight_map, "thinker")  -> ["model-00001…", "model-00002…", "model-00004…"]
shards_for_stage(weight_map, "talker")   -> ["model-00002…", "model-00003…"]
shards_for_stage(weight_map, "code2wav") -> ["model-00003…"]

同一个 shard 可同时属于两个 stage;结果去重并排序。thinker.lm_head.* 也属于 thinker stage。

select_stage(state, "thinker", "thinker.model."):keys thinker.model.norm.weight、thinker.model.layers.0.input_layernorm.weight、thinker.lm_head.weight、talker.model.norm.weight、thinkers.model.x → {"layers.0.input_layernorm.weight", "norm.weight"}。值原样保留,只改 key。

load_thinker_text:多一个 thinker.model.rotary_emb.inv_freq → LoadError;num_layers 比实际多 → 那层什么都没有 → LoadError;thinker.lm_head.weight 和 talker.* 存在不算错误。

关键问题

  1. 判断 key 属于哪个 stage 该用哪个前缀?为什么 "thinker"(不带点)不够安全?
  2. 去前缀时 "thinker.model.norm.weight" 变成什么?"thinker.lm_head.weight" 会怎样?
  3. 收集第 i 层为什么必须用 "layers.{i}."(带结尾点)?i=1 时不带点会误收哪些层?
  4. qkv 的行数是多少?为什么沿 dim 0 拼而不是 dim 1?
  5. w13 是先对每个 expert cat(gate, up) 再 stack,还是先 stack 所有 gate 再和 up 拼?
  6. 如何判断 expert id “恰好是 0..E-1 且完整”?E 从哪里得知?
  7. “多余 key”怎么检测?需要记录哪些 key 已被使用?

细节

  • stage 级前缀("thinker.")vs strip 前缀("thinker.model."):用 startswith + k[len(strip):]。
  • nn.Linear.weight 是 [out, in],多个共享输入的 Linear 合并就是在 out 维(行)上拼。
  • PackedLayer 的 9 个字段与 HF key 一一对应;mlp.gate.weight 是 router。
  • load_thinker_text 返回的第一个元素是 {"embed_tokens": ..., "norm": ...}(key 里没有 .weight)。
L2思路与常见坑思路拆分与常见坑(中度剧透)
  • shards_for_stage:一行集合推导,sorted(set)。
  • pack_layer:切出 sub;小助手 take(name):不存在 raise LoadError,存在就记入 used 并返回。expert key 匹配成功时也加入 used。
  • 收集 experts:experts.setdefault(int(id), {})[proj] = v;检查非空、连续、三种 proj 齐全;E = len(experts)。
  • 打包:qkv = cat([q, k, v], 0);w13 = stack([cat([gate_e, up_e], 0) for e in range(E)]);w2 = stack([down_e ...])。按 e = 0..E-1 的顺序。
  • 顺序陷阱:多余 key 检查必须在所有 take 之后。
  • load_thinker_text:select_stage(state, "thinker", "thinker.model.") → 对 range(num_layers) 调 pack_layer → 检查非 layers. 开头的 key 集合恰好是 {embed_tokens.weight, norm.weight}。
L3详解:答案与自检详解:答案、逐步追踪、骨架提示(重度剧透)
关键问题答案
  1. STAGE_PREFIX[stage](带点);不带点会让 "thinkers.x"、"talker2.x" 被误判。
  2. "norm.weight";thinker.lm_head.weight 不以 strip 开头,被丢弃。
  3. "layers.1" 会匹配 layers.1x.*(10–19)、layers.1xx.*;带点只匹配第 1 层。
  4. (H + 2·KVH)·D;三者 in 相同、out 不同,合并输出就是沿 dim 0 拼。
  5. 每个 expert 内 cat(gate, up) 再 stack → [E, 2I, hidden]。先分别 stack 再沿 dim 1 拼结果相同;沿 dim 0 拼会变成 [2E, I, hidden],错误。
  6. E = 收集到的不同 id 数;条件 sorted(ids) == list(range(E)) 且 E > 0,每个 id 都有三种 proj。
  7. 维护 used 集合,所有字段取完后 set(sub) - used。

自检:打包后是否等价

x = torch.randn(5, hidden)
q, k, v = (x @ L.qkv.T).split([H*D, KVH*D, KVH*D], dim=-1)   # q 应等于 x @ q_proj.T
g, u = (x @ L.w13[e].T).chunk(2, dim=-1)
y = (F.silu(g) * u) @ L.w2[e].T                              # 应等于 expert e 的 SwiGLU 输出

这也正是 M08 把 PackedLayer 还原成 M02/M03 权重的方式:qkv 按行 split,w13 按第 1 维对半切。

面试官追问 & 对照源码

对照源码:model_executor/models/qwen3_omni/qwen3_omni.py:1725-1783 · model_executor/models/qwen3_omni/qwen3_omni_moe_talker.py:61-77 · model_executor/models/qwen2_5_omni/qwen2_5_omni.py:1009-1022 · HF Qwen3OmniMoeThinkerTextModel.state_dict()