4、多核NPU调度原理:RKNN Runtime的多核调度机制、核心绑定、负载均衡策略、同步与异步执行模型

RK3588 搭载的三核 NPU(NPU0/NPU1/NPU2)并非简单的算力叠加,其实际性能上限取决于 RKNN Runtime 如何将模型的计算图切分、映射并调度到多个 NPU 核心上。理解多核调度原理,是进行多核性能调优的前提。本章将深入剖析 RKNN Runtime 的多核调度机制,包括核心绑定、负载均衡策略以及同步/异步执行模型,帮助开发者从“能用”走向“会用”。

Host CPU (App/Service) RKNN API 调用 输入/输出 Buffer 同步/异步接口 核心绑定配置 负载均衡策略 RKNN Runtime 计算图解析与切分 (算子级/层级划分) 任务队列管理 (多线程分发) 核心绑定决策 (mask 位图) 同步/异步执行引擎 (fence/event) 负载均衡调度器 (静态/动态) NPU 核心集群 NPU0 Core 0 Core 1 Core 2 NPU1 Core 0 Core 1 Core 2 NPU2 Core 0 Core 1 Core 2 共享 SRAM / 总线带宽 (内存带宽竞争点) 中断 / 事件通知 (完成回调) API 调用链 任务分发

上图展示了从 Host CPU 到 NPU 核心集群的完整调度链路。RKNN Runtime 位于中间层,负责将上层应用的计算请求转换为 NPU 可执行的指令序列,并根据核心绑定配置和负载均衡策略,将任务分发到不同的 NPU 核心上执行。下面我们将从核心原理、操作流程、实战案例和常见误区四个维度展开。

4.1 核心原理:从计算图到多核任务映射

RKNN Runtime 的多核调度本质上是计算图切分与任务映射的过程。当模型通过 rknn_init() 加载后,Runtime 会将模型解析为内部计算图(Graph),图中的每个节点代表一个算子(如 Conv、ReLU、Pooling 等)。多核调度的第一步是决定如何将这些算子分配到不同的 NPU 核心上执行。

RK3588 的 NPU 采用 MIMD(多指令多数据) 架构,每个 NPU 核心可以独立执行不同的算子。Runtime 支持两种切分粒度:

  • 算子级切分:将不同的算子分配到不同的核心。例如,将 Conv1 分配给 NPU0,将 Conv2 分配给 NPU1。这种方式的优点是实现简单,但核心间需要频繁同步,通信开销较大。
  • 层内切分:将单个大算子(如大尺寸卷积)按通道或空间维度切分到多个核心并行执行。这种方式能充分利用多核算力,但对内存带宽要求较高。

实际调度中,Runtime 会根据算子的计算量、数据依赖关系和内存访问模式,自动选择最优的切分策略。开发者可以通过 rknn_query 接口查询模型在目标平台上的实际调度信息。

核心要点:多核调度的核心矛盾在于“计算并行度”与“数据通信开销”之间的权衡。切分越细,并行度越高,但核心间数据交换的延迟也会增加。Runtime 的调度器会基于启发式算法(如关键路径分析、数据局部性优化)来寻找平衡点。

4.2 核心绑定机制:控制任务落核

核心绑定(Core Binding)是 RKNN Runtime 提供的一种手动干预手段,允许开发者指定模型在哪些 NPU 核心上运行。通过设置 rknn_core_mask 参数,可以控制任务执行的核集合。

核心绑定的典型应用场景包括:

  1. 性能隔离:当多个模型同时运行时,将不同模型绑定到不同的核心,避免相互干扰。
  2. 功耗优化:对于轻量级模型,只绑定一个核心,降低整体功耗。
  3. 延迟敏感任务:将关键任务绑定到固定核心,减少调度抖动。

核心绑定的配置方式如下:

// 设置核心掩码,例如绑定 NPU0 和 NPU1
rknn_core_mask core_mask = RKNN_NPU_CORE_0 | RKNN_NPU_CORE_1;
ret = rknn_set_core_mask(ctx, core_mask);
if (ret < 0) {
    printf("设置核心掩码失败: %d\n", ret);
}

需要注意的是,核心绑定是全局设置,会影响该上下文(Context)中所有后续的推理调用。如果需要在不同推理请求之间切换绑定策略,需要创建多个 Context 或动态调整。

核心掩码值 含义 适用场景
RKNN_NPU_CORE_0 仅使用 NPU0 低功耗、轻量模型
RKNN_NPU_CORE_0 | RKNN_NPU_CORE_1 使用 NPU0 和 NPU1 中等算力需求
RKNN_NPU_CORE_0 | RKNN_NPU_CORE_1 | RKNN_NPU_CORE_2 使用全部三个核心 高性能、大模型
RKNN_NPU_CORE_AUTO 由 Runtime 自动决定 默认模式,推荐优先使用

4.3 负载均衡策略:静态分配与动态调度

负载均衡决定了多个核心之间的任务分配比例。RKNN Runtime 提供了两种策略:

静态负载均衡:在模型加载时,根据算子的计算量(MACs)和内存访问量,预先计算每个核心的任务分配方案。这种方式的优点是调度开销小,适合计算量分布均匀的模型。缺点是如果模型输入尺寸动态变化(如不同分辨率的视频帧),静态分配可能不再最优。

动态负载均衡:在推理过程中,Runtime 根据每个核心的实际执行时间和队列长度,动态调整任务分配。这种方式能适应输入变化,但会引入额外的调度开销。RKNN Runtime 默认采用静态均衡,但在某些场景下(如连续推理不同尺寸的输入),可以尝试启用动态模式。

