前一篇准备好了 FP16 和 INT8 两块"料",这一篇把它们丢进板子的 NPU 里,看看 3 TOPS 的纸面算力到底能跑多快。所有数据都在板端实测:帧率、CPU/NPU 占用、精度损失,一个一个来。
一、测试前的准备:一张能计算时间的图
测速最忌讳拍脑袋。PC 端 toolkit 连板推理那条路不能用来测性能——模型和图像每帧都要过一遍 WiFi adb,测的是网速不是 NPU 速度(前一篇"单次推理 30 秒"就是这么来的)。所以这次直接写板端 C 程序,交叉编译后推上去,librknnrt.so 本地推理,网络零干扰。
一共写了两个:基准程序 rknn_bench.c,和一套完整的 YOLOv5 检测管线 rknn_yolo_e2e.cc。测试从底层往上分四层,一层比一层接近真实应用:
- NPU 纯推理耗时。只计时
rknn_run(),同时用官方 RKNN_QUERY_PERF_RUN 硬件计数器交叉验证,两边对得上才算数。
- 全链路单帧耗时。输入量化拷贝 → rknn_run → 输出反量化,整条链掐表,这是常规部署方式下的真实帧率。
- 多实例并发。同时建 2~3 个 context 轮流推理,看吞吐能不能叠上去。
- 端到端应用帧率。读图、预处理、推理、NMS、画框存图一条龙,测的是应用每秒能处理几张真实图片(第四节)。
输入是 bus.jpg 缩到 640×640 的真实图像(不是随机噪声),INT8 预热 10 帧后连续测 200 帧,FP16 测 50 帧。
整套流程做成了 12 个可复现脚本(基准 9 个 + 端到端 3 个),在 WSL 里按序号跑,一个脚本出一个数据点,本文所有数字都能原样复现:
| 脚本 |
内容 |
| 01_connect ~ 09_collect |
基准套件:连板 → 编译 → 部署 → INT8/FP16 基准 → 输入格式对比 → 多实例并发 → CPU 采样 → 日志回收汇总 |
| e2e_run.sh |
端到端管线:推程序 → 双模型单帧验证 → 30 帧循环计时 → 拉回检测框结果图 |
| e2e_pull.sh / e2e_redeploy.sh |
单独拉取板端检测结果图 / 板子重启后一键重新部署恢复 |
【代码块1:板端 C 基准程序核心计时逻辑(实测代码节选)】
/* 全链路计时:量化拷贝 + NPU + 反量化 */
double t0 = now_ms();
for (int i = 0; i < iters; i++) {
rknn_inputs_set(ctx, 1, &in); // 输入拷贝(含量化)
rknn_run(ctx, NULL); // NPU 推理
rknn_outputs_get(ctx, io.n_output, outs, NULL); // 输出(含反量化)
rknn_perf_run pr; // 官方硬件计数器交叉验证
rknn_query(ctx, RKNN_QUERY_PERF_RUN, &pr, sizeof(pr));
hw_us += pr.run_duration;
rknn_outputs_release(ctx, io.n_output, outs);
}
printf("full_chain=%.2f ms | npu_perf_run=%.2f ms\n",
(now_ms()-t0)/iters, (double)hw_us/1000.0/iters);
二、直接上结果:INT8 vs FP16
两种模型、四种测法跑完,数据全部拉表(INT8 200 帧、FP16 50 帧,官方计数器与本地计时交叉验证一致):
| 指标 |
FP16 |
INT8 |
说明 |
| 模型大小 |
14.90 MB |
7.77 MB |
INT8 小 48% |
| NPU 纯推理 |
51.21 ms |
25.21 ms |
官方计数器 50.18/25.23 交叉一致 |
| NPU 纯推理帧率 |
19.53 FPS |
39.66 FPS |
NPU 真实算力 |
| 全链路单帧 |
148.29 ms(6.74 FPS) |
66.85 ms(14.96 FPS) |
含输入量化 + 输出反量化 |
| 全链路单帧(float 输入) |
— |
117.11 ms(8.54 FPS) |
runtime 自动量化多耗 50 ms |
| 端到端应用帧率 |
139.69 ms(7.16 FPS) |
32.96 ms(30.34 FPS) |
uint8 直喂 + 输出直读,见第四节 |
| 推理中 CPU 占用 |
8.91% |
9.69% |
基线 0.12%,四核大量富余 |
| NPU 占用 |
接口不可读 |
接口不可读 |
load 节点恒 0,用多实例实测反推(第五节) |
| 精度(INT8 对 FP16 检测框) |
基准 |
IoU 0.959~0.992 |
5/5 目标全部一致,见第三节 |


