【昇腾CANN】图执行器:Graph Executor怎么调度一个计算图
昇腾 Runtime 的计算调度核心是 Graph Executor。它把前端框架(PyTorch、TensorFlow)传入的计算图,拆解成算子序列,调度到 NPU 上执行。理解 Graph Executor 的工作原理,能帮你理解为什么有些操作在昇腾上比在 GPU 上慢,以及怎么优化调度效率。
昇腾 Runtime 的计算调度核心是 Graph Executor。它把前端框架(PyTorch、TensorFlow)传入的计算图,拆解成算子序列,调度到 NPU 上执行。
理解 Graph Executor 的工作原理,能帮你理解为什么有些操作在昇腾上比在 GPU 上慢,以及怎么优化调度效率。
计算图是什么
计算图是深度学习框架表达计算的方式。每个节点是一个算子(MatMul、ReLU、Softmax),每条边是 tensor 的依赖关系。
# 假设我们跑这样一段代码
import torch
x = torch.randn(4, 512)
w1 = torch.randn(512, 256)
w2 = torch.randn(256, 128)
h = x @ w1 # 节点1: MatMul
a = torch.relu(h) # 节点2: ReLU
y = a @ w2 # 节点3: MatMul
昇腾的前端适配层(PyTorch Adaptor)会把这段代码翻译成一个计算图:
x → [MatMul] → h → [ReLU] → a → [MatMul] → y
Graph Executor 的职责是:接收这个图,按依赖顺序调度算子,等待每个算子完成,然后推进到下一个算子。
两级调度:Stream 和 Event
昇腾 Runtime 用 Stream(流)和 Event(事件)实现并行调度。
Stream:一个 Stream 对应一个指令序列,算子按顺序在 Stream 上排队执行。不同 Stream 之间可以并行。
Event:Event 用来同步,插入到 Stream 的某个位置,记录某个时间点,后续可以用 Event 来等待或者查询状态。
import acl
# 创建两个 stream
stream_compute, ret = acl.rt.create_stream()
stream_io, ret = acl.rt.create_stream()
# stream_compute 用于计算
# stream_io 用于数据传输
# 在 stream_compute 上执行算子
acl.rt.matmul(..., stream=stream_compute)
acl.rt.relu(..., stream=stream_compute)
# 在 stream_io 上做数据传输
acl.rt.memcpy(d_host_to_device, ..., stream=stream_io)
# 用 Event 同步
event, ret = acl.rt.create_event()
acl.rt.record_event(event, stream=stream_io)
# stream_compute 等 stream_io 完成后再执行下一步
acl.rt.stream_wait_event(stream_compute, event)
这个例子展示了常见的 IO 和计算并行:数据在 stream_io 上从 CPU 拷到 NPU,计算在 stream_compute 上跑。当数据拷完(event 被 record),stream_compute 才继续执行下一步。这样计算和数据传输可以 overlap 起来。
图切分与设备分配
单卡场景下,Graph Executor 把整张图调度到一张 NPU 上执行。多卡场景要复杂得多:图需要被切分(Graph Partition),不同部分调度到不同卡上。
昇腾的图切分策略有两种:
算子切分(Op Partitioning):按算子边界切,每个算子完整地落在某张卡上。好处是通信只在算子边界发生,坏处是如果算子很大,切分不均匀,负载就会倾斜。
Tensor 切分(Tensor Partitioning):单个大算子内部也可以切,把 tensor 的某个维度切分到不同卡上。这种方式更灵活,但需要处理跨卡的数据路由。
# 假设我们跑一个 8 卡分布式训练
import torch.distributed as dist
# 初始化分布式环境
dist.init_process_group(backend='hccl')
# 把模型切分到 8 张卡上
# 这里用的是张量并行(Tensor Parallelism)
model = MyModel()
model = torch.nn.parallel.DistributedDataParallel(model)
# 图会被切分,大概长这样:
# rank 0: attention.head[0..3]
# rank 1: attention.head[4..7]
# 中间通过 AllReduce 同步
Graph Executor 在切分的时候会尽量让通信量最小化。一个经验法则:通信发生在切分边界上。如果两段计算之间的数据依赖很强,切分边界放在那里会产生大量通信,性能反而不如单卡。
调度顺序与流水线
Graph Executor 有一个重要的优化:流水线并行。当一张图被拆成多个 Stage(阶段),Stage 之间没有数据依赖的时候,可以同时跑多个 Stage 的不同 micro-batch。
# 流水线并行示意
# Stage 1: 数据加载和预处理
# Stage 2: 模型前向
# Stage 3:Loss 计算和反向
# 不使用流水线:
# [micro_batch_1: S1 → S2 → S3] → [micro_batch_2: S1 → S2 → S3] → ...
# 使用流水线:
# mb1: [S1] → [S2] → [S3]
# mb2: [S1] → [S2] → [S3]
# mb3: [S1] → [S2] → [S3]
# 三个 stage 同时跑,GPU 利用率提升
昇腾的 Runtime 调度器在 micro-batch 数量大于 4 的时候,流水线效率比较明显。如果 micro-batch 太少(小于 3),流水线启动和收尾的开销会比较大,实际性能反而可能下降。
算子融合调度
Graph Executor 另一个核心能力是算子融合调度。它会把相邻的多个小算子合并成一个大算子(Fusion),一次调度完成。
融合的好处是减少 kernel 启动开销和中间结果的显存访问。
# 原始图
x → [MatMul] → [BiasAdd] → [ReLU] → y
# 融合后(Graph Executor 自动识别这个模式)
x → [FusedMatMulBiasReLU] → y
融合策略在昇腾的 AOE 调优引擎里配置。默认有一些保守的融合规则,但也可以手动指定:
import acl
# 查询当前融合策略
status = acl.op.get_fusion_status()
print(status)
# 手动开启/关闭某个融合模式
acl.op.set_fusion_mode("MatMul_BiasAdd_Add_ReLU", enable=True)
手动干预融合有时候能带来意外的收益,但也有风险:如果两个算子合并之后变大了,超过某些硬件限制(比如 Shared Memory 放不下),反而会更慢。
一个调试案例
之前遇到一个问题:昇腾上跑某个 Transformer 模型,单卡推理延迟比 PyTorch CPU 模式还慢。一开始怀疑是算子实现问题,后来用 profiler 查了查,发现是调度效率太低。
# 用 profiler 看调度时间
from npu_profiler import profile
with profile(activities=['cpu', 'npu'], record_shapes=True) as prof:
output = model(input_data)
print(prof.key_averages().table(sort_by="cpu_time_total", row_limit=20))
profiler 显示模型里有大量小的 ElementWise 算子(Add、Mul、Sub),每个算子都要单独调度一次,加起来调度开销占了总时间的 40%。
解决方案是让 Graph Executor 开启更多的融合模式:
# 启用所有安全的融合
acl.op.set_fusion_mode("all_safe", enable=True)
开启之后,ElementWise 算子被合并到相邻的大算子里,调度开销从 40% 降到 8%,推理延迟降低了 2.3 倍。
这个案例说明,有时候性能问题不在算子本身,而在算子之间的调度效率上。
仓库在 https://atomgit.com/cann/runtime,Graph Executor 的源码在 graph_executor/ 目录下。
鲲鹏昇腾开发者社区是面向全社会开放的“联接全球计算开发者,聚合华为+生态”的社区,内容涵盖鲲鹏、昇腾资源,帮助开发者快速获取所需的知识、经验、软件、工具、算力,支撑开发者易学、好用、成功,成为核心开发者。
更多推荐



所有评论(0)