前言

当你用 PyTorch 写好一个神经网络模型并点击"运行"时,这段 Python 代码是如何最终在昇腾 NPU 的硅片上变成真实的计算动作的?

多数开发者习惯把"模型执行"当成一个黑盒:写好forward,调用torch.compile,看着 GPU/NPU 的利用率曲线波动,便以为自己理解了深度学习框架的工作原理。这种认知方式好比只看过舞台上的演出,便以为自己懂了剧场背后的整个制片流程——你看到的是演员在灯光下表演,没看到的是剧本拆解、场景调度、道具分配、多舞台协同那套复杂得多的工程体系。

CANN 的 GE(Graph Engine)图执行引擎正是这套"剧场制片体系"的核心。它不负责写剧本(前端框架的事),也不负责当演员(NPU 芯片的事),但它决定了剧本如何拆解成场景、场景如何分配给不同舞台、演员如何在多个舞台之间流转、道具(内存)如何复用。不理解 GE,就无法理解 CANN 全栈为什么能把模型执行效率优化到逼近硬件极限。

接下来用"剧场制片"的类比,层层拆解 GE 在 CANN 架构中的中心位置、算子图构建机制、图优化 Pass 体系、算子调度策略、与昇腾硬件的映射关系,以及 GE 可视化与调试方法。每一个技术概念,都会先给出一个日常生活中的类比,再回到 GE 的真实实现细节。

GE 在 CANN 架构中的中心位置:不是"中间件",是"总制片人"

类比:剧场里的总制片人

想象你要排演一台复杂的话剧,有多个场景、多名演员、多组道具、多座舞台。谁来决策"哪场戏先排、哪组演员同时上场、哪件道具在哪些场次之间共用"?这个角色就是总制片人。

总制片人不写剧本(剧作家的事),也不当演员(演员的事),但他握有整台剧目的"场景拆解权"和"资源分配权"。没有总制片人,每个演员只会站在自己那页剧本前发呆——他们不知道该先演哪场、和谁对戏、用哪件道具。

GE 的中心位置

CANN 的全栈层次是:前端框架(PyTorch/TensorFlow)→ 适配器(TorchAir/TF Adapter)→ GE 图引擎 → 算子编译器(TBE/AscendC)→ Runtime → 昇腾 NPU 硬件。

GE 正处于这个链条的正中央。它的上游是各种前端框架适配层,下游是算子编译器和 Runtime。GE 的核心职责可以浓缩为一句话:把前端框架产出的计算图,编译成能在昇腾 NPU 上高效执行的任务序列

具体来说,GE 承担以下职责:

  1. 统一编译入口:无论来自 PyTorch 还是 TensorFlow,无论输入是 AtenIR 还是 GraphDef,进入 GE 后全部转换为 AscendIR——一种与前端框架无关的图中间表示(IR)。这使 GE 的后续优化可以脱离前端框架的具体实现,专注在图结构本身。

  2. 图级优化:在 AscendIR 上做通用编译器优化(常量折叠、公共子表达式消除、死代码消除)和融合类优化(Pattern-Based Fusion、Autofusion)。这些优化的目标是减少算子数量、降低中间张量读写、提升单算子执行效率。

  3. 引擎分区:昇腾设备有多种执行引擎(AI Core、Vector Core、AI CPU、DVPP、HCCL 等),不同算子需要分配到不同引擎。GE 通过引擎分区(Engine Partition)将整图拆分为若干子图,每个子图对应一种引擎。

  4. 调度与内存规划:编译期的流分配(Stream Allocation)决定哪些算子可以并行执行;内存规划(Memory Planning)决定每个张量在设备内存中的偏移量,使生命周期不重叠的张量可以共享同一块内存。

  5. 模型序列化与加载:编译产物(GeRootModel)序列化为 OM 文件,运行时通过 GraphLoader 加载到设备,通过 TaskSink 模式将整图任务序列预下发到设备端,运行时只需一次触发即可执行全图。

值得强调的是,GE 与 Graph Fusion、FE(前端)、CCE(算子编译器)、Runtime 的关系是上下游协作而非上下级管控。GE 调用 FE 做算子融合,调用 CCE 做算子在线编译,调用 Runtime 做模型加载与执行,但它不直接"命令"这些组件如何工作——它定义接口,各组件通过接口接入 GE 的编译流程。

