nano-vllm-omni · 提示 ← P15P18 →
#23 · Track D · 模型胶水(纯函数) · 书 p.99

P14 · Codec 窗口规划:首块小、后块大、左上下文

TTFP 要低(首块只要 4 帧),吞吐要高(之后 25 帧一块),接缝要无损(左上下文重算后切片)——三个目标落在两个纯函数里。

⏱ 40 min 难度 易 实现 mine/p14_codec_windows.py 验收 ./check.sh p14 提示 A-23 · 参考解 C-23 solutions/p14_codec_windows.py

题面速览

next_window(available, emitted, finished) -> (ctx_start, new_start, new_end) | None
  size = initial(第一块且 initial>0)否则 chunk
  available - emitted ≥ size        → new_end = emitted + size
  finished 且还有剩余                → new_end = available     (尾部 flush)
  否则                               → None(继续等)
  new_start = emitted;  ctx_start = max(0, new_start - left_ctx)

code2wav 解码 codes[ctx_start:new_end],得到 (new_end-ctx_start)·up 个样本,
只输出属于 [new_start, new_end) 的那一段(左上下文重算但不重复输出)

验收要点:首块/稳态/尾部 flush 的精确窗口 · 无首块配置 · 随机到达模式下窗口恰好平铺、无重叠无空洞 · 左上下文 ≥ 感受野时流式 == 整段解码;不足时出现接缝。

直觉与类比

同声传译

译员不会等演讲者讲完一整段才开口(TTFP 高),先听两三个词就翻第一句(小的首块),之后每次听一整句再翻(大块,更流畅)。每次开始翻新一句时,他会回想上一句的最后几个词,好让语气和指代接得上(左上下文)——这几个词不会再说一遍。演讲结束时,剩下的半句也要翻出来(finished 时 flush)。

电视剧的“前情提要”

每一集(窗口)开头都放一段“前情提要”([ctx_start, new_start)),让观众(vocoder)进入状态。剪成一部完整电影时必须把每集的提要剪掉,否则同一段剧情出现两次——slice_new_samples 就是那把剪刀。提要至少要覆盖观众需要的记忆长度(感受野),太短开场几秒就会“断片”——接缝。

框架视角 · 一个窗口的一生(以 (5, 9, 15)、upsample=4 为例)

# 量 坐标 / 形状 发生了什么
1 available, emitted 标量,全局帧数 17, 9:已产出 17 帧,前 9 帧已发过
2 size 帧数 不是首块 → 用 chunk = 6
3 (ctx_start, new_start, new_end) 全局帧下标 (5, 9, 15):上下文 5..8,新帧 9..14;15、16 留给下一次
4 codes[ctx_start:new_end] 10 帧 发给 code2wav 的那段
5 decoded [(15-5)·4] = [40],局部样本坐标 下标 0 对应全局帧 5 的第一个样本
6 切片起点 (9-5)·4 = 16 把“全局帧 9”换算成局部样本下标
7 返回值 [24] = 6 帧 × 4 只输出新帧;所有窗口首尾相接 = 17×4 = 68 个样本

易错点

逐步图:initial=2, chunk=5, left_ctx=3

▶ 逐个调用 next_window
1. (available=1, emitted=0, F)

首块要 2 帧,只有 1 帧 → None

2. (2, 0, F)

size = 2(首块)→ (0, 0, 2)

3. (6, 2, F)

size = 5,只有 4 帧新帧,未结束 → None

4. (7, 2, F)

→ (0, 2, 7),ctx_start = max(0, 2−3) = 0(左上下文被 0 截断)

5. (12, 7, F)

→ (4, 7, 12):左上下文 3 帧 4,5,6

帧:01234567891011(橙 = 上下文,蓝 = 新帧)

6. (20, 12, T)

finished,但完整的一块还够 → 仍按 5 帧切:(9, 12, 17)

7. (20, 17, T)
size?差值?分支?结果?

size=5,差值 3,不够完整一块 → flush,new_end=20,ctx_start=14 → (14, 17, 20)

8. (20, 20, T)

全部发完 → None(返回空窗口会让循环永不结束)

9. 很短的流 (1, 0, T)

首块都凑不满,直接 flush → (0, 0, 1)

三级提示

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

例 1 见逐步图。没有首块配置:next_window(6, 0, False, initial=0, chunk=6, left_ctx=10) -> (0, 0, 6),next_window(12, 6, False, ...) -> (0, 6, 12)。

例 2:slice_new_samples

window = (4, 7, 12), upsample = 2
decoded = vocoder(codes[4:12])             # 8 帧 × 2 = 16 个样本: s0 .. s15
                                           # s0..s5 属于上下文帧 4,5,6;s6..s15 属于新帧 7..11
