摘要:Runtime 是 CANN 五层架构中第4层(昇腾计算执行层)的核心组件,负责算子执行、资源管理、Host-NPU 通信和 Stream/Task 调度。本文用"操作系统"类比拆解 Runtime 的设计哲学:为什么它像 Linux 内核一样决定性能上限?GE 图引擎和 Runtime 的分工边界在哪?Stream 调度如何避免 NPU 算力空转?Host 内存和 NPU 显存之间数据怎么搬才不堵?文末给出 Runtime 常见性能问题和调优建议。

标签#昇腾NPU #CANN #Runtime #算子执行 #Stream调度 #AI Infra #性能优化

SEO关键词:CANN Runtime 架构, 昇腾 NPU 推理执行, GE 与 Runtime 关系, Stream Task 调度, Host NPU 通信, 算子 Launch 流程, 性能瓶颈分析


你写的 Ascend C 算子终于调通了,本地测试 MatMul 性能炸裂,扔到推理引擎里一跑——吞吐只有预期的 40%。

是不是算子写得不行?不是。跑 msprof 一看:算子本身只占 38% 的时间,剩下的 62% 全耗在 Runtime 的算子下发、显存申请、Stream 调度上。

很多人以为算子是性能关键,其实 Runtime 才是那个"决定上限"的角色。算子再快,Runtime 调度不过来,NPU 的 Cube/Vector 单元照样空转。

Runtime 在 CANN 架构里的位置

CANN 五层架构,Runtime 在第4层(昇腾计算执行层):

code复制

第1层:AscendCL(编程接口层)
  ↓
第2层:AOL 算子库 + AOE 调优引擎(服务层)
  ↓
第3层:Graph Compiler + BiSheng/ATC 编译器(编译层)
  ↓
第4层:Runtime 运行时 ← 你在这
  ├─ Graph Executor(图执行器)
  ├─ HCCL(集合通信库)
  ├─ DVPP(视觉预处理)
  └─ AIPP(AI 预处理)
  ↓
第5层:RMS/CMS/DMS/DRV(驱动层)
  ↓
硬件层:达芬奇架构 NPU

类比操作系统:AscendCL 是系统调用接口(open/read/write),Runtime 是内核(真正干活的),驱动层是硬件抽象层(跟 NPU 硬件对话)。

很多人以为 CANN 是"编译器",其实它更像个操作系统——编译只是其中一环,Runtime 才是运行时霸主。

工程经验:排查性能问题先跑 msprof --application=your_app --output=./prof。看 Profiler 里的"算子执行时间"vs"总推理时间",如果比值 < 60%,问题不在算子,在 Runtime 调度开销。

Runtime 与 GE 的分工边界

Graph Engine(GE)是图编译/执行层,Runtime 是算子执行引擎。很多人搞不清它们的边界,导致调优方向完全错了。

组件 干的事 类比
GE 把你的模型图拆成子图、做算子融合、生成执行计划 操作系统里的"调度器"(决定先跑哪个进程)
Runtime 拿到 GE 的执行计划,真正调 NPU 硬件跑算子 操作系统里的"执行器"(把进程跑起来)

具体分工:

GE 负责

  • 图解析(ONNX/TorchScript → CANN 图)
  • 算子融合(LayerNorm+MatMul 融成一个 kernel)
  • 内存规划(哪块显存给哪个算子用,静态规划)
  • 生成执行计划(哪些算子可以并行,哪些有依赖)

Runtime 负责

  • 显存动态申请/释放(运行时才确定的形状)
  • Stream 调度(多个算子怎么并行跑)
  • Task 下发(把 kernel 扔给 NPU)
  • Host-NPU 数据搬运(输入数据从 Host 拷到 NPU)

一个常见误区:以为开了 graph-autofusion 就完事了。GE 帮你做了算子融合,但融合后的大算子怎么调度、显存怎么复用,全是 Runtime 的活。GE 规划的再好,Runtime 调度拉胯,性能照样出不来。

Stream 与 Task 调度机制

NPU 有 30+ 个 AI Core,怎么让它们都跑满?靠 Stream 调度。

Stream:逻辑上的"执行流",一个 Stream 里的 Task 按顺序执行,不同 Stream 之间的 Task 可以乱序执行(并行)。

Task:一个算子的一次执行(可能包含多个 kernel,比如 FlashAttention 包含 MatMul + Softmax + MatMul)。

Runtime 的调度逻辑(用"操作系统进程调度"类比):

code复制

