news 2026/9/29 15:02:20

RK3588学习日记(五) Python 多线程 NPU 推理:吞吐、延迟与核绑定实测

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
RK3588学习日记(五) Python 多线程 NPU 推理:吞吐、延迟与核绑定实测

上一篇跑通了 Python 单线程 NPU 推理。这次先把预处理、推理、后处理分开计时,再测不同 Worker 数、核绑定和有界队列。共享同一个 Runtime 并发推理时,程序没有报错,输出却与串行参考不一致。

1. 这篇做什么

第 1 步 量清单帧时间(预处理 / 推理 / 后处理各花多少) 第 2 步 搭 Worker 池 + 每线程独立 Runtime 第 3 步 验证线程安全(独立 Runtime 为什么是必须的) 第 4 步 实测线程数扩展(1/2/3/4/6 线程) 第 5 步 实测 NPU 核绑定(AUTO vs 绑核) 第 6 步 有界队列 + 帧号 + 丢帧策略,测真实吞吐和延迟

前置:第 4 篇跑通(板端有 rknnlite、有mix20_int8.rknn、有后处理函数postprocess_p2.py)。

实测环境

项值
板子ELF 2 Board(RK3588)
系统Ubuntu 22.04 / kernel 5.10.209 aarch64
CPU8 核(4×Cortex-A76 + 4×Cortex-A55)
NPU3 核,devfreq 当前 1.0 GHz
Runtimelibrknnrt 2.1.0(driver 0.9.8)
Python3.10.12 / rknn-toolkit-lite2 2.3.2 / numpy 2.2.1 / OpenCV 4.10.0
模型mix20_int8.rknn(INT8,YOLOv8s-P2,nc=1,1024×1024 输入,12 输出)

本文数字来自这块板子的实测。温度、NPU 频率和散热条件会影响结果,复现时请按相同口径重新计时。

2. 分段计时:预处理、推理和后处理(第 1 步)

直接把预处理、推理、后处理三段分别计时:

importtimeimportstatisticsimportcv2importnumpyasnpfromrknnlite.apiimportRKNNLitefrompostprocess_p2importpost_process# 第 4 篇的后处理MODEL="mix20_int8.rknn"IMAGE="crack.jpg"IMG_SIZE=(1024,1024)defpreprocess(path):# OpenCV 读入 BGR;模型输入要 RGB、NCHW、uint8。img=cv2.imread(path)rgb=cv2.cvtColor(img,cv2.COLOR_BGR2RGB)rgb=cv2.resize(rgb,IMG_SIZE)# HWC → CHW;None 增加 batch 维,ascontiguousarray 保证内存连续。returnnp.ascontiguousarray(rgb.transpose(2,0,1)[None].astype(np.uint8))# 模型只加载一次,避免把初始化时间混进逐帧计时。rknn=RKNNLite()rknn.load_rknn(MODEL)rknn.init_runtime(core_mask=RKNNLite.NPU_CORE_AUTO)inp=preprocess(IMAGE)# 预热结果不进入下面的统计数组。for_inrange(3):# 预热:前几次调用明显偏慢rknn.inference(inputs=[inp])# 每轮重新预处理;三个时间戳分别截出预处理、推理、后处理耗时。pre,inf,post=[],[],[]for_inrange(15):t0=time.perf_counter()inp=preprocess(IMAGE)t1=time.perf_counter()outs=rknn.inference(inputs=[inp])t2=time.perf_counter()post_process(outs)# DFL 解码 + NMSt3=time.perf_counter()pre.append((t1-t0)*1000)inf.append((t2-t1)*1000)post.append((t3-t2)*1000)print("预处理 mean %.2f ms"%statistics.fmean(pre))print("NPU 推理 mean %.2f ms"%statistics.fmean(inf))print("后处理 mean %.2f ms"%statistics.fmean(post))print("合计 mean %.2f ms -> %.2f fps"%(statistics.fmean([a+b+cfora,b,cinzip(pre,inf,post)]),1000/statistics.fmean([a+b+cfora,b,cinzip(pre,inf,post)])))rknn.release()