有几个数字单独说一下。
INT8 恰好快一倍:NPU 纯推理 51.21 ms 对 25.21 ms,19.53 FPS 对 39.66 FPS,官方硬件计数器报 50.18 和 25.23,跟本地计时对得上。同一颗 NPU,换个量化格式吞吐直接翻倍。做实时检测的话,这个差距没有讨价还价的余地。
CPU 占用低得出乎意料。推理期间 us+sy 合计不到 10%(INT8 实测 9.69%、FP16 8.91%,基线只有 0.12%),四个 A53 基本闲着,那点开销全是输入输出数据在 CPU 上的搬运转换。换句话说,NPU 干活的时候 CPU 是空出来的,可以拿来跑业务逻辑——CAN FD 上报、协议解析、传图,互不抢资源。工业落地这点很关键。
输入格式的影响比想象中大。同一个 INT8 模型,喂 float 输入 117.11 ms,喂预量化 int8 输入只要 66.33 ms,慢了七成多。原因也不复杂:runtime 自动量化要在 CPU 上逐像素转换 640×640×3,这个活部署时自己提前做掉就行,是不花成本的提速。
还有一条更意外的:输入输出的交接方式再改一下,66.85 ms 还能降到 32.96 ms。不换模型、不超频,纯 API 用法问题。这个放到第四节细说。
三、精度损失怎么测:别只看体积和速度
只测速度不测精度等于耍流氓。测法是把端到端检测管线在板端跑两遍:同一张 bus.jpg,分别用 FP16 和 INT8 两版模型做完整推理(预处理 → NPU → 解码),把各自的检测框坐标、置信度、类别逐项对齐比较。
【代码块2:INT8 vs FP16 端到端检测结果对比(一手日志 e2e_run.log 节选)】
===== INT8 : 5 detections =====
[person] conf=0.884 box=(208,244,286,506)
[person] conf=0.868 box=(478,238,559,526)
[person] conf=0.825 box=(110,238,230,534)
[ bus] conf=0.701 box=( 91,129,554,467)
[person] conf=0.334 box=( 79,353,122,516)
===== FP16 : 5 detections =====
[person] conf=0.881 box=(208,242,286,508)
[person] conf=0.859 box=(478,238,560,525)
[person] conf=0.841 box=(109,237,232,534)
[ bus] conf=0.702 box=( 91,129,555,465)
[person] conf=0.319 box=( 79,355,121,515)
=== 逐框对齐:置信度偏差 / IoU ===
bus 0.701 <-> 0.702 Δconf=0.001 IoU=0.992
person 0.884 <-> 0.881 Δconf=0.003 IoU=0.985
person 0.868 <-> 0.859 Δconf=0.009 IoU=0.984
person 0.825 <-> 0.841 Δconf=0.016 IoU=0.972
person 0.334 <-> 0.319 Δconf=0.015 IoU=0.959

结论:
INT8 量化在这张图上几乎无损:5 个目标两版模型全部检出,零漏检零错判;逐框 IoU 全部大于 0.95,bus 的框 IoU 0.992,坐标偏差不超过 2 个像素;置信度最大偏差 0.016,有升有降。对安全帽识别这种"戴没戴"的二值判断,这个级别的波动完全无感。只有置信度阈值卡得极死、或者类别间差异很小的细粒度任务,才需要认真做量化精度评估(官方 accuracy_analysis 逐层对比是更硬的证据,我们实测三层输出余弦相似度都是 1.00000)。
四、从跑分到应用:端到端管线实测
回头看,前三层测试其实都在"伺候 NPU"——输入预先备好、输出全量转 float,为的是把 NPU 的能力单独测出来。但真实应用不长这样:一张 jpg 进去、一张画好框的图出来,中间还夹着预处理和 NMS。所以又写了第四个程序 rknn_yolo_e2e.cc,把完整检测管线在板端跑起来:
读图(jpg解码) → BGR2RGB + resize 640×640 → uint8 直喂 NPU → 推理 → 解码+NMS → 画框 → 存图
【代码块3:端到端管线核心逻辑(rknn_yolo_e2e.cc 节选)】
/* 输入: uint8 直喂 + pass_through=0, runtime 自动转 int8/fp16, 两种模型通吃 */
rknn_input in = {};
in.index = 0;
in.type = RKNN_TENSOR_UINT8;
in.fmt = RKNN_TENSOR_NHWC;
in.pass_through = 0;
in.buf = resized_img.data;
/* 输出: INT8 模型 want_float=0, 原始 int8 直读, 后处理用到哪个值才反量化哪个 */
for (int i = 0; i < loop; i++) {
t0 = now_ms();
rknn_inputs_set(ctx, 1, &in);
rknn_run(ctx, NULL); /* NPU 推理 */
rknn_outputs_get(ctx, io.n_output, outs, NULL);
t1 = now_ms(); run_ms += t1 - t0; /* 计时1: 推理+IO */
t0 = now_ms();
post_process(...); /* 解码+NMS(按需反量化) */
t1 = now_ms(); post_ms += t1 - t0; /* 计时2: 后处理 */
rknn_outputs_release(ctx, io.n_output, outs);
}
计时在同一循环里分段做,推理和后处理各掐各的表,端到端是两段之和。一开始用的是分两次循环各测一遍的办法,后来发现两次之间的系统波动会把数据搞脏,甚至算出过后处理耗时为负的笑话,才改成现在这样。
30 帧循环实测:
| 阶段 |
INT8 |
FP16 |
| 预处理(读图 + resize,单次另计) |
33.81 ms |
29.71 ms |
| 推理 + IO |
31.49 ms |
138.12 ms |
| NMS 后处理 |
1.47 ms |
1.57 ms |
| 端到端(推理 + IO + NMS) |
32.96 ms(30.34 FPS) |
139.69 ms(7.16 FPS) |