经验建议:对于大多数固定输入尺寸的视觉模型(如 YOLO 系列),静态负载均衡已经足够高效。只有在输入尺寸频繁变化或模型包含大量动态形状算子时,才需要考虑动态调度。动态调度通常通过 rknn_run 的异步模式配合自定义任务队列实现。

4.4 同步与异步执行模型

RKNN Runtime 支持两种推理执行方式:

  • 同步执行(阻塞模式):调用 rknn_run() 后,当前线程阻塞,直到推理完成并返回结果。这种方式编程简单,但 CPU 在推理期间无法执行其他任务,导致 CPU 利用率低。
  • 异步执行(非阻塞模式):调用 rknn_run_async() 后立即返回,推理在后台进行。开发者可以通过 rknn_wait() 等待推理完成,或通过回调函数获取完成通知。异步模式允许 CPU 在推理期间处理其他任务(如视频采集、图像预处理),显著提升系统吞吐量。

异步执行的核心是事件机制。Runtime 内部维护一个事件队列,每个推理请求对应一个事件。当推理完成时,Runtime 会触发相应的事件,唤醒等待的线程或调用回调函数。

// 异步推理示例
ret = rknn_run_async(ctx, &input_attrs, &output_attrs);
if (ret < 0) {
    printf("异步推理启动失败: %d\n", ret);
    return -1;
}
// 执行其他任务...
// 等待推理完成
ret = rknn_wait(ctx, &output_attrs);
if (ret < 0) {
    printf("等待推理完成失败: %d\n", ret);
    return -1;
}

4.5 实战案例:多路视频流场景下的多核调度优化

下面通过一个实际案例来演示多核调度优化的完整流程。

背景与约束:某安防项目需要在 RK3588 上同时处理 4 路 1080p 视频流,每路视频流运行一个行人检测模型(YOLOv5s)。硬件平台为 RK3588 开发板,内存 8GB,NPU 三核。初始方案中,4 路视频流共用一个 RKNN Context,使用默认的 RKNN_NPU_CORE_AUTO 配置。

初始现象:系统运行后,整体帧率仅为 12 FPS(期望 25 FPS),且 CPU 占用率高达 80%。通过 rknn_query 查询发现,三个 NPU 核心的利用率分别为 95%、60%、30%,负载严重不均。

分析过程:

  1. 检查模型计算图,发现模型包含大量小算子(如激活函数、池化),这些算子计算量小但调度开销大。
  2. 使用 perf 工具分析发现,Runtime 的调度线程成为瓶颈,频繁的核心间同步导致大量时间浪费在等待上。
  3. 进一步分析内存带宽,发现 4 路视频流共享同一内存带宽,当多个核心同时访问内存时,带宽竞争严重。

优化方案:

  1. 多 Context 隔离:为每路视频流创建独立的 RKNN Context,并将每个 Context 绑定到不同的 NPU 核心(Context0→NPU0,Context1→NPU1,Context2→NPU2,Context3→NPU0+NPU1)。这样避免了核心间的频繁同步。
  2. 异步执行:将推理调用改为 rknn_run_async(),在等待推理完成期间,CPU 执行下一帧的图像缩放和格式转换。
  3. 内存优化:使用 rknn_create_mem() 为每个 Context 分配独立的输入输出缓冲区,减少内存访问冲突。

结果对比:

指标 优化前 优化后 提升幅度
整体帧率 12 FPS 24 FPS 100%
CPU 占用率 80% 45% 降低 43%
NPU 核心利用率 95%/60%/30% 85%/82%/78% 负载更均衡

风险与代价:多 Context 方案会增加约 30MB 的内存开销(每个 Context 的模型副本),且模型加载时间从 200ms 增加到 800ms(4 个 Context 串行加载)。但考虑到推理性能的大幅提升,这些代价是可接受的。

复盘经验:多核调优的核心不是“让所有核心都跑满”,而是“让每个核心做合适的事”。在本案例中,将 4 路视频流拆分为 4 个独立 Context 并绑定到不同核心,避免了核心间的调度竞争,同时通过异步执行隐藏了 CPU 预处理延迟,最终实现了性能翻倍。

4.6 常见误区与总结

误区一:核心绑定越多越好。实际上,当模型较小时,绑定多个核心反而会因为核心间通信开销导致性能下降。建议先用 RKNN_NPU_CORE_AUTO 测试,再根据实际利用率决定是否手动绑定。
误区二:忽略内存带宽瓶颈。多核并行时,内存带宽往往成为真正的瓶颈。如果模型是内存密集型(如大通道数的卷积),即使 NPU 算力充足,性能也无法线性扩展。此时应优先优化内存访问模式(如使用更小的输入分辨率、减少中间张量)。
误区三:同步执行与异步执行混用。在同一个 Context 中,不要交替使用 rknn_run() 和 rknn_run_async(),这会导致内部状态混乱。应统一使用一种模式,并在异步模式下确保每次 rknn_run_async() 后都有对应的 rknn_wait()。

总结:多核 NPU 调度优化是一个系统工程,需要从计算图切分、核心绑定、负载均衡和执行模型四个维度综合考虑。核心原则是:先测量,后优化。通过 rknn_query 获取核心利用率、执行时间等关键指标,针对瓶颈采取对应的优化措施。在实际项目中,建议优先使用 Runtime 的自动调度能力,仅在自动调度无法满足性能要求时,再考虑手动干预。