算子图(Graph)构建机制:从剧本到场景分解

类比:剧本拆解成场景

剧场制片的第一步是把完整剧本拆解成若干独立场景,每个场景标明:出场人物、对白内容、道具需求、场景顺序。这个拆解过程必须保证:任意场景的道具需求都能追溯到之前某个场景的准备动作(依赖关系),且整台剧目的演出顺序符合剧本逻辑。

如果剧本拆解错了——比如某场景需要一把剑,但之前的场景里没有"准备剑"这个动作——演出就会卡住。

前端框架模型如何转换为 AscendIR

GE 的算子图构建,本质是将前端框架的模型表示转换为 GE 内部图表示(AscendIR)的过程。这条转换链路分为两条路径:

路径一:在线场景(框架适配层)

PyTorch 模型通过 TorchAir 适配层,将 PyTorch 的 AtenIR 转换为 AscendIR。TorchAir 的名字里,“Air"正是取自"AscendIR"的尾缀,暗示它是 AscendIR 在 PyTorch 世界的"使者”。

TensorFlow 模型通过 TF Adapter(TFA,TensorFlow Adapter),将 TensorFlow 的 GraphDef 转换为 AscendIR。

这两条适配路径的共同点是:转换发生在框架执行过程中,GE 被框架直接驱动,模型不需要先导出为文件。

路径二:离线场景(ATC 工具链)

用户将模型导出为 ONNX、PB 等格式,通过 ATC(Ascend Tensor Compiler)工具进行离线编译。ATC 调用 GE 的 Parser 模块,将 ONNX/PB/Caffe/MindSpore 等格式解析为 AscendIR。

离线场景的特点是:无需昇腾设备(纯靠 Host 侧即可完成编译),无需前端框架运行时,产物(OM 文件)可独立部署。

AscendIR 的数据结构

AscendIR 是一张有向无环图(DAG),核心数据结构包括:

  • Graph(图):承载节点、边、输入输出描述,是编译的基本处理单元。在 GE 的 C++ 实现中,ComputeGraph 类(graph/compute_graph.h)是 Graph 的核心表示。

  • Node(节点):表示算子级计算单元,包含算子类型(OpType)、输入输出 Tensor 的引用及属性(Attribute)。Node 对象通过 GetOpDesc() 获取 OpDesc,进而查询算子类型、输入输出描述、属性等信息。

  • Tensor(张量):算子的输入输出数据实体,包括 Shape、DType、Format 等元信息。Tensor 在 GE 中通过 GeTensorDesc 描述。

  • Data Edge(数据边):表示 Tensor 的生产者与消费者关系,方向由 src 节点指向 dst 节点。在 GE 的实际实现中,数据边通过 DataAnchor 表达。

  • Control Edge(控制边):表示纯依赖关系,无数据传递,用于显式约束执行顺序。通过 CtrlAnchor 表达。

GE 的实现中有一个值得注意的细节:图中并不存在独立的 Edge 对象,而是通过"锚点(Anchor)"来描述连边关系。DataAnchor 用于表示数据边,CtrlAnchor 用于表示控制边。每个锚点维护其对端锚点,从而表达节点间的连接关系。这种设计避免了显式的 Edge 对象管理,使图的遍历和修改更为高效。

以下代码展示了如何在 GE 中构建一个简单的计算图:

// 创建一个计算图并添加算子节点
#include "graph/compute_graph.h"
#include "graph/node.h"
#include "graph/op_desc.h"

