M04 · 多模态拼接:placeholder 计数、embedding 合并、DeepStack
用户发来一段音频和一张图:前端必须提前知道要插多少个占位 token,thinker 要把编码器输出按顺序塞进这些位置,视觉特征还要通过 DeepStack 再注入前几层。
mine/m04_mm_merge_deepstack.py
验收 ./check.sh m04
提示 A-4 · 参考解 C-4 solutions/m04_mm_merge_deepstack.py
题面速览
- 在系统中的位置:前端(API 进程)用 HF processor 把
<audio>、<image>展开成 N 个占位 token——N 必须和编码器真实输出的 token 数完全一致。thinker 里inputs_embeds = embed_tokens(ids),再把 AuT 音频编码器与 ViT 的输出按顺序写进占位位置。 - 音频 token 数:AuT 以 100 帧 mel 为一窗,每个完整窗口经 3 次 stride-2 卷积得 13 个 token;余下
r = n % 100帧同样过 3 次:
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
- Embedding 合并:每个模态
mask = (input_ids == placeholder_id),out[mask] = mm_embeds[modality](按出现顺序)。数量不一致必须报错——线上这类错误通常意味着前后端 processor 版本不一致。 - DeepStack:ViT 从若干中间层各导出一份视觉特征;LLM 前 L 层中,第 i 层输出之后在视觉 token 的位置上把第 i 份特征加到 hidden 上。
- 边界:没有某模态时 mm_embeds 可以缺 key,但若存在占位符就必须报错;不要原地修改 text_embeds。
验收要点:音频 token 数与 HF _get_feat_extract_output_lengths 一致(含 100 的边界)· 图像/音频 embedding 按序落位、文本行不变、输入不被修改 · 数量不一致或缺 embedding 报错 · DeepStack 后整模型输出与 HF 一致 · DeepStack 只影响视觉行。
直觉与类比
前端是提前进场的朋友:按“会来几个人”在一排座位上放包(占位 token)。编码器是后到的大巴,带来真正的乘客(embedding 行)。merge 就是“从左到右把包换成人”:第 k 个包的位置坐第 k 位乘客。图像和音频是两个旅行团,各自按自己的包排序,可以交错坐。人数和包数对不上——多一个少一个——都要当场报错。还有:不要在别人的座位表(text_embeds)上直接涂改,先复印一份(clone)。
每次 stride-2 卷积像把纸条对折:长度变成 (len - 1) // 2 + 1,即 ceil(len/2)。100 → 50 → 25 → 13,每个完整窗口贡献 13 个;只有不满 100 的尾巴需要真的折三次。余数为 0 时 (0 - 1) // 2 + 1 = 0——这依赖 floor division 向负无穷取整。
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 |
易错点
audio_token_count里用if r == 0、int()、math.floor:对多元素 tensor 不可用;公式本身已正确给出 0。out = text_embeds没有 clone,写入变成原地修改调用方的 tensor。- 以 mm_embeds 为主循环:漏掉“有占位符但没给 embedding”的报错。
- 数量不一致时截断 / 补零而不是报错。
- DeepStack 注入放在 layer 调用之前(off-by-one),或
zip(layers, deepstack)把多出来的 layer 跳过了。 hidden[visual_mask] += …原地改了输入;或out += visual_embeds(n_vis 行对 T 行)。
逐步图:merge 与 DeepStack
H = 2,image 占位 id = 90,audio 占位 id = 91:
input_ids = [7, 90, 90, 8, 91, 91, 9]
image mask:FTTFFFF
audio mask:FFFFTTF
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 必须仍是原值。
| 输入 | 结果 |
|---|---|
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 合法) |
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]]。
会得到 [[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。
关键问题
- audio_token_count 要同时支持 int 和 LongTensor。哪些写法(
math.floor、int(...)、if 分支)会在 tensor 输入下出问题?只用哪些运算符就够了? - r = 0 时
(r - 1) // 2 + 1给出多少?这依赖//对负数的什么行为? - “按顺序”替换是什么意思?第 k 个占位符位置取 mm_embeds 的第几行?
out[mask] = emb能保证吗? - 怎样保证 text_embeds 不被修改?
out = text_embeds之后再写会发生什么? - 某模态在 placeholder_ids 里但不在 mm_embeds 里,什么时候报错、什么时候静默跳过?
- visual_embeds 的行数和 visual_mask 什么关系?
hidden[visual_mask]形状是多少? - 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详解:答案详解:答案、逐步追踪、骨架提示(重度剧透)
关键问题答案
math.floor、int(n)、if r == 0都不能用于多元素 tensor;只用% // + - *。(0 - 1) // 2 + 1 = -1 + 1 = 0,后两步也是 0;依赖 floor division。C 风格截断除法会得到 1,100 帧会被错算成 14。- 第 k 个 True 位置取第 k 行;布尔索引赋值按位置升序写入,能保证。
clone();不 clone 时 out 与 text_embeds 是同一块内存。- 缺 embedding 且该模态有 ≥1 个占位符 → 报错;占位符数为 0 → 跳过(mask 仍要返回)。
visual_embeds.shape[0] == visual_mask.sum();hidden[visual_mask]是[n_vis, H]。- 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。
面试官追问 & 对照源码
- chunked prefill 时一张图被切在两个 chunk 之间,编码器输出与 DeepStack 特征怎么分发?
- vllm-omni 的 encoder cache 以什么为 key?
对照源码: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