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的边缘板上YOLOv8s640x640输入推理用厂商SDK的C接口预处理用OpenCV后处理自写解码NMS。打点结果大概是预处理8.4msNPU推理36.8ms后处理9.7ms。三段相加54.9ms和实测的55.1ms非常接近换算下来约18.2 FPS。单看这个数字很多人的第一反应肯定是模型太慢了然后马上想换轻量模型、上INT8量化。这当然对但细看会发现另一个问题CPU两段总共占了18.1msNPU只用了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::vectoruint8_t input_data; // 预处理结果 std::vectorfloat output_data; // NPU 输出原始张量 std::atomicbool ready_for_infer{false}; std::atomicbool ready_for_post{false}; }; // 线程A预处理 void ThreadPreprocess(BlockingQueueint 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); } } // 线程BNPU推理 void ThreadInfer(BlockingQueueint 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(BlockingQueueint 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.9MBFP16约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 flushNPU写完后要做cache invalidate。这一步漏了会出现偶发错检这种最恶心的bug因为NPU读到的可能是CPU cache里还没落盘的旧数据或者CPU读到的NPU输出其实是cache里的过期副本。我踩过最狠的一次就是没做cache同步。表现很魔性跑一两个小时没问题一旦系统负载上来突然连续几帧检测结果全乱过几秒又恢复。后来翻SDK的API列表才发现有个专门的同步接口加上之后连续跑了三天没出过一次错。所以内存池不是发一个堆就完事配套的同步逻辑才是隐藏考点。4. 实测数据四种编排方式在同一块板子上的帧率对比4.1 四组实测数据我不喜欢嘴上说快了多少直接上我在项目里记录的实测表。同一块板子、同一个模型权重、同一段1080p测试视频只改线程编排和内存策略跑60秒统计平均帧率方案预处理NPU推理后处理稳态吞吐说明A 单线程串行8.4ms36.8ms9.7ms18.2 FPS基准B 预处理与推理两级流水线隐藏在推理后36.8ms9.7ms21.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.5msNMS 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、后处理5ms4个槽位足够让三个线程在绝大多数时间不互相阻塞。但如果你把模型换成推理只要12ms的轻量模型4个槽位有时会让队列里堆满等待处理的帧实测延迟会飘这时候把槽位数降到3个反而让端到端延迟更稳。原则很简单缓冲区数量只要能覆盖最大段的耗时倍数就够多了并不能提高吞吐只会增加延迟和内存占用。换句话说槽位数不是性能指标而是延迟与吞吐之间的一个旋钮。做实时交互类应用槽位宁少勿多做离线批量分析多几个槽位反而能平滑峰值负载。最后再说说我现在的成品状态这一套三级流水线加内存池加双缓冲的编排在我手头的边缘设备上稳定跑了两周YOLOv8s单路视频流27 FPS左右CPU占用大约1.5个核NPU基本满载。下一步我准备扩展到四路视频流每路仍然走同一个预处理线程和同一个推理线程只是给每路加上独立的槽位队列目标大概在平均每路25 FPS的水平。如果你也在折腾YOLO部署建议先别急着换模型拿打点工具把三段耗时数字亮出来再决定到底该优化哪一段。