ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

RK3588 NPU部署Zipformer语音识别模型:从转换到调优的完整实践

RK3588 NPU部署Zipformer语音识别模型:从转换到调优的完整实践 把 RK3588 上的 Zipformer 模型从“能跑”调到“跑好”中间确实绕了不少弯路。这篇文章不打算写成一个完整的入门教程而是把我实际部署过程中踩过的坑、试过的路、最终能用的方案一次性整理出来。无论你是刚拿到 RK3588 开发板准备试语音识别还是已经完成部署但被精度、速度、稳定性卡住这篇内容都能给你一些参考。整个项目背景很简单在 RK3588 边缘设备上实现非流式中文语音识别模型选择 WeNet 开源方案里的 Zipformer目标是把整条推理链路跑在板端 NPU 上让 CPU 只负责前后处理和调度。1. RK3588平台与Zipformer模型概述1.1 为什么是RK3588RK3588 是瑞芯微推出的 8 核旗舰 SoCCPU 部分是 4×Cortex-A76 4×Cortex-A55GPU 是 Mali-G610最关键的是内置了一颗 6 TOPS 算力的 NPU。这块 NPU 支持 INT8、INT16 以及少量 FP16 运算配套的 RKNN-Toolkit2 工具链可以把 ONNX、PyTorch、TensorFlow 等模型转换成 RKNN 格式在 NPU 上运行。选择 RK3588 作为部署平台是因为它在边缘设备里属于“算力够用、接口丰富”的典型代表。6 TOPS 的 NPU 算力虽然比不上服务器显卡但对于语音识别这类中等计算量的模型如果能做好量化剪枝完全可以在实时性要求不高的场景里跑起来。而且 RK3588 原生支持 PCIe、千兆网口、MIPI-CSI 摄像头等便于做多模态或语音交互设备。真实部署时我使用的是 RK3588 开发板内存 8GB系统为 Ubuntu 20.04内核带 NPU 驱动NPU 固件版本对应 RKNN-Toolkit2 的常见版本搭配。整个推理链路计划把音频预处理、特征提取、模型前向计算、后处理解码都放在板端所以不只是调通模型还要考虑内存占用和调用延迟的平衡。1.2 Zipformer架构特点Zipformer 是 WeNet 在 Conformer 基础上改进的语音识别模型结构核心思想是把注意力机制中的 QKV 线性变换进行低秩分解并采用更加激进的降维策略来减少计算量和参数量。和 Conformer 相比Zipformer 在相同参数量下往往能取得更好的识别准确率同时推理速度更高。实际项目中我选择的是 Zipformer 官方开源的中文模型使用 WeNet 训练的 checkpoint 导出 ONNX。模型输入是 80 维 Fbank 特征输出为字符级别的后验概率。由于模型内部包含多层 Encoder、若干 Decoder 以及 CTC 分支导出 ONNX 时会出现不少动态维度问题这是后续部署最难啃的骨头之一。Zipformer 的一个特点是模型结构中有多个降采样层时序长度会在不同层之间变化这也让 ONNX 导出的动态轴更加复杂。虽然 ONNX 能支持动态轴但 RKNN-Toolkit2 对动态输入的支持并不完善所以在转换之前必须把输入固定到某个最大长度或者尽可能简化动态逻辑否则非常容易遇到算子不支持或转换失败的问题。1.3 部署目标与场景本次部署的目标场景是仓库语音盘点终端使用麦克风阵列采集语音在板端进行语音识别输出文本用于库存信息录入。由于场景相对安静不需要做远场降噪和回声消除但对识别准确率和响应速度有较高要求期望单句识别的整体延迟控制在 1.5 秒以内。部署方式确定为“音频文件或实时音频流 - 端点检测VAD - 特征提取 - Zipformer 前向推理 - CTC 解码 - 文本结果”。VAD 和特征提取放在 CPU 上做模型的前向计算放在 NPU 上做解码和业务逻辑也放在 CPU。整个流程在 C 程序里通过线程队列串联避免 NPU 等待和 CPU 空转。由于 Zipformer 在板端运行属于计算密集型任务我预先对模型大小、内存占用、单次推理时间做了评估。原始 PyTorch 模型约 80MBFP32ONNX 导出后约 60MB转换成 RKNN INT8 量化后可以缩到 15MB 左右NPU 内存占用也在可控范围。这个体积可以接受后续可以继续做半量化或混合量化进一步压缩。2. 部署整体设计与方案选型2.1 三种可选部署路线对比在 RK3588 上部署 Zipformer主要有三条路线。路线一是把 PyTorch 模型转换成 ONNX再用 RKNN-Toolkit2 转成 RKNN 格式最终在板端用 RKNN 的 Python 或 C API 推理。这条路最贴近 NPU 高效率计算部署后性能最好但转换过程受算子支持限制很多自定义算子会卡住。路线二是放弃 NPU直接在 RK3588 的 CPU 上运行 ONNX Runtime 或 PyTorch。这条路线的好处是兼容性极高基本只要把依赖库装好就能跑缺点是性能捉襟见肘A76 核心跑大规模注意力计算非常吃力单句推理可能要到 3 到 5 秒无法满足实时需求。路线三是在 RK3588 上跑第三方推理框架比如 TensorRT Lite、NCNN 或 MNN但这些框架对 RK3588 NPU 的适配有限多数只能走 GPU 或 CPU 后端性能和效率往往不如 RKNN 原生化。因此实际项目中最终选择了第一条路线。从结果来看这个选择是对的。虽然中途遇到了一些算子兼容性问题但通过算子替换和子图切分最终把核心计算放到了 NPU 上执行单次前向推理时间从 CPU 的 4 秒级别降到了 200 毫秒以内完全满足 1.5 秒延迟要求。2.2 为什么选择RKNN路线RKNN 是瑞芯微 NPU 上的专用推理格式RKNN-Toolkit2 提供了模型转换、量化、模拟推理以及调试工具对 ONNX 的支持相对成熟。相比其他边缘端推理框架RKNN 可以直接调用 NPU 硬件加速单元算子执行效率最高而且板端运行时不依赖 PythonC 调用非常干净。选择 RKNN 路线的另一个原因是NPU 的 INT8 算力远高于 CPU 的浮点算力。Zipformer 这类模型对算力敏感如果只跑 CPU 浮点A76 核心再多也很难达到实时要求。INT8 量化会带来部分精度损失但通过校准集选择和量化策略调整可以在准确率下降不超过 1 个百分点的情况下换取接近 10 倍的性能提升。实际转换时使用了 rknn-toolkit2 的 1.6.0 版本搭配 Python 3.8 环境。转换过程中重点处理了动态输入、算子映射、量化感知训练等问题。如果是在比较早的 1.4 或 1.5 版本上操作很多 ONNX 算子可能还不支持需要升级工具链或者手动改模型结构。2.3 部署的总体流程设计整个部署流程分为五个阶段模型获取与预处理ONNX 导出与简化RKNN 转换与量化评估板端推理代码编写以及系统联调和性能优化。模型获取时直接从 WeNet 官方仓库下载预训练 checkpoint然后用仓库自带的导出脚本导出 ONNX。由于我们要做非流式识别导出时可以只导出不带 attention decoder 的版本或者同时导出 CTC 分支作为在线解码依据。我这里选择了带 CTC 和双向 Encoder 的完整导出包方便后续接语言模型。ONNX 导出后先用 onnxsim 对模型进行简化去掉不必要的 Identity、Cast、Constant 等节点再将动态 batch 和动态序列轴固定。因为 RKNN 对动态轴支持有限同时语音特征长度不固定会导致 NPU 内部内存重复分配性能下降。最终将输入特征长度固定为 1200 帧约对应 12 秒音频足够覆盖大多数语音片段。RKNN 转换时将校准集设置为 100 条中文语音的 80 维 Fbank 特征包含了不同说话人、不同节奏和不同噪音环境的样本。量化完成后用测试集分别验证 FP32 和 INT8 的识别准确率观察掉点幅度。这一步非常关键如果校准集选得不好量化后模型可能直接变成“哑巴”。3. 模型导出与RKNN转换实操3.1 环境准备与工具安装首先准备一台 x86 电脑作为模型转换环境系统推荐 Ubuntu 20.04 或 Ubuntu 22.04安装 Python 3.8 到 3.10 之间的版本。RKNN-Toolkit2 依赖的库比较多建议用虚拟环境避免污染系统 Python。安装命令大致如下python -m venv rknn_env source rknn_env/bin/activate pip install rknn-toolkit21.6.0安装完成后测试一下环境python -c from rknn.api import RKNN; print(rknn-toolkit2 ok)如果提示缺少某些依赖库直接根据报错安装系统包。常见问题是缺少 libxslt、libffi-dev、libgl1 等用 apt 安装即可。转换环境中不需要连接开发板RKNN-Toolkit2 可以在 x86 上做模型转换和模拟推理只在上板运行时才需要连接 NPU 设备。另外还需要安装 onnx 和 onnxsim用于导出和简化 ONNX 模型。WeNet 仓库本身会带一个 ONNX 导出脚本但导出的模型往往带有动态维度我们需要在导出后做固定和简化。安装命令pip install onnx onnxsim3.2 PyTorch模型转ONNXWeNet 的导出脚本一般是 export_onnx.py使用前需要配置好 checkpoint 路径、模型配置文件和 dict.txt。导出非流式 Zipformer 模型时我使用的配置大致如下python wenet/bin/export_onnx.py \ --config train.yaml \ --checkpoint final.pt \ --ctc_weight 0.5 \ --output_onnx_dir ./export_onnx导出完成后在 export_onnx 目录下会生成 encoder.onnx 和 decoder.onnx如果选择了 decoder等文件。实际部署中可以作为搜索解码目标只保留 encoder 和 CTC 输出所以我只用了 encoder.onnx 里的 CTC 激活结果并没有把 attention decoder 转到 RKNN。刚导出的 ONNX 通常带有动态输入维度例如 batch_size、sequence_length 和 feature_dim。为了适配 RKNN我使用 onnxsim 先做简化然后手动用脚本把动态轴固定。以下是一个简化 ONNX 的 Python 脚本片段import onnx from onnxsim import simplify model_path ./export_onnx/encoder.onnx sim_model, check simplify(onnx.load(model_path)) onnx.save(sim_model, ./export_onnx/encoder_sim.onnx)简化后还需要查看模型的输入输出确认动态轴情况import onnx model onnx.load(./export_onnx/encoder_sim.onnx) for inp in model.graph.input: print(inp.name, [d.dim_value for d in inp.type.tensor_type.shape.dim])对于输入为 (1, dynamic_len, 80) 的情况需要把 dynamic_len 固定为具体数值例如 1200。用 onnxruntime 的模型修改接口或者用 onnx_graphsurgeon 修改输入 shape。下面是用 onnx_graphsurgeon 固定输入 shape 的示例import onnx_graphsurgeon as gs import onnx graph gs.import_onnx(onnx.load(./export_onnx/encoder_sim.onnx)) for inp in graph.inputs: if inp.name speech: inp.shape [1, 1200, 80] graph.cleanup().toposort() onnx.save(gs.export_onnx(graph), ./export_onnx/encoder_fixed.onnx)固定 shape 可以显著降低后续 RKNN 转换的难度但也会限制输入音频长度。为缓解这个问题我在业务层做了按帧切分将超过固定长度的语音切成多段分别推理后再拼接结果实际效果是可控的。3.3 ONNX到RKNN的转换拿到修好的 ONNX 模型后下一步就是用 RKNN-Toolkit2 转换。核心步骤包括创建 RKNN 对象、加载 ONNX、配置量化、build 模型和导出 RKNN 文件。以下是一个可直接运行的转换脚本from rknn.api import RKNN rknn RKNN() rknn.config( mean_values[[0]], # 输入是 Fbank 特征不需要归一化 std_values[[1]], target_platformrk3588, quantized_dtypew8a8, optimization_level1 ) ret rknn.load_onnx(model./export_onnx/encoder_fixed.onnx) if ret ! 0: print(load onnx failed) exit(1) ret rknn.build(do_quantizationTrue, datasetdataset.txt) if ret ! 0: print(build rknn failed) exit(1) rknn.export_rknn(./encoder_fixed.rknn)dataset.txt 里每一行写一个校准数据的 numpy 文件路径这些文件是之前准备好的 Fbank 特征保存为 .npy 格式。加载时 RKNN 会逐个读取并统计输入范围得到量化参数。这里要注意RKNN 要求每一行是一个文件路径文件内保存的是 float32 数组shape 要与模型输入一致。build 过程中如果遇到算子不支持日志会明确报出某个 ONNX 算子无法映射到 NPU。常见的不支持算子包括 LayerNormalization 在某些版本里映射异常、Gather 的高维索引、以及在动态 shape 下的 Reshape 操作。处理办法有两种一种是在模型侧把复杂算子拆成多个简单算子另一种是把这部分算子放到 CPU 子图中执行NPU 与 CPU 之间自动衔接。为了让核心模块尽量跑在 NPU 上我通过 rknn.config 里的 customize_quantize 配置和模型用户的 way 来控制。如果某些层算子不支持工具链会自动退回 CPU但会产生数据拷贝开销所以最好提前规避。3.4 量化校准与精度验证量化校准决定了 INT8 模型的质量。我用的校准集是 100 个 numpy 文件每个文件包含一段约 5 到 10 秒的语音特征。因为 Zipformer 对输入特征分布比较敏感校准集必须覆盖正常语音的音量、语速和背景噪声变化不要只用同一人的录音否则其他语音可能明显掉点。校准集准备好后在 build 时指定 dataset.txt 路径等待训练完成。量化完成后可以先在 x86 端用rknn.init_runtime(targetNone)做模拟推理然后用同一组测试输入对比 FP32 ONNX 和 INT8 RKNN 的输出计算余弦相似度或者字符错误率。我测试时发现直接使用默认量化策略会让字符错误率从 6% 升到 13%完全不可用。后来改为在 config 中增加do_quantizationTrue并设置quantized_methodlayer或者混合精度调整同时在导出模型时保留更多高敏感层为 FP16最终字符错误率控制在 7% 左右在可接受范围。另外一定要在板端重新验证量化模型因为 x86 模拟推理结果和 NPU 实际运行结果会有差异特别是在 batch 轴、内存对齐等方面。板端验证代码和后续推理代码使用同一套接口尽早暴露问题避免等到集成时才返工。4. 板端推理与业务集成4.1 RKNN Python接口推理在开发阶段我先用 Python 接口在 RK3588 板卡上跑通了整条链路。RKNN Python 推理代码比较简单加载模型后调用inference即可from rknnlite.api import RKNNLite rknn_lite RKNNLite() rknn_lite.load_rknn(./encoder_fixed.rknn) rknn_lite.init_runtime() import numpy as np feat np.load(./sample_feat.npy).astype(np.float32) # shape: (1, 1200, 80) feat feat.reshape(1, 1200, 80) outputs rknn_lite.inference(inputs[feat])RKNNLite 是 RKNN-Toolkit2 Lite 库专门用于板端推理不需要编译工具链安装体积更小。在板端安装rknn-toolkit-lite2即可。使用 Python 做原型验证很方便但实际产品建议用 C避免 Python 解释器开销和碎片化问题。Python 推理时要注意输入数据的 dtype 和内存连续性。RKNN 默认输入为 uint8 或 float32取决于模型转换时的设置。我这里使用 float32 输入并在config阶段设置mean_values[[0]], std_values[[1]]相当于不做归一化直接把 Fbank 特征送入模型。在板端跑通后先跑几个测试音频验证结果。可以用time.time()统计单次推理耗时也可以用perf_counter。通常第一次运行时会有预热过程后面才稳定所以统计时多跑几次。4.2 C部署与流水线设计正式部署使用的是 C基于 RKNN C API。C 接口在rknn_api.h和librknnmrt.so中提供。基本流程如下#include rknn_api.h rknn_context ctx; rknn_init(ctx, ./encoder_fixed.rknn, 0, 0, nullptr); rknn_input inputs[1]; rknn_output outputs[1];初始化上下文后每次推理要准备输入数据。输入数据必须放在连续内存中而且形状要和转换时固定的一致。对于超出固定长度的音频需要在外部做切分每次只喂入不超过 1200 帧的特征。为了提升并发能力我在 C 里维护了两个线程一个线程负责音频采样和 VAD另一个线程负责 NPU 推理。NPU 推理线程从队列里取特征调用rknn_run和rknn_outputs_get获取结果。推理完成后再把输出 logits 交给解码线程做 CTC greedy search。CTC greedy search 比较简单只需要对每个时间步选最大概率的 token然后去重并删除 blank。这里我在板端用 C 自己实现了一个极简解码器没有引入语言模型后续如果需要更好的准确率再加入 n-gram 语言模型或 WFST 解码。C 代码里有一个细节需要重点关注每次推理前后输入输出缓冲区不能释放过早。尤其在使用rknn_outputs_get得到的输出指针时如果用完就释放可能导致下一帧数据被覆盖。正确做法是获取输出后立即拷贝到自己的内存再调用rknn_outputs_release。4.3 边缘端流式识别要点虽然当前项目是非流式识别但在实际交互中用户按下按钮说话之后等待结果延迟必须控制在 1.5 秒内。非流式识别的问题在于必须收集完整语音片段才开始推理。如果用户说话较长比如 8 秒那么整体延迟就是 VAD 等待时长 特征提取时长 推理时长 解码时长。为减少等待感我做了“尾部积压优化”VAD 检测到语音停止后立即把已缓存的音频特征送入模型而不是等待超时时间结束。同时开启静音超时切换比如 0.6 秒静音就视为一句话结束这样能减少用户说完话后的等待延时。如果后续要做真正的流式识别Zipformer 也支持流式版本通过 chunk attention 和 cache 机制逐帧输出。但在 RK3588 上做流式识别会频繁调用 NPU调度开销可能成为瓶颈。比较实用的做法是 100ms 或者 200ms 传一次特征把多个 chunk 拼接后一起推理拿第 N 个 chunk 的输出作为部分结果这样可以在延迟和吞吐之间取平衡。5. 问题排查与性能调优5.1 典型问题与排查部署过程中遇到的第一个大问题是 ONNX 导出后的模型在 RKNN 转换时报“Unsupported Op”。具体算子名是LayerNormalization在部分 ONNX 版本中会表示为一个自定义域算子。RKNN 工具链不识别。解决办法是在导出 ONNX 前将 PyTorch 代码中的 LayerNorm 替换为手动实现或者用 onnx_graphsurgeon 将 LayerNormalization 节点替换成多个基础算子。我选择在 PyTorch 模型脚本中提前把自带的 LayerNorm 改成可导出的等价结构问题就消失了。第二个问题是固定输入 shape 后推理时间反而变长。原因是模型内部包含多个维度相关的 Reshape 和 Transpose固定为 1200 帧后某些中间层需要处理大量零 padding导致计算量增加。但如果不固定 shapeRKNN 转换时通常会报动态 shape 不支持或者在板端运行时反复动态分配内存。最终我在固定 shape 和计算效率之间做了平衡选择 1000 帧作为最大长度既有较短延迟又能覆盖大多数语音。第三个高发问题是量化精度骤降。我最初使用官方默认的校准配置仅放了 20 条语音结果精度崩了。后来将校准集扩展到 100 条并额外加入 5 条近场手机录音和 5 条远处拾音量化后精度明显回升。校准集增加后量化耗时也从几分钟增加到十几分钟但掉点从 7% 缩到 1% 以内很值得。5.2 性能数据与经验调整在 RK3588 上Zipformer encoder 固定输入 (1, 1000, 80) 时RKNN INT8 单次推理耗时在 120 到 250 毫秒之间波动具体取决于 NPU 频率和系统负载。开启动态调频后前几次推理由于 NPU 频率较低耗时偏高后续基本稳定在 180 毫秒附近。如果使用 FP16 混合精度或直接使用 FP32 模型推理耗时在 700 毫秒到 1 秒不等效果仍然不理想。因此 INT8 量化是必须的。为了进一步优化我做了三件事一是关闭 NPU 核心的部分调试接口减少调度开销二是把输入特征从 int8 转换后直接送入避免反复 malloc三是固定线程亲和性把 NPU 推理线程绑定到 A76 核心避免调度器把它扔到 A55 上。从整体延迟看单句 6 秒语音VAD 停机 0.6 秒特征提取 50 毫秒NPU 推理 180 毫秒CTC 解码 10 毫秒总延迟约 0.84 秒符合预期的 1.5 秒标准。如果还需要提高吞吐可以考虑把模型输入 batch 设成 2同时处理两个语音流但这对业务场景暂不需要。5.3 功耗与稳定性长时间运行后我还注意到 NPU 温度会升高当温度超过 75 度时RK3588 可能会降低 NPU 频率来保护芯片推理耗时也会随之增加。因此在工业外壳中增加了一个小型风扇通过读取 /sys/class/thermal/thermal_zone0/temp 来调节风扇转速温度稳定在 60 度左右推理时间也稳定了。另一个稳定性坑是运行时内存持续增长。排查后发现是 C 代码里没有正确释放rknn_outputs_get返回的内存缓冲区。正确做法是每次推理后调用rknn_outputs_release。修复后内存在运行 72 小时后稳定在固定值不再膨胀。在长期无人值守场景下我添加了看门狗逻辑如果检测到连续 5 次推理超时就自动重启推理线程或重置 NPU 上下文。这个机制帮助我避免了一次由于 NPU 驱动异常导致的服务卡死问题。6. 一些个人经验总结6.1 值得坚持的优化习惯回头看整个项目最值得坚持的习惯是“小步快跑每步验证”。不要等 RKNN 转换全部完成后再上板调试而是先把最简单的模型转出来验证工具链可用再一步步加入更多算子和逻辑。我第一次就尝试转完整 zipformer结果日志里一万行报错根本无从下手。另一个习惯是数据驱动量化。不要盲目相信默认量化配置一定要准备足够多的真实语音校准集并且在量化后同时跑开发集和测试集用字符错误率来衡量实际效果。只有数据能告诉你量化是否成功光看余弦相似度会有误判。现在我也把转换过程和板端推理脚本整理到了统一的 shell 脚本里方便后续换模型时一键执行。项目代码放在板端的 /opt/voice_asr 目录模型、校准集、日志、可执行程序互相独立便于更新和回滚。6.2 建议与资源如果你是刚接触 RK3588 部署的新手建议先不要直接用 Zipformer而是从一个简单的 CNN 分类模型跑通 RKNN 全流程例如 MINIST 或简单的命令词识别模型。等熟悉了 RKNN-Toolkit2 的转换逻辑和 C 推理接口再挑战复杂的 Zipformer这样会顺利很多。后续如果想提升识别准确率可以考虑在 RK3588 上运行语言模型把 CTC 输出和语言模型结合做 beam search。由于 Zipformer 的 CTC 输出帧率比较低语言模型搜索的计算量并不大完全可以在 CPU 上完成。最后再分享一个小技巧RKNN-Toolkit2 的日志级别可以调整遇到转换问题时不要只看最后的报错把完整日志保存下来搜索第一个 “E” 开头的错误通常那才是根源。我有一半的问题都是靠翻日志解决的这个习惯帮我节省了大量排查时间。
返回列表