板端实测数据(按上面的打印格式整理;落盘的板端脚本输出为 JSON):

预处理 mean 5.40 ms NPU 推理 mean 155.39 ms 后处理 mean 73.67 ms 合计 mean 234.46 ms -> 4.27 fps

这组时间是后面比较的基线。计时时需注意三个口径:

  1. 后处理占 73.67 ms,接近三分之一——DFL 解码在 1024 输入下要处理 87040 个候选点,纯 numpy 后处理耗时不可忽略。只优化推理是不够的;
  2. 前几次调用偏慢:本机前 5 次是 195.65 / 198.24 / 183.55 / 160.78 / 163.08 ms,预热后稳定在161.75 ms(15 次均值,中位 161.43 ms)。下文统一先预热,避免将启动阶段计入稳定态均值;
  3. 第 4 篇记录的236.1 ms 是单帧、未预热的推理耗时,本文的234.46 ms 是全链路(含后处理)耗时。两者不是同一计时范围,不能直接比较。

3. Worker 池 + 每线程独立 Runtime(第 2 步)

投帧线程把任务放进队列;N 个 Worker 各自持有一个 Runtime,从队列取任务;结果带帧号返回主线程。

这里用的是 CPython 3.10 的threading.Thread,它创建真实的系统线程;但受 GIL 限制,同一解释器中的 Python 字节码不能靠这些线程在多个 CPU 核上同时执行。下文的 17.61 fps 是多个 Worker 各持一个 Runtime 时,只计 NPU 推理的实测吞吐;它不证明 Python 预处理或 DFL/NMS 后处理实现了多核并行。rknnlite调用内部是否释放 GIL,本文没有单独验证。

importqueueimportthreading STOP=object()defmake_runtime():# 在 Worker 内创建 Runtime;不要把主线程的 rknn 实例传给多个线程共用。rknn=RKNNLite()rknn.load_rknn(MODEL)rknn.init_runtime(core_mask=RKNNLite.NPU_CORE_AUTO)returnrknndefworker(task_queue,result_queue,stop_event):rknn=make_runtime()# ← 关键:每个 Worker 自己一个实例try:whilenotstop_event.is_set():# 队列为空时等待任务;STOP 是退出哨兵,不是一帧图像。item=task_queue.get()try:ifitemisSTOP:returnframe_id,frame=item outs=rknn.inference(inputs=[frame])# 结果带回帧号,主线程才能匹配原帧;Worker 完成顺序可能不同。result_queue.put((frame_id,outs))finally:task_queue.task_done()# ← 放 finally,否则主线程 join 会永久阻塞finally:# 正常退出或异常退出都释放本线程自己的 Runtime。rknn.release()

Worker 这段代码有两点需要注意:

  • task_done()放finally:Worker 抛异常时也要归还队列计数,否则退出流程会卡死;
  • 每个 Worker 独立RKNNLite实例:下一节用输出对比验证共享实例的问题。

这段代码同时出现了stop_event和STOP,但阻塞在task_queue.get()时,单独设置stop_event不会唤醒 Worker。退出时仍要给每个 Worker 放入一个STOP;板端测试脚本采用的就是哨兵退出方式。这段代码只展示 Worker 的主要处理逻辑,不能把它当成完整的启动和退出程序。

4. 共享与独立 Runtime 的输出对比(第 3 步)

先获取串行参考输出(同一输入串行推理 3 次,确认模型本身是确定的),再对比两种多线程写法:

  • 写法 A:所有线程共享同一个RKNNLite实例;
  • 写法 B:每个线程各自一个RKNNLite实例。

对比口径:把并发得到的每个输出张量,与串行参考逐元素求最大绝对偏差。

