[犀牛鸟] Qwen3-TTS ncnn 移植:纯 C++/ncnn 端到端复现 #6799
caixuehe
started this conversation in
Show and tell
Replies: 0 comments
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Uh oh!
There was an error while loading. Please reload this page.
-
将 Qwen3-TTS 移植至 ncnn:一次纯 C++ 的端到端复现
本文记录了使用 C++ 与 ncnn 对 Qwen3-TTS(0.6B CustomVoice,12 Hz tokenizer)进行完整复现的过程。
最终成果是一个可执行文件:输入一句中文文本,输出一段 24 kHz 的语音,推理阶段仅依赖 ncnn,不再需要
PyTorch。整个工程中,真正耗费时间的并非代码实现本身,而是使 C++ 侧的输出与 PyTorch 原版在数值上
逐级对齐。这一目标表述起来简单,实践中却问题频出。先将最终结果列于此处,后文的全部内容基本都是
为了让这几个数字成立:
(表中的 Talker 与 Code Predictor 是 Qwen3-TTS 对其两个内部组件的命名,codes 指二者逐帧生成的离散音频码,下一节将展开说明。)
代码与完整的排障记录见仓库:https://github.com/caixuehe/Qwen3-TTS-ncnn。
移植对象的整体结构
任务描述为"TTS 移植",最初我以为对象是单个模型,阅读代码后才发现是一整条流水线,其中包含两个自回归
网络与两套编解码结构。文本首先经过一个 Qwen2 的 BPE 分词器;随后进入 Talker(一个 28 层的 Qwen3
风格 Transformer),逐帧生成离散的音频 token;每一帧除 Talker 输出的主 token 之外,还由一个名为
Code Predictor 的 5 层小模型补充 15 个残差码;最后这 16 个码交由 codec(12 Hz tokenizer,属
Mimi/Moshi 一系,由 RVQ 反量化、8 层 Transformer 与 SEANet 卷积上采样构成)还原为波形。
完整链路为:文本 → Talker(含 Code Predictor)→ codes(每帧 16 个)→ codec → 波形。
贯穿全程的关键约束:ncnn 不存在 batch 维度
最初的方案自然是直接使用 pnnx 将整个 codec 转换为 ncnn。转换过程本身可以产出
.param与.bin文件,但一经运行即发生段错误,日志中大量出现
broadcast across batch axis与unbind along batch axis。经排查,原因在于 ncnn 根本没有 batch 这一维度,而 HuggingFace 的注意力与 RVQ 实现中充斥着跨 batch 的广播与 unbind 操作,pnnx 强行转换后,这些算子在 ncnn 端无法执行。
这也解释了 ncnn_llm 为何选择以 C++ 手写注意力,而非依赖 pnnx 转换整个 attention 模块。
明确这一约束之后,分工便随之确定:卷积、归一化、激活、MLP 等前馈算子经 pnnx 转换为 ncnn;注意力、
RVQ 与自回归循环则由 C++ 手工实现。
耗时最久的三个问题
第一个问题: 采样默认开启,导致输出不可复现。 Qwen3-TTS 默认
do_sample=True,主模型与 subtalker均在采样,同一段文本每次生成的结果都不同,无法与 ncnn 的输出逐一比对。将两者均改为 greedy 解码后,
相同输入两次运行得到的 codes 与波形完全一致。这一步是后续所有数值对齐工作的前提,必须最先完成。
第二个问题: Transformers 的 mask 构建无法被 pnnx trace。 codec 中的 Transformer 一经 trace
便抛出
RuntimeError: unordered_map::at,调用栈停在masking_utils的_vmap_for_bhqkv。transformers 4.57 使用
torch.vmap与torch._export的高阶算子构建 causal / sliding mask,jit.trace无法处理这类算子。解决方式是在转换阶段替换create_causal_mask与create_sliding_window_causal_mask,改用arange、比较运算和masked_fill构造一个普通的加性mask。由于 ncnn 端的 mask 由 C++ 另行实现,trace 中的这一实现仅作占位,从简即可。
第三个问: 卷积栈转换后 SNR 仅 17.3 dB,音质明显劣化。 这是全程排查耗时最久的问题。初步转换
完成后模型可以运行,但输出与 PyTorch 版本的相对误差达到 13.7%。排查过程如下:首先怀疑运行时 fp16,
关闭全部
opt.use_fp16_*选项后误差毫无变化;随后做了一版全栈逐层的相对误差剖面,发现误差自第一层起便有 4e-4,并被 SnakeBeta(
sin(x·exp α),导数极大)沿深度逐级放大;继而逐一尝试卷积相关的各类opt 开关,仍无改善。最终注意到
.bin文件的体积恰为 fp32 的一半,才定位到根因:pnnx.export的fp16参数默认为True,权重被存储为 fp16,每层约千分之一的误差在数十层的 codec 中逐级累积,便形成了上述结果。改为
fp16=False重新导出后,maxdiff 由 0.21 降至 9.8e-5,SNR 恢复至 80 dB。排查许久,症结不过是一个默认参数。由此得到的教训是:当两个框架的数值无法对齐时,应首先核查权重的
存储精度,而非径直深入运行时。
Talker:非标准结构与两级生成
Talker 的实现难度明显高于 codec,原因有二:一是其结构存在若干非标准之处,二是其生成过程为两级嵌套。
结构方面,Talker 使用交错版的 mRoPE,位置编码按
[24,20,20]三段在通道维上交错。我推导了每个通道应取用的位置分段(
base = c % (HD/2),继而modality = base<60 ? base%3 : 0),结果表明对 TTS而言三个位置维实际相同,交错最终退化为普通 RoPE。此外还有 Qwen3 的 QK-norm 与 GQA:q、k 投影之后
需按 head_dim 再做一次 RMSNorm,8 个 kv 头广播至 16 个 q 头。
生成方面为两级结构:每帧先由 Talker 生成一个主 token,再由 Code Predictor 自回归地补出 15 个残差码;
这 16 个码的嵌入相加,再叠加文本或 pad 嵌入,构成下一步 Talker 的输入。第一版实现完成后,codes 仅有
10% 能够对上;观察输出发现前两帧完全一致,自第三帧起开始偏离。检查后发现遗漏了
repetition_penalty=1.05——生成主 token 时需对已出现过的 token 施加惩罚。补齐这一项,再加上suppress 与
min_new_tokens,codes 即全部对齐,1200 个无一偏差。验证方法,以及一个 off-by-one 错误
整个过程采用的验证方法朴素但可靠:从 PyTorch 侧将每一级的中间张量 dump 出来作为参考答案,C++/ncnn
侧每实现一级便核对一级。Talker 的 28 层(含交错 mRoPE、QK-norm、GQA)一次即实现正确,prefill 的
maxdiff 为 2.26e-4。
过程中有一个值得记录的反面教训。我曾尝试将 SnakeBeta 单独抽出,以 2D 输入测试其精度,结果测得的
偏差全部是虚假的:ncnn 接收 2D 输入时会将 channel 置于 H 轴,与其在完整栈中经过 conv1d 之后的内存
布局完全不同。换言之,单独抽取某个算子来测试 ncnn 的保真度这条路径不可行,必须将其置于完整栈中,
以逐层相对误差进行比对。
prompt 组装环节也出过一次问题。拼装得到的 25 个 prefill 嵌入每一行均偏差 0.73,而相同流程以 Python
实现则完全精确,问题必然出在 C++ 侧的
text_projection。逐层核查后发现linear_fc1.bias is not None——ResizeMLP构造函数签名中默认bias=False,但实例化出的对象为bias=True,两个 Linear 均带 bias,而我的实现遗漏了。补齐之后 maxdiff 由 0.73 降至 2.3e-6。结论是:不应轻信构造函数的默认值,而应核查实例的实际配置。
整合与性能优化
将上述各部分整合为一个可执行文件
qwen3_tts:文本输入后依次经过 BPE、prompt 组装、Talker、codes、codec,最终输出波形。第一版实现以正确性为先,每一步都将整个序列从头重算,单次运行耗时 15 至 25 分钟,
不具备实用性。随后为 Talker 加入 KV-cache,每步仅计算新 token 的 Q/K/V 并对缓存执行注意力,再配合
OpenMP,耗时降至 60 秒左右,提速约 20 倍。
这一步同样出现了问题。KV 版本运行后 codes 全部错误,但主 token 正确,仅子码整齐地全部 +1。如此
规整的偏移显然是逻辑错误而非数值误差。排查确认:加载 Code Predictor 权重时,读取
cnorm之后未将指针越过 norm 所占的区段(H 个 float),导致 cp_head 与 cp_emb 整体错位一行。修正指针偏移后
codes 重新全部对齐,端到端波形相对 reference.wav 的 SNR 为 63.4 dB。剩余差距主要来自 reference
使用了 chunked-decode,边界处本身存在裁剪。
局限与未完成的工作
运行仅在 Linux(x86_64)上验证;Windows 端由 GitHub Actions CI 保证可编译,但实际运行还需另行配置
权重。当前打通的是中文加预置说话人(Vivian)这一条路径,更换说话人或语言需要修改 codec 前缀的若干
token id。Talker 已使用增量 KV-cache,Code Predictor 仍为每帧重算(其规模很小,开销可以接受),
SIMD 与进一步的并行化尚未展开。至于 63.4 dB 这一指标,听感上已与原版无法区分,但并非逐比特相等——
跨框架的浮点运算无法达到逐比特一致,且 reference 使用了 chunked-decode,边界处天然存在差异。
经验总结
回顾整个过程,真正节省时间的经验有以下几条。ncnn 不存在 batch 维度,因此前馈算子交由 ncnn、注意力
与 RVQ 与自回归循环以 C++ 实现,这一分工确定得越早越好。若要实现逐比特对齐,前提是将采样固定为
greedy 解码。新版 transformers 的 mask 构建使用了 vmap,pnnx 无法 trace,在转换阶段替换为普通张量
mask 即可。pnnx 默认将权重存储为 fp16,欲实现数值对齐必须设置
fp16=False,代价是模型体积翻倍。若要测试某个算子在 ncnn 中的精度,不应将其单独抽出——会被内存布局误导——而应置于完整栈中观察逐层
相对误差。数值无法对齐时,应优先核查权重精度与实例参数(例如 bias 是否存在),而非轻信默认签名。
最后,KV-cache 将自回归复杂度由 O(T²) 降至 O(T),是该系统从"可以运行"到"可以使用"的分界线。
致谢:模型来自 QwenLM/Qwen3-TTS;推理框架为
Tencent/ncnn 与 pnnx;C++ 侧的 LLM 解码参考了
futz12/ncnn_llm。
Beta Was this translation helpful? Give feedback.
All reactions