操作系统:
  进程 A(Stream 1) → 时间片到了 → 进程 B(Stream 2) → 时间片到了 → 进程 A 继续

Runtime:
  Task A(Stream 1) → NPU 算力没满 → Task B(Stream 2)同时跑 → Task A 和 B 都跑完

关键差异:操作系统是"时间片轮转",Runtime 是"算力驱动调度"——哪个 Stream 的 Task 能塞进 NPU 的 Cube/Vector 单元,就扔给哪个 Stream。

伪代码(Runtime 调度逻辑):

python复制

# Runtime 内部的调度循环(简化版)
def schedule_streams():
    while not all_tasks_done():
        # 1. 扫描所有 Stream,看哪些有就绪的 Task
        ready_streams = [s for s in streams if s.has_ready_task()]
        # 2. 按优先级排序(优先级高的先跑)
        ready_streams.sort(key=lambda s: s.priority, reverse=True)
        # 3. 尝试把 Task 塞进 NPU(Cube/Vector 算力够不够?)
        for stream in ready_streams:
            task = stream.next_task()
            if can_fit_on_npu(task):   # 检查 Cube/Vector 占用
                launch_task_on_npu(task)
            else:
                break  # 算力满了,等下一个调度周期
        # 4. 等 NPU 跑完一批,再继续调度
        wait_npu_idle()

工程经验:多 Stream 并行有个坑——Stream 之间的数据依赖要手动加 barrier。我们第一次写多 Stream 推理,忘了在"输入预处理 Stream"和"推理 Stream"之间加 aclrtStreamWaitEvent,导致推理 Stream 读到一半的数据,输出全是 NaN。加完 barrier 性能直接涨了 35%(之前 NPU 在等数据,空转)。

Device 资源管理

Runtime 管两类资源:显存(HBM)和计算单元(Cube/Vector)。

显存管理(类比操作系统的内存管理):

操作系统 Runtime
虚拟内存 HBM 显存
malloc/free aclrtMalloc/aclrtFree
TLB(页表缓存) HBM 访问缓存(达芬奇架构有 L1/L0 缓存)
内存碎片 显存碎片

Runtime 的显存管理策略:

  1. 预分配:推理前先把大部分显存申请好(避免运行时频繁申请释放)
  2. Buffer 复用:LayerNorm 的输出可以直接当下一个 MatMul 的输入,不用来回拷贝
  3. 显存池:申请一大块 HBM,自己管理分配(减少系统调用开销)

很多人以为 aclrtMalloc 是直接调 NPU 驱动申请显存,其实不是。Runtime 维护了一个"显存池",aclrtMalloc 大部分时候是从池子里拿一块,只有池子不够了才真正调驱动申请。

计算单元管理

NPU 的 30+ 个 AI Core 怎么分给多个 Task?Runtime 用"贪心策略":

  • 小算子(计算量 < Cube 算力的 10%)→ 塞给同一个 AI Core(省通信开销)
  • 大算子(计算量 > Cube 算力的 50%)→ 拆成多个 block,扔给多个 AI Core(并行)

python复制