void BuildSimpleGraph() {
  // 创建计算图对象,指定图名称
  auto graph = std::make_shared<ge::ComputeGraph>("simple_add_graph");
  
  // 创建 Data 节点(图的输入)
  auto data_op_desc = std::make_shared<ge::OpDesc>("data", "Data");
  // 设置输出 Tensor 描述:Shape=[1,3,224,224], Format=NCHW, DType=DT_FLOAT16
  ge::GeTensorDesc data_output_desc;
  data_output_desc.SetShape(ge::GeShape({1, 3, 224, 224}));
  data_output_desc.SetFormat(ge::FORMAT_NCHW);
  data_output_desc.SetDataType(ge::DT_FLOAT16);
  data_op_desc->AddOutputDesc(data_output_desc);
  auto data_node = graph->AddNode(data_op_desc);
  
  // 创建 Add 节点
  auto add_op_desc = std::make_shared<ge::OpDesc>("add", "Add");
  // 设置两个输入和一个输出
  add_op_desc->AddInputDesc(data_output_desc);   // 输入0来自 Data
  add_op_desc->AddInputDesc(data_output_desc);   // 输入1也来自 Data(示意)
  add_op_desc->AddOutputDesc(data_output_desc);  // 输出
  auto add_node = graph->AddNode(add_op_desc);
  
  // 通过 DataAnchor 建立数据边:data_node 的输出锚点 → add_node 的输入锚点
  // 第一个参数:src 节点的输出索引;第二个参数:dst 节点的输入索引
  ge::GraphUtils::AddEdge(data_node->GetOutDataAnchor(0),
                          add_node->GetInDataAnchor(0));
  ge::GraphUtils::AddEdge(data_node->GetOutDataAnchor(0),
                          add_node->GetInDataAnchor(1));
  
  // 创建 NetOutput 节点(图的输出)
  auto output_op_desc = std::make_shared<ge::OpDesc>("output", "NetOutput");
  output_op_desc->AddInputDesc(data_output_desc);
  auto output_node = graph->AddNode(output_op_desc);
  
  // 建立 Add 输出到 NetOutput 输入的边
  ge::GraphUtils::AddEdge(add_node->GetOutDataAnchor(0),
                           output_node->GetInDataAnchor(0));
}
// GE uses Anchor-based edge representation instead of explicit Edge objects
// to avoid the overhead of managing independent edge lifetimes. Anchors are
// owned by their parent Nodes, so graph mutation (adding/removing nodes) does
// not require a separate edge garbage collection pass. This design reduces the
// memory allocation pressure in large graphs with millions of edges.

这段代码揭示了 GE 图表示的几个关键设计决策:

  • 图的构建是显式的:开发者需要手动创建 Node、设置 OpDesc、建立 Anchor 连接。这与 PyTorch 的的动态图(每次执行自动记录操作)形成鲜明对比。
  • OpDesc 是算子的"元数据容器":算子类型、输入输出 Tensor 描述、属性都存放在 OpDesc 中,Node 只是 OpDesc 的图结构载体。
  • 图的输入输出通过特殊的 Data 和 NetOutput 节点标记,这使 GE 能明确区分"外部输入"和"中间张量"。

图优化 Pass 体系:从"粗剪"到"精剪"的多轮编辑

类比:剧本的多轮编辑

剧场制片人拿到初版剧本后,不会直接开排。他会做多轮编辑:

第一轮"粗剪":删掉明显多余的场景(比如两场戏讲的是同一件事),合并可以连续演出的场景(减少换景时间)。

第二轮"精剪":根据演员档期(类比硬件资源)调整场景顺序,把可以同时排练的场景分配到不同排练厅(类比流分配)。

第三轮"终剪":所有场景分配完毕后,再整体审视一遍,看看有没有因为分配而导致的新冗余(比如两个排练厅之间需要频繁搬运同一件道具,不如让它们共用一个排练厅)。

GE 的图优化 Pass 体系正是这种多轮编辑的结构化实现。

Pass 基础设施:两类 Pass 与执行框架

GE 的优化 Pass 分为两类:

GraphPass:以整图为单位运行,通过 PassManager(passes/pass_manager.h)管理顺序执行。调用方通过 AddPass(name, pass) 注册,再调用 Run(graph) 按序执行。GraphPass 适用于需要全图视角才能决策的优化,例如死代码消除(需要判断某个节点是否对最终输出有贡献)。

NodePass(BaseNodePass):以节点为单位运行,通过 GEPass 框架(passes/base_pass.h)遍历图的每个节点。NodePass 适用于逐节点决策就能完成的优化,例如常量折叠(判断单个算子是否所有输入都是常量)。

