M06 · Code2Wav 的流式因果卷积与转置卷积
Code2Wav 把 codec 上采样成波形,核心是因果 Conv1d 与因果 ConvTranspose1d;流式解码要求任意分块拼起来与整段处理逐样本一致,且每个样本一旦“确定”就立刻吐出。
mine/m06_streaming_causal_conv.py
验收 ./check.sh m06
提示 A-6 · 参考解 C-6 solutions/m06_streaming_causal_conv.py
题面速览
- 在系统中的位置:stage 2(Code2Wav)把
[B, 16, T]的 codes 变成[B, 1, T·upsample]的波形:embedding 平均 → 滑窗 transformer → 因果转置卷积上采样 → ConvNeXt → 多级(因果卷积 + SnakeBeta)解码。vllm-omni 默认用“左上下文重算再切片”的无状态方案(P14);本题练另一种——带状态的流式卷积,是理解两种方案取舍的关键。 - 因果 Conv1d(stride 1):离线模块在整段左边补 (K-1)·d 个 0,输出长度 = 输入长度,第 t 个输出只依赖输入 ≤ t。流式版只需保存最近 (K-1)·d 个输入作为状态,每次拼到新块前面再卷积。
- 因果 ConvTranspose1d(stride s,kernel K):
输入帧 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 个
- bias 只能在输出时加一次;每块卷积都带 bias,重叠区会被加两次。
- “尽早输出”验收:喂入第 n 帧后,累计输出必须正好是
max(0, n·s − (K−s))个——多了说明输出了未确定的样本,少了说明增加了不必要的延迟(直接影响首包音频延迟 TTFP)。
验收要点:因果卷积任意分块与离线一致、每块输出数等于输入数(含 dilation、K=1)· 转置卷积任意分块 + flush 与离线一致(多种 K/s)· 每喂 1 帧后的累计输出数恰好是理论最早值。
直觉与类比
抄写员每写一个字都要回头看前面 (K-1)·d 个字。稿纸一页页送来,翻页时前一页已收走——所以翻页前他把最后 (K-1)·d 个字抄在便签上,贴到下一页最前面。第一页之前的便签是空白(全 0),正好等于离线版“左边补 0”。注意:新的一页很短(比便签还短)时,新便签要从“旧便签 + 新页”连起来的末尾抄,而不是只从新页里抄。
每来一帧就在长纸条上从第 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 |
易错点
- state 从 x 末尾取:块长 < context 时 state 变短,下一块输出个数不对。
- K=1(context=0)时
buf[..., -0:]等于整个 buf → state 无限增长、输出越来越多。 - bias 进了每块卷积 → 重叠区多出 bias(例 3 中所有值多 0.5)。
- 左裁剪按块做 → 后面每块都少 K-s 个样本,总长度不对。
- 输出了 ≥ frames·s 的位置 → 那些值之后还会被加,与离线不一致;输出太少 → 结果仍可能对,但“尽早输出”计数对不上。
- K == s 时右裁剪写
t[..., :-0]得到空张量。 - from_hf 里转置卷积的 stride 是
m.conv.stride[0](tuple)。
逐步图:转置卷积的 acc / base(K=4, s=2, weight=[1,2,3,4], bias=0.5)
位置: 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]
本块卷积覆盖全局 [0,4) = 1,2,3,4;frames = 1;可定稿 [0, 2)——但它们全在左裁剪 [0, 2) 内。
输出 [];base → 2;acc(未加 bias)= 34(位置 2,3)
[2,6) += 10,20,30,40 → acc = 13243040;frames = 2;定稿 [2, 4)。
输出 [13.5, 24.5](加 bias 一次);base → 4;acc = 3040
[4,8) += 100..400 → acc = 130240300400;frames = 3;定稿 [4, 6)。
输出 [130.5, 240.5];base → 6;acc = 300400——位置 6,7 还在等未来帧。
total = (3-1)·2 + 4 = 8,定稿 [6, 8) 后去掉右侧 2 个 → 输出 []。
acc 的长度始终 ≤ K-s:它就是“还在等后续帧”的尾巴。累计输出数 0, 2, 4 = max(0, 2n − 2) ✓
- 每帧单独带 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”的边界。
关键问题
- 因果 Conv1d 的 state 要保存多少样本?K=1 时是多少?此时
buf[..., -n:]在 n=0 时返回什么? - 第一次 step 之前 state 是什么?它的 batch / channel 维从哪来?
- 转置卷积收到 n 帧后,哪个位置之前的输出不会再被修改?为什么?
- 每次 step 都裁掉本次输出的前 K-s 个会错在哪?用例 3 的 [2,1] 分块想一想。
- bias 应该在什么时刻加?在每块卷积里传 bias 哪些位置会错、错多少?
- flush 时总输出长度是多少?右裁剪后还剩多少?
- 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详解:答案与自检详解:答案、逐步追踪、骨架提示(重度剧透)
关键问题答案
- 保存 (K-1)·d 个;K=1 时为 0,
buf[..., -0:]返回整个 buf——必须特判。 - 全零
[B, C_in, context],第一次 step 时用x.new_zeros(x.shape[0], x.shape[1], context)懒创建(同时继承 dtype/device)。 - 位置 < n·s:未来帧 i ≥ n 只写 [i·s, i·s+K),起点 ≥ n·s。
- 例 3 [2,1] 分块时,第二块 [130.5, 240.5] 会被错裁成 []。左裁剪只作用于全局 0..K-s-1。
- 在“输出某个已确定位置”时加一次;每块卷积里加会让块间重叠位置多加。
- 总长 (L-1)·s + K,去掉左右各 K-s 后剩 L·s − (K-s)。例 3:8 − 4 = 4。
- 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比较。
面试官追问 & 对照源码
- 有状态流式 vs 无状态“左上下文重算”:对 batching、abort、CUDA graph 各有什么影响?
- Code2Wav 里的滑窗 attention 如何做成流式?
对照源码:model_executor/models/qwen3_omni/qwen3_omni_code2wav.py:124-218 · :447-508 · HF Qwen3OmniMoeCausalConvNet · HF Qwen3OmniMoeCausalTransConvNet