把 ZipVoice 从 onnxruntime 移植到 MNN —— 7 个让人怀疑人生的细节
实测平台:华为 PPA AL20(麒麟 710,2018 年中端芯片)
模型:ZipVoice distill INT8/FP32
推理框架:阿里 MNN 3.5
引言
理论上,把一个 ONNX 模型从 onnxruntime 换成 MNN 应该是无感的——输入输出格式不变,模型权重不变,只是底层算子实现换了一套。但实际工程中,每一个"应该不变"的细节都可能成为坑。
笔者最近在做一个端侧 AI 故事 App 的优化,把 ZipVoice 这个 Flow Matching 架构的 TTS 模型从 sherpa-onnx 默认的 onnxruntime 后端迁移到 MNN,前后调试了将近一天,踩了 7 个坑。每一个坑都让代码看起来跑得通,结果听起来全是杂音或者"杂音里隐约能听到几个字"。
本文按调试发现顺序,记录这 7 个细节。读者如果在做类似的迁移,希望能少走一点弯路。
坑 1:INT8 量化模型在 MNN 上精度崩溃
ZipVoice 官方在 sherpa-onnx 里发布的是 INT8 量化模型(decoder 仅 125 MB),用 onnxruntime 跑没有任何问题。把decoder.int8.onnx用mnnconvert转成 MNN 格式后,模型能加载,能跑通,但输出的数值范围和 ONNX 版本完全对不上。
笔者写了一个对比脚本,用相同的随机输入分别跑两个后端:
[OnnxRuntime] v range=[-1.4617, 1.1162], mean=0.0337 [MNN INT8] v range=[-11.3677, 16.8902], mean=2.7471 Max diff: 17.4输出范围扩大了约 10 倍。最后送到 vocoder 的特征数量级完全错了,结果就是清一色的杂音。
根本原因:MNN 在某些 ARM 设备上对 INT8 ONNX 的反量化处理路径与 onnxruntime 有差异。具体到麒麟 710,CPU 不支持 i8sdot 指令(The device supports: i8sdot:0),MNN 内部的 INT8 算子降级到一条不太一致的实现路径。
解决方案:换用 FP32 模型。HuggingFace 仓库k2-fsa/ZipVoice/zipvoice_distill下提供了未量化的fm_decoder.onnx(478 MB)和text_encoder.onnx(17.6 MB),转 MNN 后精度正常:
[OnnxRuntime] v range=[-1.4617, 1.1162], mean=0.0337 [MNN FP32] v range=[-1.5554, 1.1672], mean=0.0377 Max diff: 0.23代价是模型文件大 4 倍,运行时内存占用也更大。但音质能保住。
坑 2:mel 缩放系数 feat_scale 默认不是 1.0
完成模型替换后,输出依然是杂音,但波形幅度异常微弱([-0.04, 0.04],正常应该是 [-0.5, 0.7])。这说明送给 vocoder 的特征数值"太小了"。
笔者翻 sherpa-onnx 源码,在offline-tts-zipvoice-model-config.h里找到一行:
floatfeat_scale=0.1;// 默认值!完整的 mel 处理流程是:
ComputeMel: mel = log(magnitude + 1e-10) * feat_scale // ×0.1 GenerateChunk: 直接送给 encoder/decoder(数值小一个数量级) Vocoder 之前: mel_for_vocoder = mel / feat_scale // ×10 还原笔者一开始没注意到这个参数,按 librosa 的常规做法直接log(mel)没有缩放,结果 vocoder 输入数值大了一个数量级,模型输出也跟着错了一个数量级。
修复:mel = log(mel + 1e-10) * 0.1,送 vocoder 之前再mel * 10。修改后波形幅度立刻恢复到正常的 [-0.59, 0.66]。
这是一个非常小的细节,但没有它整个管线就是错的。论文里也没明确说这个值,必须读源码才能发现。
坑 3:mel filterbank 默认 slaney 归一化与模型不兼容
修复feat_scale后,输出有声音了,能听到节奏接近正常语音的"啊嗯哦",但听不清字也不像参考音色。
笔者先怀疑 vocoder 的 ISTFT 实现,把中间数据 dump 出来用 librosa 重新做 ISTFT,结果一样。这说明问题更靠前——在 mel 提取阶段就错了。
继续查 sherpa-onnx 源码的MelBanksOptions配置:
mel_opts.is_librosa=true;mel_opts.use_slaney_mel_scale=false;mel_opts.norm="";而 kaldi-native-fbank 库的MelBanksOptions默认值是:
std::string norm="slaney";// 默认 slaneybooluse_slaney_mel_scale=true;// 默认 true笔者一开始只显式设置了is_librosa=true,没有覆盖另外两个,结果用了默认的 slaney 归一化和 slaney mel 刻度,与 ZipVoice 训练时的特征不匹配。模型看到的"音色指纹"分布和它训练时见过的不一样,自然生成不出对应的语音。
修复:
melOpts.is_librosa=true;melOpts.norm="";// 不归一化melOpts.use_slaney_mel_scale=false;// 不用 slaney 频率刻度这一改音色立刻变得能识别为雷军的声音。
坑 4:Vocoder 不是输出波形,而是输出三个频域张量
ZipVoice 用的 vocoder 是 vocos。笔者一开始想当然地认为 vocoder 输出就是音频波形,写代码时只指定了一个输出名y:
std::vector<std::string>vocoderOutputs={"y"};转换 MNN 时,MNN 只保留这一个输出,丢弃了另外两个。运行时vocoderOutputs.size() == 1,后续处理直接报错。
读 vocos 源码才发现,它输出的是三个张量:
| 输出名 | 含义 |
|---|---|
| mag | 幅度谱(每个频率点多响) |
| x | 复数实部因子 (cos 分量) |
| y | 复数虚部因子 (sin 分量) |
最终的 STFT 复数表示是:
real = mag * x imag = mag * y然后做 ISTFT 才能得到波形。
修复:转换 MNN 时不指定输出名(让 MNN 保留所有输出),代码中显式按outputs[0]/[1]/[2]取出三个张量。
automagVar=vocoderOutputs[0];autoxVar=vocoderOutputs[1];autoyVar=vocoderOutputs[2];为什么 vocos 这样设计而不直接输出波形?因为这种"幅度+方向"的拆解让模型更容易学习。把三个网络头分别预测 mag、x、y,比让一个头同时预测复数实部+虚部要稳定得多。
坑 5:ISTFT 实现差异,60 倍速度差
ISTFT 的数学原理是把每帧 STFT 系数做 IFFT 得到时域片段,再做 overlap-add 合成连续波形。笔者一开始为了快速验证逻辑,用 O(n²) 的暴力 IDFT 写了一版实现:
for(intn=0;n<nFft;++n){floatsum=0;for(intk=0;k<fftBins;++k){sum+=stftReal[k]*cos(angle)-stftImag[k]*sin(angle);// 共轭对称部分if(k>0&&k<nFft/2){sum+=...}}frameSamples[n]=sum/nFft;}测试用的合成(200 字文本,819 帧 STFT),这段代码跑了整整60 秒。
替换成 kaldi-native-fbank 自带的 IStft 后(内部是 kissfft,O(n log n)):
knf::IStftistft(stftConfig);autowaveform=istft.Compute(stftResult);同样的输入,1053 毫秒完成。提速 60 倍。
教训:永远不要在生产代码里写暴力 IDFT。FFT 不是优化,是必需。
坑 6:MNN 部分设备不支持 int64 张量
ZipVoice 的 encoder 输入有tokens、prompt_tokens两个 int64 张量。笔者按 ONNX 模型的输入类型直接传 int64,MNN 在麒麟 710 上抛出错误:
E/MNNJNI: CpuBinary: unsupported data type (bits: 64, code: 0) E/MNNJNI: Create execution error: 7排查后发现,麒麟 710 的 CPU 后端对 int64 的部分二元算子(如 Gather、Cast)实现不完整。
修复:手动把 int64 转成 int32 再传给 MNN:
autotokensVar=_Input({1,tokensLen},NCHW,halide_type_of<int32_t>());auto*tokensPtr=tokensVar->writeMap<int32_t>();for(inti=0;i<tokensLen;++i)tokensPtr[i]=(int32_t)tokens[i];token 值范围在几百以内,int32 完全够用。这个改动之后 encoder 跑通。
值得注意的是,这是 MNN 在特定设备上的算子实现差异,不是模型本身的问题。同样的 MNN 模型在更新的芯片上可能直接支持 int64。
坑 7:native 库全局对象析构冲突,进程崩溃
最隐蔽也最难定位的一个坑。
笔者的项目同时链接了三个 native 模块:sherpa-onnx(Flutter 插件自带)、MNN 推理引擎、kaldi-native-fbank。三者都通过各自的 .so 加载,App 单独使用每一个都正常。但合在一起,进程退出时偶尔崩溃,logcat 里栈是这样的:
F/libc: Fatal signal 6 (SIGABRT), code -1 Abort message: 'terminating' #04 std::terminate() #05 std::__1::thread::~thread() #06 __cxa_finalize #07 exit #08 libsherpa-onnx-c-api.so__cxa_finalize是 C++ 进程退出时调用的全局析构函数。崩溃发生在 sherpa-onnx 里某个std::thread对象析构时。
根本原因:每个 .so 都有自己的全局对象(如静态std::thread、std::mutex),多个库之间的析构顺序在动态链接器层面不可控。当 sherpa-onnx 已经清理了自己内部的线程,而其他库还在引用相关资源时,会触发pthread_mutex_lock called on a destroyed mutex这种典型的"使用已销毁对象"错误。
修复:确保运行期不在多个 native 库间共享 C++ 全局状态。如果只是用 MNN 做推理,把 MNN 的相关代码完全独立成一个 .so,且不要在 Dart Isolate 中触发 sherpa-onnx 的初始化(Isolate.spawn会让 sherpa-onnx 的全局构造函数在新线程上执行,加剧析构竞争)。
最终笔者把 MNN TTS bridge 的代码从 native 编译列表中移除(保留 LLM bridge),只保留 sherpa-onnx 路径,崩溃消失。
这个问题的教训:多 native 库共存时,C++ ABI 层面的全局对象生命周期非常脆弱。在生产项目中应该尽量让每个 .so 自包含,避免共享全局状态。
性能小结
经过上述 7 个坑全部填平后,MNN 后端在麒麟 710 上的实测数据:
| 阶段 | 耗时 |
|---|---|
| Tokenization(拼音查表) | < 1 ms |
| Mel 提取(kaldi-native-fbank) | ~100 ms |
| Encoder(FP32 MNN) | 123 ms |
| Decoder × 4(FP32 MNN) | 62 秒 |
| Vocoder(MNN) | 0.5 秒 |
| ISTFT(kaldi kissfft) | 1 秒 |
| 总计 | 66 秒 |
但需要诚实地说明,这套方案在麒麟 710上并没有取得性能优势。原因是 FP32 模型内存占用较大(240 MB decoder),无法一次性合成长文本,必须分段;而每段都要带着完整的参考音频特征做计算,分段越多重复开销越高。在中低端硬件上,分段后的总时间会回到与 onnxruntime 相当甚至更慢。
真正的端侧 TTS 加速,还需要等待支持 SME2、FP16 硬件加速的旗舰芯片,配合 MNN 对这些指令集的优化。
结语
这次迁移的核心结论:模型部署不是"换个引擎"那么简单,每个推理框架对算子、量化、张量类型、输出绑定都有自己的脾气。文档里写的"compatible with ONNX"只是一个宽泛承诺,真正的兼容性要在目标设备上实际跑过才知道。
7 个坑里有 4 个(坑 2、3、4、5)是因为读源码不够仔细,看了文档就以为懂了。剩下的 3 个(坑 1、6、7)是 MNN 在特定硬件上的实现细节,文档里没写。
笔者把详细的转换命令、对比脚本和最终代码整理在了项目仓库里,文末列出。希望这篇能让其他做类似工作的同学少踩几个坑。
附:相关资源
- ZipVoice 论文:arXiv:2506.13053
- sherpa-onnx 仓库:k2-fsa/sherpa-onnx
- MNN 仓库:alibaba/MNN
- kaldi-native-fbank 仓库:csukuangfj/kaldi-native-fbank
- ZipVoice FP32 模型:k2-fsa/ZipVoice on HuggingFace
