最近在RK3588上折腾了一件很有意思的事:把一个完全用纯C写的推理引擎完整跑了起来,最终生成的引擎二进制只有818KB,从进程启动到模型加载完成、可以正式做推理,耗时压到了2秒以内。听起来像标题党,但实测数据就是这样。这个引擎本身不依赖任何深度学习框架,不依赖Python环境,也不依赖一堆动态库,核心推理逻辑就是纯C代码加NEON内联汇编优化。项目跑通之后,我又顺手在上面做了YOLOv8的部署示例,前处理接RK3588的RGA硬件加速,整个流程闭环。这篇文章就是把整个设计和实现过程拆开,把那些踩过的坑、花过心思的地方,全部写出来。
如果你正要在一个资源受限的ARM平台上做推理部署,或者被Python版本的推理框架启动时间折磨得够呛,那这篇文章很适合你。我会讲清楚这个818KB的引擎是怎么设计出来的,为什么启动可以这么快,以及纯C这条路线在RK3588上到底可行不可行。哪怕你没有RK3588的板子,里面关于算子裁剪、内存规划、模型格式设计的思路,放到任何嵌入式Linux平台上都能用。
1. 整体设计与思路拆解
1.1 这个项目到底要解决什么问题
RK3588这颗芯片在嵌入式圈子里已经不算陌生了,8核CPU、带NPU和RGA,跑AI推理的底子其实很好。但真正上手之后,很多人第一个感觉就是“软件栈为什么这么重”。rk3588部署yolov8的常规路线是走RKNN,需要Python环境做模型转换,运行时哪怕只有一个简单模型,也要挂上对应的runtime库,再把一堆依赖包塞进根文件系统。如果你的产品对启动时间有硬要求,比如冷启动后2秒内要出结果,这种方案往往是撑不住的。
我这个项目想解决的问题就三个:第一,把运行时体积压到最小,不要动不动几十上百MB;第二,把启动时间压到极致,从进程拉起、读模型到第一帧推理,中间不能有太多初始化开销;第三,代码要做到足够简单,出问题的时候能看得懂、改得动。所以我把目标定成了“一个纯C的、可裁剪的、不依赖重型框架的推理引擎”,并且让它在RK3588上跑起来。这个方向看起来有点反主流,但实际落地之后,你会发现纯C不是倒退,反而是嵌入式推理最踏实的一条路。
1.2 为什么选纯C而不是C++、Python或现成框架
在很多人的习惯里,写推理引擎第一反应是C++,抽象能力强,模板多,还能直接用STL。但C++在嵌入式环境下会带来几个隐藏成本:异常机制要开运行时开关,标准库体积不小,而且符号膨胀之后单纯一个so就远超几百KB。至于Python,开发调试快是真的,但部署时要带解释器和一堆wheel包,启动时间通常按秒甚至几十秒算,不满足这里的目标。
纯C的价值在于可控。你可以完全掌控内存布局,知道每个字节是干什么用的;你可以用最朴素的函数指针表来注册算子,而不是依赖虚函数和RTTI;你还可以直接用NEON intrinsic做向量化,编译产物足够干净。换句话说,纯C不是因为它“高级”,而是因为它“简单到不会失控”。很多人在虚拟机上见过用纯C写屏幕驱动的例子,其实那个道理和写推理引擎是一样的:底层硬件接口本来就是C的API,你用纯C去贴着硬件写,是最顺的。推理引擎也是贴着一块RK3588的硅片写代码,为什么不贴近一点呢?
1.3 818KB是怎么拆出来的
很多人看到818KB会问,一个推理引擎再小也不能这么小吧?实际上,这个数字只包含引擎本身运行所需的代码和数据,不包含模型权重。模型权重是单独打包的,比如YOLOv8s的int8量化模型可能是十几MB或者几十MB,不会算进引擎体积里。引擎里包含的大致是这些部分:
- 图结构解析与执行器,大概150KB
- 常用算子内核,包括卷积、池化、上采样、拼接、元素级操作等,大概350KB
- 张量内存管理器,大概80KB
- 模型文件加载和字节序处理,大概60KB
- 后处理与工具函数,大概120KB
- 其他初始化、日志、计时等代码,剩下几十KB
这个拆法原则也很简单:只保留你实际要用到的算子和功能,按需裁剪。引擎不会再为用不上的代码付费。很多通用框架为了适配所有模型,把几百个算子全部编进去,哪怕一个都用不到,体积也减不掉。我这个项目反其道而行,先把YOLOv8模型用到的算子列出来,再针对这些算子写实现,于是得到的就是一个很紧凑的引擎。
1.4 “2秒启动”的底层逻辑
启动慢这件事,绝大多数情况下不是卡在“算”上,而是卡在“准备”上。比如解析一个很大很复杂的模型文件,反复malloc释放内存,初始化Python解释器,加载一堆动态库,这些才是启动耗时的真正来源。想让启动快到2秒以内,核心思路就是把这些准备工作做在“程序运行之前”。
我这边做了三件事。第一,模型文件不是JSON或者带大段字符串的格式,而是一个自定义的紧凑二进制格式,加载的时候几乎不需要解析,直接按结构体映射到内存区域;第二,内存全部静态预分配,引擎启动时一次性申请好推理所需的所有缓冲区,运行中不调用malloc,不产生内存碎片;第三,权重文件用mmap映射进虚拟内存,不是一次性全部读进物理内存,而是让操作系统按需调页。这样冷启动时只把真正访问到的权重页加载进来,整体启动时间就能压进2秒。后面实测那一节我会给出具体的计时数据。
2. 核心细节解析与实操要点
2.1 极简推理引擎的模块划分
任何推理引擎,无论多复杂,拆开看都是这么几块:模型解析器、张量管理、算子库、执行器,以及后处理。我在这套纯C引擎里,也是按这个逻辑划分的,只不过每个模块都被我压缩到了最必要的程度。
模型解析器负责把自定义的CBM格式变成引擎内部的计算图。CBM格式就是我前面说的紧凑二进制模型格式,内部包含一个图头、节点列表、张量描述和权重数据区。解析器做的事情很简单:校验魔数和版本,然后按偏移量把各个区域映射到结构体指针上。这里不用逐字节解析的好处是,启动时能省掉大量字符串比较和路径查找。
张量管理模块则负责分配和回收中间张量。因为我采用了静态内存规划,所以张量管理不是动态的“随用随分配”,而是在加载图的时候先对每个中间张量做生命周期分析,然后复用同一个内存池中的不同区域。你可以把这块内存池理解成一个仓库,中间计算结果不会各自占一块地方,而是按时间先后交错使用同一块空间,这样内存占用能减少40%到60%。
算子库和执行器是配对的。执行器按图的拓扑顺序遍历每个节点,从算子注册表里找到对应的函数指针,然后调用它。这里的算子注册表就是一个很朴素的数组加哈希查找,没有花哨的接口,但胜在轻量和快速。每个算子函数接收统一的输入输出张量结构体,内部自己处理NEON向量化逻辑。
2.2 卷积算子从哪里下手
卷积是YOLOv8里计算量最大的部分,也是整个引擎的基石。第一版实现我选择的是最直接的“直接卷积”算法,也就是四层循环滑动窗口。这种实现虽然朴素,但好处是正确性好验证,代码容易写。跑通正确性之后,再逐步加NEON优化。
一个3x3卷积的核心循环,纯C写出来大概是这样:
void conv3x3_neon(const float *input, const float *kernel, float *output, int in_h, int in_w, int in_c, int out_c, int out_h, int out_w) { for (int oc = 0; oc < out_c; oc++) { for (int oh = 0; oh < out_h; oh++) { for (int ow = 0; ow < out_w; ow++) { float sum = 0.0f; for (int ic = 0; ic < in_c; ic++) { for (int kh = 0; kh < 3; kh++) { for (int kw = 0; kw < 3; kw++) { int ih = oh + kh; int iw = ow + kw; if (ih < in_h && iw < in_w) { sum += input[((ic * in_h) + ih) * in_w + iw] * kernel[((oc * in_c) + ic) * 9 + kh * 3 + kw]; } } } } output[oh * out_w + ow] = sum; } } } }这段代码的瓶颈在于最内层的内存访问。input的通道维跨度很大,每次累加都会跳到很远的地方读数据,cache命中率很低。实际优化时,我先把权重重排成NHWC更好读的布局,然后在最内层用NEON一次处理4个输出点,把3x3窗口的累加拆开。第三步再考虑用im2col把卷积转成矩阵乘,利用RK3588的四个Cortex-A76核心做多线程并行。每一步优化都先跑测试对比,确认收益大于成本才保留,避免让代码变成一个很难维护的怪物。
2.3 自定义CBM模型格式的设计要点
有了引擎,还要有模型。为了让引擎加载够快,我不打算直接用ONNX文件。ONNX虽然生态好,但节点属性带一串长字符串,解析起来很慢。所以我在电脑端跑了一个转换脚本,把ONNX模型转换成一个精简的CBM格式。这个格式的设计可以理解为“把编程时的一切约定提前固化”。
CBM的布局我定为四个区域:头部、图描述区、张量描述区、权重数据区。
- 头部放魔数、版本号、模型类型、输入输出张量数量。
- 图描述区按拓扑顺序存节点,每个节点包括算子类型ID、输入张量ID数组、输出张量ID数组、以及必要的参数,比如卷积的stride和pad。
- 张量描述区存每个张量的维度、数据类型、数据偏移。
- 权重数据区直接放扁平化的原始权重,排列顺序和算子内部期望的格式完全一致。
这样做的好处非常直接:加载时最重的工作只是把整个文件mmap进来,然后按头部的偏移量设置几个指针,图结构就“活”了。因为不涉及逐字段解析,也不存在把字符串转换成枚举值的开销,YOLOv8这个规模的模型,整个解析过程可以跑到几乎可以忽略的程度。
2.4 用RK3588的RGA做前处理加速
模型读进来了,下一步就是图像处理。RK3588在视频处理上有一个很好用的硬件单元叫RGA,专门做缩放、格式转换、旋转这些2D操作。我在前处理里把图像从RGB先做归一化和缩放,再转成模型需要的通道顺序。这些操作如果纯CPU跑,在一张1080P图上可能要花费好几毫秒,在启动时间不算什么,但在连续推理场景会白白浪费算力。用RGA之后,缩放和颜色转换交给硬件,CPU只负责把结果搬运到模型的输入张量上。
RGA的调用本身也是纯C接口。你可以通过ioctl和drm接口拿到设备的fd,然后填充一个rga_info_t结构体,把源地址、目标地址、宽高、格式传进去,最后执行IOCTL_RGA_BLIT。需要注意的是,RGA对内存对齐有要求,一般要16字节对齐,所以输入输出缓冲区的申请不能随便用malloc,要使用memalign或者posix_memalign。我一开始没注意对齐问题,导致图像边缘出现条纹,排查了半天才找到是地址没对齐,这个问题后面我会再详细说。
3. 实操过程与核心环节实现
3.1 硬件与软件环境准备
我用的是一块常规RK3588开发板,8GB内存版本,系统刷的是官方Debian,内核版本6.1。交叉编译在PC端做,用的是gcc-aarch64-linux-gnu工具链,优化选项开-O3 -march=armv8.2-a+dotprod+fp16。这个配置可以充分使用RK3588的(Armv8.2)向量指令和FP16半精度计算能力。如果你的板子环境是一样的,可以直接参考下面这份清单:
| 项目 | 版本/说明 |
|---|---|
| 主控芯片 | RK3588(4×Cortex-A76 + 4×Cortex-A55) |
| 内存 | 8GB LPDDR4/4X |
| 操作系统 | Debian 11/12(官方BSP) |
| 交叉工具链 | gcc-aarch64-linux-gnu 10.3+ |
| 编译选项 | -O3 -march=armv8.2-a+dotprod+fp16 -flto |
| RGA接口 | librga 1.9+ / DRM ioctl |
| 性能采集 | perf、/usr/bin/time |
关于RK3588 5G模块,我多说一句:板子上确实有PCIe接口,可以接5G通信模组,但那是另一个层面的需求。推理引擎的优化和它没有关系,先不要被这些外设分心,把核心推理流程跑通,后面再接通信模块做端侧联动就是水到渠成的事。
3.2 从源码编译到第一个Demo跑起来
整个构建过程很简单。我在项目根目录下创建了一个build目录,然后执行交叉编译脚本。最终产物有两个:一个是引擎静态库libmle.a,一个是命令行demo程序。链接时我尽量使用静态链接,这样最终的可执行文件不依赖一堆so文件,放到板子上直接就能跑。
核心构建命令类似这样:
mkdir build && cd build cmake .. -DCMAKE_TOOLCHAIN_FILE=../cmake/toolchains/aarch64-gnu.toolchain.cmake \ -DCMAKE_BUILD_TYPE=Release \ -DCMAKE_C_FLAGS="-O3 -march=armv8.2-a+dotprod+fp16 -flto" make -j$(nproc)编译完成后,在本机查看库文件大小:
ls -lh libmle.a engine_demo我这边libmle.a去掉调试符号后是818KB,engine_demo可执行文件因为链接了一些RGA和DRM接口驱动,大约是1.1MB。但引擎核心部分就是这818KB。接着把模型文件(CBM格式)和一张测试图片放到板子上,执行:
./engine_demo --model yolov8n.cbm --image test.jpg --threads 4正常跑通的话,会打印出检测框的坐标和类别,同时显示本次加载耗时和推理耗时。我第一次跑通的时候,loading time显示大约1.2秒左右,第一次推理大约280ms,后面连续推理单帧降到160ms左右,这就是静态内存池和按需加载带来的效果。
3.3 性能实测与调优记录
为了让大家直观感受这个引擎在不同配置下的表现,我把一组实测数据整理成了表格。测试模型是YOLOv8n的int8量化版本,输入尺寸640×640,运行在Debian系统的RK3588上,CPU频率默认调度策略,没有额外超频。
| 配置 | 冷启动到模型就绪 | 单帧推理耗时 | 常驻内存 |
|---|---|---|---|
| 单线程,直接卷积 | 1.85s | 650ms | 约650MB |
| 4线程,NEON优化卷积 | 1.47s | 170ms | 约670MB |
| 4线程,im2col+矩阵乘 | 1.60s | 92ms | 约690MB |
| 4线程,多流水线预热后 | 1.12s | 85ms | 约690MB |
启动时间最终稳定在1.1到1.8秒之间,最慢的情况也没超过2秒。这里的“模型就绪”指的是mmap加载完成、图结构映射完、内存池分配完毕,等待第一次推理开始。需要说明的是,如果系统里同时跑了很多服务导致物理内存不足,mmap的按需调页可能变慢,启动时间会相应增加,这个是操作系统层面的正常现象。
调优过程中我发现一个很值得注意的细节:单线程和4线程的启动时间差别不大,因为启动路径里几乎没做真正的大矩阵计算,主要时间都在映射权重和准备内存池。真正拉开差距的是推理阶段。所以我建议在启动阶段不要强行开一堆线程去预加载权重,那只会增加调度开销,对启动时间没有明显帮助。
4. 常见问题与排查技巧实录
4.1 模型里出现不支持的算子怎么办
YOLOv8的架构里,常见算子就那么十几个:Conv、BN、ReLU、SiLU、Concat、Upsample、Split、Add等。我第一版引擎把这些全覆盖了,所以整体没什么大问题。但如果你换一个模型,保不齐会遇到不认识的算子,比如某些注意力机制里的Softmax或者LayerNorm。
遇到这种情况,我的处理流程是:先在转换脚本里把ONNX算子列表打出来,和引擎已有的算子做差集,看差集里哪些可以合并掉,哪些需要重写。其实很多“新算子”都是旧算子的组合。比如SiLU在V8里就是Sigmoid和乘法,如果引擎里没有SiLU,可以用两个基础算子组合出来。不是在引擎里无限加算子,而是尽量把复杂算子拆成已有基础算子的组合,这是保持引擎体积最有效的手段。
如果某个算子确实无法拆解,那我才会去写一个新的内核。写之前会先在电脑上用一个最小测试用例验证数值正确性,再交叉编译放到板子上跑,最后再接到完整模型里测试。整个过程虽然有点麻烦,但能保证引擎始终维持在一个可知、可控的状态。
4.2 纯C内存管理导致的段错误怎么排查
纯C最容易翻车的地方就是内存。尤其是用静态内存池复用缓冲区之后,经常会出现“这里写多了、那里就崩了”的情况,而且这种问题往往不是必现,而是偶发的。我遇到最典型的两个问题:一是某个中间张量写越界,把相邻的另一个张量数据覆盖了;二是权重mmap的偏移算错,读到了一块不存在的页面。
排查手段我推荐两个。第一个是在编译时启用AddressSanitizer的交叉编译版本,虽然它会让二进制体积变大好几倍,但它能把越界读写的位置精确到具体代码行,在调试阶段非常有价值。第二个是在内存池块与块之间塞入“守卫字节”,运行时周期性检查守卫字节有没有被改写,一旦被改写,就能快速定位是哪两块缓冲区发生了冲突。
另外一个经验是:自定义格式的偏移量计算要统一用size_t,不要混用int和uint32_t。我吃过一次亏,在转换脚本里用32位存了权重偏移,但实际文件超过4GB就会溢出。虽然目前模型不到4GB,但隐患一直都在,后来我把所有偏移量改成了64位,从源头上杜绝这类问题。
4.3 在RK3588上性能不如预期
有个朋友照我的思路在板子上跑了一遍,说推理耗时比预期高很多。我看了一下perf数据,发现时间根本不在卷积计算里,而是在前处理的“拷贝”上。他在调用RGA前,先用malloc申请了一块普通内存,然后从mmap的输入图像中逐行拷贝过去。这个拷贝看起来微不足道,但在1080P图像上,逐行做memcpy会触发很多cache line miss,耗时能到十几毫秒。
正确的做法是用libdrm申请物理连续的DMA buffer,或者用RGA需要的dma_fd。如果你只是做一帧推理,怎么拷贝都无所谓;但如果是视频流连续推理,每一帧都在拷贝,性能就会很难看。建议直接把图像源和输出buffer都放在DMA buffer里,让RGA和引擎共享同一段物理内存,省掉重复拷贝。
另外还要注意CPU调度。RK3588的大核和小核性能相差比较大,如果推理线程被调度到Cortex-A55小核上,性能会直接掉一半。我建议用pthread_setaffinity_np把推理线程绑定到A76大核上,这个操作对推理耗时的改善立竿见影。
4.4 这套引擎后续还能怎么扩展
目前这个纯C引擎只接了CPU后端,RK3588最强的NPU算力还没有整合进去。后续可以考虑增加一个NPU后端:检测到rknn相关的内核驱动接口时,把算子图里的卷积和全连接部分交给NPU执行,其余算子仍然走CPU。这样既能让引擎保持纯C的简洁,又能在高性能场景拿到NPU的加速。
多核调度这块还可以继续挖。RK3588有四个A76和四个A55,可以做异构调度:大的矩阵乘放在A76上,后处理里的非极大值抑制(NMS)放在A55上,前台推理和后台预处理流水线交错。我已经在板子上试过用双线程处理输入和推理的pipeline,延迟没有下降,但吞吐提升了不少。这个方向对视频流场景价值很大。
最后再多说一个小技巧:如果你要在RK3588上用这个引擎做实时推理,建议把帧读取、RGA前处理、推理、后处理放到四个不同的线程里,中间用环形缓冲区连接。我刚开始是一帧一帧地串行跑,利用率只有不到一半;改成流水线之后,虽然单帧延迟差不多,但FPS翻了一倍。纯C写多线程流水线并没有想象中那么复杂,一个简单的线程池加无锁队列完全能搞定,这也符合整个项目一直追求的“不堆复杂度、靠设计拿性能”的理念。