nano-vllm-omni · 提示 ← M03M05 →
#4 · Track M · Qwen-Omni 模型实现 · 书 p.31

M04 · 多模态拼接:placeholder 计数、embedding 合并、DeepStack

用户发来一段音频和一张图:前端必须提前知道要插多少个占位 token,thinker 要把编码器输出按顺序塞进这些位置,视觉特征还要通过 DeepStack 再注入前几层。

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

题面速览

f = (r - 1) // 2 + 1
tokens_r = ((f - 1) // 2 + 1 - 1) // 2 + 1
total = tokens_r + 13 * (n // 100)        # 例:n = 100 -> 13;n = 250 -> 33

验收要点:音频 token 数与 HF _get_feat_extract_output_lengths 一致(含 100 的边界)· 图像/音频 embedding 按序落位、文本行不变、输入不被修改 · 数量不一致或缺 embedding 报错 · DeepStack 后整模型输出与 HF 一致 · DeepStack 只影响视觉行。

直觉与类比

用包占座

前端是提前进场的朋友:按“会来几个人”在一排座位上放包(占位 token)。编码器是后到的大巴,带来真正的乘客(embedding 行)。merge 就是“从左到右把包换成人”:第 k 个包的位置坐第 k 位乘客。图像和音频是两个旅行团,各自按自己的包排序,可以交错坐。人数和包数对不上——多一个少一个——都要当场报错。还有:不要在别人的座位表(text_embeds)上直接涂改,先复印一份(clone)。

对折纸条:音频 token 数

每次 stride-2 卷积像把纸条对折:长度变成 (len - 1) // 2 + 1,即 ceil(len/2)。100 → 50 → 25 → 13,每个完整窗口贡献 13 个;只有不满 100 的尾巴需要真的折三次。余数为 0 时 (0 - 1) // 2 + 1 = 0——这依赖 floor division 向负无穷取整。

炖汤分次加料:DeepStack

ViT 浅层特征偏“细节”,深层偏“语义”。DeepStack 按配方在第 i 次搅拌之后加入第 i 份料,而且只加进有蔬菜的那几碗(视觉行)。先加料再搅拌(注入放在 layer 之前)味道就不一样了——可是文本行根本没加料,只尝文本行是发现不了这个 bug 的。

框架视角 · shape trace

# tensor shape 发生了什么
1 num_mel_frames int / [B] 余数窗口三次 (len-1)//2+1 + 每个完整窗口 13;只用 % // + - *,int 和 tensor 都适用
2 input_ids [T] 前端已按 token 数展开占位符
3 masks[m] bool [T] input_ids == placeholder_ids[m];每个模态都返回(没出现就是全 False)
4 校验 — mask.sum() vs mm_embeds[m].shape[0];缺 embedding 但有占位也报错
5 inputs_embeds [T, H] text_embeds.clone(),布尔索引按升序位置写入第 0, 1, 2… 行(转成输出 dtype)
6 layer i [T, H] hidden = layer(hidden)
7 DeepStack i hidden[visual_mask]: [n_vis, H] i < K 时在视觉行上加 deepstack[i],返回新 tensor

易错点

逐步图:merge 与 DeepStack

▶ 从占位符到 DeepStack 注入
1. 占位符与 mask

H = 2,image 占位 id = 90,audio 占位 id = 91:

input_ids   = [7, 90, 90, 8, 91, 91, 9]

image mask:FTTFFFF
audio mask:FFFFTTF

2. 按序替换(clone 后写入)
mm_embeds = {"image": [[100,100],[200,200]], "audio": [[-1,-1],[-2,-2]]}
inputs_embeds = [[ 0,  1],
                 [100,100],   # image #0
                 [200,200],   # image #1
                 [ 6,  7],
                 [ -1, -1],   # audio #0
                 [ -2, -2],   # audio #1
                 [ 12, 13]]

out[mask] = emb 按 True 的升序位置依次写入 emb 的第 0, 1, … 行——正好就是“按顺序”。调用后 text_embeds 必须仍是原值。

3. 报错情况
输入 结果
ids = [90, 90, 5],mm_embeds = {"image": 1 行} ValueError(2 个占位 vs 1 行)
ids = [91, 5],placeholder_ids = {"audio": 91},mm_embeds = {} ValueError(有占位符但没有 embedding)
ids = [7, 8],mm_embeds = {} 正常返回(没有占位符,缺 key 合法)
4. run_layers:level i 在 layer i 之后

layers = [*2, +10, *3],hidden = ones(3,1),visual_mask = [T, F, T],deepstack = [[[1],[5]], [[100],[200]]]:

row0 row1 row2
layer0 (*2) 2 2 2
+ level 0 3 2 7
layer1 (+10) 13 12 17
+ level 1 113 12 217
layer2 (*3) 339 36 651

level 只有 2 个,layer2 之后不注入。结果 [[339], [36], [651]]。

5. 错把注入放在 layer 之前

会得到 [[342], [36], [666]]——非视觉行(row1)完全不受影响,所以只看文本行永远发现不了这个 bug,HF 对拍才会失败。

三级提示

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

audio_token_count:

n n // 100 余数 r f → 第二次 → 第三次 total
0 0 0 0→0→0 0
50 0 50 25 → 13 → 7 7
99 0 99 50 → 25 → 13 13
100 1 0 0→0→0 13
101 1 1 1→1→1 14
330 3 30 15 → 8 → 4 43

audio_token_count(torch.tensor([50, 100, 330])) → tensor([7, 13, 43])。注意 99 与 100 都是 13,101 跳到 14。

apply_deepstack:apply_deepstack(hidden=zeros(4, 2), visual_mask=[F, T, T, F], visual_embeds=[[1,1],[2,2]]) → [[0,0],[1,1],[2,2],[0,0]],hidden 本身仍是全 0。

merge 与 run_layers 的数字见上方逐步图。纯文本(无占位符、mm_embeds = {})时输出等于 text_embeds 的一份拷贝,masks 中每个模态都是全 False。

关键问题

  1. audio_token_count 要同时支持 int 和 LongTensor。哪些写法(math.floor、int(...)、if 分支)会在 tensor 输入下出问题?只用哪些运算符就够了?
  2. r = 0 时 (r - 1) // 2 + 1 给出多少?这依赖 // 对负数的什么行为?
  3. “按顺序”替换是什么意思?第 k 个占位符位置取 mm_embeds 的第几行?out[mask] = emb 能保证吗?
  4. 怎样保证 text_embeds 不被修改?out = text_embeds 之后再写会发生什么?
  5. 某模态在 placeholder_ids 里但不在 mm_embeds 里,什么时候报错、什么时候静默跳过?
  6. visual_embeds 的行数和 visual_mask 什么关系?hidden[visual_mask] 形状是多少?
  7. L 个 layer、K 个 level(K < L),哪几个 layer 之后会注入?

细节

  • 公式来历:100 帧 → 50 → 25 → 13;前端 processor 必须用同一个公式算占位符个数。
  • % // + - * 对 int 和 LongTensor 都是逐元素整数语义;对负数两者的 // 都是 floor,所以 (-1) // 2 = -1。
  • masks 对 placeholder_ids 里的每个模态都要有一项;DeepStack 的 visual_mask 就来自这里。
  • 不同模态的占位符可以交错,每个模态各自计数。
  • 编码器输出 dtype 可能与 text_embeds 不同(ViT fp32、LLM bf16),写入前转成输出 dtype;DeepStack 加法同理。
  • DeepStack 是“加”不是“替换”,并返回新 tensor。
L2思路与常见坑思路拆分与常见坑(中度剧透)
  • audio_token_count:一行表达式即可:r = n % 100 → 三次 (len - 1) // 2 + 1 → 加 13 * (n // 100)。不要写分支。
  • merge 的循环:先 out = text_embeds.clone();对 placeholder_ids 的每个 (modality, token_id):算 mask、存进 masks、统计 n;再看 mm_embeds 里有没有。以 placeholder_ids 为主循环,“有占位符却缺 embedding”自然被覆盖。
  • 校验:缺模态时 n > 0 → ValueError,n == 0 → 跳过;存在时 emb.shape[0] != n → ValueError。错误消息带上模态名和两边数量。
  • 写入:out[mask] = emb.to(out.dtype)。易错:out[mask.nonzero()] = emb 多一维;torch.where(mask[:, None], emb, out) 形状对不上。
  • apply_deepstack:out = hidden.clone();out[visual_mask] = out[visual_mask] + visual_embeds。
  • run_layers:for i, layer in enumerate(layers):先 hidden = layer(hidden),再 i < len(deepstack_embeds) 时注入。
L3详解:答案详解:答案、逐步追踪、骨架提示(重度剧透)
关键问题答案
  1. math.floor、int(n)、if r == 0 都不能用于多元素 tensor;只用 % // + - *。
  2. (0 - 1) // 2 + 1 = -1 + 1 = 0,后两步也是 0;依赖 floor division。C 风格截断除法会得到 1,100 帧会被错算成 14。
  3. 第 k 个 True 位置取第 k 行;布尔索引赋值按位置升序写入,能保证。
  4. clone();不 clone 时 out 与 text_embeds 是同一块内存。
  5. 缺 embedding 且该模态有 ≥1 个占位符 → 报错;占位符数为 0 → 跳过(mask 仍要返回)。
  6. visual_embeds.shape[0] == visual_mask.sum();hidden[visual_mask] 是 [n_vis, H]。
  7. layer 0 … K-1 之后各注入一次,level i ↔ layer i(0-based)。

核对:image mask 的 True 位置是 1、2,于是第 1 行 ← image[0] = [100,100],第 2 行 ← image[1];audio 的 4、5 同理。DeepStack 第 0 行:1 →(*2) 2 →(+1) 3 →(+10) 13 →(+100) 113 →(*3) 339;第 1 行不是视觉行:1 → 2 → 12 → 36。

与 serving 的联系:audio_token_count 在 API 进程(HF processor)里决定 prompt 长度,merge_multimodal 在 thinker stage 里消费编码器输出——两处公式不一致时,报错发生在 merge 的数量校验处,所以校验必须严格而不能“截断/补零”。DeepStack 的 visual_mask 就是 merge 返回的 image(及 video)mask。

面试官追问 & 对照源码

对照源码:model_executor/models/qwen3_omni/qwen3_omni_moe_thinker.py:1018 · :1137-1168 · :605-606 · HF _get_feat_extract_output_lengths · HF Qwen3OmniMoeThinkerTextModel._deepstack_process