importnumpyasnpdefinfer(rknn,inp):# 将输出统一转成 float32,便于逐元素比较同一输入的结果。return[np.asarray(o,dtype=np.float32)foroinrknn.inference(inputs=[inp])]defmax_diff(a_list,b_list):# 先逐张量取最大绝对误差,再取所有输出中的最大值;0.0 表示此处逐元素一致。returnmax(float(np.max(np.abs(a-b)))fora,binzip(a_list,b_list))

板端实测结果:

实验完成报错输出正确与串行参考最大偏差
串行 3 次(确定性基线)3030.0
共享 Runtime,2 线程 × 3 轮8/轮0030.25 / 32.28 / 32.09
共享 Runtime,4 线程 × 3 轮16/轮0033.66 / 32.28 / 32.28
独立 Runtime,4 线程160160.0(19.29 fps)

结论(本文实测):

  • 串行推理是确定的,偏差 0.0,说明偏差不是模型抖动带来的;
  • 共享一个 Runtime 并发推理:一次异常都不抛,但每一次输出都是错的(偏差 30~34)。只看报错日志无法发现这个问题,必须核对输出;
  • 每线程独立 Runtime:0 报错、0 偏差,吞吐 19.29 fps。

所以 Worker 里那句make_runtime()不能省,也不能把rknn传进线程共享。

5. 线程数扩展实测(第 4 步)

每个 Worker 独立 Runtime、只计推理。1/2/3/4 Worker 各跑 8 帧;6 Worker 为每线程 6 帧、重复 2 轮(便于和 NPU 核数对照):

Worker 数吞吐相对单线程推理均值p95
16.08 fps0.99×164.52 ms163.23 ms
211.53 fps1.89×171.44 ms262.48 ms
317.61 fps2.88×168.51 ms199.78 ms
418.62 fps3.04×209.07 ms366.51 ms
621.6 / 18.87 fps(两轮)—268~314 ms360~589 ms

「相对单线程」以同轮A_single的 6.12 fps 为基线(20 帧,推理均值 163.49 ms),因此表内 1 Worker 的 6.08 fps 对应 0.99×,不是 1.00×。这与第 1 步另一次测得的稳定态 161.75 ms 不是同一轮测试。

这张表主要看吞吐和尾延迟:

  • 1→3 线程接近线性(6.08 → 17.61 fps,2.88×),和 NPU 的3 个核对上;
  • 3→4 线程吞吐约多 6%(18.62 fps),但 p95 从 199.78 ms 涨到366.51 ms——吞吐增幅有限,尾延迟明显增加;
  • 6 线程的结果不稳定:吞吐 18.87~21.6 fps,均值延迟 268~314 ms,p95 最高 588.87 ms。这组数据不能支持继续增加线程一定更快。

结论:对本文的 1024 输入、INT8 YOLOv8s-P2 测试,3 个 Worker 已达到 17.61 fps;继续增加 Worker 时,吞吐增幅有限或波动变大,尾延迟也会上升。

6. NPU 核绑定要不要做(第 5 步)

init_runtime支持core_mask,可以给每个 Worker 指定固定核:

fromrknnlite.apiimportRKNNLite# 这段只展示三个独立 Runtime 的核绑定初始化,线程启动与推理不在片段中。formaskin(RKNNLite.NPU_CORE_0,RKNNLite.NPU_CORE_1,RKNNLite.NPU_CORE_2):rt=RKNNLite()rt.load_rknn(MODEL)rt.init_runtime(core_mask=mask)# ← 每个 Worker 绑一个核

实测常量值:NPU_CORE_AUTO=0、NPU_CORE_0=1、NPU_CORE_1=2、NPU_CORE_2=4、NPU_CORE_0_1=3、NPU_CORE_0_1_2=7。

对比"3 Worker 全部 AUTO"和"3 Worker 分别绑 0/1/2",各跑 3 轮:

轮次全部 AUTO分别绑核
第 1 轮17.94 fps(164.58 ms)17.57 fps(168.65 ms)
第 2 轮17.70 fps(168.18 ms)16.52 fps(179.13 ms)
第 3 轮15.00 fps(197.80 ms)17.48 fps(170.19 ms)

