把 GPT-SoVITS 搬上高通 QCS8550 NPU:从训练好的音色到 OpenAI 兼容 TTS 服务

weixin_46587843 2026-09-28 17:21:17

一台高通 QCS8550 开发板,一个用 GPT-SoVITS v2Pro 训练好的中文音色。目标很简单:让它在板子的 NPU 上跑起来,
并且像 OpenAI 的 /v1/audio/speech 一样可以直接调用。过程并不简单——静态图改写、8 MB 的 TCM、fp16 下溢、
训练过头的"嗡嗡声"、退出时的段错误……这篇文章按时间顺序记录整个过程和踩过的坑。

代码已开源:github.com/zhouzengming/gpt-sovits-qnn

先看结果

最终在开发板上,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 在推理时到底要跑哪些模型

拆开 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
bertRoBERTa 前 22 层128 token
t2s_prefillGPT 首次处理 [参考音素 + 目标音素 + 参考语义 token]512
t2s_decodeGPT 每次生成一个 tokenKV cache 1024
vits_encSoVITS 编码器 + flow500 帧(10 秒)、128 个音素
vits_genHiFiGAN 声码器96 帧窗口

其余的——分词、G2P 规则、embedding 查表、位置编码、采样、KV cache 的维护——都放在 CPU 上用 numpy 做。

二、第一步:一切改成静态形状

NPU(QNN HTP)要求输入形状固定。GPT-SoVITS 自带的 onnx_export.py 只支持 v1/v2,而且是动态形状:
GPT 的 KV cache 每一步用 torch.cat 变长,VITS 的长度随文本变化。所以第一件事是重写导出:

  • GPT 拆成 prefill 和单步 decode 两个图。 KV cache 固定长度 1024,用一个加性 mask 标出哪些位置有效;
    每步 decode 只输出新 token 的 k/v,由 CPU 写回缓存。采样(top_k=15、重复惩罚 1.35)在 CPU 上做,完全复刻原实现,
    包括"前 11 个 token 不允许结束"这种细节。
  • VITS 用 mask 处理补齐。 编码器里的注意力、卷积都带上 padding mask,补齐的部分不影响有效部分。
  • BERT 只保留前 22 层。 GPT-SoVITS 取的是 hidden_states[-3],也就是第 22 层的输出,后两层白算。
  • embedding 放 CPU。 查表很便宜,而且之前在另一个项目(Qwen3-Reranker 上 NPU)里踩过"图里带 embedding
    时 link 失败"的坑。

导出后逐图和原版 PyTorch 对比:GPT 的 logits 误差在 1e-5 量级、30 步 decode 的 top-1 完全一致;
VITS 除了最后 0.1 秒(补齐边界)外 SNR 98 dB。

三、第二步:AI Hub 编译,撞上 8 MB 的 TCM

用 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 上:

图真机延迟
g2pw18.2 ms
bert13.9 ms
t2s_prefill36.0 ms
t2s_decode11.95 ms / 步
vits_enc47.9 ms
vits_gen97.2 ms / 窗

decode 是大头:每秒语音 25 步,约 0.3 秒。

四、fp16 靠不靠谱:真机上验证精度

AI Hub 可以提交 inference job,把输入发到真机上跑再取回输出。但它一次只能跑一个图,自回归的 GPT 没法在远端一步步驱动。
我的做法是:本地用 onnxruntime 跑一遍完整合成、记录每个图的真实输入,再把这些输入发到真机,拿 fp16 的输出和本地 fp32 对比:

  • GPT:logits 相对误差约 1.4e-3,采样用到的前 15 个候选与 fp32 完全一致(只有两个分数几乎相等的候选互换了顺序,
    对 top-k 采样没有影响)。
  • VITS:波形 SNR 约 31 dB,看起来不高,但换算到听感更相关的 log-mel 距离只有 0.058——作为对比,fp32 模型输出和原始录音之间是 1.08。

最后用 whisper-small 转写合成结果算字错误率:我这套静态图的实现平均 3.6%,原版 GPT-SoVITS PyTorch 推理 4.3%,清晰度持平。

五、设备端运行时:借鉴另一个项目的踩坑经验