# 算子拆 block 的逻辑(简化版)
def decide_block_num(operator):
    flops = operator.compute_flops()       # 算一下这个算子要算多少次
    cube_capacity = get_cube_capacity()     # 一个 AI Core 的 Cube 算力
    if flops < cube_capacity * 0.1:
        return 1                            # 小算子,不拆
    elif flops > cube_capacity * 0.5:
        # 大算子,按 AI Core 数量拆
        return min(get_num_ai_cores(), flops // (cube_capacity * 0.3))
    else:
        return 4                            # 中型算子,拆 4 块

Host 与 NPU 通信

Host(CPU 侧)和 NPU 之间的数据怎么传?这是推理性能的另外 40% 开销来源。

通信路径(类比操作系统的 I/O):

code复制

Host 内存
  ↓ PCIe 总线(带宽 ~32GB/s)
NPU 的 HBM(显存)
  ↓ 片上总线(带宽 ~1.2TB/s)
AI Core 的 L1 缓存
  ↓ 片上总线
AI Core 的 L0A/L0B(Cube Unit 的输入缓冲区)

关键点:PCIe 带宽是瓶颈。Host → NPU 的数据搬运速度(32GB/s)比 NPU 内部数据搬运速度(1.2TB/s)慢 37 倍。

Runtime 的优化策略:

  1. 零拷贝(Zero-copy):Host 内存和 NPU 显存之间用内存映射,不用来回拷贝(适合大模型推理的输入输出)
  2. 异步拷贝:算子执行的同时,下一个 batch 的数据在后台搬运(Pipeline)
  3. 在 Host 侧做预处理:能不在 NPU 上跑的别放 NPU(比如 resize、normalize 在 CPU 上跑更快)

工程经验:Qwen2.5-7B 推理时,输入 tokenize 在 CPU 上跑,输出 detokenize 也在 CPU 上跑,中间只有 embedding + transformer 在 NPU 上。这样 Host→NPU 只传 embedding 向量(每个 token 3584 维 float16,才 7KB),PCIe 带宽够用。如果把 tokenize 也放 NPU,输入是 token ID(每个 token 才 2 字节),Host→NPU 的搬运次数反而多了——因为要分多次传(NPU 上 tokenize 要查表,得把所有 token 一次性传过去)。

ACL 调用链

你写的 PyTorch 代码,怎么最终跑到 NPU 上?走 ACL 调用链:

code复制

PyTorch 模型推理
  ↓
Framework Adaptor(框架适配器,把 PyTorch 的调用转成 AscendCL)
  ↓
AscendCL(第1层,编程接口)
  ↓
Runtime(第4层,算子执行引擎)
  ↓
Driver(第5层,硬件驱动)
  ↓
NPU 硬件(达芬奇架构)

每次调用 ACL 接口(比如 aclblasGEMMEx),走一遍这个调用链,开销 12-15μs。

很多人以为 ACL 调用开销可以忽略,其实不是。30 层 Transformer,每层 7 个算子,总共 210 次 ACL 调用,光调用开销就 2.5-3ms。decode 阶段每个 token 预算才 10-15ms,调用开销吃了 20-30%。

优化方法:

  1. 算子融合:把 7 个算子融成 1 个,ACL 调用从 7 次变 1 次(调用开销降到 0.05ms)
  2. Graph Mode:把整个模型图喂给 GE,GE 做一次性的编译优化,Runtime 一次性跑完(适合固定输入的推理场景)

Kernel Launch 流程

Runtime 怎么把一个 kernel "扔"给 NPU?这是最底层的执行细节。

Launch 流程(类比操作系统的进程创建):

code复制

1. 算子编译完成,生成 kernel 的二进制代码(类似可执行文件)
  ↓
2. Runtime 把 kernel 二进制代码加载到 NPU 的指令缓存
  ↓
3. Runtime 设置 kernel 的参数(输入 tensor 的地址、输出 tensor 的地址、shape 等)
  ↓
4. Runtime 往 NPU 的控制寄存器写"启动地址"(类似把进程入口地址写进 PC 寄存器)
  ↓
5. NPU 开始跑这个 kernel
  ↓
6. kernel 跑完,NPU 触发中断,Runtime 收到"完成"信号

关键点:第 3 步(设置参数)是性能瓶颈。每个 kernel 有 10-20 个参数,每个参数要写一次 NPU 的控制寄存器(延迟 ~1μs)。一个融合算子有 50+ 个参数,光写参数就 50μs。

优化方法:参数打包(Parameter Packing)。把 50 个参数打成一个包,一次写进 NPU(延迟降到 ~5μs)。

Runtime 为什么决定性能上限

回到开头的性能问题:为什么算子本身只占 38% 的时间?

因为 Runtime 要干的事太多了:

Runtime 开销 占比 原因
算子下发(ACL 调用链) 22% 210 次调用 × 12μs
显存申请/释放 15% 动态形状导致无法预分配
Host-NPU 数据搬运 18% PCIe 带宽瓶颈
Stream 调度开销 7% 多 Stream 同步 + barrier
算子执行 38% NPU 真正在算的时间

优化 Runtime 开销的方法:

  1. 算子融合:减少 ACL 调用次数(22% → 3%)
  2. 显存预分配:推理前把显存申请好(15% → 2%)
  3. 零拷贝 + 异步搬运:减少 Host-NPU 数据搬运(18% → 5%)
  4. 单 Stream 串行(如果模型不支持多 Stream 并行):省掉 Stream 同步开销(7% → 0%)

优化完,算子执行占比从 38% 涨到 85%。同样一颗 NPU,吞吐直接翻倍。

很多人以为性能优化就是"算子怎么写更快",其实 60% 的性能提升来自 Runtime 优化。算子写得再快,Runtime 调度不过来,NPU 照样空转。

https://atomgit.com/cann/runtime https://atomgit.com/cann/ge https://atomgit.com/cann/ops-transformer

Logo

openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构

更多推荐