结论:两组区间重叠(AUTO 15.0~17.9 fps,绑核 16.5~17.6 fps),差异落在采样噪声范围内——本文不下"绑核更好/更差"的结论。首轮曾出现绑核 13.65 fps、AUTO 17.48 fps 的差距,复测没能重现,按噪声处理。

只跑一轮容易误判。每组重复 3 轮,再看结果区间是否重叠。

7. 有界队列 + 帧号 + 丢帧策略(第 6 步)

视频流持续输入时,处理不过来就需要在丢帧和排队之间取舍。这里按设定帧率模拟投帧,采用有界队列;队列满时丢帧,不让等待时间继续累积。

task_queue=queue.Queue(maxsize=3)# 有界队列,别用无限队列frame_id=0# 用目标帧率算相邻两帧的时间间隔;这里是模拟输入,不是直接读摄像头。interval=1.0/source_fpswhilerunning:# 按起始时间和帧号算目标时刻,避免每轮都固定 sleep 导致误差累积。target=t_start+frame_id*interval now=time.perf_counter()iftarget>now:time.sleep(target-now)# 模拟真实摄像头的帧率iftask_queue.full():dropped+=1# 丢帧,不阻塞采集else:# 入队时记录时间戳,后面才能算从投帧到出结果的端到端延迟。task_queue.put((frame_id,time.perf_counter()))frame_id+=1

任务和结果都带frame_id:没有帧号时,乱序完成的结果会被画到错误的帧上。本节片段与第 2 步不是同一条完整流水线:这里入队的是帧号和时间戳,板端脚本用预加载的同一张输入图测试固定帧率;第 2 步的 Worker 片段则从任务中取图像,不能把两段直接拼接运行。

板端实测(每次 4 秒):

配置投帧丢帧完成交付帧率端到端延迟(均值)
只推理,2 Worker,15 fps 源6175412.10 fps341.59 ms
只推理,3 Worker,15 fps 源6106114.44 fps156.29 ms
只推理,3 Worker,30 fps 源121437818.03 fps291.83 ms
全链路(预处理+推理+后处理),3 Worker,15 fps 源6175412.17 fps391.91 ms(p95 456.25)
全链路,3 Worker,20 fps 源81265512.13 fps417.36 ms

同为 3 Worker、15 fps 源、只计推理的另一轮复测为 14.23 fps。单次结果会波动,表中保留首次测试的 14.44 fps。

按处理范围分别看这组结果:

  1. 只做推理时,3 Worker 能跟上 15 fps 源(0 丢帧,端到端延迟 156 ms ≈ 一帧推理时间);2 Worker 跟不上(丢 7 帧、延迟涨到 341 ms,说明队列在堆积);
  2. 把后处理放回 Worker 后,同一个 15 fps 源丢了 7 帧,交付帧率为12.17 fps,端到端延迟为391.91 ms。后处理单帧均值为 73.67 ms;
  3. 本文测试的全链路交付帧率约 12 fps;另一次 3 Worker 突发投帧、只计推理的吞吐为 17.61 fps。两项测试的投帧方式和处理范围不同,不能直接当作同口径性能比值;Python 后处理耗时不能忽略;
  4. 源帧率提到 30 fps 时,丢帧 43/121,仅推理的交付帧率为 18.03 fps;提高输入帧率并没有让这组配置跟上输入。

当时还用同一套多线程代码跑了真实裂缝图,记录为检出 94 框(最高 conf 0.573)和 33 框(0.500);校准图、全景图这类无裂缝图记录为检出 0 框。两张裂缝图没有留存逐框输出,本文不把检出数量当作可复核的正确性证据。

8. 实测数据汇总