最初我的设备端代码打算用高通的 qai_appbuilder。恰好在另一个会话里,我刚把 Qwen3-Reranker 部署到同一块板子上,
踩了一堆坑。对照那份记录,把设备端改成了直接调用 QNN C API 的 C++ 运行库(ctypes 调用),吸收了这些经验:

  • QCS8550 在 Linux 上只能用 aarch64-oe-linux-gcc11.2 这套 QNN 库,别的会报 Unsupported SoC model 66;它要求 glibc ≥ 2.34。
  • 运行用户要在 system 组里,否则打不开 /dev/adsprpc-smd。
  • 多个 context 用 REGISTER_MULTI_CONTEXTS 分组加载(共享 spill-fill 缓冲),不支持时退回独立加载。
  • AI Hub 编译后的 context binary 会重排输入顺序(比如 decode 变成 kT_cache, v_cache, mask, x),输出则被改名为
    output_0..N。运行库必须按名字喂输入、按序号取输出。
  • 输入输出直接指向 numpy 的内存,每步 decode 少一次 50 MB KV cache 的拷贝。

没有板子的时候,用 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。

六、音质问题三连

跑通只是开始。仔细听之后,陆续发现了几个音质问题,每个都值得单独说。

1. 吸气时的高频"电流音":微调产生的 500 Hz 整数倍音调

频谱上能看到一组固定的细线: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 对极小的能量有下限截断,这个频段几乎不受约束,微调就让伪影自由长了出来。

2. 吸气时的"嗡嗡"声:SoVITS 训练过头了

加了高频滤波后电流音没了,但吸气时仍有很强的"嗡嗡"声,而第 16、12 轮的 SoVITS 没这么明显。

呼吸本应是宽带噪声,"嗡嗡"声对应的是其中混进了一串窄带音调峰(648、805、953、1203 Hz……)。我把它量化成一个指标:
呼吸段 400–4000 Hz 内、高于局部中值 6 dB 以上的能量之和,它给出的排序和耳朵听到的一致。然后逐轮测:

SoVITS用训练录音的真实 token 重合成用 GPT 新生成的 token
底模4.85.1
第 4–16 轮3.7 – 4.71.8 – 6.2
第 20 轮11.5(40 条里 32 条超标)12.6(12 段全部超标)

结论很清楚:

  • 和 GPT 无关——同一批 GPT token 只换 SoVITS,嗡嗡声就有无之别;
  • 和"训练数据里有呼吸声"无关——第 4–16 轮渲染同样的呼吸都很干净;
  • 是 SoVITS 在第 16 轮到第 20 轮之间训练过头了。19 分钟的数据,20 轮对 SoVITS 来说太多。

于是部署改用第 16 轮。这个评估后来做成了仓库里的 eval_sovits_ckpts.py,只用 GPT-SoVITS 训练目录里现成的数据就能逐轮比较。

插曲:我也试过让 Gemini(通过 agy 命令行)试听打分。它确实能"听"——训练录音转写一字不差——但对细微呼吸噪声的判断
很不稳定:同样三个版本盲评三次,其中一个版本一次 1 分、一次 9 分。这类细微音质评估,还是耳朵加客观指标靠谱。

3. 声音发闷:滤波削掉了空气感,模型本身也偏暗

换成第 16 轮后又听出"有点闷"。一开始以为是之前加的 12 kHz 低通,量化后发现一半一半:

  • 低通确实把 12–16 kHz 压到了比训练录音低约 20 dB,这部分是声音的"空气感";
  • 但模型输出在 4–10 kHz 本身就比训练录音低 5–6 dB,原版 PyTorch、底模都一样,是 v2Pro 声码器的特点,滤波根本碰不到这个频段。

最后提供了几个可选项:notch 只在那几个固定频率做极窄陷波,去掉细音而保留空气感;presence_db 可以在 4 kHz 以上做一点高架提升。
对比试听后,我最终选择了不加任何后处理的版本。

4. 分段之间的"断裂感"

长文本需要切成多句分别合成再拼接。拼接处听起来有断裂感。我先去看了原版 GPT-SoVITS 怎么做:

  • 拼接只是每段后面补 0.3 秒全零静音,没有任何交叉淡化;
  • 唯一和连贯性有关的是"并行推理":一批里有多段时,把它们拼起来一起送进 VITS 解码再切开——但 API 默认一批只有 1 段,并不触发。