GEPass 框架的一个精妙设计是重遍历机制:如果某个 NodePass 修改了图结构(添加或删除了节点),后续节点看到的图已经不同于遍历开始时。GEPass 通过 AddRePassNode 和 AddImmediateRePassNode 让 Pass 声明"这个新节点需要被其他 Pass 再处理一遍"。其中"立即重遍"(ImmediateRePass)使修改可以立即被当前轮次中的后续 Pass 看到,避免多轮迭代的性能开销。

三个阶段优化:为什么不能一次性做完所有优化

GE 将图优化分为三个阶段,对应前文类比中的"粗剪"、“分配后优化”、“终剪”:

阶段一:OriginalGraph 优化(分区前)

此时所有算子尚未被分配到具体引擎,优化器可以自由地做跨引擎的算子融合和消除。这一阶段的核心 Pass 包括:

  • MergeInputMemcpyPass、SwitchDataEdgesBypass:规范化控制流,消除冗余的数据边绕行。
  • ConstantFuseSamePass、CommonSubexpressionEliminationPass:消除冗余常量,合并相同的子表达式。
  • FuseDataNodesWithCommonInputPass:合并有相同输入的 Data 节点,减少数据拷贝。
  • ConstantFoldingPass、CastRemovePass、ReshapeRemovePass:逐个消除或简化节点。
  • SwitchToStreamSwitchPass、MergeToStreamMergePass、AttachStreamLabelPass:将控制流算子转换为流控制语义,为后续的流分配做准备。
  • MultiBatchPass、SubgraphMultiDimsPass:处理动态批处理和多维度动态推理。

阶段二:SubGraph 优化(分区后)

引擎分区后,每个子图被分配给特定引擎(如 FE 融合引擎),各引擎对分配给自己的子图做引擎特定的优化。这一步是多线程并行的——不同引擎的子图互不干扰,通过线程池(默认 16 线程)并行执行。

阶段三:AfterOptimizeSubGraph 优化(合并后)

子图优化后合并回整图,再做全图视角的后优化。此时需要处理子图边界引入的 Memcpy 节点、引擎特定优化后的新常量折叠机会、子图间的内存读写冲突等。

两条融合路线:手写 Pattern 与自动融合

GE 的融合优化走两条路线:

路线一:手写 Pattern 融合

通过 Pattern Matcher 框架(compiler/graph/fusion/)实现声明式融合规则。开发者描述"什么样的子图模式应该被融合",框架负责在目标图中匹配和替换。

Pattern Matcher 的匹配算法采用回溯搜索:从 Pattern 图的输出节点出发,在目标图中查找类型匹配的节点,沿数据边反向遍历 Pattern 图和目标图,逐节点匹配。如果某条分支不匹配,回溯到上一个分支点尝试下一个候选。

为什么从输出节点开始匹配? 因为输出节点通常比中间节点少得多——输出节点的类型和数量是 Pattern 最具区分度的部分。从输出开始匹配可以快速剪枝,避免大量无效的中间节点匹配。

路线二:自动融合(Autofusion)

基于算子分类和依赖分析,自动识别可融合的算子组合。这个子系统(compiler/graph/optimize/autofuse/)不仅做融合决策,还涉及融合后算子的代码生成——这是一条从算子分类到代码生成的完整路径。

自动融合在精度调整后、格式调整前执行。这个时机选择很关键:精度已经确定(不会再插入 Cast),但格式尚未固定(还有变换的空间)。

常量折叠:不止于"编译期求值"

GE 的常量折叠优化(ConstantFoldingPass)不仅做常规的"2+3=5"式编译期求值,还做了几项增强:

  1. Shape 计算类算子的常量折叠:当 Shape、Rank、Size 等算子的输入 Shape 为静态时,将其计算结果直接替换为 Const 节点。

  2. Shape 调整类算子的优化:ExpandDims、Squeeze、Unsqueeze 等算子在输入 Shape 为静态时,可以直接从图中删除(其 Shape 转换效果已被 InferShape 固化到后续算子的输入输出描述中)。

  3. 空 Tensor 处理:所有输出均为空 Tensor(Shape 中包含 0)的算子,可替换为 Const 节点。该 Const 仅用于承载描述原算子的输出 Shape 信息,不占用实际数据内存。

  4. 常量折叠与 InferShape 协同:这是 GE 的一项关键增强。InferShape 推导出的输出 Shape 可能使后续算子变成"输入全部为常量"的状态,从而触发新的常量折叠机会。GE 让 InferShape 与常量折叠交替执行,直到没有新的折叠机会为止(不动点迭代)。

