1. 瓶颈到底在哪:把一次YOLO推理拆成CPU和NPU的三段时线
1.1 一次完整的YOLO推理包含哪三段,每段都落在哪个设备上
部署YOLO模型到带NPU的边缘设备上,最让我头疼的不是模型本身,而是明明板子算力够、内存也没炸,视频流帧率却一直上不去。把推理时间单独打点,NPU只用了36ms左右,可整个环节从图像采集到检测框画出来要54ms出头,多出来的十几毫秒全耗在预处理、后处理,以及CPU等NPU、NPU等CPU的间隙上。后来我做了一版多线程加速推理的编排,才把这段隐藏的浪费挤了出来。
先说清楚一次推理到底拆成哪三段。从视频源拿到的通常是一帧YUV或BGR排列的原始图像,第一步是预处理:做letterbox(保持宽高比缩放并补边到640x640)、BGR转RGB、HWC转CHW、除以255归一化,有些部署还要把FP32转成FP16。这段活本质上是大批量数据搬运和简单算术,没有任何智能成分,适合CPU做也必须在CPU做,因为在多数边缘设备上,NPU只负责算卷积,不直接接管图像解码和像素格式转换。
第二段才是NPU推理。张量喂给NPU,模型在NPU上跑,输出一个1xNxC的原始张量。以YOLOv8s为例,640x640输入,在常见的6TOPS左右算力NPU上,大概需要25-40ms。这个时间基本由模型结构、量化方式和NPU算力决定,单纯从部署层面能压的空间不大。
第三段是后处理。把原始张量解码成一堆候选框,包括坐标映射回原图、置信度过滤、类别预测、NMS去重,最后输出检测结果。这段有大量分支、排序和逻辑判断,放NPU上反而浪费,绝大多数项目都在CPU侧用代码实现。
这三段如果由一个主线程顺序执行,CPU在预处理时NPU闲置,NPU推理时CPU干等,后处理时NPU又闲着。每一段都真实需要时间,但系统吞吐却等于三段之和,硬件利用率自然上不去。
1.2 串行基线数据:等待时间比干活时间更费帧率
我先把一个朴素实现完整跑了一遍。某款带NPU的边缘板上,YOLOv8s,640x640输入,推理用厂商SDK的C接口,预处理用OpenCV,后处理自写解码+NMS。打点结果大概是:预处理8.4ms,NPU推理36.8ms,后处理9.7ms。三段相加54.9ms,和实测的55.1ms非常接近,换算下来约18.2 FPS。
单看这个数字,很多人的第一反应肯定是"模型太慢了",然后马上想换轻量模型、上INT8量化。这当然对,但细看会发现另一个问题:CPU两段总共占了18.1ms,NPU只用了36.8ms,整条链路里CPU和NPU的有效工作占比其实不低,但它们是串行错开的,谁干活另一个都在干等。如果能把CPU的18.1ms藏到NPU的36.8ms背后,帧率立刻就能从18 FPS跳到27 FPS左右。
这里的关键结论是:等待比干活更容易通过编排优化。NPU推理时间通常最难压缩,但CPU侧时间完全可以通过多线程流水线藏掉。标题里说的"多线程加速推理",核心就是这个"藏"字——不是让每一段跑得更快,而是让三段互相重叠,把关键路径从"三段之和"压缩成"最长的那一段"。
2. 三线程流水线编排:让CPU和NPU不再互相等对方
2.1 流水线线程模型:生产者-消费者拆解
明白了瓶颈在哪,做法就顺理成章了:按三段式拆出三个线程,组成一条三级流水线。
- 线程A(预处理线程):等一帧原始图像进来,做letterbox、格式转换、归一化,把数据写进预先分配好的输入缓冲,然后通过一个有界队列把该帧的缓冲索引交给线程B。
- 线程B(推理线程):从队列拿到缓冲索引,调用NPU推理接口,等结果返回,再把输出缓冲索引交给线程C。这个线程真正的CPU计算很少,大部分时间阻塞在NPU完成回调上,但必须单独开,因为厂商SDK的推理调用往往是同步阻塞式的。
- 线程C(后处理线程):拿输出张量,解码坐标、滤框、NMS,把最终检测结果发给上层业务。
三个线程之间用两个有界队列连接,队列里放的不是图像数据本身,而是缓冲区槽位的编号。数据不搬来搬去,只是在槽位的状态标记之间流转。这样跑起来,单帧的端到端延迟在第一帧时确实会略变大一点,因为流水线需要填满,但稳态吞吐立刻按最长段而非三段之和来算。
我写了个简化示意,方便理解槽位状态机的流转:
constexpr int kPoolSize = 4; struct Slot { std::vector<uint8_t> input_data; // 预处理结果 std::vector<float> output_data; // NPU 输出原始张量 std::atomic<bool> ready_for_infer{false}; std::atomic<bool> ready_for_post{false}; }; // 线程A:预处理 void ThreadPreprocess(BlockingQueue<int>& infer_queue) { while (running) { int slot = pool.AcquirePreprocessSlot(); Preprocess(cur_frame, pool.slots[slot].input_data); pool.slots[slot].ready_for_infer = true; infer_queue.Push(slot); } } // 线程B:NPU推理 void ThreadInfer(BlockingQueue<int>& post_queue) { while (running) { int slot = infer_queue.Pop(); RunNpuInference(pool.slots[slot].input_data, pool.slots[slot].output_data); pool.slots[slot].ready_for_post = true; post_queue.Push(slot); } } // 线程C:后处理 void ThreadPostprocess(BlockingQueue<int>& free_queue) { while (running) { int slot = post_queue.Pop(); DecodeAndNms(pool.slots[slot].output_data, results); pool.slots[slot].ready_for_infer = false; free_queue.Push(slot); } }线程B和C的逻辑就是对称地取槽位、做自己的事、标记下一个状态。队列本身用互斥锁加条件变量就够,因为真正频繁传递的是槽位编号这种int,数据本身没有在线程间拷贝。槽位生命周期是:预处理空闲到预处理完成,再到推理完成,再到后处理完成,最后回到预处理空闲。阻塞队列的底层实现不复杂,标准库的condition_variable足够应付这类场景。
2.2 多路输入怎么办:每路一个任务队列还是共享线程池?
上面说的是单路视频流。很多人做多路视频流时会犯同一个错误:开很多线程,每个线程自己完整串行一遍预处理到后处理,相当于把单路的问题复制好几份。这样确实能多路并行,但CPU侧会互相抢核,而且每路都要预分配一整套缓冲,内存压力大,线程调度开销也白白增加。
更稳的做法是:所有视频流的原始帧都进同一个预处理线程,预处理线程不管你是哪一路,只把输入缓冲准备好,在结构体里带上路序号;推理线程仍然保持单线程,因为一片NPU通常同一时间只能执行一个模型实例,同时发起多个推理调用并不会变快,反而增加SDK内部的排队开销;后处理线程按路序号把结果分发给不同的回调或队列。对2-4路YOLOv8s级别的视频流,这个结构非常舒服,CPU占用依然可控,内存也只需要按最大并发路数准备若干套槽位。
要注意的是,多路输入时预处理线程要做的活会成倍增加。如果CPU核数充足,把预处理线程按路数拆成两个也是合理的,但前提是NPU推理时间仍然是瓶颈。判断依据很简单:看预处理线程是否长期处于满负荷状态,CPU占用是否接近100%。如果预处理队列总是空的,说明它跟得上;如果队列经常堆满帧,说明预处理已经喂不饱NPU了,这时才需要加线程。
3. 内存池与双缓冲:NPU内存不能想申请就申请
3.1 内存池的设计:预分配加槽位状态机
标题里说"充分利用CPU、NPU内存",说实话,这句话真正落地的关键不在"省内存",而在"别反复申请和释放内存"。嵌入式环境里,NPU的运行时内存往往连接着CPU并不直接管理的物理内存池,厂商SDK通常提供独立的分配接口,而不是让你直接malloc一个数组传进去。每次推理都调分配、推理、释放,时间上会额外消耗0.5-2ms,还可能让NPU内存池越用越碎,碎片化到后面甚至会出现分配失败。
我的做法是启动阶段一次性算清楚最大资源需求。以YOLOv8s为例,输入640x640x3,如果SDK要求FP32输入就是约4.9MB,FP16约2.4MB;输出张量来自三个尺度特征图,加起来大约55万多个float,也就是2.2MB。按4个槽位准备,每槽一份输入和一份输出,再加上队列缓冲和结果结构体,总共也就二十几MB。整个运行期间不再做任何动态申请。
每个槽位其实就是CPU可写、NPU可读的一片连续内存。为了保证能和NPU的DMA引擎配合,分配出来的内存还要做对齐。不同平台要求不一样,常见的是64字节或128字节对齐,有些平台要求2MB大页才算真正物理连续。这一条务必去翻SDK文档,做错的话要么推理直接失败,要么性能悄悄掉一截,排查时非常难受。
3.2 解锁零拷贝的隐藏条件:连续内存与缓存同步
内存池的最大价值是能尝试"零拷贝"路径。很多NPU SDK支持把一块CPU侧分配的连续内存注册给NPU,推理时由NPU直接读这块内存,而不是先把数据拷贝到NPU私有的内存区。省掉一次拷贝,对小图可能看不出什么,但640x640x3的数据量,反复拷贝一次就是几十到几百微秒,在流水线里属于白白丢掉的利润。
零拷贝能成立有三个前提,缺一个都跑不起来。
第一,内存必须是物理连续的。虚拟地址连续骗不过DMA引擎,这就是为什么要用厂商SDK的专用分配接口,而不是普通malloc。
第二,内存要对齐。各平台要求不同,常见的就是64字节、128字节,有的要求2MB大页。SDK初始化的时候会有相关配置项,一定要仔细看。
第三,CPU写完后要做cache flush,NPU写完后要做cache invalidate。这一步漏了会出现"偶发错检"这种最恶心的bug,因为NPU读到的可能是CPU cache里还没落盘的旧数据,或者CPU读到的NPU输出其实是cache里的过期副本。
我踩过最狠的一次就是没做cache同步。表现很魔性:跑一两个小时没问题,一旦系统负载上来,突然连续几帧检测结果全乱,过几秒又恢复。后来翻SDK的API列表才发现有个专门的同步接口,加上之后连续跑了三天没出过一次错。所以内存池不是发一个堆就完事,配套的同步逻辑才是隐藏考点。
4. 实测数据:四种编排方式在同一块板子上的帧率对比
4.1 四组实测数据
我不喜欢嘴上说快了多少,直接上我在项目里记录的实测表。同一块板子、同一个模型权重、同一段1080p测试视频,只改线程编排和内存策略,跑60秒统计平均帧率:
| 方案 | 预处理 | NPU推理 | 后处理 | 稳态吞吐 | 说明 |
|---|---|---|---|---|---|
| A 单线程串行 | 8.4ms | 36.8ms | 9.7ms | 18.2 FPS | 基准 |
| B 预处理与推理两级流水线 | 隐藏在推理后 | 36.8ms | 9.7ms | 21.4 FPS | 后处理仍卡关键路径 |
| C 三级流水线加内存池 | 已隐藏 | 36.8ms | 已隐藏 | 26.3 FPS | 关键路径只剩推理加少量同步 |
| D 三级流水线加后处理优化 | 已隐藏 | 36.8ms | 已隐藏 | 27.1 FPS | 后处理时间减半后接近极限 |
两个细节值得留意。B方案只把预处理藏起来,收益并不大,因为后处理的9.7ms还暴露在关键路径上。C方案把两段CPU活都隐藏后,稳态帧率立刻跳到26.3 FPS,相比基准几乎提高44%。D方案顺手把后处理从9.7ms优化到4.8ms后,帧率只提升了0.8 FPS,因为此时瓶颈已经在NPU推理加上少量同步开销,CPU侧无论多快都不会让总吞吐有明显变化。
4.2 数据背后的木桶切换逻辑
四组数据放在一起,能提炼出一个特别实用的判断方法:帧率上不去时,先逐个打点三段各自的耗时,然后问自己,哪一段的时间仍然暴露在关键路径上。如果预处理和后处理已经并行于推理,它们就不该出现在周期公式里;如果确实不在公式里了,但实际帧率还是上不去,那就去看同步开销和队列是否堆积。
木桶是会切换的。当CPU侧两段被藏起来,木桶变成NPU推理本身;但如果换一个推理只要15ms的更轻模型,木桶会一下子切回CPU预处理或后处理,这时反而要回头优化NMS或改用硬件缩放模块。很多人做完三级流水线后说"没提升",多半是原模型本来就很小,CPU侧时间才是真瓶颈,这种场景下流水线依然有效,只是提升幅度没有推理时间长的模型那么夸张。
从数据还能看出,D方案的收益接近极限,想再往上走就得动模型本身了。这也是我经常给同事的建议:部署优化做到一个量级之后,如果还需要更高帧率,就该回模型侧去谈剪枝、蒸馏或量化,而不是继续压部署的边角料。
5. 后处理优化与线程粒度微调:挤出最后几毫秒
5.1 letterbox与NMS的优化空间
流水线搭完之后,我的目光又回到了预处理和后处理上。因为这些CPU侧时间虽然被藏进了流水线,但稳态吞吐仍然受限于三段里的最大者。当NPU推理是瓶颈时,CPU侧优化看起来可有可无;可一旦换成轻量模型或INT8量化后的模型,CPU侧时间立刻变成新瓶颈。所以CPU侧优化早晚要做,不如提前做。
预处理里最容易被优化的是letterbox。传统写法是先resize再copyMakeBorder补边,等于做了两次数据搬运。改成单次仿射变换直接输出640x640的图,OpenCV的warpAffine会同时完成缩放和补边,我实测从4.2ms降到2.6ms,代价只是边界像素的插值细节略有差别,对检测精度影响很小。如果平台有专门的图像缩放硬件模块,这一步还能更激进地卸载。
后处理里的NMS是另一个大头。常规两层循环写法,在目标多的时候能飙到8-10ms。我试过两个改进。第一个是把得分低于0.25的框在解码阶段直接丢掉,不参与后续IoU计算,因为绝大多数位置在解码后得分都很低,提前删掉能大幅缩减NMS的输入规模。第二个是按类别分桶做NMS,而不是所有类别混在一起两两比较,类别之间的框本来就不会互相抑制,混着算纯属白费算力。两个改完,最坏情况从9.7ms降到4.8ms,平均值只有3.5ms左右。
5.2 线程粒度微调:要不要为后处理拆两个线程?
有的部署框架喜欢把后处理继续拆成"解码线程"和"NMS线程",理论上能让后处理内部也流水线化。但我的实测结果是:解码1.5ms,NMS 2.0ms,分开成两个线程后,因为线程切换和同步开销,总时长反而多了0.3ms。
线程粒度不是越细越好。只有当单个子任务明显大于1ms级别的切换成本时,才值得拆。2ms左右的任务拆成两段,基本是负优化。反过来,如果你做的是目标极多、NMS要跑5ms以上的极端场景,那NMS内部用OpenMP并行或SIMD优化,收益会比拆线程更直接。
同样道理,预处理线程在某些平台上也可以用像素格式的并行化来减时间。比如把BGR转RGB和归一化合成一个循环,让编译器自动向量化,比分开做快不少。这类微优化单独看都是零点几毫秒,但放到流水线里,它们会降低CPU侧的占用率,给上层业务留出余量,长期看价值不小。
6. 踩坑笔记:线程数、缓存一致性、队列深度的经验教训
6.1 线程数不是越多越好
我最初做多线程加速时,看到板子有4个CPU核,就习惯性地开了4个预处理线程。结果帧率不仅没涨,反而从26.3 FPS掉到了24.1 FPS。CPU四个核全在忙,但NPU一直等任务排队混乱。
原因不复杂:预处理只要1个线程就足够喂饱NPU了,因为预处理8ms、NPU推理37ms,一个生产者线程的产量远超NPU消费量。多余的线程都在做无用功,还和主线程抢CPU缓存和内存带宽。正确的卡点意识是:你需要多少个生产者线程,取决于消费速度,而不是CPU核数。单路视频流场景,预处理和后处理各1个线程通常最省心。
如果确实要做多路,也应优先考虑共享线程池而不是每路独立线程。我给四路视频流做的一个版本里,预处理线程是1个,推理线程是1个,后处理线程是2个,因为当时后处理的帧率跟不上,两个后处理线程各处理一部分结果,队列才没有堆积。这个比例完全是靠打点压出来的,不是拍脑袋定的。
6.2 cache flush也要算进成本里
前面提到零拷贝要处理cache一致性,但很多人会忽略一点:cache flush本身也是耗时操作。我对一块4MB的输入缓冲做一次完整flush大约要1ms,如果每次推理都flush整块内存,流水线的利润会被吃掉一小半。
优化的办法是只flush真正写入的区域。比如letterbox补边只写了640x640x3约1.2MB的区域,那就只flush那一段,而不是整个4MB。NPU输出读回来之前,也只需要invalidate输出张量所在的区间,用不着碰别的地方。这个细节能让每帧省下0.3-0.5ms,积少成多,在低帧率场景里可能就是5%的提升。
还有一点容易踩:如果平台支持异步NPU推理,需要确认回调是不是在驱动线程里触发的。有的SDK在回调里直接做内存同步会把调用线程阻塞住,这时记得把后处理逻辑丢回自己的线程池,避免回调线程卡住导致后续推理请求无法下发。
6.3 队列深度怎么选
缓冲槽位数(也就是队列深度)要匹配三段耗时比例。我按4个槽位跑得最稳:预处理8ms、推理37ms、后处理5ms,4个槽位足够让三个线程在绝大多数时间不互相阻塞。但如果你把模型换成推理只要12ms的轻量模型,4个槽位有时会让队列里堆满等待处理的帧,实测延迟会飘,这时候把槽位数降到3个,反而让端到端延迟更稳。
原则很简单:缓冲区数量只要能覆盖最大段的耗时倍数就够,多了并不能提高吞吐,只会增加延迟和内存占用。换句话说,槽位数不是性能指标,而是延迟与吞吐之间的一个旋钮。做实时交互类应用,槽位宁少勿多;做离线批量分析,多几个槽位反而能平滑峰值负载。
最后再说说我现在的成品状态:这一套三级流水线加内存池加双缓冲的编排,在我手头的边缘设备上稳定跑了两周,YOLOv8s单路视频流,27 FPS左右,CPU占用大约1.5个核,NPU基本满载。下一步我准备扩展到四路视频流,每路仍然走同一个预处理线程和同一个推理线程,只是给每路加上独立的槽位队列,目标大概在平均每路25 FPS的水平。如果你也在折腾YOLO部署,建议先别急着换模型,拿打点工具把三段耗时数字亮出来,再决定到底该优化哪一段。