一文彻底搞懂 CANN Runtime:昇腾推理真正的执行核心
摘要:Runtime作为CANN架构的核心执行引擎,其调度效率直接影响昇腾NPU的算力利用率。本文通过操作系统类比,剖析Runtime的设计原理:1)与GE图引擎的分工,Runtime负责动态资源调度和算子执行;2)Stream-Task机制实现多核并行,避免NPU空转;3)显存池化和零拷贝优化Host-NPU通信瓶颈。实践表明,60%的性能问题源于Runtime调度开销而非算子本身,通过融合算子
摘要: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 的显存管理策略:
- 预分配:推理前先把大部分显存申请好(避免运行时频繁申请释放)
- Buffer 复用:LayerNorm 的输出可以直接当下一个 MatMul 的输入,不用来回拷贝
- 显存池:申请一大块 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 的优化策略:
- 零拷贝(Zero-copy):Host 内存和 NPU 显存之间用内存映射,不用来回拷贝(适合大模型推理的输入输出)
- 异步拷贝:算子执行的同时,下一个 batch 的数据在后台搬运(Pipeline)
- 在 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%。
优化方法:
- 算子融合:把 7 个算子融成 1 个,ACL 调用从 7 次变 1 次(调用开销降到 0.05ms)
- 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 开销的方法:
- 算子融合:减少 ACL 调用次数(22% → 3%)
- 显存预分配:推理前把显存申请好(15% → 2%)
- 零拷贝 + 异步搬运:减少 Host-NPU 数据搬运(18% → 5%)
- 单 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
openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构
更多推荐



所有评论(0)