nano-vllm-omni · 提示 ← M05M07 →
#6 · Track M · Qwen-Omni 模型实现 · 书 p.38

M06 · Code2Wav 的流式因果卷积与转置卷积

Code2Wav 把 codec 上采样成波形,核心是因果 Conv1d 与因果 ConvTranspose1d;流式解码要求任意分块拼起来与整段处理逐样本一致,且每个样本一旦“确定”就立刻吐出。

⏱ 60 min 难度 难 实现 mine/m06_streaming_causal_conv.py 验收 ./check.sh m06 提示 A-6 · 参考解 C-6 solutions/m06_streaming_causal_conv.py

题面速览

输入帧 i 写入输出位置 [i·s, i·s + K)(重叠相加)
离线:y = conv_transpose(x),长度 (L-1)·s + K,再裁掉开头 K-s 和结尾 K-s 个样本
流式:收到 n 帧后,位置 < n·s 的样本不会再被后续帧修改 → 可以输出
      (再减去全局开头裁掉的 K-s 个);flush 时输出剩余部分并裁掉结尾 K-s 个

验收要点:因果卷积任意分块与离线一致、每块输出数等于输入数(含 dilation、K=1)· 转置卷积任意分块 + flush 与离线一致(多种 K/s)· 每喂 1 帧后的累计输出数恰好是理论最早值。

直觉与类比

因果卷积 = 带一张“便签”的抄写员

抄写员每写一个字都要回头看前面 (K-1)·d 个字。稿纸一页页送来,翻页时前一页已收走——所以翻页前他把最后 (K-1)·d 个字抄在便签上,贴到下一页最前面。第一页之前的便签是空白(全 0),正好等于离线版“左边补 0”。注意:新的一页很短(比便签还短)时,新便签要从“旧便签 + 新页”连起来的末尾抄,而不是只从新页里抄。

转置卷积 = 每隔 s 格盖一个宽 K 的印章

每来一帧就在长纸条上从第 i·s 格开始盖一个宽 K 格的印章;K > s 时相邻两章叠印 K-s 格。一格什么时候能剪下来?当以后再也不会有章盖到它:已来了 n 帧,下一个章最早从 n·s 盖起,所以 < n·s 的格子都定稿了。纸的底色(bias)只能刷一次。离线版还会把纸条两头各剪掉 K-s 格:开头那刀只剪一次(全局位置),结尾那刀要等收工(flush)才知道在哪。

框架视角 · shape trace

阶段 张量 形状 发生了什么
Conv · 首块前 state [B, C_in, (K-1)·d] 全 0;B / dtype / device 第一次 step 才知道,所以懒创建
Conv · step buf = cat([state, x], -1) [B, C_in, (K-1)·d + t] “便签 + 新页”
F.conv1d(buf, w, b, dilation=d) [B, C_out, t] 无 padding:长度减 (K-1)·d,正好剩 t
新 state [B, C_in, (K-1)·d] 从 buf 的末尾取
TransConv · step F.conv_transpose1d(x, w, None, stride=s) [B, C_out, (t-1)·s + K] 块内叠印已做好;覆盖全局 [frames·s, frames·s + len)
acc(从全局 base 开始) [B, C_out, ≤ K-s] 块间叠印:新结果加到 acc 对应位置
输出 [B, C_out, ·] 全局 [base, frames·s),加 bias 一次,去掉落在 [0, K-s) 里的部分
TransConv · flush 输出 [B, C_out, ·] 剩余 pending 全部定稿,去掉结尾 K-s 个(通常恰好全被裁掉)
累计 喂入 n 帧后已输出 max(0, n·s − (K−s)) K = 2s 时就是 (n-1)·s

易错点

逐步图:转置卷积的 acc / base(K=4, s=2, weight=[1,2,3,4], bias=0.5)