算子调度(Scheduling)策略:多舞台并行的资源编排

类比:多舞台并行演出

回到剧场类比。如果你有四组演员、三座舞台,你希望让四组演员尽快完成所有场景的排练,你会怎么安排?

一种朴素的做法是:让所有演员排队,一组一组地上舞台。这种做法的好处是调度简单,坏处是舞台大部分时间只用到 1/3 的容量(其他两座舞台空着)。

更好的做法是:分析场景之间的依赖关系(哪些场景需要同一组演员,哪些场景需要的道具正在被另一场景使用),把无依赖关系的场景分配到不同舞台,让它们同时排练。

这就是 GE 算子调度策略的核心思想:在分析图的数据依赖关系的前提下,把可以并行执行的算子分配到不同 Stream(流),最大化硬件资源的利用率

拓扑排序与调度顺序的生成

调度顺序生成的第一步是拓扑排序(Topological Sorting)。AscendIR 是有向无环图,拓扑排序保证:对于任意一条数据边 (u, v),u 在排序中出现在 v 之前。这确保了算子执行时,其输入张量已经就绪。

但拓扑排序只是一个"合法顺序",不一定是"最优顺序"。GE 在拓扑排序的基础上,进一步做流分配(Stream Allocation),让可以并行的算子进入不同的流。

流分配:从逻辑流到物理流

流分配是 GE 调度策略中最复杂的部分之一。昇腾设备上的计算任务通过"流"(Stream)来组织和调度。流是设备侧的执行队列——同一条流内的任务严格按序执行,不同流之间的任务可以并行执行。

流分配的质量直接影响模型执行效率:分配的流太少,无法充分利用硬件并行能力;分配的流太多,又会带来过多的同步开销(Event/Notify)和资源占用。

GE 的流分配分为以下几个步骤:

步骤一:逻辑流分配(AssignLogicalStreams)

根据引擎类型和并行度,为每个算子分配逻辑流。在静态 Shape 场景下,这一步通过 Pass 链式架构完成,每个 Pass 负责一类分流规则:

  • UpdateForMdeGroupPass:根据 NewStreamId 属性为节点分配新流(最高优先级)。
  • AssignByLabelPass:根据 StreamLabel 属性分流,相同 StreamLabel 的算子分配到同一条流。
  • IndependentStreamPass:为独立引擎(如 HCCL)的算子分配独立流。
  • AssignByDependencyPass:根据数据依赖关系进行流复用——如果前驱子图中有可复用的流,且满足条件(scheduler_id 相同、不是独立引擎、无引擎冲突),则复用该流而非分配新流。
  • UpdateForParallelGroupPass:根据 PARALLEL_GROUP 属性为节点重新分配流,同一并行组的节点分配到同一条新流。
  • AllReduceParallelPass:当开启计算通信并行时,将 AllReduce 算子的后继非 HCOM 节点分配到新流,使 AllReduce 与反向计算可以并行执行。

步骤二:插入同步节点(InsertSyncNodes)

不同流上的算子之间需要同步事件来保证执行顺序正确性。GE 支持两种同步机制:Event(普通事件,Send/Recv 配对)和 Notify(更细粒度的同步)。系统在相邻不同流节点之间插入一对 Send/Recv 事件。

插入事件后,系统会通过三重优化消除冗余事件:OptimizeBySendEvents(消除同一条流内的冗余 Send)、OptimizeByRecvEvents(消除接收方向上的冗余)、OptimizeByStreamActivate(通过 StreamActive 机制优化跨流事件——当流 A 通过 StreamActive 激活了流 B,则流 A 到流 B 之间不需要额外的 Event)。

步骤三:物理流拆分(SplitStreams)

逻辑流分配不考虑 task 数量限制,但物理流承载的 task 数量有上限。当某条逻辑流上的 task 数量超过硬件限制时,需要拆分为多条物理流,并在拆分点前后插入同步事件。

