[犀牛鸟] Qwen3-TTS 移植 ncnn 简明教程 #6798
mingshi2333
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.
Uh oh!
There was an error while loading. Please reload this page.
-
把 Qwen3-TTS 完整适配到 ncnn 的记录。分两阶段:先把模型各组件导出为 ncnn 格式并逐个对齐精度,再给 decoder 注入 kvcache、用 C++ 复刻生成循环。结果:分词、说话人编码、LLM 解码、MTP、声码器全部跑在纯 C++/ncnn 上,greedy 与 PyTorch fp32 逐 token 一致(x-vector 克隆 704/704、ICL 克隆 8176/8176),同机同 8 线程 CPU 比 PyTorch fp32 快约 4 倍。同一套方法适用于大多数「LLM + 残差码本 + 神经声码器」结构的 TTS(CosyVoice、Fish-Speech 等)。
torch 2.12.1+cu126、transformers 4.57.3(上游 pin,5.x 会挂)、pnnx 20260526、ncnn 1.0.20260526(python 侧)、ncnn 源码 ≥ 244f30c(C++ 侧,需要 SDPA kvcache 和 RotaryEmbed)。目录:0 环境 · 1 结构 · 2 拆模块 · 3 黄金参考 · 4 导出与对齐 · 5 kvcache · 6 python 端到端 · 7 C++ · 8 克隆 · 9 Vulkan · 10 性能 · 11 Windows · 12 复现
0. 环境准备
两个坑:qwen_tts 包的 25Hz 路径连带 import torchaudio/sox,12Hz 用不到,用
shim_qwen_tts.py只加载 12Hz 模块绕开。Qwen3TTSTokenizerV2Model.from_pretrained走标准 HF 流程,无需改动。先跑通原版、存下输出音频当基准。1. 先搞清楚结构
动手前先读 modeling 源码 + 挂 hook 抓真实张量形状,理清模型由几块组成、每块进出什么——后面所有拆图决策都基于此。
Qwen3-TTS = Talker(自回归 LLM,出码本 0)+ Code Predictor(MTP 子模型,每帧补齐码本 1..15)+ Codec Decoder(码→波形)。克隆另有 ECAPA-TDNN(波形→x-vector)和 Mimi Encoder(波形→参考码)。
[上一帧 hidden, talker表(码本0)],过 head[0] 出码本 1),再 14 步自回归:第 s 步用表 [s-1] 嵌入、head[s] 出码本 s+1(这个错位看漏会整帧全错)。三个关键事实(都实测确认过):
get_rope_index三段位置 id 完全相同,interleaved 重排在等行输入下是恒等映射。5 行脚本对拍apply_multimodal_rotary_pos_emb与标准apply_rotary_pos_emb,逐元素差 0.0——导出直接用 ncnnRotaryEmbed,不写自定义层。min_new_tokens=2在 HF 里是"前 2 步把 EOS logit 置 -inf 取次优",不是"采到 EOS 跳过再采"——按后者写 greedy 必然分叉。2. 拆成 10 个模块
原则:控制流(采样、逐步选表、提示拼接)留在宿主语言,纯张量计算进图——这些控制流 jit.trace 带不走,塞进图只会形状全冻结。最终 8 张核心图 + speaker/mimi 两个克隆编码器 = 10 张。
ids (n,)i32(n,1024)ids (n,)i32(n,1024)hidden (1,len,1024)、mask、cos/sin (1,len,64)(1,len,1024)(n,1024)(n,3072)(1,len,1024)ids (n,)i32,预加 组号×2048 偏移(n,1024)(1,1024)out0..out14 (1,2048),按需 extract语义码 (1,T)i32、声学码 (15T,)i32 预偏移预展平、mask/cos/sinwav (1920T,)mel (frames,128)(1024,)wav (1,1,L),L 为 1920 倍数、mask/cos/sinlatent (512,T)关键实现决策(每条对应一个 pnnx/ncnn 坑):
repeat_interleave(dim=-3),不要 HF 默认的unsqueeze+expand+reshape——前者被 pnnx 的 GQA+mask SDPA pass 折成一个 ncnnSDPA,后者在标动态维导出时残留pnnx.Expression/Tensor.expand,加载报错(静态形状能转,但 LLM 导出必然标动态维)。trace 前替换ALL_ATTENTION_FUNCTIONS["sdpa"]。RotaryEmbed官方约定,tests/test_rotaryembed.cpp)。CPU 实现对整维表也碰巧正确,Vulkan 不惯着。(len,len)或 step 时(past+1,1)),官方约定(test_sdpa.cpp 里 mask 全 2D)。3D c=1 的 mask 各实现处理方式不同(x86 显式广播、朴素实现顺带兼容、Vulkan 按 3D 传 shader),别赌没被单测覆盖的形态。Embed是((const int*)bottom)[i]直接重解释,不是 float 取整。且 int 数据不能过 Reshape 等变换层(SIMD packing 按 float 搬运会毁位模式),码序列由调用方预展平直喂。unbind/enumerate(tensor)会被 pnnx 转成无参数的 ncnn Slice(param 里就一行Slice unbind_0 1 3 in0 1 2 3,切分参数全无),运行时空指针段错误。codec 图里的tensor_split也出过同样的坏 Slice。解法:15 张声学码本融合成 30720×256 大表,调用方给码加组号×2048偏移。stack(...)后接sum(dim=0),pnnx 会转出空轴数组的 Reduction(-23303=0),结果直接错(单独sum(dim=0)没问题,是这个组合坏)。用ones(1,15) @ x.reshape(15,-1)替代。x[..., :-r]被 pnnx 静默丢弃(最小复现里整个切片从图里消失),改F.pad(x,(0,-r))转成带相对端点的Crop,动态长度安全。CausalConvNet里extra_padding依赖shape[-1]的 python 算术被 trace 部分固化(50 帧进 25 帧出)。stride=1 时 extra≡0,替换成固定左 pad,转置卷积右裁剪同理。**kwargs转发的 position_embeddings 也会被 trace 冻结形状,wrapper 里内联层逻辑、位置参数直传。exp(α)/exp(β)、EuclideanCodebook 的embed_sum/clamp(usage)在 trace 前折成常量 buffer。3. 黄金参考
PyTorch fp32 + greedy(主/子 talker 都
do_sample=False),固定文本和 24kHz 参考音频,把码矩阵 (T,16) 和波形存成 npz:三个注意:
4. 导出与逐模块对齐
decoder 类模块的 wrapper(绕开 HF 的 mask 构造/DynamicCache/mrope):
pnnx 两组 inputshape 标注动态维:
每个模块导出后立刻对拍,必须换两种长度跑(形状固化只有换长度才暴露),合成输入下判据用相对误差(单位高斯过 28 层放大到 ±50,绝对 1e-4 会误报)。实测(fp32):
一个 python 绑定坑(症状跨进程随机、排查耗时,单列):同一张 28 层图,一个进程输出全对、换进程输出随机垃圾(相对误差 0.5~0.85 飘)。kv 版和 nokv 版同进程互拍逐位相同 → 图无罪。对"能复现/不能复现"的两份等价脚本做二分,收敛到一行
ncnn.Mat(np.ascontiguousarray(x)).clone()。根因:ncnn python 绑定Mat(numpy)只借用缓冲区、无 keep_alive,临时数组在Mat()返回后、.clone()前被 GC,clone 拷到已释放内存——症状随 GC 时机而随机。.numpy()视图(所有者张量活着)无恙,唯独"拥有内存的临时对象"中招。修复一行:统一先持有变量再 clone。方法论:图不对先做同图/同进程/同输入的自我对拍,钉死"谁在撒谎"再动手。5. kvcache 注入
pnnx 不生成 cache 接口。不改 pnnx/ncnn 源码,直接按 ncnn
docs/developer-guide/kvcache.md文本改写 param(add_kvcache.py)——SDPA 层本身支持 cache 输入输出(7=1),param 是纯文本:运行时约定:cache Mat 布局
(w=head_dim, h=seq, c=kv_heads)。prefill 不喂 cache(空 Mat = past 0)配因果 mask。step 喂回上一步输出的 cache,mask 全零(past+1, 1),cos/sin 按绝对位置查表。SDPA 内部沿 h 拼接并输出新 cache,宿主只做 Mat 搬运(引用计数零拷贝)。6. python 端到端
C++ 之前先用 python + ncnn 把完整生成循环跑到逐 token 一致(
test_e2e_greedy.py,分词器/x-vector 暂借 torch,其余全 ncnn)。目的是隔离变量:端到端出错时能区分导出错还是 C++ 错。这一步钉死生成循环的全部语义(提示构造、双轨相加、MTP 索引、HF 处理器顺序:重复惩罚 → min-new-tokens EOS 屏蔽 → suppress[2048,3072)∖{2150}→ argmax)。实测 44 帧 704/704 token 一致、EOS 步一致、解码波形 SNR 70.3 dB。这份脚本即 C++ 的规范。
7. C++ 运行时
仓库 mingshi2333/Qwen3-TTS-ncnn 结构(只依赖 ncnn):
分词器不引正则库:GPT-2 预分词正则只有七个分支,逐码点扫描器一百来行写完,\p{L}/\p{N} 用区间表近似(拉丁扩展/CJK/假名/谚文/全角等)。拿 HF 输出当真值对拍,混合语料(CJK、emoji、缩写、特殊 token、多空格)139/139 逐 id 一致——区间表覆盖不足会在这里暴露。
采样器验证拆两半:确定性部分把 transformers warper 类(temperature/top-k/top-p)当真值,24 组随机 logits 对拍加温后的概率分布逐值一致。随机性单独验,同 seed 双跑 bit 级相同。
实测
e2e_codes0/704 token 不匹配,RTF ~2.5(8 线程 fp32,性能见 §10)。两个默认值坑:ncnn 在支持的 CPU 上默认开 fp16 packed/storage,~1e-3 漂移在 28 层自回归链上足够翻 argmax,对齐档必须显式use_fp16_* = false。不同构建的 libncnn(内核分块不同)之间连 fp32 都有 ~1e-3/28 层的正常差异。单元判据放宽到相对误差,E2E argmax 才是真正的门。8. 声音克隆
x-vector 路的难点在 mel 前端:python 侧一行
librosa.feature.melspectrogram,C++ 侧从 FFT 手写,每个常数抄准即可——radix-2 FFT、periodic hann、两侧 reflect pad 384、center=False、幅值sqrt(re²+im²+1e-9)、slaney 128 bins fmax 12000、log-clamp 1e-5,任一记错下游 cos_sim 就掉。与 torch 对拍 cos_sim 1.0000000。ECAPA 图导出三处 trace 前改写:torch.chunk换静态切片。ASP 的 length mask 恒为全 1,直接消掉(顺带消除.item())。repeat(1,1,T)换成x*0 + mean广播(repeat 会形状冻结,zeros_likepnnx 不支持)。ICL 路:mimi encoder 图 + C++ split-RVQ 出参考码。提示 = 文本通道(ref 文本 ++ 目标文本 ++ tts_eos)与 codec 通道(codec_bos ++ 每帧 16 码嵌入和)逐位相加接在共享前缀后,解码时 ref++gen 一起过 codec 再按帧比例切掉参考段。实测参考码 vs CPU-torch 0/1616,生成 vs 黄金参考 0/8176(511 帧长程自回归零漂移)。
内置音色(CustomVoice checkpoint):spk_id 就是 codec 词表行(serena=3066 等 9 个),speaker 行 = codec_embedding(spk_id) 查行,插入位置与 x-vector 相同。该 checkpoint 默认非流式文本模式,提示结构变为「全部文本(+tts_eos) 骑在 codec_pad 行上 + 末行 tts_pad+codec_bos」,trailing 恒为 tts_pad。codec 权重与 Base 完全相同(sha 一致),只需重导出 talker 家族。实测 41/41 帧 0/656 token 与同域 CPU 参考一致,非流式+内置音色下 greedy 正常触发 EOS(与 ICL 不触发形成对照)。音色/语言名字由
model.json清单解析(make_model_json.py从 checkpoint config 生成)。9. Vulkan
NCNN_VULKAN=ON构建,运行时opt.use_vulkan_compute=true,上游原版 ncnn 直接全过(0/704)。一个约定坑:最初喂 HF 风格整维 rope 表 + 3D mask,Vulkan 全错、CPU 对。逐 blob 探针(同进程 CPU/Vulkan 对比,
examples/vk_probe.cpp)锁定 RotaryEmbed/SDPA,查官方单测确认是我方约定错误:ncnn rope 表约定 halfdim 行宽、mask 约定 2D,CPU 实现恰好宽容(按 Mat 实际行宽步进/隐式广播)掩盖了。教训:跨后端对拍失败先查 tests/ 确认输入约定,再怀疑实现。10. 性能
Ryzen 7745HX(8 物理核 / 16 超线程)+ RTX 4060 Laptop,0.6B fp32,44 帧(3.52s 音频),同机实测:
extract每个 blob 单独建一次VkCompute并submit_and_wait阻塞一次,KvCacheNet每步 57 次提交。改用单条VkCompute批量取(57→1)+ predictor 的 forward/head 融进一次提交(30→15),降到 2.25、反超 CPU,parity 不变。诊断与实现见 docs/vulkan-ar-decode-perf.md。11. Windows:只有 Linux 机器怎么验证
两层验证:GitHub Actions windows-latest(MSVC)做权威证明,本地 mingw-w64 交叉编译 + Wine 做推送前预验证。
toolchain 静态链接
-static -static-libgcc -static-libstdc++,免去往 Wine 拷 DLL。交叉编译立刻抓到一个坑:int32_t was not declared——Fedora gcc 的 libstdc++ 头传递包含<cstdint>,mingw 的不包含,本地编过≠可移植。mingw 交叉包默认无 OpenMP(pragma 静默忽略),ncnn 退化单线程,只影响速度不影响正确性。Wine 11 下 Windows 二进制全绿:tokenizer 139/139、sampler 24/24、Base E2E 0/704(单线程 23.5s)、CustomVoice E2E 0/656。Wine 跑的是真 Windows 代码路径(PE 加载、msvcrt、win32 线程),CI 只剩 MSVC 方言差异(/utf-8、NOMINMAX、_USE_MATH_DEFINES 已在 CMake 里预防)。
12. 复现
感谢 ncnn / pnnx / ncnn_llm / Qwen3-TTS。文中四个 pnnx 转换问题(动态维下 repeat_kv 残留 Expression、unbind 转无参数 Slice 段错误、动态维负切片静默丢弃、stack+sum(dim=0) 丢轴)都做了独立最小复现,后续单独提 issue。
Beta Was this translation helpful? Give feedback.
All reactions