▶ x = [1, 10, 100],逐帧 step
1. 离线答案
位置:        0    1    2    3    4     5     6     7
帧0 (1):     1    2    3    4
帧1 (10):              10   20   30    40
帧2 (100):                       100   200   300   400
求和+bias:  1.5  2.5  13.5 24.5 130.5 240.5 300.5 400.5   (长度 (3-1)·2+4 = 8)
裁掉开头 2、结尾 2 -> [13.5, 24.5, 130.5, 240.5]
2. step(帧0)

本块卷积覆盖全局 [0,4) = 1,2,3,4;frames = 1;可定稿 [0, 2)——但它们全在左裁剪 [0, 2) 内。

输出 [];base → 2;acc(未加 bias)= 34(位置 2,3)

3. step(帧1)

[2,6) += 10,20,30,40 → acc = 13243040;frames = 2;定稿 [2, 4)。

输出 [13.5, 24.5](加 bias 一次);base → 4;acc = 3040

4. step(帧2)

[4,8) += 100..400 → acc = 130240300400;frames = 3;定稿 [4, 6)。

输出 [130.5, 240.5];base → 6;acc = 300400——位置 6,7 还在等未来帧。

5. flush()

total = (3-1)·2 + 4 = 8,定稿 [6, 8) 后去掉右侧 2 个 → 输出 []。

acc 的长度始终 ≤ K-s:它就是“还在等后续帧”的尾巴。累计输出数 0, 2, 4 = max(0, 2n − 2) ✓

6. 两种 bug 的样子
  • 每帧单独带 bias 卷积再重叠相加:重叠位置 bias 加两次 → [14.0, 25.0, 131.0, 241.0](每个多 0.5)。
  • 左裁剪按块做:用 [2,1] 分块时,第二块 [130.5, 240.5] 会被错误地裁掉 2 个变成 []。

三级提示

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

例 1:因果 Conv1d,K=3,d=1,weight=[1,2,3],bias=0:

x = [1, 2, 3, 4, 5]
离线:左补 2 个 0 → y[t] = 1·x[t-2] + 2·x[t-1] + 3·x[t] = [3, 8, 14, 20, 26]
流式分块 [2, 3]:
step([1,2])     -> [3, 8]          state 变成 [1, 2]
step([3,4,5])   -> [14, 20, 26]    state 变成 [4, 5]

例 2:带 dilation,K=2,d=3,weight=[1,1]:y[t] = x[t-3] + x[t],x = [1..6],离线 y = [1, 2, 3, 5, 7, 9]。

分块 [1, 4, 1]:
step([1])          -> [1]           state = [0, 0, 1]
step([2, 3, 4, 5]) -> [2, 3, 5, 7]  state = [3, 4, 5]
step([6])          -> [9]           state = [4, 5, 6]

第一块只有 1 个样本、比 state 短:state 要从“旧 state + 新块”拼起来的末尾取,所以仍含两个补出来的 0。

例 3:见上方逐步图(K=4, s=2)。分块 [2, 1] 也一样:step(帧0..1) → [13.5, 24.5],step(帧2) → [130.5, 240.5],flush → []。

例 4:K == s(K=3, s=3, weight=[1,2,3]):x = [1, 10],离线 = [1,2,3,10,20,30];step(帧0) → [1,2,3],step(帧1) → [10,20,30],flush → []。检查“裁剪量为 0”的边界。

关键问题

  1. 因果 Conv1d 的 state 要保存多少样本?K=1 时是多少?此时 buf[..., -n:] 在 n=0 时返回什么?
  2. 第一次 step 之前 state 是什么?它的 batch / channel 维从哪来?
  3. 转置卷积收到 n 帧后,哪个位置之前的输出不会再被修改?为什么?
  4. 每次 step 都裁掉本次输出的前 K-s 个会错在哪?用例 3 的 [2,1] 分块想一想。
  5. bias 应该在什么时刻加?在每块卷积里传 bias 哪些位置会错、错多少?
  6. flush 时总输出长度是多少?右裁剪后还剩多少?
  7. from_hf 里 dilation 和 stride 从 HF 模块的哪个属性取?nn.ConvTranspose1d.stride 是 int 还是 tuple?