以下代码展示了流分配在 GE 编译流程中的位置:

// GE 编译流程中流分配的调用位置(简化示意)
#include "graph/build/stream/stream_allocator.h"

void BuildGraphWithStreamAllocation(ge::ComputeGraphPtr graph) {
  // 步骤1:逻辑流分配
  auto stream_allocator = std::make_shared<ge::StreamAllocator>();
  auto status = stream_allocator->AssignLogicalStreams(graph);
  // 此时每个 Node 已获得 stream_id 属性
  
  // 步骤2:插入同步节点(Event/Notify)
  // 遍历所有数据边和控制边,当相邻两个节点属于不同流时插入同步
  status = stream_allocator->InsertSyncNodes(graph);
  // InsertSyncNodes must happen after AssignLogicalStreams because
  // synchronization requirements depend on the stream assignment results.
  // Doing it before stream assignment would insert unnecessary events between
  // nodes that will later be assigned to the same stream.
  
  // 步骤3:优化冗余同步事件
  status = stream_allocator->OptimizeSyncEvents(graph);
  
  // 步骤4:物理流拆分(当设备不支持无限深度流时)
  bool is_unlimited = CheckDeviceCapability();
  if (!is_unlimited) {
    status = stream_allocator->SplitStreamAndRefreshTaskDef(graph);
    // 拆分后需要更新 StreamActive 节点的激活列表
    status = stream_allocator->UpdateActiveStreams(graph);
  }
  
  // 步骤5:生成同步事件节点(Send/Recv 算子)
  status = stream_allocator->GenerateSyncEventNodes(graph);
  // 这些节点在后续 TaskGenerator 阶段会被转换为设备侧的 Event Record/Wait 任务
}

多流并行的三种场景

GE 的多流并行技术支持以下场景:

  1. 计算与通信并行:AllReduce(集合通信)与 Convolution(矩阵计算)无拓扑依赖时可并发执行。在 LLM 训练场景中,梯度聚合(AllReduce)与反向计算并行可显著缩短训练时间。

  2. 不同计算引擎并行:AI Core(矩阵运算)、Vector Core(向量运算)、DVPP(图像预处理)等不同引擎的 task 可下发到不同引擎上并发执行。

  3. 相同计算引擎内并行:当计算图中某个节点无法占满一个计算引擎的全部计算资源,且拓扑结构可并发时,该引擎的不同拓扑集合的 task 可并发执行。

以下是流分配优化效果的对比数据:

维度 单流执行 多流并行(优化后) 差异来源
LLM-65B 全量图执行时间 基准值 100% 约 70%(提升约 30%) 计算与通信并行、矩阵与向量并行
盘古-71B 全量图执行时间 基准值 100% 约 85%(提升约 15%) 模型结构差异导致并行度不同
设备内存占用增加量 基准值 0% 约 7% 多流导致更多并发内存需求
Host 调度开销 逐算子下发(高) 一次下发(TaskSink) Sink 模式消除 Host-Device 交互

GE 与昇腾硬件的映射:从编译蓝图到硅片执行

类比:从排练计划到正式演出

剧场制片人完成所有场景拆解、排练安排、道具分配后,最终需要把这些书面计划变成真实的演出。这个阶段面临的核心问题是:排练计划中的抽象描述(“场景三需要一把剑”),如何映射到剧场后台的具体物品(“道具间的第三排第二把剑”)?

在 GE 的上下文中,这个映射分为两个层面:

  1. 任务描述(Task Description)的生成:GE 编译器将优化后的图转换为任务序列(ModelTaskDef),每个任务包含算子二进制、输入输出偏移量、Stream ID 等信息。

  2. Runtime 加载与执行:GE 运行时将编译产物加载到昇腾设备,通过 TaskSink 模式将任务序列预下发到设备端,执行时只需一次触发。

Task Description 如何传递给 Runtime

GE 编译的最终产物是 GeRootModel,它包含根图(Root Graph)和每个子图对应的 GeModel。每个 GeModel 包含:

  • 任务序列(ModelTaskDef):Protocol Buffer 格式,描述每个算子的执行任务。
  • 权重数据(weight_buffer):模型参数。
  • TBE Kernel 存储(tbe_kernel_store):编译后的算子二进制。
  • 内存布局信息:Stream 数量、Event 数量、内存大小等。

