在 Android 模拟器 Shell 下运行 ncnn 推理的性能排查记录
本文是对 《ChineseOCR Lite Android 纯C++版 - 初学者学习项目》 中一个未解问题的深入探讨。在那篇文章中,我将 chineseocr_lite 的 Android 版本去掉了 Java/JNI 层,编译为独立的
main可执行文件,通过 adb shell 直接在 Android 设备上运行。文章末尾记录了一个现象:在模拟器上通过 App 调用很快,但直接通过 Shell 调用就很慢,当时仅做了初步猜测,并未深入排查。本文将完整还原这个问题的排查过程。
背景
在上一篇文章中,我完成了 chineseocr_lite 的纯 C++ 移植,去掉了所有 Java 层,使用标准 main 入口和命令行参数接收图片路径,支持在 Android 真机和模拟器上直接运行。
编译目标架构为 arm64-v8a,推送到模拟器执行后,模型能跑通,但速度极慢,处理一张 1080P 图片需要几秒。而同样的模型在手机 App 上却很快。为了找出原因,我进行了一系列排查。
排查过程
1. 为什么 App 调用快,Shell 调用慢?
最初我怀疑是 GPU 加速的问题。在代码中设置 net.opt.use_vulkan_compute = true 后,App 端能运行且 CPU 占用率不高,而 Shell 端 CPU 直接拉满。
我通过 logcat 抓取日志分析:
vbnet
vulkan version is 4206869
DbNet use vulkan compute
Load vulkan library directly. so 0x31254b642b2d987d
Failed to get gpu service
searching for layers in '/data/app/.../lib/x86_64'
no vulkan device
日志中的 vulkan version is 4206869 是标准的 Vulkan API 版本编码(对应 1.3.277),仅凭版本号无法判断具体是哪种 Vulkan 实现(硬件驱动、软件模拟层或其他)。但 Failed to get gpu service 和 no vulkan device 表明 ncnn 最终没有找到可用的 Vulkan 物理设备,实际回退到了 CPU 计算。
因此,App 下并没有真正使用 GPU 硬件加速。 那为什么 App 比 Shell 快?
真正的原因是 Android 的 ABI 自动选择机制:
当通过 JNI/App 调用时,Android 的包管理系统会根据设备架构自动从 APK 中选择最合适的 so 库。我的 APK 中同时包含了 x86_64 和 arm64-v8a 等多种架构的库。在 x86_64 的电脑模拟器上,系统会自动选择 x86_64 版本的 so,实现原生执行,无需指令集转译。
而 Shell 中直接运行的 main 可执行文件,是我单独编译的 arm64-v8a 版本。在 x86_64 的电脑模拟器上运行 ARM 程序时,必须经过指令集转译层实时翻译,这个开销非常大,导致速度极慢。
| 运行方式 | 实际执行架构 | 是否需要转译 | 速度 |
|---|---|---|---|
| App (JNI) | x86_64(系统自动选择) | 否,原生执行 | 快 |
| Shell (./main) | arm64-v8a(强制运行) | 是,实时转译 | 极慢 |
2. 为什么手机上的模拟器比电脑模拟器快?
同一个 arm64-v8a 的 main 程序,在手机上安装的模拟器(如 VMOS、光速虚拟机等)的 Shell 里运行很快,但在电脑模拟器(如 MuMu)的 Shell 里很慢。
原因分析:
| 环境 | 宿主机架构 | 虚拟机内架构 | 程序架构 | 执行方式 |
|---|---|---|---|---|
| 手机模拟器(VMOS等) | ARM64 | ARM64 | arm64-v8a | 原生执行 |
| 电脑模拟器(MuMu等) | x86_64 | x86_64 | arm64-v8a | 指令集转译 |
手机本身是 ARM 架构,手机上的虚拟机里跑的也是 ARM 系统。因此 arm64-v8a 的程序在手机模拟器里是原生执行,没有转译开销。
而电脑是 x86_64 架构,在 Shell 中运行 ARM 程序时,必须经过转译层逐条翻译指令。特别是 ncnn 大量使用了 ARM NEON 向量指令,转译这类指令的效率极低,即使开启多线程,每个线程都在做"翻译+执行",整体速度依然很慢。
3. 编译 x86_64 后的动态库链接问题
既然电脑模拟器是 x86_64 架构,我尝试将 main 编译为 x86_64 版本,绕过指令集转译。
编译成功后再次运行,遇到了新的报错:
vbnet
CANNOT LINK EXECUTABLE "./main": library "libicu.so" not found: needed by /system/lib64/libharfbuzz_ng.so in namespace (default)
程序依赖了 OpenCV,而 OpenCV 中的某些模块(如涉及文字渲染的 highgui/harfbuzz)底层依赖了系统的 libicu.so。由于 Android 的命名空间隔离机制,默认情况下 Shell 进程的 Linker 无法访问系统级库。
我尝试从系统中拷贝 libicu.so 并设置 LD_LIBRARY_PATH:
bash
cp /system/lib64/arm64/libicu.so ./
export LD_LIBRARY_PATH=/sdcard/Download:$LD_LIBRARY_PATH
./main
结果报出架构不匹配的错误:
java
"/storage/emulated/0/Download/libicu.so" is for EM_AARCH64 (183) instead of EM_X86_64 (62)
原因分析:
我拷贝的 /system/lib64/arm64/libicu.so 是模拟器专门为转译层准备的 ARM64 库,而当前运行的 main 是 x86_64 架构,Linker 拒绝加载不同架构的动态库。
4. 定位正确的 x86_64 系统库
我重新在系统中搜索 libicu.so:
bash
find /system -name libicu.so
# /system/etc/mumu-configs/translators/38816/lib64/arm64/libicu.so
# /system/lib64/arm64/libicu.so
# /system/apex/com.android.i18n/lib64/libicu.so
前两个路径都带有 arm64 标识,是给转译层用的。而 /system/apex/ 目录是 Android 较新版本用来存放核心系统模块的地方,这里的库才是对应系统架构(即 x86_64)的原生库。
由于 Shell 进程默认不会搜索 APEX 路径,我需要手动将这个路径加入环境变量:
bash
export LD_LIBRARY_PATH=/system/apex/com.android.i18n/lib64/:$LD_LIBRARY_PATH
./main s.raw
这次程序顺利运行,输出结果:
less
width: 1080, height: 1920
size:8294400
use time:446.956
ocr result: 温馨提示
执行时间从原来的几十秒骤降到了 446 毫秒,达到了正常水平。
总结
在 Android 模拟器的 Shell 下运行 C++ 推理程序,有几个关键点需要注意:
-
App 与 Shell 的性能差异本质: 不是 GPU 加速,而是 ABI 架构匹配问题。APK 包含多种架构的 so 库,Android 系统会自动为设备选择最优架构(如 x86_64 模拟器选 x86_64 so),实现原生执行;而 Shell 中直接运行的单一架构可执行文件,在跨架构时必须经过指令集转译,性能损失巨大。
-
架构必须匹配: 在 x86_64 电脑模拟器上,务必编译 x86_64 架构的程序。跨架构的指令集转译会带来巨大的性能损耗,导致多线程也难以奏效。而手机上的 ARM 虚拟机不存在这个问题,因为 arm64-v8a 程序可以原生执行。
-
动态库依赖与隔离: Android 的命名空间隔离会阻止 Shell 访问部分系统库。如果遇到 not found 且确认库存在于系统中,需排查是否是架构不匹配(误用了 arm64 目录下的库),或是需要通过
LD_LIBRARY_PATH显式指定类似/system/apex/...这种被隐藏的库路径。
此外,对于纯推理的命令行程序,建议在 CMake 阶段就剥离 OpenCV 中不必要的 UI 模块(如 highgui),可以从源头减少这类系统级动态库的依赖问题。