
最近我把 Zipformer 语音识别真正跑到了 RK3588 板子上前后折腾了一个周末踩了三个大坑。这块板子本身性能不弱8 核4 个 A76 大核 4 个 A55 小核内存带宽也够但真正把它和 Zipformer 这种时序模型组合在一起的时候你会发现光是编译环境、模型导出、性能调优这三关就能劝退不少人。这篇文章就把我实际的踩坑和填坑过程记录一下包含完整的编译命令给后面想在 RK3588 上做语音识别或者需要部署 WeNet 系列模型的朋友做个参考。我最终的方案可以概括成一句话在 x86 服务器上把 PyTorch 模型导出为 ONNX再到 RK3588 板子上用 ONNX Runtime 的 C 接口做推理。这样做的好处是板子上的依赖非常少不需要装完整的 PyTorch也不需要在 ARM 平台上编译 LibTorch最终运行包体积能控制在几十 MB 级别。对嵌入式语音识别这种场景来说这套路线是目前最省心、也最稳定的一种。1. 项目背景与整体方案RK3588 上到底怎么跑 Zipformer1.1 RK3588 这块板子适合干什么RK3588 是瑞芯微这几年在边缘 AI 领域的主力 SoC4 个 A76 大核加 4 个 A55 小核常见配置有 8GB、16GB、32GB 内存版本。做边缘盒子、智能座舱、工业检测、多传感器融合平台的时候很多人都会优先看它。CPU 性能在 ARM 处理器里属于第一梯队内存带宽也够用芯片上还集成了一块 6 TOPS 算力的 NPU理论上可以分担不少 AI 计算。但 NPU 不是什么都适合跑。尤其是语音识别这种时序模型Zipformer 的 encoder 里有大量变长序列的 Attention、LayerNorm还有一些自定义的动态处理逻辑这些在 NPU 上的算子支持度并不理想。我一开始也考虑过走 NPU调研后放弃了。与其把时间花在算子适配、量化、精度对齐上不如直接用 CPU 推理。RK3588 的 CPU 跑一个 2M 量级的 Zipformer 模型把线程和大核优化做好实时率完全够用。1.2 Zipformer 在语音识别里的位置Zipformer 是 WeNet 社区在 2022 年提出的一种编码器结构。整体思路是在 Transformer 的基础上加入多个可学习的下采样和上采样块把计算复杂度降下来在相同的计算量下比传统 Conformer 更省资源同时识别精度不差。对边缘端来说最直接的好处就是模型文件小、推理速度快、精度也有保障。现在很多开源中文语音识别项目默认就带 Zipformer 结构的预训练模型下载下来就能直接用于部署。我这次用的就是 WeNet 开源仓库里的预训练中文模型字典、模型文件、配置文件都是现成的。所以整篇文章的重点不在训练模型而在部署链路怎么搭以及在 RK3588 这个 ARM 平台上会遇到哪些意想不到的问题。1.3 完整的部署链路长什么样我这里说的“跑通”指的是在 RK3588 板子上启动一个支持 WebSocket 的流式/非流式语音识别服务客户端发送一段 PCM 音频进去服务端返回识别文字。整体链路分成四段在 x86 服务器上准备预训练模型把 PyTorch 的模型导出成 ONNX。把 ONNX 模型、字典文件、模型配置传到 RK3588。在 RK3588 上编译 WeNet 的 C runtime使用 ONNX Runtime 执行推理。启动服务测试音频调性能。有人可能会问为什么不直接在板子上装 PyTorch 推理答案是能跑但不划算。PyTorch 的 ARM 版体积大内存占用高线程管理也不如 ONNX Runtime 透明。更重要的是C runtime 加 ONNX Runtime 的部署方式后续如果要包成系统服务、做性能优化或者塞进一个已有的边缘一体机项目里都会顺畅很多。所以我强烈建议用 ONNX 这条线后面所有坑的描述也都是基于这条线展开的。2. 第一个坑编译环境一开始就想把我劝退2.1 现象在板子上编译一个大项目有多难受先说结论如果直接拿 WeNet 的 C runtime 在 RK3588 上从头编译第一时间会让你怀疑人生。我的板子是 16GB 内存版本系统是 Ubuntu 22.04理论上编译环境不算差。但第一次执行 cmake 时项目会去拉一堆依赖包括 gRPC、protobuf、glog、onnxruntime有些还是直接从 GitHub 下载源码编译。在板子上下载这些网速慢、超时、失败重试每一步都在消耗耐心。更头疼的是内存。gRPC 和 onnxruntime 源码编译都属于“内存大户”并发做高的时候链接阶段内存直接爆掉进程会被 OOM Killer 干掉。我当时甚至怀疑是板子坏了后来看日志才发现是内存不够。编译进程没了CMake 缓存还留在那里重新 make 时会出现各种 undefined reference非常误导人。2.2 填坑第一步先解决网络和内存问题先说软件源。如果你用的是 Ubuntu 系统第一步建议把默认的 ports.ubuntu.com 换成国内镜像。不要小看这一步后面所有 apt 包和依赖下载速度都跟它有关。我这边直接把源替换成了阿里云镜像sudo sed -i s/ports.ubuntu.com/mirrors.aliyun.com/g /etc/apt/sources.list sudo apt update然后是给板子加 Swap。16GB 内存听着不小但编译链接时内存会吃得很厉害保险起见我加了一个 8GB 的 swap 文件。sudo fallocate -l 8G /swapfile sudo chmod 600 /swapfile sudo mkswap /swapfile sudo swapon /swapfile这里有个细节要注意/swapfile 的权限一定要改成 600否则启用 swap 会报 insecure permissions 的警告。如果想重启后自动挂载在 /etc/fstab 里加一行/swapfile none swap sw 0 02.3 填坑第二步不要编译 onnxruntime用预编译包WeNet 的 CMake 默认会去找 onnxruntime 的源码找不到就自动拉取编译。源码编译 onnxruntime 在 ARM 板子上时间非常可观我在 RK3588 上试过一次编译了快两个小时才结束中间还差点把系统拖到锁死。这个选择其实不划算。正确的做法是直接下载 ONNX Runtime 官方发布的 ARM64 预编译包。我这里用的是 1.16.0 版本下载后解压把 lib 和 include 目录提供给 CMake 使用。命令如下wget https://github.com/microsoft/onnxruntime/releases/download/v1.16.0/onnxruntime-linux-aarch64-1.16.0.tgz tar zxvf onnxruntime-linux-aarch64-1.16.0.tgz然后在 cmake 配置时告诉它 onnxruntime 的路径。这一步能帮你砍掉大半编译时间。2.4 最后实际的编译命令依赖梳理好之后编译本体其实没那么可怕。我用的编译命令如下cd runtime/server/x86 mkdir -p build cd build cmake -DCMAKE_BUILD_TYPERelease \ -DGRPC_BUILD_TESTSOFF \ -DWEBNET_BUILD_TORCHOFF \ -DWEBNET_BUILD_ONNXON \ -DWEBNET_BUILD_GLOGON \ -DONNXRUNTIME_DIR$HOME/onnxruntime-linux-aarch64-1.16.0 \ -DCMAKE_BUILD_PARALLEL_LEVEL4 \ .. make -j4这里有两个关键点。第一个是 WEBNET_BUILD_TORCHOFF这个开关用来关闭 LibTorch 依赖。我们既然用 ONNX 推理就没必要再拉一个几百 MB 的 Torch 库。第二个是 make 并发用的 -j4 而不是 -j8四个大核干活四个小核留出来处理系统调用和 IO实测编译稳定性好很多不会再被 OOM 干掉。编译产物里最核心的是一个 websocket_server_main 可执行文件后续整个识别服务就是靠它跑起来的。3. 第二个坑模型导出时ONNX 的算子和动态轴把我卡了半天3.1 出现的问题模型部署的关键一步是把 PyTorch 训练好的 Zipformer 权重导出成 ONNX。如果前面准备不充分会遇到两件让人头大的事。第一导出过程各种报错。Zipformer 的 encoder 里有一段 mask 处理逻辑在 PyTorch 里是一个 Boolean mask 操作当 opset 版本设置不对时ONNX 导出器根本不支持这种写法直接抛 Unsupported operator。第二即使导出来了在板子上运行时也会报 shape mismatch。绝大多数情况下是因为导出时没有配置 dynamic_axes。如果 seq_len 被固定模型只能接受某个固定长度的音频输入。实际操作中语音长度从几秒到十几秒不等shape 一旦对不上推理直接挂掉。3.2 为什么会出现这种问题PyTorch 的 torch.onnx.export 在设计上为了稳妥默认会把所有输入都当作固定形状。一旦你不指定 dynamic_axes模型里的时间维度就会被固化。对语音识别模型来说time 维一定是动态的因为音频长度不可能恒定所以这一步是必须的。另一个细节是 opset 版本。opset 对应的是 ONNX 算子集的版本。版本太低很多新算子不支持版本太高板子上的 onnxruntime 版本旧了又可能出现算子不兼容。我试了几组组合之后最终锁在 opset11。这个版本在 Attention、LayerNorm 相关算子上覆盖比较完整而且能被 RK3588 上装的 onnxruntime 1.16.0 正常加载。3.3 我实际用到的导出配置我这里直接用 WeNet 自带的导出脚本导出前先确认好三个文件config 文件train.yaml模型配置checkpoint 文件final.pt训练完的权重dict 文件dict.txtUnicode 词典导出命令长这样python3 wenet/bin/export_onnx.py \ --config exp/zipformer/train.yaml \ --checkpoint exp/zipformer/final.pt \ --dict exp/zipformer/dict.txt \ --chunk_size 16 \ --output_onnx exp/zipformer/zipformer_16chunk.onnx有些版本还需要指定编码器层数、注意力头数等参数这些按模型配置文件里的值填即可。导出完成会多出一个 .onnx 文件。如果你的项目没有现成脚本用 torch.onnx.export 手动写的话记得设置 dynamic_axesimport torch dummy_speech torch.randn(1, 1200, 80) dummy_speech_lengths torch.tensor([1200], dtypetorch.int32) torch.onnx.export( model, (dummy_speech, dummy_speech_lengths), zipformer.onnx, opset_version11, input_names[speech, speech_lengths], output_names[log_probs], dynamic_axes{ speech: {0: batch, 1: time}, speech_lengths: {0: batch}, }, )dummy_speech 的维度是 (N, T, D)其中 D 是 fbank 特征维度比如 80 维。T 是 seq_len导出时填一个有代表性的长度比如 1200大约对应 12 秒音频。3.4 导出后的验证不能省导出不是一锤子买卖。我强烈建议在 x86 服务器上先用 onnxruntime 把导出的模型跑一遍输入一个随机张量对比 PyTorch 原模型和 ONNX 模型的输出。如果最大误差在 1e-4 量级以内基本说明模型没有损坏。如果差异很大通常就是动态轴或者 mask 实现方式不对。在服务器上做验证非常方便确认没问题再往板子传不要等到板子上才 debug。我自己有一次就是因为没验证把模型传到板子上跑结果所有音频都识别成空字符串排查了半天才发现是导出时忘了保留 encoder 的 padding mask导致 Attention 计算引入了大量无效位置的干扰。4. 第三个坑能跑了但 RTF 上不去CPU 直接被打满4.1 现象默认配置下实时率惨不忍睹模型和 runtime 都就绪服务也起来了。但第一个测试音频一跑我整个人都愣住了。默认配置下 RTF 大约在 0.7 到 0.9。RTF 是什么处理音频耗时除以音频本身时长。RTF 小于 1 代表处理速度比实时快0.7 到 0.9 意味着 10 秒音频要花 7 到 9 秒才能识别完这种状态根本谈不上“实时语音识别”。同时 CPU 占用率直接拉满任务管理器里几个核全部 100%。但有意思的是有些大核心其实在闲着反而是小核心在忙。这就是没做线程亲和性导致的操作系统把推理线程调度到了小核上。4.2 定位思路我先用 top 和 pidstat 确认了线程分布发现 onnxruntime 里的并行计算线程并没有全部跑在 A76 上。RK3588 的大小核架构是 4 个 A76 大核加 4 个 A55 小核默认调度器并不总是把计算密集型线程放到大核。对语音识别这种计算密集任务来说小核算力严重拖后腿。其次是 onnxruntime 的线程数设置。C 接口里intra_op_num_threads 决定单个算子内部的并行度inter_op_num_threads 决定算子之间的并行线程数。对于单个流式识别请求inter 设置为 1 到 2 就够了intra 可以设为 4如果同时有多路请求再根据总负载调整。4.3 填坑操作绑核加线程控制想让推理线程绑定在大核上最简单的方式是用 taskset 启动服务。RK3588 在标准 Linux 下 CPU 编号一般是 0 到 3 为小核4 到 7 为大核。想确认的话可以看 CPU 最大频率cat /sys/devices/system/cpu/cpu4/cpufreq/cpuinfo_max_freq cat /sys/devices/system/cpu/cpu0/cpufreq/cpuinfo_max_freq如果 cpu4 的最大频率明显高于 cpu0说明 4 到 7 是大核。然后这样启动服务taskset -c 4-7 ./build/bin/websocket_server_main \ --port 8082 \ --config exp/zipformer/train.yaml \ --onnx_model exp/zipformer/zipformer_16chunk.onnx \ --dict exp/zipformer/dict.txt \ --chunk_size 16同时在服务端代码里显式设置 onnxruntime 的 Session 选项控制线程数量。C 接口大概是这样Ort::SessionOptions session_options; session_options.SetIntraOpNumThreads(4); session_options.SetInterOpNumThreads(1); session_options.SetGraphOptimizationLevel(GraphOptimizationLevel::ORT_ENABLE_ALL);如果先用 Python 验证等价写法是import onnxruntime as ort opts ort.SessionOptions() opts.intra_op_num_threads 4 opts.inter_op_num_threads 1 opts.graph_optimization_level ort.GraphOptimizationLevel.ORT_ENABLE_ALL session ort.InferenceSession(zipformer.onnx, sess_optionsopts, providers[CPUExecutionProvider])4.4 实测效果与流式取舍调整之后我再测 RTF结果如下模式线程配置RTF备注非流式默认配置未绑核默认线程0.7 到 0.9CPU 占用高延迟大非流式优化后4 线程绑大核0.2 到 0.3满足实时场景流式chunk164 线程绑大核0.3 到 0.4首字延迟 300 到 500ms非流式模式下RTF 从 0.75 降到 0.25 左右也就是 10 秒音频约 2.5 秒识别完已经足够应付“边录边识别”的需求。在 RK3588 上如果还要同时跑视觉 SLAM 或图像检测任务CPU 份额要提前规划。这块板子经常被拿来做多传感器融合的边缘盒子不可能把全部 CPU 都给语音识别。我当时用 taskset 把 4 个大核固定给语音识别视觉任务走 NPU 和另外的空闲算力两者基本能共存。这也是我为什么一直强调“大核绑定”这个方案省心。5. 完整编译命令清单从零到能跑的雏形5.1 准备工作清单整个部署需要的东西并不多一台 x86 Linux 机器装好 Python3、PyTorch、WeNet 仓库一块 RK3588 板子系统建议 Ubuntu 20.04 或 22.04一份 Zipformer 预训练模型直接用 WeNet 开源版本即可模型目录结构大致是这样exp/zipformer/ |-- train.yaml |-- final.pt |-- dict.txt |-- zipformer_16chunk.onnx5.2 x86 端模型导出git clone https://github.com/wenet-e2e/wenet.git cd wenet # 先装依赖 pip3 install torch onnx onnxruntime # 下载预训练模型解压到 exp/zipformer python3 wenet/bin/export_onnx.py \ --config exp/zipformer/train.yaml \ --checkpoint exp/zipformer/final.pt \ --dict exp/zipformer/dict.txt \ --chunk_size 16 \ --output_onnx exp/zipformer/zipformer_16chunk.onnx导出完成后把 exp/zipformer 目录整体拷贝到板子上。5.3 板端编译 WeNet runtime# 1. 换源和安装依赖 sudo sed -i s/ports.ubuntu.com/mirrors.aliyun.com/g /etc/apt/sources.list sudo apt update sudo apt install -y build-essential cmake git python3-pip # 2. 加 swap避免 OOM sudo fallocate -l 8G /swapfile sudo chmod 600 /swapfile sudo mkswap /swapfile sudo swapon /swapfile # 可选echo /swapfile none swap sw 0 0 | sudo tee -a /etc/fstab # 3. 下载 onnxruntime arm64 预编译包并解压 cd ~ wget https://github.com/microsoft/onnxruntime/releases/download/v1.16.0/onnxruntime-linux-aarch64-1.16.0.tgz tar zxvf onnxruntime-linux-aarch64-1.16.0.tgz # 4. 编译 WeNet runtime cd runtime/server/x86 mkdir -p build cd build cmake -DCMAKE_BUILD_TYPERelease \ -DGRPC_BUILD_TESTSOFF \ -DWEBNET_BUILD_TORCHOFF \ -DWEBNET_BUILD_ONNXON \ -DWEBNET_BUILD_GLOGON \ -DONNXRUNTIME_DIR$HOME/onnxruntime-linux-aarch64-1.16.0 \ -DCMAKE_BUILD_PARALLEL_LEVEL4 \ .. make -j45.4 启动服务并测试cd build taskset -c 4-7 ./bin/websocket_server_main \ --port 8082 \ --config ../../exp/zipformer/train.yaml \ --onnx_model ../../exp/zipformer/zipformer_16chunk.onnx \ --dict ../../exp/zipformer/dict.txt \ --chunk_size 16启动后看到 listening on port 这样的日志就算成功了。用项目自带的客户端测一下./bin/websocket_client_main \ --host 127.0.0.1 \ --port 8082 \ --wav test.wav能返回文本说明整条链路已经通了。6. 几个容易忽略的隐蔽问题6.1 音频格式必须严格是 PCM16 16kHz 单声道这个我在一开始忽略过。客户端传上去一段采样率 44.1kHz 的立体声 WAV服务端也不报错结果识别出来全是乱码。原因是前端 fbank 参数是按 16kHz 配置的采样率不对整个特征分布就乱了。另外通道数必须是单声道位深建议 16bit。如果是其他采样率先用 ffmpeg 转一下ffmpeg -i input.wav -ar 16000 -ac 1 -sample_fmt s16 output.wav6.2 多路识别并发导致线程争抢服务端如果同时接多路客户端识别ONNX Runtime 的线程池会被多个 Session 共享。每个 Session 都开 4 个 intra 线程的话总线程数会飙升反而引发调度抖动。我的做法是多路并发场景下每个 Session 的 intra_op_num_threads 调成 2或者干脆用统一的 onnxruntime Env并限制总并发数。6.3 长时间运行的稳定性跑长时服务内存泄漏是最恶心的问题。我遇到过一次每处理一个音频内存涨十几 MB跑一天就崩了。后来定位到是服务端在每轮识别时重复创建 Session旧 Session 没有释放。正确做法是一开始创建好全局 Session识别时只重新喂数据不重新构建。C 接口上用智能指针持有 Session避免手动释放的坑。6.4 别忘调整 CPU 调频策略还有一个细节RK3588 默认的 CPU governor 会影响推理稳定性。如果希望延迟稳定建议把大核的 governor 设置为 performanceecho performance | sudo tee /sys/devices/system/cpu/cpu4/cpufreq/scaling_governor如果想省电保留 ondemand 也可以但首包延迟偶尔会有波动。7. 一点个人体会RK3588 上跑 Zipformer前后折腾下来最大的感受是这种嵌入式部署70% 的时间花在环境编译20% 花在模型导出真正写业务逻辑只占很小一部分。如果提前把 onnxruntime 预编译包准备好、把 swap 先加上、把 opset 固定好至少能少踩一半的坑。后期如果还想继续优化可以考虑把模型做 int8 量化。Zipformer 这种结构在 ONNX Runtime 上量化支持得还可以量化后不损失太多精度的前提下RTF 还能再降一截。另外如果 RK3588 的 NPU 对动态 shape 的支持以后更完善也可以重新评估 NPU 推理路线。但就目前我踩完坑之后的判断CPU 加 ONNX Runtime 这条路已经够稳也是投入产出比最高的方案。希望这篇填坑实录能帮你少走点弯路。