TaskGenerator(build/task_generator.h)负责将优化后的图转换为任务序列。对于每个节点,TaskGenerator 根据算子的 OpKernelLibName 调用对应的执行引擎来生成任务。生成的 TaskDef 包含算子二进制、输入输出偏移量、Stream ID、工作空间大小和偏移等信息。

GE 如何感知 NPU 芯片拓扑

GE 通过 Runtime 接口感知 NPU 芯片的硬件拓扑信息。具体来说:

  • AI Core/AI Vector Core 资源划分:通过查询设备能力接口(如 aclrtGetDeviceInfo)获取 AI Core 的数量、AI Vector Core 的开关状态等信息。这些信息影响引擎分区决策(哪些算子可以分配到 AI Core,哪些只能走 Vector Core)。

  • 内存拓扑:通过 aclrtMalloc 等接口分配设备内存,GE 的内存规划结果(每个张量的偏移量)最终通过改写 Task 的参数区来实现——每个 Task 的 Args 表中存放的是"基准地址 + 偏移量"的计算结果。

  • 流容量上限:不同型号的昇腾设备支持的流数量、单流 task 容量不同。GE 的 StreamAllocator 在物理流拆分阶段会查询设备能力(FEATURE_TYPE_PERSISTENT_STREAM_UNLIMITED_DEPTH),决定是否需要拆分。

TaskSink 模式:从"逐算子下发"到"一次下发"

GE 的 TaskSink 模式是其与昇腾硬件映射关系中的关键优化。传统 Host 调度模式下,Host 逐算子下发任务到 Device,每个算子的下发都需要一次 Host-Device 交互。当模型有上千个算子时,Host 调度成为性能瓶颈。

TaskSink 模式的核心思想是:在编译期将整图的 Task 序列序列化到 OM 文件中,运行时通过一次 rtModelExecute 调用将所有 Task 预加载到设备端。此后模型执行时,Host 只需触发一次执行信号,设备端自动完成所有 Task 的调度与执行

这好比剧场演出:粗剪时期每个场景单独确认(逐算子下发),正式演出后整台剧目一次性开演(TaskSink),演员按照排练好的顺序自主完成所有场景,无需制片人每场戏都跑来说"接下来演哪场"。

以下代码展示了 Runtime 加载和 TaskSink 的关键流程:

// DavinciModel 初始化和 TaskSink 的核心流程(简化示意)
#include "runtime/v1/graph/load/model_manager/davinci_model.h"

ge::Status DavinciModel::Init(const GeModelPtr &ge_model) {
  // 阶段1:内存映射——分配 Feature Map / Weight / Variable 内存
  auto ret = InitModelMem();
  // Memory must be allocated before tensor address binding. If memory
  // is allocated after task generation, the address references in task arguments
  // would be invalid, requiring a full task regeneration pass.
  
  // 阶段2:I/O 节点初始化——建立输入/输出的地址映射(Zero-Copy)
  ret = InitIoNodes();
  
  // 阶段3:算子节点初始化——注册 Kernel Handle,分配控制流硬件资源
  ret = InitNodes();
  
  // 阶段4:TaskSink——将任务下沉到设备
  ret = DoTaskSink();
  // DoTaskSink 内部调用:
  //   - BindModelStream: 将所有逻辑流绑定到 rtModel 句柄
  //   - InitTaskInfo + DistributeTask: 遍历 ModelTaskDef,
  //     为每个 Task 创建 TaskInfo 对象并调用 Distribute() 下发到设备
  //   - aclmdlRIBuildEnd: 通知底层运行时"模型构建完毕"
  
  return SUCCESS;
}

// 执行阶段:一次 rtModelExecute 触发设备端全图执行
ge::Status DavinciModel::Run() {
  // 从 DataInputer 队列取输入数据
  auto input_data = data_inputer_.Pop();
  // 将输入地址写入模型的 Args 表(Zero-Copy 路径)
  HandleInputData(input_data);
  // 一次调用,触发设备端全部 Task 的执行
  aclError acl_ret = rtModelExecute(rt_model_handle_);
  // 等待设备完成
  acl_ret = rtStreamSynchronizeWithTimeout(exec_stream_);
  return (acl_ret == ACL_SUCCESS) ? SUCCESS : FAILED;
}

