7,727
社区成员
发帖
与我相关
我的任务
分享一台高通 QCS8550 开发板,一个用 GPT-SoVITS v2Pro 训练好的中文音色。目标很简单:让它在板子的 NPU 上跑起来,
并且像 OpenAI 的/v1/audio/speech一样可以直接调用。过程并不简单——静态图改写、8 MB 的 TCM、fp16 下溢、
训练过头的"嗡嗡声"、退出时的段错误……这篇文章按时间顺序记录整个过程和踩过的坑。
最终在开发板上,6 个模型全部以 fp16 跑在 Hexagon NPU 上,CPU 侧只用 numpy:
| 指标(12.34 秒语音,3 句话) | 结果 |
|---|---|
| 整段合成耗时 | 5.70 s(RTF 0.46) |
| 流式首段音频到达 | 2.03 s |
| 服务启动(加载 6 个模型 + 预热) | 1.8 s + 2.7 s |
| 清晰度(whisper-small 转写的字错误率) | 3.6%,原版 GPT-SoVITS PyTorch 推理是 4.3% |
这个数字是一步步磨出来的:第一次在板上跑通时 RTF 是 0.89,把多音字模型也搬上 NPU 后降到 0.58,
做成常驻服务、去掉冷启动开销后是 0.46。
拆开 GPT-SoVITS v2Pro 的推理流程,一句话要经过这些步骤:
文本 ─ 分句、分词、文本规范化 ─ G2PW 多音字判断 ─> 音素
─ BERT(chinese-roberta-wwm-ext-large)─> 音素级文本特征
─ GPT(text-to-semantic,24 层 Transformer,自回归)─> 语义 token(每秒语音 25 个)
─ SoVITS(VITS 编码器 + flow + HiFiGAN 声码器)─> 32 kHz 波形
另外还有三个只和参考音频有关的模型:HuBERT(提取参考音频的语义 token)、ERes2Net(说话人向量)、
SoVITS 的 ref_enc(音色向量)。对于固定音色,这些完全可以在 PC 上预先算好存成文件,设备上根本不需要。
最终上 NPU 的是 6 个图:
| 图 | 内容 | 静态形状 |
|---|---|---|
g2pw | 多音字模型的 BERT 主体 | 8 个多音字 × 64 token |
bert | RoBERTa 前 22 层 | 128 token |
t2s_prefill | GPT 首次处理 [参考音素 + 目标音素 + 参考语义 token] | 512 |
t2s_decode | GPT 每次生成一个 token | KV cache 1024 |
vits_enc | SoVITS 编码器 + flow | 500 帧(10 秒)、128 个音素 |
vits_gen | HiFiGAN 声码器 | 96 帧窗口 |
其余的——分词、G2P 规则、embedding 查表、位置编码、采样、KV cache 的维护——都放在 CPU 上用 numpy 做。
NPU(QNN HTP)要求输入形状固定。GPT-SoVITS 自带的 onnx_export.py 只支持 v1/v2,而且是动态形状:
GPT 的 KV cache 每一步用 torch.cat 变长,VITS 的长度随文本变化。所以第一件事是重写导出:
hidden_states[-3],也就是第 22 层的输出,后两层白算。导出后逐图和原版 PyTorch 对比:GPT 的 logits 误差在 1e-5 量级、30 步 decode 的 top-1 完全一致;
VITS 除了最后 0.1 秒(补齐边界)外 SNR 98 dB。
用 Qualcomm AI Hub 的 submit_compile_and_link_jobs(先编译成 qnn_dlc,再 link 成 context binary)编译 fp16 模型。
GPT 的两个图顺利通过,VITS 整图在 link 阶段失败:
A single op, "q::ConvLayer.opt.activations_to_vtcm" requires 0x1a0b000 bytes of TCM,
which is greater than the TCM size of 0x800000!
"/dec/ups.3/ConvTranspose_2d"
HiFiGAN 第 4 个上采样层处理 10 秒音频时需要 27 MB 的 TCM(NPU 的片上内存),而 v73 只有 8 MB。
解决办法是把声码器单独成图,按 96 帧(约 1.9 秒)的窗口、两侧各留 12 帧重叠分段运行,只保留每个窗口中间的部分拼起来。
第一次拼接出来 SNR 只有 14 dB——而且加大重叠也不改善。定位下来,误差全在序列的首尾各 5 帧:我在第一个窗口前面补了零,
而整段生成时卷积在边界处的行为并不等价于"前面有一段零"。改成首尾窗口贴住序列边界(不补零)之后,
拼接结果与整段生成的 SNR 达到 106 dB,等于完全一致。
编译完成后在真机上测速,所有算子都在 NPU 上:
| 图 | 真机延迟 |
|---|---|
| g2pw | 18.2 ms |
| bert | 13.9 ms |
| t2s_prefill | 36.0 ms |
| t2s_decode | 11.95 ms / 步 |
| vits_enc | 47.9 ms |
| vits_gen | 97.2 ms / 窗 |
decode 是大头:每秒语音 25 步,约 0.3 秒。
AI Hub 可以提交 inference job,把输入发到真机上跑再取回输出。但它一次只能跑一个图,自回归的 GPT 没法在远端一步步驱动。
我的做法是:本地用 onnxruntime 跑一遍完整合成、记录每个图的真实输入,再把这些输入发到真机,拿 fp16 的输出和本地 fp32 对比:
最后用 whisper-small 转写合成结果算字错误率:我这套静态图的实现平均 3.6%,原版 GPT-SoVITS PyTorch 推理 4.3%,清晰度持平。
最初我的设备端代码打算用高通的 qai_appbuilder。恰好在另一个会话里,我刚把 Qwen3-Reranker 部署到同一块板子上,
踩了一堆坑。对照那份记录,把设备端改成了直接调用 QNN C API 的 C++ 运行库(ctypes 调用),吸收了这些经验:
aarch64-oe-linux-gcc11.2 这套 QNN 库,别的会报 Unsupported SoC model 66;它要求 glibc ≥ 2.34。system 组里,否则打不开 /dev/adsprpc-smd。REGISTER_MULTI_CONTEXTS 分组加载(共享 spill-fill 缓冲),不支持时退回独立加载。kT_cache, v_cache, mask, x),输出则被改名为output_0..N。运行库必须按名字喂输入、按序号取输出。没有板子的时候,用 SDK 自带的 x86 HTP 模拟器验证主机端调用逻辑:同一批 context binary 在 x86 上也能跑(很慢,decode 一步约 4 秒),
足以检查加载、输入映射、输出顺序这些容易出错的地方。
第一次在板上运行,音频正常生成、decode 实测 12.4 ms/步,和 AI Hub 的 11.95 ms 几乎一样。但程序结束时刷出 9 行undefined m_mutex handle object,然后 Segmentation fault。
原因是我没有在退出前释放 QNN 资源:进程退出时 FastRPC 会话先被拆掉,HTP 对象才销毁。显式调用 close()、再注册一个 atexit
之后就干净了。这个问题在 x86 模拟器上完全不会出现——模拟器没有 FastRPC。
跑通只是开始。仔细听之后,陆续发现了几个音质问题,每个都值得单独说。
频谱上能看到一组固定的细线:11、13、13.5、14.5、15.7 kHz,全是 500 Hz 的整数倍——典型的 HiFiGAN 上采样周期性伪影。
关键的判断是:我的输出、真机输出、原版 PyTorch 输出里这组细线一模一样,训练录音里没有。所以这不是移植的问题,而是模型本身的。
再往前追:SoVITS 底模完全没有这组细线,微调第 4 轮就出现了,之后各轮来回波动(第 20 轮最强,13.5 kHz 高出背景 16 dB)。
训练数据 12 kHz 以上能量很低,mel loss 对极小的能量有下限截断,这个频段几乎不受约束,微调就让伪影自由长了出来。
加了高频滤波后电流音没了,但吸气时仍有很强的"嗡嗡"声,而第 16、12 轮的 SoVITS 没这么明显。
呼吸本应是宽带噪声,"嗡嗡"声对应的是其中混进了一串窄带音调峰(648、805、953、1203 Hz……)。我把它量化成一个指标:
呼吸段 400–4000 Hz 内、高于局部中值 6 dB 以上的能量之和,它给出的排序和耳朵听到的一致。然后逐轮测:
| SoVITS | 用训练录音的真实 token 重合成 | 用 GPT 新生成的 token |
|---|---|---|
| 底模 | 4.8 | 5.1 |
| 第 4–16 轮 | 3.7 – 4.7 | 1.8 – 6.2 |
| 第 20 轮 | 11.5(40 条里 32 条超标) | 12.6(12 段全部超标) |
结论很清楚:
于是部署改用第 16 轮。这个评估后来做成了仓库里的 eval_sovits_ckpts.py,只用 GPT-SoVITS 训练目录里现成的数据就能逐轮比较。
插曲:我也试过让 Gemini(通过 agy 命令行)试听打分。它确实能"听"——训练录音转写一字不差——但对细微呼吸噪声的判断
很不稳定:同样三个版本盲评三次,其中一个版本一次 1 分、一次 9 分。这类细微音质评估,还是耳朵加客观指标靠谱。
换成第 16 轮后又听出"有点闷"。一开始以为是之前加的 12 kHz 低通,量化后发现一半一半:
最后提供了几个可选项:notch 只在那几个固定频率做极窄陷波,去掉细音而保留空气感;presence_db 可以在 4 kHz 以上做一点高架提升。
对比试听后,我最终选择了不加任何后处理的版本。
长文本需要切成多句分别合成再拼接。拼接处听起来有断裂感。我先去看了原版 GPT-SoVITS 怎么做:
再测我自己的输出:每段在最后一个字发完后只剩 5–20 毫秒的尾音,就直接跳到全零静音,尾音来不及自然衰减。
改成段首 10 ms 淡入、段尾 40 ms 淡出,停顿按标点决定(句末 0.3 秒;长句在逗号处切开时只停 0.15 秒),断裂感明显减轻。
切句规则也按需求重写了:按句末标点切,超过 30 字的再按逗号切,不足 5 字的并入相邻段。其中一个细节是字数要按文本规范化之后算:
"2026年9月26日"读出来是"二零二六年九月二十六日",按原文计数会让带数字的句子实际超长。
| 尝试 | 结果 | 结论 |
|---|---|---|
| 把多音字模型 G2PW 也搬上 NPU | 首段文本处理 4.98 s → 1.28 s,设备上不再需要 onnxruntime | ✅ 采用 |
| w8a16 量化(INT8 权重 / INT16 激活,真实数据校准) | decode 只快 3%,采样候选一致率掉到 93%;声码器反而慢了近一倍;编码器编译失败 | ❌ 继续用 fp16 |
| KV cache 从 1024 缩到 768 | decode 快 15%,输出逐字节相同 | ❌ 收益太小 |
量化和缩短 KV cache 的结果说明:每步 decode 并不只卡在 KV cache 或权重精度上——权重减半只快了 3%,缓存缩短四分之一只快了 15%。
在不改模型结构的前提下,单步 12 ms 已经没有太多空间。
G2PW 是一个 BERT 结构的多音字模型,原本在 CPU 上用 onnxruntime 跑。搬上 NPU 时:
exp(logit − 全局最大值) × mask / 求和,当这个字允许的读音的 logit 远小于全局最大值时,fp16 直接下溢为 0;Dma execution failed on the skel side。最后用 FastAPI 包了一层 /v1/audio/speech,官方 openai Python SDK 可以直接调用:
stream: true 时逐句返回;这里差点埋下一个死锁:最初我用线程锁保护整个请求,但所有 NPU 调用都在同一个工作线程上执行。流式响应跨 await 持有锁时,
第二个请求一进来就占住了唯一的工作线程去等锁,而第一个请求剩下的句子排在它后面——谁也走不了。改成在请求层面用 asyncio 锁排队后,
一个流式加两个普通请求同时发也都正常完成。
还有一个测量上的小坑:用 curl 的 time_starttransfer 测流式首包,得到 0.059 秒——其实那只是响应头,服务在开始合成前就先把头发出去了。
用 head -c 1 计时才测到真实的首段音频:2.03 秒。
最后把整个流程整理成了 gpt-sovits-qnn。使用方式尽量简单:
# 1. 把 GPT-SoVITS 训练得到的 logs/<实验名>/ 复制到仓库的 logs/ 下
# 2. 把 QAIRT SDK 2.50 解压到 qairt/(README 里有直接下载链接)
# 3. 一条命令完成:导出 → 对比验证 → 生成资源 → AI Hub 编译 → 下载 → 打包
GSV_ROOT=/path/to/GPT-SoVITS GPT_EPOCH=15 SOVITS_EPOCH=16 bash scripts/run_pipeline.sh <实验名>
有个小发现:训练目录 logs/<实验名>/ 里没有 GPT-SoVITS 导出的半精度模型,只有训练时保存的完整 checkpoint。
直接读这些 checkpoint 转换(GPT 的 epoch=N 对应导出的第 N+1 轮),反而用上了 fp32 权重,比从半精度模型转换还略精确一点。
docs/NOTES.md。