最有意思的是拿这组数和第二节全链路对比:同一个 INT8 模型,NPU 推理都是 25 ms 左右,端到端却比全链路快了一倍(32.96 对 66.85)。账拆开就明白了。全链路 66.85 ms 里 NPU 只占 25.21 ms,剩下约 42 ms 大头是输出反量化——214 万个输出元素在 CPU 上逐个乘 scale 加 zp 转成 float,光这一项就吃掉约 40 ms。端到端版本改成 want_float=0 直读 int8 原始输出,后处理解码到哪个候选框、才反量化哪个值,一帧 NMS 只需要几百次乘法,IO 开销从 42 ms 压到 6 ms。部署时照抄这个写法就行。
FP16 那边连 IO 都天生吃亏:端到端 139.69 ms 里 NPU 只占 51.21 ms,剩下约 88 ms 全是格式转换(输入 uint8→fp16、输出 fp16→fp32)。精度高一档,搬运成本翻几倍。这是选 INT8 的又一条理由。
顺便说个插曲。FP16 第一版跑出来全是 conf=0.996 的饱和值,满屏零面积垃圾框,NMS 单帧跑了半秒多。查了半天,根因特别朴素:FP16 模型的输入张量类型是 FLOAT16,程序还在沿用 INT8 的套路(pass_through=1 硬塞量化后的 int8 数据),NPU 把这串 bit 按 fp16 解释,数值自然全错。正确写法是输入统一 uint8 + pass_through=0 让 runtime 自动转换;输出 INT8 模型设 want_float=0 直读、FP16 模型设 want_float=1 转 float32。改完之后 FP16 的 5 个检测框和 INT8 完全对齐,第三节的数据就是修复后的。
还有个口径问题要交代:循环计时不包含读图加 resize 的 30 ms 上下(INT8 33.81 ms、FP16 29.71 ms)。那基本是 JPEG 软解码的开销,真实相机管线从 V4L2 直接拿 NV12 帧,不走这条路,算进去反而冤枉板子。NMS 后处理只占端到端的 1%~5%,四核 A53 跑 640 输入的后处理毫无压力,以后加跟踪、加业务逻辑都有富余。
五、资源占用:多开几路看看余量
测完单路,又试了多路并行——工业场景经常要同时处理多个相机。同时建 2 个、3 个模型 context 轮流推理:

| 实例数 |
聚合吞吐 |
每路帧率 |
| 1 |
14.96 FPS |
14.96 FPS |
| 2 |
14.85 FPS |
7.42 FPS |
| 3 |
15.02 FPS |
5.01 FPS |
结果有点反直觉:吞吐完全不随实例数增长。1 路 14.96 FPS,2 路 14.85,3 路 15.02,基本纹丝不动。单实例就已经把这颗 NPU 打满了,多开两三路只是让 NPU 把时间片切给各路,每路等比变慢。
这个结果顺手补上了一个遗憾:debugfs 的 rknpu/load 节点在这版内核恒为 0,NPU 占用率百分比一直读不出来。但"多开无增益"本身就是满载的最好证明——NPU 是串行调度推理任务的,没有并发算力可挤。
对多路需求的启示也很直接:别指望多开实例提速。要么用更小的输入或更轻的模型把单帧压到 10 ms 级,要么接受每路 5~7 FPS 的分时结果,要么外挂多颗 NPU。一台板子看两三个点位、每秒几帧够用的场景,这块板是够格的。
六、小结
这一篇的答案:INT8 跑 YOLOv5s,NPU 纯算力 39.66 FPS,落到完整检测管线(含 IO 和 NMS)还有 30.34 FPS,推理期间 CPU 占用不到 10%。
生产上选 INT8 几乎不用犹豫:快一倍、小一半、搬运成本低几倍,精度损失在这个场景下无感。CPU 解放是最实用的部分——算法在 NPU、业务在 CPU,互不打架。最后一点,部署姿势本身就是优化:uint8 直喂加输出直读,模型一行不改,帧率翻倍。