GE 可视化与调试:从 dot 图到性能瓶颈定位

类比:彩排录像回放

剧场彩排时,制片人通常会录下整场彩排,再回放分析:哪场戏的换景时间太长、哪组演员在舞台上等待的时间太多、哪件道具被频繁搬运。

GE 的可视化与调试工具正是这套"彩排录像回放"机制的技术实现。

dot 图导出工具的使用

GE 支持将计算图导出为 dot 格式(Graphviz),开发者可以通过 dot 工具将图可视化为 PNG/SVG 等格式。dot 图导出在以下场景尤为有用:

  • 验证图结构正确性:检查算子连接关系是否符合预期,是否存在意外的边或缺失的边。
  • 对比优化前后:将同一张图在优化前和优化后分别导出 dot 图,直观对比算子数量、图结构的变化。
  • 调试引擎分区结果:通过 dot 图查看每个算子被分配到了哪个引擎(通常通过节点颜色或标签区分),判断分区是否合理。

导出 dot 图的方式通常通过在 GE 编译流程中插入图打印函数实现。GE 的 Graph 类提供了 Dump 接口,可以将图结构序列化为文本格式。

GE 优化前后算子图的对比分析

对比分析的核心是比较同一张图在不同阶段(优化前 vs 优化后,分区前 vs 分区后)的算子数量、算子类型分布、图深度、内存占用等指标。

以下代码展示了如何在 GE 编译流程中插入图统计逻辑:

// 在编译流程的关键节点打印图统计信息
#include "graph/compute_graph.h"
#include "graph/node.h"

void PrintGraphStats(const ge::ComputeGraphPtr &graph, const std::string &stage_name) {
  int64_t total_nodes = graph->GetAllNodes().size();
  int64_t data_nodes = 0;
  int64_t add_nodes = 0;
  int64_t const_nodes = 0;
  // 遍历所有节点,统计各类型算子数量
  for (const auto &node : graph->GetAllNodes()) {
    std::string type = node->GetOpDesc()->GetType();
    if (type == "Data") data_nodes++;
    else if (type == "Add") add_nodes++;
    else if (type == "Const") const_nodes++;
    // 可扩展统计其他算子类型
  }
  printf("[GE][%s] TotalNodes=%ld Data=%ld Add=%ld Const=%ld\n",
         stage_name.c_str(), total_nodes, data_nodes, add_nodes, const_nodes);
  // Printing graph statistics at each compilation stage helps identify
  // which pass is most effective at reducing node count. Without these stats,
  // developers must manually compare two dot graphs, which is error-prone for
  // large models with thousands of nodes.
}

图级别性能瓶颈定位方法

GE 提供了多层次的性能可观测性:

  1. Profiling 数据采集:GE 支持分层性能采集(API 层、Host 层、Device 层),采集数据通过 msprof 统一上报。开发者可以通过 Profiling 数据定位:哪些算子的执行时间最长、哪些流之间存在等待、Event 同步开销占比等。

  2. Dump 模块:GE 的 Dump 模块支持将模型的中间张量数据导出到文件,开发者可以通过对比输入/输出数据,定位具体哪个算子产生了异常输出。

  3. 模型缓存机制:GE 支持编译结果缓存(build/model_cache.h)。通过 ComputeHashForConstNodes 对常量节点计算 SHA256 哈希作为缓存键。如果两次编译的图结构相同(哈希一致),则直接加载缓存的编译产物,跳过重复编译。这为性能调试提供了"快速复现"能力——修改代码后,无需等待全量编译即可验证运行时行为(假设图结构未改变)。

结尾

GE(Graph Engine)是 CANN 全栈中连接前端框架与昇腾 NPU 硬件的关键组件,承担图编译与图执行的双重职责。本文从 GE 在 CANN 架构中的中心位置切入,通过"剧场制片"的类比,拆解了算子图构建、图优化 Pass 体系、算子调度策略、硬件映射机制、可视化与调试方法六个核心主题。


仓库地址:https://atomgit.com/cann/ge

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