slice_new_samples(decoded, (4, 7, 12), 2) -> [s6, ..., s15]
slice_new_samples([0, 1, 2, 3], (0, 0, 2), 2) -> [0, 1, 2, 3]     # 没有左上下文时整段都是新的

例 3:一次完整的流(initial=2, chunk=5, left_ctx=3,共 14 帧分 6 批到达):

批次 本批帧数 available finished 本批循环得到的窗口 之后 emitted
0 1 1 F (无) 0
1 2 3 F (0, 0, 2) 2
2 4 7 F (0, 2, 7) 7
3 3 10 F (无) 7
4 1 11 F (无) 7
5 3 14 T (4, 7, 12), (9, 12, 14) 14

调用者每批循环调用 next_window 直到 None,每拿到一个窗口令 emitted = new_end;所有 [new_start, new_end) 恰好拼成 [0, 14)。

关键问题

  1. 函数没有内部状态,“第一个窗口”怎么从参数里判断?
  2. initial=0 表示什么?此时首块用多大?
  3. finished=True 但剩余 ≥ 一个完整窗口时,一次发出全部剩余还是仍按 chunk 切?
  4. finished 且 available == emitted 时返回什么?返回 (…, 20, 20) 会怎样?
  5. ctx_start 为什么不能为负?它和 emitted 之间的帧被解码几次、输出几次?
  6. decoded 的第 i 个样本属于窗口里第几帧?new_start 的第一个样本在 decoded 中的下标?
  7. left_ctx 要多大,流式结果才和整段解码逐样本相同?依赖 vocoder 的什么性质?
L2思路与常见坑思路拆分与常见坑(中度剧透)
  • next_window 三步:① 算 size(首块判断:emitted == 0 and initial > 0);② 决定 new_end:完整窗口 / flush / None;③ 由 emitted 和 left_ctx 算 new_start、ctx_start。
  • 判断顺序:先完整窗口,再 flush。把 finished 放前面,(20, 12, True) 会返回 (9, 12, 20)——不丢帧,但违反“完整窗口可用时返回完整窗口”。
  • flush 条件要求 available > emitted。
  • slice 公式:起点 (new_start − ctx_start) * upsample,终点 (new_end − ctx_start) * upsample(即 len(decoded))。用全局 new_start * upsample 切局部 decoded,例 2 会得到 decoded[14:24],只剩 2 个样本。
  • 可加断言 len(decoded) == (new_end − ctx_start) * upsample。
L3详解:答案与接缝例子详解:答案、逐步追踪、骨架提示(重度剧透)
关键问题答案
  1. 首块 ⇔ emitted == 0(还没发出过任何帧)且 initial > 0。
  2. 没有特殊首块,首块也用 chunk。
  3. 仍然按 chunk 切;剩下不足一块时才 flush。
  4. None;返回空窗口会让 emitted 停在原地,循环永不结束。
  5. 流开头 new_start − left_ctx 可能为负,帧下标没有负数。上下文帧会被解码不止一次(作为上一窗口新帧一次,作为下一窗口上下文再一次),但只输出一次。
  6. decoded[i] 属于局部帧 i // upsample,即全局帧 ctx_start + i // upsample;new_start 的第一个样本在 (new_start − ctx_start) * upsample。
  7. left_ctx ≥ vocoder 的(因果)感受野;依赖“因果 + 局部”。

左上下文 vs 感受野:一个能手算的例子。玩具 vocoder:帧 i 输出 2 个样本 [10·S, 10·S+1],S = 窗口内 codes[max(0,i−2) .. i] 之和(感受野 r=2,只看窗口内部)。codes = [3,1,4,1,5,9,2,6],initial=3, chunk=3,一次性全部可用。

left_ctx 窗口 本窗口 decoded 切出的新样本
2 (0,0,3) 30,31,40,41,80,81 30,31,40,41,80,81
2 (1,3,6) 10,11,50,51,60,61,100,101,150,151 60,61,100,101,150,151
2 (4,6,8) 50,51,140,141,160,161,170,171 160,161,170,171
1 (2,3,6) 40,41,50,51,100,101,150,151 50,51,100,101,150,151
1 (5,6,8) 90,91,110,111,170,171 110,111,170,171

整段解码:30,31,40,41,80,81,60,61,100,101,150,151,160,161,170,171。left_ctx=2 时完全一致;left_ctx=1 时每个窗口第一帧(加粗)出现接缝:帧 3 本该看到 codes[1..3] = 1+4+1 = 6,却只看到 codes[2..3] = 4+1 = 5。

面试官追问 & 对照源码

对照源码:model_executor/stage_input_processors/qwen3_omni.py:765-842 · model_executor/models/qwen3_omni/qwen3_omni_code2wav.py:447-508