再测我自己的输出:每段在最后一个字发完后只剩 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 缩到 768decode 快 15%,输出逐字节相同❌ 收益太小

量化和缩短 KV cache 的结果说明:每步 decode 并不只卡在 KV cache 或权重精度上——权重减半只快了 3%,缓存缩短四分之一只快了 15%。
在不改模型结构的前提下,单步 12 ms 已经没有太多空间。

G2PW 上 NPU 的两个坑

G2PW 是一个 BERT 结构的多音字模型,原本在 CPU 上用 onnxruntime 跑。搬上 NPU 时:

  1. 整个模型 fp16 上真机,1795 行判断里只有 1720 行和原模型一致。 问题在输出头:它的 masked softmax 写成
    exp(logit − 全局最大值) × mask / 求和,当这个字允许的读音的 logit 远小于全局最大值时,fp16 直接下溢为 0;
    另外输出头里有一个"先判断词性、再选读音表"的 ArgMax,两个词性分数接近时 fp16 会让它翻转。
    解决办法:NPU 只算到被查询字的 768 维隐向量,输出头挪到 CPU 用 fp32 算——计算量只有几十万次乘加,之后 1795 行全部一致。
  2. AI Hub 测速用随机数当输入。 G2PW 有几个输入是"查表的下标",随机值越界后真机直接报 Dma execution failed on the skel side。
    在模型入口对下标做一次 Clip(合法输入不受影响)就好了,顺便让板上服务对异常输入更健壮。

八、做成 OpenAI 兼容的 TTS 服务

最后用 FastAPI 包了一层 /v1/audio/speech,官方 openai Python SDK 可以直接调用:

  • 启动时一次性加载全部模型并预热一次,把分词词典、多音字模型等所有懒加载都触发掉,请求时不再读硬盘;
  • 支持 wav / pcm(24 kHz,与 OpenAI 一致)等格式,stream: true 时逐句返回;
  • 只有一个 NPU,请求排队串行处理。

这里差点埋下一个死锁:最初我用线程锁保护整个请求,但所有 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 权重,比从半精度模型转换还略精确一点。

经验总结

  1. 先证明问题在哪一层。 高频电流音和嗡嗡声最初都像是移植引入的,但"原版 PyTorch 输出里也有"一句话就排除了移植,
    "底模没有、第 4 轮就有"又把问题钉在了微调上。先对照、再下结论,省掉了很多在错误方向上的优化。
  2. 把听感量化。 "嗡嗡声""发闷""断裂"都是主观描述,找到和听感一致的客观指标(呼吸段音调能量、频谱倾斜、段尾时长)之后,
    才能逐轮、逐版本地比较。
  3. fp16 的风险集中在少数算子上。 大部分网络 fp16 都没问题,出事的是 softmax 前的大动态范围、离散的 ArgMax 这类地方。
    把这一小块挪回 CPU,代价几乎为零。
  4. 别只看 profile。 AI Hub 的 profile 给的是单图计算时间;真正的端到端表现还要算上 CPU 侧的调度、冷启动、首包延迟。
    RTF 从 0.89 到 0.46,很大一部分收益来自这些"模型之外"的地方。
  5. 经验要沉淀下来。 这次能少走很多弯路,是因为上一个项目把"只有 gcc11.2 这套库能用""输入会被重排"这些坑都记了下来。
    这次的新坑也都整理进了仓库的 docs/NOTES.md。
...全文
18 回复 打赏 收藏 举报
写回复
用AI写文章
回复
切换为时间正序
请发表友善的回复…
发表回复

7,727

社区成员

发帖
与我相关
我的任务
社区描述
本论坛以AI、IoT、PC 、XR、Auto等核心板块组成,为开发者提供便捷及高效的学习和交流平台。 高通开发者专区主页:https://qualcomm.csdn.net/
物联网人工智能开源 企业社区 北京·东城区
社区管理员
  • csdnsqst0050
  • chipseeker
加入社区
  • 近7日
  • 近30日
  • 至今
社区公告
暂无公告

试试用AI创作助手写篇文章吧