M07 · 按 stage 加载与打包权重
一个 Qwen3-Omni checkpoint 同时装着 thinker、talker、code2wav 的权重;每个 stage 进程只该打开自己的 shard、只取自己的张量,并把 q/k/v、gate/up、各 expert 打包成 kernel 友好的布局。
mine/m07_stage_weight_loading.py
验收 ./check.sh m07
提示 A-7 · 参考解 C-7 solutions/m07_stage_weight_loading.py
题面速览
- 在系统中的位置:三个 stage 进程各自加载同一个 checkpoint 目录。vllm-omni 的做法:同一个 wrapper 类按
model_stage只构建一个子模块,load_weights按前缀thinker./talker./code2wav.分流。本题更进一步:根据model.safetensors.index.json的weight_map只打开相关 shard。 - 打包布局(vLLM 风格):
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]
- key 形如
thinker.model.layers.{i}.self_attn.q_proj.weight、thinker.model.layers.{i}.mlp.experts.{e}.gate_proj.weight、thinker.model.layers.{i}.mlp.gate.weight。 - 完整性检查:“少加载一个张量”在 PyTorch 里不报错——参数保持随机初始化,输出悄悄变成噪声。所以缺 key、多 key、expert 编号不连续、某个 expert 缺 gate/up/down,都必须抛
LoadError。
要实现: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 个 |
易错点
- stage 前缀不带点:
"thinkers.x"、"talker2.x"被误判。 - 层前缀不带点(
"layers.1")→ 第 1 层吞掉layers.10..19的 key,然后报“多余 key”或 expert 冲突。 - 用
str.replace去前缀:可能替换掉中间的子串;应startswith+ 切片。 - w13 拼成
[up | gate]→ 形状对但 SiLU 用在了错误的那一半,expert 输出全错。 - 按 dict 遍历顺序 stack expert → id 乱序时 expert 被错配;expert id 若按字符串排序会得到 0, 1, 10, 11, 2…
- “多余 key”检查放在所有 take 之前 → 把还没被 take 的合法 key 误报为多余。
- 把
thinker.lm_head.weight当成错误:它不在thinker.model.下,M08 单独取。
逐步图:pack_layer 的记账
p = "layers.0."(带结尾点);sub = {去前缀后的 key: tensor},之后所有操作都在 sub 上,key 变成 self_attn.q_proj.weight 这样的短名。
sd 里同时存在 layers.1.* 也没关系。
^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 集合恰好是三种。
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)
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.* 存在不算错误。
关键问题
- 判断 key 属于哪个 stage 该用哪个前缀?为什么
"thinker"(不带点)不够安全? - 去前缀时
"thinker.model.norm.weight"变成什么?"thinker.lm_head.weight"会怎样? - 收集第 i 层为什么必须用
"layers.{i}."(带结尾点)?i=1 时不带点会误收哪些层? - qkv 的行数是多少?为什么沿 dim 0 拼而不是 dim 1?
- w13 是先对每个 expert
cat(gate, up)再 stack,还是先 stack 所有 gate 再和 up 拼? - 如何判断 expert id “恰好是 0..E-1 且完整”?E 从哪里得知?
- “多余 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详解:答案与自检详解:答案、逐步追踪、骨架提示(重度剧透)
关键问题答案
STAGE_PREFIX[stage](带点);不带点会让"thinkers.x"、"talker2.x"被误判。"norm.weight";thinker.lm_head.weight不以 strip 开头,被丢弃。"layers.1"会匹配layers.1x.*(10–19)、layers.1xx.*;带点只匹配第 1 层。- (H + 2·KVH)·D;三者 in 相同、out 不同,合并输出就是沿 dim 0 拼。
- 每个 expert 内
cat(gate, up)再 stack →[E, 2I, hidden]。先分别 stack 再沿 dim 1 拼结果相同;沿 dim 0 拼会变成[2E, I, hidden],错误。 - E = 收集到的不同 id 数;条件
sorted(ids) == list(range(E))且 E > 0,每个 id 都有三种 proj。 - 维护 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 维对半切。
面试官追问 & 对照源码
- TP=2 时 qkv 怎么切?为什么不能简单地按行对半切打包后的 qkv?
- talker 进程为什么还需要 thinker 的部分权重(Qwen2.5-Omni 的 embed_tokens)?
对照源码: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()