指标实测值
单帧全链路(单线程)234.46 ms(预处理 5.40 + 推理 155.39 + 后处理 73.67)→ 4.27 fps
纯推理稳定态161.75 ms(首帧约 195~198 ms)
独立 Runtime 扩展1/2/3/4/6 Worker = 6.08 / 11.53 / 17.61 / 18.62 / 18.87~21.6 fps(6 Worker 为每线程 6 帧、重复 2 轮)
共享 Runtime(并发)不报错,输出 100% 错误(偏差 30~34)
核绑定 vs AUTO(3 轮)差异在噪声范围内,无显著差异
本文测试的帧率约 12 fps(3 Worker、15 fps 源、全链路交付);17.61 fps(3 Worker、突发投帧、仅推理吞吐),两者口径不同
端到端延迟仅推理 156 ms;全链路 392 ms(15 fps 源)

每个 Worker 独立 Runtime 时,仅推理吞吐从 6.08 fps 增至 17.61 fps;包含预处理和后处理的交付帧率约 12 fps。后处理单帧均值为 73.67 ms,所以不能用纯推理吞吐代表整条处理链路。

9. 常见问题

现象原因解决
检测框全是乱的,但程序不报错多线程共享了一个 Runtime 实例每个 Worker 独立RKNNLite+ 独立init_runtime
线程加多了反而更慢超过 NPU 核数(3)后线程在排队先测 3 个 Worker,再用吞吐和 p95 判断是否需要增加
只跑一轮就以为绑核更快采样噪声每个配置重复 3 轮,看区间是否重叠
程序退不出来task_done()没放在finally把task_done()放finally,并给每个 Worker 发STOP
延迟越来越大用了无限队列,帧在堆积有界队列 + 队列满丢帧
结果画到了错误的帧上结果没带帧号任务/结果都带frame_id,主线程只取最新完成结果
第一次推理特别慢Runtime 首次调用的一次性开销先预热 3 次再开始计时
get_sdk_version报 Runtime not inited调用顺序不对先init_runtime()再查询
读不到 NPU 占用率/sys/kernel/debug/rknpu/load需要 root普通用户读不到,属正常

10. 下一篇

本文这版实现的全链路交付帧率约 12 fps,后处理耗时不能忽略。下一篇单独验证 DMA-BUF 与 RGA:用 dma-heap 申请共享内存,把 fd 交给 RGA 做缩放和 YUYV→RGB 转换,并通过中断计数确认硬件参与;摄像头采集与 NPU 的零拷贝绑定留到后续篇章。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/29 14:59:42

模型优化器实战:从180ms到75ms的推理加速全流程

1. 模型优化器到底在解决什么问题第一次接触 Model-Optimizer 这个概念,是在一个推荐系统的排序模型上。当时线上推理延迟死活压不下去,单次请求要跑 180ms,业务方要求降到 80ms 以内。我一开始的想法很朴素——换更小的模型、砍特征、减层数…

作者头像 李华
网站建设 2026/9/29 14:58:55

Java物流管理系统毕业设计:JSP+SQL Server从需求到数据库落地

简介:这是一份基于Java的物流管理系统设计与实现的完整文档资料,面向正在学习JavaWeb开发、需要完成毕业设计或课程设计的学生,以及希望了解物流管理系统业务流程的开发人员。文档以JSP技术和B/S结构为核心,系统介绍了客户信息管理…

作者头像 李华
网站建设 2026/9/29 14:56:34

汽车传感器与执行器:从原理到系统的工程解读

1. 为什么汽车工程师都应该吃透这本教材聊到《Automotive Sensors and Actuators: Principles, Systems, and Electronics》这本书,我的第一反应不是"教材"两个字,而是一句话:传感器的数据质量,决定了控制算法的天花板。…

作者头像 李华
网站建设 2026/9/29 14:48:59

工业总线入门:从RS-485到Modbus RTU,一文理清选型与调试

刚入行那会儿,我在一个自动化仓库项目里做调试。柜子里密密麻麻的端子排,看起来像一堵由彩色电线砌成的墙,每根线对应一个传感器、一个电磁阀、一个限位开关。查个断线故障,经常要在几百根线里翻半天。后来项目改造,把…

作者头像 李华