实测平台:华为 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.onnxmnnconvert 转成 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 里找到一行:

float feat_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";          // 默认 slaney
bool use_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] 取出三个张量。

auto magVar = vocoderOutputs[0];
auto xVar   = vocoderOutputs[1];
auto yVar   = vocoderOutputs[2];

为什么 vocos 这样设计而不直接输出波形?因为这种"幅度+方向"的拆解让模型更容易学习。把三个网络头分别预测 mag、x、y,比让一个头同时预测复数实部+虚部要稳定得多。


坑 5:ISTFT 实现差异,60 倍速度差

ISTFT 的数学原理是把每帧 STFT 系数做 IFFT 得到时域片段,再做 overlap-add 合成连续波形。笔者一开始为了快速验证逻辑,用 O(n²) 的暴力 IDFT 写了一版实现:

for (int n = 0; n < nFft; ++n) {
    float sum = 0;
    for (int k = 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::IStft istft(stftConfig);
auto waveform = istft.Compute(stftResult);

同样的输入,1053 毫秒完成。提速 60 倍。

教训:永远不要在生产代码里写暴力 IDFT。FFT 不是优化,是必需。


坑 6:MNN 部分设备不支持 int64 张量

ZipVoice 的 encoder 输入有 tokensprompt_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:

auto tokensVar = _Input({1, tokensLen}, NCHW, halide_type_of<int32_t>());
auto* tokensPtr = tokensVar->writeMap<int32_t>();
for (int i = 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::threadstd::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 在特定硬件上的实现细节,文档里没写。

笔者把详细的转换命令、对比脚本和最终代码整理在了项目仓库里,文末列出。希望这篇能让其他做类似工作的同学少踩几个坑。


附:相关资源

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