细节

  • nn.Conv1d.weight 是 [C_out, C_in, K];nn.ConvTranspose1d 的是 [C_in, C_out, K]。都可以直接交给 F.conv1d / F.conv_transpose1d。无 padding 的 conv1d 输出长度 = 输入 − (K-1)·d。
  • “已确定”:帧 i 影响 [i·s, i·s+K);已收到 n 帧时,未来帧只写 ≥ n·s,所以 < n·s 都确定。
  • 左裁剪针对整条流的前 K-s 个样本(全局 0..K-s-1),需要记录“已输出到哪个全局位置”。
  • from_hf:Qwen3OmniMoeCausalConvNet 有 m.conv、m.dilation、m.stride(只支持 1);Qwen3OmniMoeCausalTransConvNet 的 stride 从 m.conv.stride[0] 取。权重 .detach(),bias 可能为 None。
L2思路与常见坑思路拆分与常见坑(中度剧透)
  • StreamingCausalConv1d 状态:weight、bias、dilation、context = (K-1)·d、state(初始 None,首次 step 按输入 B、C 建全零)。step = 拼接 → 更新 state → 无 padding 的 F.conv1d。
  • state 更新顺序:先 buf = cat([state, x], -1),再从 buf 末尾取 context 个作新 state,最后对 buf 卷积。
  • K=1:用 buf[..., buf.shape[-1]-context:] 或显式判断 context == 0。
  • TransConv 状态:frames(已收帧数)、base(下一个要输出的全局位置)、acc(从 base 开始的未确定样本累加缓冲区)、trim = K - s。
  • step:(1) 本块不带 bias 的 F.conv_transpose1d(x, w, None, stride=s),覆盖 [frames·s, frames·s + len);(2) 必要时右侧补零延长 acc,把结果加到对应区间(下标减 base);(3) frames += t;(4) 输出 [base, frames·s)。
  • 输出函数(step / flush 共用):从 acc 切出前 (upto − base) 个,acc 保留剩余;加 bias;skip = max(0, trim − 旧 base) 去掉落在 [0, trim) 的部分;base = upto。
  • 分块不变性:块内重叠由单个 conv_transpose1d 完成,块间重叠由 acc 负责;线性 + 无 bias 时两者组合与离线一致。
L3详解:答案与自检详解:答案、逐步追踪、骨架提示(重度剧透)
关键问题答案
  1. 保存 (K-1)·d 个;K=1 时为 0,buf[..., -0:] 返回整个 buf——必须特判。
  2. 全零 [B, C_in, context],第一次 step 时用 x.new_zeros(x.shape[0], x.shape[1], context) 懒创建(同时继承 dtype/device)。
  3. 位置 < n·s:未来帧 i ≥ n 只写 [i·s, i·s+K),起点 ≥ n·s。
  4. 例 3 [2,1] 分块时,第二块 [130.5, 240.5] 会被错裁成 []。左裁剪只作用于全局 0..K-s-1。
  5. 在“输出某个已确定位置”时加一次;每块卷积里加会让块间重叠位置多加。
  6. 总长 (L-1)·s + K,去掉左右各 K-s 后剩 L·s − (K-s)。例 3:8 − 4 = 4。
  7. Conv:m.dilation、m.stride;TransConv:m.conv.stride[0](tuple)。

自检清单:

  • 因果卷积:例 1、例 2 的手工权重按 [5]、[1,1,1,1,1]、[2,3] 分块,结果都是同一个序列;每块输出个数 == 输入个数。
  • 转置卷积:K=8, s=4 逐帧喂入,累计输出数应为 0, 4, 8, 12, …(= max(0, 4n−4))。
  • K==s:无裁剪,每帧输出 s 个,flush 为空。
  • 所有 step 与 flush 输出 cat 起来,与 HF 模块离线 forward 用 torch.testing.assert_close 比较。

面试官追问 & 对照源码

对照源码:model_executor/models/qwen3_omni/qwen3_omni_code2wav.py:124-218 · :447-508 · HF Qwen3OmniMoeCausalConvNet · HF Qwen3OmniMoeCausalTransConvNet