1. 从“一路视频,多个模型”说起
搞嵌入式AI的朋友应该都有这种感觉:RK3588这颗芯片,在边缘端真算得上“六边形战士”。8核CPU、Mali-G610 GPU、6TOPS算力的NPU,还有完善的ISP和硬件编解码单元,一颗SoC几乎能把视觉相关的活儿全包了。但资源丰富是一回事,真正把资源用起来、用出效率,又是另一回事。
我最近在做的项目,正好卡在这个点上:一块RK3588板子上,接了一路RTSP摄像头,同时又接了USB摄像头和本地视频文件作为输入源。业务侧的需求是,同一路视频流要同时跑yolov8检测、车辆颜色分类、还有一个人形分割模型。如果按常规做法——每个模型开一个独立线程、各拉各的流、各占各的NPU内存——用不了多久板子就会卡死或者NPU OOM。于是就有了这篇文章的核心主题:RK3588边缘AI视觉中的“同源多任务调度”。
所谓“同源”,指的是多个AI推理任务共享同一个或同一组视频输入源,在这个前提下做统一的任务调度和NPU资源管理。这篇内容主要写给两类人:一类是把yolov8往RK3588上部署、但发现多模型同时跑就跑不动的开发者;另一类是正在做边缘视频监控、多路视觉业务集成的朋友。我会把我在实际项目中调通的方案、踩过的坑、排过的错,尽量系统地讲清楚,希望能让你少走几步弯路。
2. 整体设计与调度思路拆解
2.1 为什么需要“同源多任务”而不是“各自为战”
你可能会想:既然RK3588算力够强,每个任务独立拉流、独立推理不就行了?如果业务简单,比如就一路摄像头跑一个yolov8,确实可以这么干。但一旦任务变多,问题就来了。
首先是内存问题。RK3588的NPU内存通常在系统内存里划分,可用的CMA区域或者ION buffer是有限的。每个模型加载进去,都要占用固定的权重空间和输入输出buffer。yolov8s的权重大约50MB,分类模型小一点,分割模型又大一截。三个模型各加载一份,同时还要给推理session留足动态内存,内存直接就紧张了。
其次是编解码资源冲突。RK3588的VPU(视频编解码单元)能力有一定上限,当多路视频同时硬解码时,会有通道数的限制和多路抢占的问题。如果每个线程都从摄像头拉流、各自硬解,很容易出现“谁都能跑,但谁都跑不顺”的情况。
第三是CPU调度抖动。每个模型推理前的预处理(resize、归一化、颜色空间转换)都是CPU密集操作。三个线程同时预处理,系统平均负载飙升,可能导致推理任务没有得到及时调度,帧率波动剧烈。
同源多任务的核心价值,就是把这些重复的拉流、解码、预处理工作合并成一份,让不同模型共用同一份输入数据,再把NPU的推理请求统一排队、统一调度。数据只有一份,但可以喂给不同的模型;NPU只有一份,但可以通过任务队列合理分配,让每个模型都有稳定的推理机会。
2.2 调度器的三层结构设计
在设计“同源多任务调度”的时候,我参考了快递分拣中心的工作方式:视频流入库相当于快递进场,解码模块就是卸货口,每个模型就是不同的分拣线。卸下来的包裹放在传送带上,分拣线按需取件,而传送带的控制中心统一调配流量。
这里的核心调度结构分为三层:
第一层是数据生产层。负责统一拉取视频流、解码、抽帧,把图像帧放到一个循环缓冲区中。这个缓冲区是整个系统最关键的公共资源,所有模型共享同一份帧数据。缓冲区大小需要根据推理速度和业务实时性要求来设计,我后面会详细讲参数选择。
第二层是任务管理层。维护一个“推理请求队列”,每个模型根据自己的运行频率和当前负载,向队列提交推理请求。请求中包含模型ID、帧ID、优先级、期望的最晚推理完成时间。调度器根据优先级和截止时间,决定先处理哪个请求。
第三层是NPU执行层。RK3588的NPU支持同步和异步两种推理模式。同步模式简单但串行,异步模式可以重叠预处理和推理,吞吐量更高。执行层把队列中的请求一次一个(或分批)交给NPU执行,并在推理完成后把结果返回给对应的业务线程。
这三层各司其职,数据生产层解决“数据重复”的问题,任务管理层解决“如何分配”的问题,NPU执行层解决“效率最大化”的问题。
2.3 选型对比:为什么不直接用多线程 + 同步推理
最开始我的想法很简单:每个模型一个std::thread,都在主循环里同步调用RKNN的rknn_run,不就行了?但实际跑起来之后,发现三个问题。
第一个是排队严重。NPU的rknn_run是串行执行的,三个线程同时调用,底层会阻塞等待。由于NPU执行时间不等(yolov8s在RK3588上大约30-50ms,分类模型5-10ms,分割模型80-100ms),如果分割模型先占用了NPU,检测模型的实时性就完全没法保证。
第二个是帧数据拷贝开销。每个模型虽然共享同一个视频源,但如果各写各的预处理逻辑,每个模型都需要一份RGB数据。如果输入源是1080p,一帧RGB888就是约6MB,三个模型同时拷贝就是18MB的搬运量,这还不算在NPU输入格式转换上的CPU计算开销。数据显示,在RK3588上1080p图像从NV12转RGB并做letterbox,大约要耗费15-25ms的CPU时间——三个模型各自做一遍,CPU就没时间干别的了。
第三个是优先级完全不可控。检测模型需要实时响应,分类模型可以稍慢一点,分割模型即使延迟200ms也没关系。但多线程同步推理没有“优先级”的概念,全是操作系统的线程调度说了算,这显然不符合实际业务需求。
所以最终我放弃了“各跑各的”方案,改成共享输入源、集中调度、异步执行的架构。这个改造直接解决了NPU冲突和CPU超额开销的问题,帧率稳定性也从“忽高忽低”变成了“平平稳稳”。
3. 核心实现细节与调度策略
3.1 循环缓冲区:多模型共享帧数据的关键
循环缓冲区是整个调度系统的心脏。它存储的是经过解码后、尚未做模型特定预处理的原始帧(NV12格式或BGR格式),以及对应的帧元信息(时间戳、帧序号、来源通道ID等)。
缓冲区的大小直接决定了系统的延迟和丢帧策略。如果缓冲区太大,视频源产生的帧堆积过多,业务取到的帧就是“过时”的帧,实时性变差;如果太小,模型处理不过来时就会频繁丢帧,漏掉目标出现的瞬间。
我的经验值是:缓冲区深度 = 最大单模型推理耗时 / 视频帧间隔 × 1.5。比如1080p@25fps的摄像头,帧间隔是40ms。假设最慢的分割模型推理耗时100ms,那么缓冲区深度 = 100/40 × 1.5 ≈ 4帧。预留1.5倍是为了应对瞬时抖动,避免缓冲区直接打满导致生产者阻塞。
这里有一个取舍细节:缓冲区深度的“帧”是整个视频帧的引用计数,不是拷贝。也就是说,每个模型从缓冲区取帧时,并不是复制一份完整的图像数据,而是增加一次引用计数,等到所有模型都用完了这帧,才真正释放内存。这样,无论有多少个模型共享同一帧,内存中只有一份图像数据。我在实现时用了一个简单的shared_ptr + 引用计数器,实测在1080p分辨率下,3个模型同时取帧,内存占用几乎没有额外增长。
3.2 任务队列与调度策略:时间片轮转还是优先级抢占
任务队列的设计是整个系统的调度中枢。这个队列中的每个元素代表一次具体的推理请求。请求的结构体大致如下:
struct InferenceRequest { int model_id; // 哪个模型来执行推理 uint64_t frame_id; // 对应哪一帧视频数据 int priority; // 0-255,数值越高优先级越高 uint64_t deadline_us; // 截止时间,超过这个时间结果就没意义了 void* input_data; // 输入数据指针(已由调用方完成预处理) RKNN_OUTPUT* output; // 推理结果存放位置 };调度策略方面,我对比过两种方案。
第一是固定优先级抢占:每个模型分配一个固定优先级,调度器始终执行优先级最高的请求。这个方案实现简单,但坏处是低优先级任务可能长期得不到执行,出现“饿死”现象。比如检测模型的优先级高,一直有请求进来,分割模型就永远排不上队。
第二是最早截止时间优先:调度器从请求队列中取 deadline_us 最小的请求优先执行。这个方案的好处是每个模型都能根据自己的最大容忍延迟设置截止时间,长期得不到执行的任务会因为截止时间逼近而获得更高调度权重。
我最终采用的是“优先级 + 截止时间”的混合策略。在队列头部,先按优先级排序,优先级相同的请求内部再按截止时间排序。同时设置一个上限:如果某个请求的 deadline_us 距离当前时间小于某个阈值(比如20ms),则“强制插队”,即使它的优先级不是最高。这样可以避免极端情况下的饿死问题。
具体来说,RK3588上的yolov8检测设置了最高优先级,因为检测结果要驱动后续业务逻辑;车辆分类稍微低一些,因为它可以在检测框确定之后再快速执行;分割模型的最低,只要保证每秒2-3次推理即可。
3.3 RKNN推理的异步模式:把等待时间变成处理时间
在RK3588上跑RKNN模型,官方提供了两种接口:同步模式(rknn_run)和异步模式(rknn_run_async)。
同步模式好理解,调用rknn_run之后线程阻塞,等待NN输出。这个模式编码简单,但CPU在等待期间完全闲着,非常浪费。
异步模式则不同,rknn_run_async提交任务后立即返回,系统内部会把推理任务交给NPU执行,CPU线程可以去做别的事情(比如下一个模型的预处理)。当NPU执行完成后,再通过查询或回调方式获取结果。
在“同源多任务调度”这个场景下,异步模式几乎是必须的。因为多个模型共享一个NPU,如果第一个模型用同步模式老老实实等NPU跑完,第二个模型即使已经预处理完毕,也只能排在后面干等。而异步模式下,第二个模型可以在第一个模型NPU执行的这段时间里继续做自己的预处理,把CPU的空闲时间利用起来。
我的实现方式是这样的:
// 执行循环,运行在独立的推理线程中 while (running) { auto req = scheduler->dequeue(); // 获取最高优先级请求 if (!req) { std::this_thread::sleep_for(std::chrono::milliseconds(2)); continue; } // 异步提交推理 rknn_run_async(ctx, &req->input, &req->output); // 不等NPU,先处理后续的预处理任务 auto preprocess_task = scheduler->prepare_next(); if (preprocess_task) { do_preprocess(preprocess_task->frame, preprocess_task->dst); } // 等待NPU完成 rknn_wait(ctx, &req->output); // 把结果通知对应模型线程 notify_complete(req->model_id, req->frame_id, req->output); }这个循环的精髓在于:把NPU执行时间和CPU预处理时间重叠起来。在等待NPU完成的同时,当前线程先去做下一个请求的预处理,等NPU跑完了再回来取结果。实测下来,综合吞吐量比同步模式提升了30%左右。
3.4 多路输入源的管理与时间戳同步
在RK3588上接多个输入源是一个很常见但不简单的需求。我在项目中同时接了RTSP网络相机、USB摄像头和一个本地视频文件。三种输入源的帧率、分辨率、时间基准都不一样,如果直接混在一起,调度器的“帧”概念就会混乱。
RTSP相机我用的是FFmpeg硬解码(RK3588的VPU支持H264/H265硬解),USB摄像头走V4L2读取YUYV格式,再转成NV12;本地视频文件也用FFmpeg解出来。三路源的解码线程共享同一个“数字时钟”,这个时钟以RTSP相机的PTS(显示时间戳)为主基准,其他路源在数据到达时与主时钟对齐,打上统一的frame_id。
frame_id采用32位递增计数器,从0到UINT32_MAX循环。调度器看到frame_id,就能知道哪一帧先到、哪一帧后到,即使不同源的帧率不同,也能保证模型拿到的输入帧顺序一致,不会出现“检测的是第100帧,分类的是第101帧”的错位情况。
4. 实操过程:在RK3588上一步步实现和验证
4.1 环境与基础配置
这块内容需要你手头有一块RK3588开发板,我这边用的是正点原子的RK3588开发板,系统是Debian 11,内核版本5.10。NPU驱动和RKNN Toolkit版本要匹配,我用的是rknn-toolkit2 1.5.2版本配套的librknnrt.so。
环境准备的关键步骤:
- 确认NPU驱动正确加载:执行
ls /dev/rknpu,如果输出rknpu设备节点,说明驱动正常。 - 确认librknnrt版本:
strings /usr/lib/librknnrt.so | grep version,确认版本与你的RKNN模型匹配。版本不匹配经常导致推理报错或结果不对。 - 设置NPU工作模式:RK3588的NPU支持0(低功耗)、1(平衡)、2(性能)三种模式,在业务代码里可以通过
rknn_set_npu_core_mask或系统节点/sys/kernel/debug/rknpu/freq调整。我的项目因为要长时间稳定跑,设在模式1,NPU频率默认,温控和性能平衡得比较好。如果只求性能,可以切到模式2,但要注意散热。
说到散热,这里顺手提一句我在RK3588上的经验:性能模式下NPU长时间满载,核心温度会快速上升到85℃以上,板载风扇会全速转。板子上的PWM风扇转速可以通过读取/sys/class/hwmon/hwmon*/fan1_input这种节点拿到数值。我做个一个简单的监控告警:当风扇转速持续超过5000RPM且温度超过75℃时,主动降低非关键模型(分割模型)的推理频率,让算力回归到主要任务上。这个策略对设备长期稳定运行很有帮助。
4.2 模型转换:yolov8和分割模型部署到RK3588
模型转换是RKNN部署里最容易出幺蛾子的环节。yolov8官方是PyTorch格式,要跑在RK3588上必须先转成ONNX,再通过RKNN Toolkit转换为RKNN格式。
转换流程如下:
第一步,导出ONNX模型。用yolov8官方仓库的export.py脚本,导出时加上--opset 12参数,并且要固定输入尺寸为640x640。不要用动态shape,RKNN对动态shape支持不好。
python export.py --weights yolov8s.pt --include onnx --opset 12 --imgsz 640第二步,用RKNN Toolkit写转换脚本。以下是我在项目中使用的转换配置:
from rknn.api import RKNN rknn = RKNN() rknn.config( mean_values=[[0, 0, 0]], std_values=[[255, 255, 255]], target_platform='rk3588' ) # 加载ONNX模型 ret = rknn.load_onnx(model='./yolov8s.onnx') assert ret == 0, "load_onnx failed" # 配置量化:yolov8s我用int8量化,精度损失在2%以内 ret = rknn.build( do_quantization=True, dataset='./dataset.txt', # 量化校准图片列表 pre_compile=False ) assert ret == 0, "build failed" # 导出RKNN ret = rknn.export_rknn('./yolov8s.rknn') assert ret == 0, "export failed"这个过程有两个容易踩的坑。
第一个是量化校准数据集。dataset.txt里要准备好几十张覆盖不同场景的图片(光照变化、目标大小变化),必须是模型训练时类似的分布。我一开始图省事只放了10张图片,量化后的模型在夜间场景下漏检严重,后来扩展到100张各类场景的图片,问题明显改善。
第二个是某些算子兼容性问题。yolov8的检测头中包含一些自定义算子(如DFL结构),RKNN在转换时可能不完全支持,需要手动改模型结构或替换算子。我用的是yolov8官方仓库导出,配合rknn-toolkit2 1.5.2,不存在算子问题。但如果你用的是其他框架或改动过的网络结构,转换时就要留意报错信息,必要时需要把不支持的算子抽出来改用CPU实现。
分割模型我用的是一个轻量级的人形分割模型(基于PP-HumanSeg的TensorFlow版本),转ONNX后转RKNN的流程与yolov8类似,唯一区别是输入尺寸是192x192,量化比特为int8。分类模型体积更小,用的是ResNet18的timm版本,输入224x224,转换零压力。
4.3 调度器代码核心实现
调度器的代码不算太长,但几个关键细节都集中在这里。我先把核心部分放出来,再逐个解释。
class TaskScheduler { public: explicit TaskScheduler(size_t queue_size) : capacity(queue_size) {} bool enqueue(InferenceRequest&& req) { std::lock_guard<std::mutex> lock(mtx); if (req_queue.size() >= capacity) { return false; // 队列满,拒绝请求 } req_queue.push_back(std::move(req)); cv.notify_one(); return true; } InferenceRequest dequeue() { std::unique_lock<std::mutex> lock(mtx); cv.wait(lock, [this] { return !req_queue.empty() || stop_flag; }); if (req_queue.empty() && stop_flag) { return {}; // 返回空对象作为终止信号 } // 找到最高优先级的请求 auto it = std::max_element( req_queue.begin(), req_queue.end(), [](const InferenceRequest& a, const InferenceRequest& b) { if (a.priority != b.priority) return a.priority < b.priority; return a.deadline_us > b.deadline_us; } ); InferenceRequest req = std::move(*it); req_queue.erase(it); return req; } // 判断是否可以立即执行(用于异步模式下的预处理准备) bool has_next() { std::lock_guard<std::mutex> lock(mtx); return !req_queue.empty(); } private: std::mutex mtx; std::condition_variable cv; std::vector<InferenceRequest> req_queue; size_t capacity; bool stop_flag = false; };这段代码有两个细节值得说明。
第一,为什么用std::vector而不是std::priority_queue。因为在实际实现中,排队条件不只是优先级,还有截止时间的逼近(强制插队逻辑),以及在队内移除某个特定请求的需求(比如某帧数据在其他模型处理中失效)。std::priority_queue不支持遍历和删除特定元素,而业务逻辑中经常需要单独处理某个请求,所以用vector做容器,每次取max_element即可。队列长度一般不超过十几项,遍历开销可以忽略。
第二,dequeue返回后,调用方(推理线程)就拿到了最高优先级的请求。但如果CPU此刻需要做预处理,而NPU又在忙,怎么处理?我的做法是:推理线程先调用rknn_run_async提交请求,然后在NPU执行期间,从调度器再拿一批请求做预处理(调用预处理函数,不调用推理)。这样可以充分利用NPU等待时间。具体实现代码在上文3.3节已给出。
4.4 多模型加载与NPU内存分配策略
在调度器跑起来之前,各个模型的RKNN context要先创建好。RK3588的NPU内存分配,有个值得注意的点:每个模型创建独立的context,但NPU内存池是共享的。
我在项目中先加载yolov8s,再加载分类模型,最后加载分割模型。加载顺序会影响内存碎片——大模型先加载,再加载小模型,内存利用率最高。如果先加载了分割模型(占内存大),再加载yolov8(也大),容易出现NPU内存分配失败。
每个模型的context用rknn_init创建,创建时指定RKNN_FLAG_PRIOR_HIGH之类的标志可以设置NPU优先级。我的分配策略是:yolov8s优先级最高(RKNN_FLAG_PRIOR_HIGH),分类模型中等(不设或RKNN_FLAG_PRIOR_MEDIUM),分割模型最低(RKNN_FLAG_PRIOR_LOW)。这样即使调度器队列为空、模型自行调用rknn_run,也不会出现低优先级模型长期霸占NPU的情况。
加载完成后,每个模型需要预先分配输入输出buffer。RKNN提供了rknn_create_mem来分配内部连续内存,建议每个模型都只分配一次,不要在推理循环中反复创建释放。反复创建释放会导致内存碎片化,长时间运行后NPU分配失败的概率会大幅上升。
4.5 各任务结果回传与业务集成
调度器处理完一个请求后,需要把结果高效地回传给对应的业务模块。我的实现方式是每个模型维护一个独立的结果队列,推理线程完成NPU执行后,把解析好的结果(检测框数组、分类标签、分割掩码)放入对应模型的队列中,由业务线程按需取用。
在解析yolov8输出时,我遇到一个和RK3588强相关的问题:RKNN的输出数据排列。Yolov8的检测头输出是一个 (1, 84, 8400) 的tensor,8400是三个尺度(80x80+40x40+20x20)的总预测框数。但RKNN在int8量化后,输出数据可能与PyTorch里的顺序略有不同,需要做一次维度重排,否则解析出来的框坐标全是乱的。
我的解析策略是这样的:
void parse_yolov8_output(int8_t* output, float scale, int num_classes, int img_w, int img_h, float conf_thresh, std::vector<DetBox>& boxes) { // 重点是输出数据的排布:RKNN的int8输出是NHWC格式 // 这里把8400个候选框遍历一遍,筛选置信度高的框 for (int i = 0; i < 8400; ++i) { float conf = sigmoid(output[i * (4 + num_classes) + 4]); // 第一类的置信度 if (conf < conf_thresh) continue; // 反量化、解析box坐标(这里省略坐标解码细节) float cx = (output[i * (4 + num_classes) + 0] - zero_point) * scale; float cy = (output[i * (4 + num_classes) + 1] - zero_point) * scale; float w = (output[i * (4 + num_classes) + 2] - zero_point) * scale; float h = (output[i * (4 + num_classes) + 3] - zero_point) * scale; // 得到检测框 boxes.push_back({cx - w/2, cy - h/2, cx + w/2, cy + h/2, conf, 0}); } // 最后做NMS nms(boxes, 0.45); }分割模型的结果解析类似,但输出是一个(1, 2, 192, 192)的argmax结果,我直接按像素取最大概率类,生成一张二值掩码图,缩放回原图分辨率后用于后续的人形区域裁剪处理。
4.6 实测效果与性能数据
整套系统跑通之后,我对性能做了几组对比测试。测试条件是RK3588开发板,Debian 11,NPU模式1(平衡),风扇常转。视频源:1080p@25fps RTSP摄像头 + USB摄像头 + 本地1080p文件,三个模型同时共用视频帧。
测试数据如下:
| 测试场景 | 检测模型帧率 | 分类模型帧率 | 分割模型帧率 | CPU占用率 | 内存占用 |
|---|---|---|---|---|---|
| 单模型独立跑 | 25 fps | 25 fps | 15 fps | 60% | 1.2GB |
| 多线程独立跑(未调度) | 8 fps | 10 fps | 4 fps | 95% | 1.8GB,且偶有OOM |
| 同源调度(同步模式) | 18 fps | 18 fps | 6 fps | 70% | 1.4GB |
| 同源调度(异步模式) | 22 fps | 20 fps | 8 fps | 65% | 1.4GB |
可以看到,同源调度在稳定性上的提升是明显的。多线程独立跑时,所有模型的帧率都被拖到很低的水平,CPU几乎被打满,还时不时报NPU内存不足。切换成同源调度后,CPU占用大幅下降,检测模型的帧率从8fps提升到18fps以上,异步模式还能再进一步。
需要说明的是,分割模型帧率只有8fps,是因为分割模型的输入分辨率较小(192x192),但在RK3588上int8量化后的推理本来就要大约80-100ms,能达到8fps已经接近硬件极限。如果想提高分割模型帧率,可以降低分辨率或改用更轻量的模型,这个要根据业务需要来权衡。
5. 常见问题与排查技巧实录
5.1 NPU内存分配失败的问题
这是我在多模型加载阶段遇到的最典型问题。当三个模型依次加载时,有时在加载第三个模型时会出现rknn_init返回-1或类似“get memory failed”的错误。
排查过程大概是这样:
先用dmesg查看内核日志,确实看到了NPU内存申请失败的记录。然后用free -m查看系统内存,发现可用内存还有,但系统CMA区域已经满了。RK3588的NPU内存默认从Linux核的CMA区分配,CMA区域默认大小是256MB左右,而我的三个模型权重加输入输出buffer总共需要约200MB。
解决思路有几个方向:
- 修改内核启动参数,把CMA区域调大。在
/boot/uEnv.txt或对应引导文件中追加coherent_pool=8M cma=512M,重启后生效。这个方法可以一劳永逸地解决NPU内存不足的问题。 - 确认每个模型的NPU内存不要重复申请。比如某些模型如果输入尺寸固定,可以在初始化时就分配好,不要在推理时反复动态申请。
- 调整模型加载顺序。大模型先加载,小模型后加载,能显著减少内存碎片。踩过坑之后,我现在都会先打印每个模型
rknn_query获得的内存占用信息,再决定加载顺序。
5.2 推理结果错乱:RKNN输出格式的坑
yolov8s在RK3588上部署后,检测框位置完全是乱的(框和物体对不上,坐标严重偏大或偏小)。这个问题排查了很久,最后发现锚点不在模型结构,而在输出解析。
RKNN的int8量化输出的数据不是浮点数,而是int8整数,需要反量化换算成真实的浮点值。我当时解析时漏掉了反量化步骤,把int8数据直接当float用了,结果自然是全错。反量化公式是:
float_value = (int8_value - zero_point) * scale其中zero_point和scale可以在rknn_query(RKNN_QUERY_OUTPUT_ATTR)中查询到。
另外一个坑是int8量化的类别置信度偏移。yolov8的输出中置信度是sigmoid后的值,但在量化过程中,网络的输出层可能没经过sigmoid(量化模型为了精度保留线性输出),需要在解析时自己加一层sigmoid。这个细节在RKNN官方示例里有时不会特别强调,如果你发现置信度普遍偏高或偏低,可以检查一下这个点。
5.3 多模型同时推理导致的NPU性能下降
即使加了调度,有时仍然感觉NPU执行时间变长。yolov8s单独跑时推理时间约35ms,但三个模型一起跑时,yolov8s的推理时间会涨到45ms甚至50ms。
我排查这个问题的时候一度以为是调度器逻辑问题。后来用RKNN提供的profiling工具(在build模型时设置rknn.config(optimization_level=3)或通过环境变量开启NPU profiling)才发现,NPU在多模型并发时,会动态调整频点。当系统负载高、温度升高时,NPU频率会自动降频保护。
解决方法是:
- 控制NPU并发度。同时只有一个模型在NPU上执行,不要同时提交两个rknn_run_async。我的调度器天然满足这个条件,但如果你的代码里模型A和模型B各自独立提交推理,就可能出现并发,反而导致性能更差。
- 降低非关键模型的推理频率。比如分割模型本来要求5fps就够了,可以主动做“跳帧”,不要每个视频帧都提交分割任务,隔几帧提交一次。这样能减少NPU总负载,保持关键模型的速度。
- 优化散热,让温度稳定在75℃以下。温度过高导致降频是RK3588上很常见的问题,尤其在密闭的工控箱里使用时。这里又得提一下PWM风扇的重要性了——实测在风扇从3000RPM提升到6000RPM后,NPU频率能保持在高档位,推理时间稳定。读取风扇节点的方法我上文提到过,这也是我为什么建议在边缘设备上保留风扇转速读取和调速功能的原因。
5.4 多路RTSP拉流卡顿与延时问题
RK3588拉多路RTSP流时,经常出现画面卡顿、延时逐渐增大。排查后发现,问题主要集中在两个环节。
一是解码线程的CPU繁忙度。RK3588的VPU虽然能硬解H264,但FFmpeg的demux和packet拷贝仍然占用CPU。如果解码线程和预处理线程绑定在同一个CPU核,互相抢资源,就可能导致解码不及时。解决办法是设置线程亲和性(pthread_setaffinity_np),把解码线程绑定到CPU 4-5大核,把预处理线程绑定到CPU 6-7大核,避免争抢。
二是RTSP的接收buffer设置。有些网络摄像头码流波动大,如果FFmpeg内部的socket接收buffer太小,高码率段会丢包花屏。通过设置av_dict_set(&opts, "buffer_size", "2048000", 0)和av_dict_set(&opts, "rtsp_transport", "tcp", 0),可以有效缓解。TCP传输虽然延迟略高于UDP,但稳定性要好很多,在局域网边缘场景下我一般都推荐TCP。
5.5 常见问题速查表
| 现象 | 可能原因 | 解决办法 |
|---|---|---|
| rknn_init 失败 | NPU内存不足 / CMA区太小 | 调大cma到512M,调整模型加载顺序 |
| 推理结果坐标乱 | int8反量化遗漏或zero_point/scale设置错误 | 用rknn_query读取正确的zero_point和scale |
| 置信度普遍不靠谱 | 网络输出层未做sigmoid | 解析时手动加sigmoid |
| 多模型一起跑NPU变慢 | 温度过高降频 | 优化散热,控制并发,降低非关键模型频率 |
| RTSP拉流卡顿 | 解码线程和预处理线程抢CPU | 设置线程亲和性,大核分开放 |
| 长时间运行后NPU报错 | 内存碎片化 | 推理buffer只分配一次,不要在循环中反复创建 |
| 风扇转速不可调 | PWM风扇驱动未配置 | 确认 /sys/class/hwmon/hwmon*/pwm1 节点,设置手动模式 |
| 多模型结果帧不同步 | 各模型独立拉流 | 统一数据源,使用公共缓冲区 + frame_id |
6. 调度系统的扩展方向
这套“同源多任务调度”的方案,目前解决了三个模型共享输入源的问题。但实际业务往往比这个更复杂,我这里再聊聊几个扩展方向。
第一个方向是多路视频输入 + 分区域处理。如果一路摄像头画面里有多个感兴趣区域,可以对不同区域跑不同的模型。比如在园区监控场景,画面左半区跑车辆检测,右半区跑人形分割。这个时候可以在数据生产层做一次ROI裁剪,把裁剪区域作为“虚拟源”,再交给调度器统一调度。好处是底层的帧缓冲、任务队列、NPU执行层都不需要改动,只需在预处理环节根据区域ID做一次裁剪即可。
第二个方向是按需动态加载/卸载模型。有些边缘场景,业务规则不是固定的,比如白天跑检测+分类,晚上切到低照度增强+检测。如果所有模型都常驻NPU内存,内存压力很大。可以考虑在调度器中加入“动态模型注册/注销”的机制,业务切换时卸载不用的模型,加载新模型,释放NPU内存。卸载前需要确保没有正在执行的推理请求。
第三个方向是负载感知的动态帧率调节。调度器可以根据当前NPU队列长度和最近推理耗时,动态调整各模型的推理频率。比如检测任务历史平均耗时为40ms,那么它可以被分配25fps的最大频率;分割任务历史平均耗时为100ms,但业务上只需要2fps的实时性,那么它就隔40帧提交一次。这种动态调节在系统负载变化较大的场景很实用。
第四个方向是结果融合与多模型协同。同源多任务调度的天然优势是,多个模型处理的是同一帧数据,所以模型结果之间天然具有时间一致性。可以做到先用检测模型框出目标,再把目标区域送给分类模型或分割模型去细化——这就是经典的“两阶段级联”架构。由于所有模型使用的是同一帧缓冲区的数据,不存在帧偏移问题,级联精度比各模型独立处理要高。
我在项目中就用了这个思路:yolov8先检测出人形目标框,然后分割模型只对目标框区域做前背景分割,而不是对全图做分割。这样分割模型可以跑更小的输入尺寸(比如64x64),推理时间从100ms降到25ms左右,而整个系统保持了对全图“隐式”的感知能力。这个优化不改变调度器的核心逻辑,只需要在预处理环节添加一个ROI提取步骤,收益却非常显著。
7. 一些实际操作中的体会
写到最后,再分享几个我在这套系统开发过程中的个人体会。
第一,调度器本身不复杂,复杂的是把“业务节奏”和“NPU节奏”对齐。单纯做好任务队列、优先级、异步推理,只能保证系统能跑;真正跑得好,需要你花时间了解每个模型在不同输入下的真实耗时分布,并在调度策略中针对性地调整参数。我的做法是在代码里加入一个轻量级的统计模块,记录每个模型的推理耗时、队列等待时间、丢帧率,运行一段时间后导出看一下,哪边是瓶颈基本一目了然。
第二,RK3588的硬编解码能力相当强大,但使用时要提前规划好通道和分辨率。H264/H265硬编码一路1080p视频,对VPU的占用并不高,但如果你同时要硬解多路RTSP流并做硬编码输出,就需要留意VPU的总通道数限制。RK3588的VPU在某些固件版本下多路并发存在限制,我在开发过程中就遇到过VPU资源争抢导致画面花屏的问题,后来降低了解码路数或分辨率才解决。
第三,不要忽视温度对NPU性能的巨大影响。我在RK3588上遇到过很多“莫名其妙的性能下降”,最后排查下来多半是温度墙。在部署边缘AI设备时,散热设计一定要做足,风扇PID策略要合理。在软件层面,用pwm-fan节点控制风扇转速,再配合温度监测动态降频,是保证设备长期稳定运行的必备手段。
第四,如果你在做正式的边缘AI项目,建议从第一天就把“监控和日志”引入系统中。我在这套调度器里加入了每个请求的耗时统计、帧率上报、以及NPU内存/温度/风扇转速采集,定期输出到本地日志。这样即使在客户现场出了问题,也能快速定位是哪个环节产生了瓶颈,而不用像以前一样靠猜。
大概就写这么多。这套同源多任务调度方案并不是什么黑科技,本质上就是把数据流的管理和资源调度做好,但在RK3588这样的边缘设备上,这种合理的设计往往能带来30%-50%的综合性能提升。希望这篇内容包括代码和踩坑记录,能在你做RK3588边缘视觉项目时帮上一点忙。