CANN pto-isa:Graph Compiler 到底怎么把计算图变成 PTO 虚拟指令?
摘要:本文分析了Transformer模型在昇腾NPU上性能下降40%的原因——计算图被切分成过多小算子,导致NPU计算单元等待数据搬运。华为CANN通过引入虚拟指令集PTO(PTO ISA)解决这一问题,将编译流程解耦为前端生成PTO指令和后端映射到硬件指令。PTO作为中间表示层,支持跨硬件算子融合优化,解决了硬件指令碎片化、算子融合受限和跨框架计算图不统一三大工程问题。文章详细拆解了Graph

个人主页:
文章目录
之前帮同事调一个 Transformer 推理性能问题,模型在 PyTorch 上跑得好好的,转到昇腾 NPU 上之后吞吐直接掉了 40%。打开 Graph Compiler 的编译日志一看——整个计算图被切成了一百多个小算子,每个算子都要做数据搬运、格式转换、内存分配,NPU 的计算单元大部分时间在等数据。
这个问题本质上不是算子写得不好,而是编译链路没有全局视野——逐算子编译只看局部,不知道整个计算图里哪些数据可以复用、哪些算子可以融合、哪些内存可以静态分配。
CANN 的解决思路是在 Graph Compiler 和底层硬件指令之间插了一层虚拟指令集 PTO(PTO Instruction Set Architecture)。Graph Compiler 不直接生成 NPU 的底层指令,而是先生成 PTO 虚拟指令,再由后端的指令映射模块把 PTO 翻译成真正能在昇腾 NPU 上执行的机器码。
这篇文章拆开这条编译链路:从为什么需要虚拟 ISA、PTO 长什么样、Graph Compiler 怎么生成 PTO、昇腾 NPU 怎么执行底层指令,到 Transformer 推理的完整编译流程。
为什么 PTO 存在:编译系统的"普通话"
直接说结论:PTO 存在的核心价值是解耦前端编译和后端指令生成。
没有 PTO 的时候,Graph Compiler 需要直接面对不同代际的昇腾 NPU 硬件——Ascend 910、Ascend 950PR、Ascend 950DT 的底层指令集都不一样,每适配一款新硬件就要改一遍 Graph Compiler 的指令生成逻辑。硬件迭代一次,编译器跟着改一次,维护成本线性增长。
有了 PTO 之后,编译流程变成:
PyTorch/TensorFlow 计算图
↓ (前端:图解析 + 图优化)
Graph Compiler 中间表示(HIR/MIR/LIR)
↓ (指令选择 + Tile 调度)
PTO 虚拟指令(90+ 标准 Tile 级操作)
↓ (指令映射 + 寄存器分配)
昇腾 NPU 底层机器码(达芬奇架构指令)
前端只需要生成一份 PTO,后端针对每款硬件写一份 PTO→机器码的映射规则。新增硬件?只改后端映射,前端不动。新增算子?只改前端生成逻辑,后端映射自动复用。
这个思路和 LLVM IR、NVPTX 本质上是一样的——虚拟 ISA 是编译系统的"普通话",让前后端用同一套语言对话。
PTO 的全称是 PTO Instruction Set Architecture,它是 CANN 五层架构中第 3 层(编译层)的核心组件,和 Graph Compiler 配合工作。pto-isa 仓库定义了这套虚拟指令集的规范:90+ 标准 Tile 级操作,覆盖矩阵乘、向量运算、数据搬运、内存管理这些算子开发的基础操作。它的定位是跨平台算子开发的中间表示,不是底层硬件指令集。
仓库地址:https://atomgit.com/cann/pto-isa
为什么 AI 编译需要虚拟 ISA:三个绕不开的工程问题
问题 1:硬件指令集碎片化
昇腾 NPU 的达芬奇架构指令集是和硬件微架构绑定的。Ascend 910 的 Cube 单元指令和 Ascend 950 的 Cube 单元指令不一样,更不用说 Vector 单元、Scalar 单元的差异。如果 Graph Compiler 直接生成硬件指令,每款硬件要维护一套独立的指令生成逻辑——CANN 要支持的硬件组合一多,这套逻辑就崩了。
问题 2:算子融合的优化空间被硬件指令集限制
算子融合是 AI 编译最核心的优化手段之一。FlashAttention 把 Attention 的多个中间步骤融合成一个算子,省掉四次 HBM 读写。但如果 Graph Compiler 直接操作硬件指令,融合策略会被硬件约束绑架——某些融合模式在 Ascend 910 上能生成高效指令,在 Ascend 950 上反而性能下降,因为两款硬件的 Cube/Vector 并行度不一样。
PTO 把融合优化和指令生成解耦:融合在 PTO 层做,指令生成在后端的映射阶段做。前端只管生成语义正确的 PTO 指令序列,后端根据目标硬件的微架构特点做指令调度和优化。
问题 3:跨框架的计算图表示不统一
PyTorch 的 Dynamo 导出的计算图、TensorFlow 的 XLA 生成的 HLO、ONNX 的算子原型——它们的计算图表示都不一样。Graph Compiler 要做统一编译,先得把这些异构计算图都 lowering 到同一套中间表示,再生成统一的 PTO 指令。
如果跳过 PTO 直接生成硬件指令,等于把"统一中间表示"和"硬件指令生成"绑死在一起——换一个输入框架就要改一遍指令生成逻辑。
Graph Compiler 如何生成 PTO:从计算图到虚拟指令的完整流水线
Graph Compiler 的生成流程可以拆成四个阶段。每个阶段的输入输出、核心数据结构、关键变换,下面逐一拆解。
阶段 1:计算图解析与 HIR 生成
Graph Compiler 拿到的是框架导出的计算图(PyTorch 的 TorchScript、TensorFlow 的 GraphDef、ONNX 的 ModelProto)。第一步是把这些异构计算图统一 lowering 到 Graph Compiler 的 HIR(High-Level IR)。
HIR 是算子树状表示,每个节点对应一个算子(MatMul、Softmax、LayerNorm 等),边表示数据流依赖。这一步主要做三件事:
- 算子原型对齐:把不同框架的算子映射到 CANN 的算子原型。比如 PyTorch 的
torch.nn.functional.scaled_dot_product_attention会映射到 ops-transformer 仓库的FlashAttentionScore算子原型。 - 形状推导:根据输入张量的形状信息,推导每个算子输出的形状、dtype、layout。
- 数据类型统一:把框架特有的数据类型(比如 PyTorch 的
bfloat16)映射到 CANN 的通用数据类型表示。
// HIR 阶段的算子节点表示(伪代码,展示数据结构设计)
struct HIRNode {
std::string op_type; // 算子类型:"MatMul", "Softmax", ...
std::vector<Tensor*> inputs; // 输入张量(含形状/dtype信息)
std::vector<Tensor*> outputs; // 输出张量
std::map<std::string, Attr> attrs; // 算子属性(axis, alpha, ...)
std::vector<HIRNode*> consumers; // 消费本节点输出的下游节点
};
阶段 2:图优化与 MIR 生成
HIR 生成之后,Graph Compiler 做图优化。优化后的计算图 lowering 到 MIR(Middle-Level IR),这一步开始引入 Tile 化的计算模型。
核心优化手段:
算子融合。把相邻算子合并成一个融合算子,减少中间结果的 HBM 读写。典型融合模式:MatMul + BiasAdd + GELU、Softmax + MatMul(即 FlashAttention 的核心思路)。融合决策在 MIR 层做——分析算子的数据访问模式、Tile 大小、寄存器压力,决定是否融合、怎么融合。
内存复用。分析张量的生命周期,让不同算子的输出张量复用同一块显存。比如 Transformer 的 Attention 输出和 FFN 的输入之间如果没有算子依赖,这两块显存可以复用。
死代码消除。去掉计算图中对最终输出没有贡献的节点。训练计算图转推理计算图时,Dropout、某些归一化算子会被消除。
MIR 的每个节点不再是一个完整算子,而是一个 Tile 级的计算任务。一个 MatMul 会被拆成若干个 Tile,每个 Tile 的计算对应一次 Cube 单元调用。
// MIR 阶段的 Tile 任务表示(伪代码)
struct MIRTile {
std::string tile_type; // Tile 类型:"matmul_tile", "vector_tile", ...
TileShape shape; // Tile 形状:(M, N, K) for MatMul
MemoryRegion* input_A; // 输入 A 的内存区域(可能在 SRAM 或 HBM)
MemoryRegion* input_B; // 输入 B
MemoryRegion* output_C; // 输出 C
int parallel_strategy; // 并行策略:row_split / col_split / batch_split
};
阶段 3:指令选择与 LIR 生成
MIR 的 Tile 任务确定之后,下一步是指令选择——给每个 Tile 任务分配对应的 PTO 虚拟指令序列。这一阶段的输出是 LIR(Low-Level IR),它是 PTO 指令的线性序列。
指令选择的核心数据结构是一张指令映射表:
| Tile 任务类型 | 所选 PTO 指令 | 备注 |
|---|---|---|
| MatMul Tile (Cube) | PTO_CUBE_MM |
矩阵乘,调用 Cube 单元 |
| Vector Add Tile | PTO_VECTOR_ADD |
逐元素加法,调用 Vector 单元 |
| Softmax Tile | PTO_VECTOR_SOFTMAX |
Softmax,Vector 单元 + 临时寄存器 |
| Transpose Tile | PTO_DMA_TRANSPOSE |
数据重排,调用 DMA 引擎 |
| Reduce Tile | PTO_VECTOR_REDUCE |
归约操作(sum/max/…) |
每个 PTO 指令是一个 Tile 级操作,它的操作数不是单个数值,而是一块连续内存区域(Tile)。这和传统 CPU 指令集的标量操作完全不一样——PTO 的一条指令可能对应 Cube 单元的数千次并行乘加。
// LIR 阶段的 PTO 指令表示(伪代码)
struct PTOInstruction {
PTOOpcode opcode; // 操作码:PTO_CUBE_MM / PTO_VECTOR_SOFTMAX / ...
TileOperand dst; // 目标 Tile(输出)
std::vector<TileOperand> src; // 源 Tile(输入,可能有多个)
InstructionAttr attr; // 指令属性:dtype, layout, ...
};
// 生成 PTO 指令序列的伪代码
std::vector<PTOInstruction> lower_tile_to_pto(const MIRTile& tile) {
if (tile.tile_type == "matmul_tile") {
return {PTOInstruction{
.opcode = PTO_CUBE_MM,
.dst = tile.output_C,
.src = {tile.input_A, tile.input_B},
.attr = {.dtype = tile.shape.dtype, .layout = "FRACTAL_NZ"}
}};
}
// ... 其他 Tile 类型
}
阶段 4:PTO 指令调度与寄存器分配
LIR 生成之后,最后一步是指令调度和寄存器分配。这一步决定:每条 PTO 指令在什么时间点执行、用哪些寄存器存放中间结果、Cube/Vector/Scalar 单元怎么并行。
调度策略考虑三个约束:
- 数据依赖约束:如果指令 B 读指令 A 的产出 Tile,B 必须在 A 完成后才能发射。
- 资源约束:Cube 单元和 Vector 单元可以并行,但同一类单元的内部算术逻辑要串行。
- 显存层次约束:SRAM 的大小有限,要合理安排 Tile 的加载/驱逐策略,尽量减少 HBM 访问。
调度完成后,PTO 指令序列和调度信息一起输出,作为后端指令映射阶段的输入。
昇腾 NPU 如何执行底层指令:从 PTO 到机器码的完整映射
PTO 是虚拟指令,不能直接在硬件上执行。昇腾 NPU 的执行引擎需要把 PTO 指令映射成达芬奇架构的底层机器码。
指令映射的核心:PTO opcode → 达芬奇微操作码
达芬奇架构的计算单元分三类:Cube(矩阵乘)、Vector(向量运算)、Scalar(标量运算)。PTO 的 90+ 标准操作会根据操作类型被映射到不同单元的微操作码。
映射规则示例(以 Ascend 910 为例):
| PTO 指令 | 目标硬件单元 | 达芬奇微操作码 | 说明 |
|---|---|---|---|
PTO_CUBE_MM |
Cube | CUBE_MM |
矩阵乘,FP16/INT8 输入 |
PTO_VECTOR_ADD |
Vector | V_ADD |
逐元素加法 |
PTO_VECTOR_SOFTMAX |
Vector | V_SOFTMAX + V_REDUCE |
Softmax 拆成两步走 |
PTO_DMA_LOAD |
DMA | DMA_L1TO_L0 |
数据从 L1 缓冲区搬到 L0 缓冲区 |
PTO_SINC |
Scalar | SCALAR_ADD |
标量加法(用于循环计数等) |
映射阶段还会做寄存器分配:把 PTO 指令的 Tile 操作数绑定到具体的物理寄存器(URG、L0A、L0B、L0C 等)。这个分配结果会影响指令的发射延迟——如果两个相邻指令竞争同一个寄存器文件,就得插入等待周期。
Kernel 生成:从机器码到可执行 Task
指令映射完成后,生成的是线性机器码序列。但这还不是一个可以提交给 NPU 执行的完整 Kernel。
Kernel 生成的最后一步是把机器码序列封装成 Task 描述符,包含:
- 机器码本身的二进制数据
- Task 的类型(Vector Task / Cube Task / DMA Task)
- 依赖关系(这个 Task 需要等哪些 Task 完成)
- 块/线程配置(Grid/Block 维度,对应 Launch 参数)
Runtime 运行时读取 Task 描述符,把机器码加载到 NPU 的指令缓存,触发执行。
// Kernel 生成伪代码(展示 Task 描述符结构)
struct TaskDescriptor {
TaskType type; // VECTOR_TASK / CUBE_TASK / DMA_TASK
void* machine_code; // 机器码指针
size_t machine_code_size; // 机器码字节数
std::vector<int> depend_ids; // 依赖的 Task ID 列表
Dim3 grid_dim; // Grid 维度
Dim3 block_dim; // Block 维度
};
// Runtime 提交 Task 的伪代码
void submit_task(const TaskDescriptor& task) {
// 1. 把机器码加载到 NPU 指令缓存
load_machine_code_to_icache(task.machine_code, task.machine_code_size);
// 2. 设置 Grid/Block 配置
configure_grid_block(task.grid_dim, task.block_dim);
// 3. 等待依赖的 Task 完成
wait_dependencies(task.depend_ids);
// 4. 触发执行
trigger_execution();
}
Transformer 推理中的编译链路:从输入到输出的完整路径
把上面所有阶段串起来,看一个 Transformer 推理请求在 CANN 上的完整编译和执行链路。
编译期(只需执行一次)
假设要部署的模型是 Qwen3-32B,推理框架用的是 PyTorch + TorchAir 图模式。
步骤 1:计算图导出。PyTorch 模型通过 TorchAir 导出成统一的计算图表示(绕过 PyTorch 的 eager 执行,直接拿到静态计算图)。
步骤 2:Graph Compiler 前端处理。计算图被 lowering 到 HIR,完成算子原型对齐和形状推导。这一步会把 nn.MultiHeadAttention 映射到 ops-transformer 仓库的 FlashAttentionScore + GEMM 算子组合。
步骤 3:图优化。Graph Compiler 做算子融合——把 MatMul + GELU 融合成一个算子,把 Softmax + MatMul 融合成 FlashAttention 模式。同时做内存复用分析,让 KV Cache 的显存块在整个推理过程中静态分配。
步骤 4:PTO 生成。优化后的计算图被 lowering 到 MIR(Tile 化),再生成 LIR(PTO 指令序列),最后做指令调度和寄存器分配。输出是一份完整的 PTO 指令序列 + 调度信息。
步骤 5:指令映射 + Kernel 生成。后端把 PTO 指令映射成 Ascend 910 的机器码,封装成 Task 描述符。所有 Task 的依赖关系和并行策略都在这一步确定。
步骤 6:序列化。编译完成的模型(包含 Task 描述符、权重、KV Cache 配置)被序列化成离线模型文件,部署时直接加载,不需要重新编译。
推理期(每个请求执行一次)
步骤 7:模型加载。Runtime 运行时把离线模型加载到 NPU 显存,初始化 KV Cache 缓冲区。
步骤 8:推理执行。每个 token 的生成经历 Prefill 阶段(处理输入序列)+ Decode 阶段(逐 token 生成)。每个阶段都按照编译期确定的 Task 调度顺序执行——FlashAttention 的 PTO 指令先跑,GELU 的 PTO 指令等 MatMul 完成后再跑,Cube 和 Vector 单元的并行在指令映射阶段已经安排好。
步骤 9:KV Cache 管理。Transformer 推理的核心瓶颈之一是 KV Cache 的显存占用。编译期已经通过 PTO 指令的调度分析确定了 KV Cache 的显存布局(Paged Attention 模式下的分块管理),推理期只需要按调度好的地址做 DMA 搬运,不需要动态显存分配。
# Transformer 推理编译链路的伪代码(展示关键调用)
import torch
import torch_npu
from torchair import TorchAirConfig
# 编译期:导出计算图 + Graph Compiler 编译
model = load_qwen3_32b()
config = TorchAirConfig(enable_graph_mode=True, enable_fusion=True)
with torchair.inference_session(config) as session:
# 这一步触发 Graph Compiler 的完整编译流水线
# HIR → MIR → LIR(PTO) → 机器码 → Task 描述符
session.compile(model, example_inputs)
# 推理期:加载编译结果 + 执行
runtime = torch_npu.npu.runtime.Runtime(session.get_compiled_model())
kv_cache = runtime.allocate_kv_cache(max_seq_len=8192)
for token in input_tokens:
# 每个 token 按照编译好的 Task 调度顺序执行
output = runtime.forward(token, kv_cache)
从 PTO 看 CANN 编译层的设计取舍
PTO 这套虚拟指令集的设计,背后有几个关键的工程取舍:
Tile 级指令 vs 算子级指令。PTO 选择 Tile 级而不是算子级作为虚拟指令的粒度。好处是后端的指令映射有更大的调度空间——可以把多个小 Tile 合并成一次大的硬件调用,也可以把一个大 Tile 拆成多次小调用以适配 SRAM 大小。如果 PTO 是算子级的,这些调度决策就被前端固定死了。
90+ 标准操作 vs 开放扩展。pto-isa 仓库定义了 90+ 标准 Tile 级操作,覆盖大部分算子开发的需求。但 AI 算子在不停演进——MoE、GroupedQueryAttention、MLA 这些新结构不一定能直接用标准操作组合出来。PTO 的设计是标准操作 + 自定义 Tile 操作并行——常用算子走标准路径,新算子可以定义自定义 PTO 操作,只要后端的指令映射能处理。
跨平台 vs 硬件特化。PTO 是虚拟 ISA,理论上可以映射到不同的硬件架构。但 PTO 的 Tile 操作集本身是针对达芬奇架构的计算特点设计的(Cube/Vector/Scalar 三分法、SRAM/HBM 两级存储、DMA 数据搬运)。如果要把 PTO 映射到其他厂商的 NPU,指令映射层的改动量不小。所以 PTO 的"跨平台"目前主要在昇腾系列硬件内跨代兼容,不是通用虚拟 ISA。
继续深入:Graph Compiler 与 pto-isa 仓库
这篇文章拆了 Graph Compiler 生成 PTO 的完整流水线——从计算图解析、图优化、Tile 调度、PTO 指令生成、到指令映射和 Kernel 生成。这条链路是 CANN 编译层的核心,理解了它,就理解了昇腾 NPU 上 AI 模型为什么能跑、怎么跑得快。
如果想深入掌握这套编译系统,建议按这个顺序继续学习:
-
先读 pto-isa 仓库的指令集规范。90+ 标准 Tile 级操作的定义、每个操作的输入输出语义、Tile 的内存布局约定。仓库地址:https://atomgit.com/cann/pto-isa
-
再读 Graph Compiler 的图优化实现。GE 图引擎的源码里可以看到算子融合、内存复用、多流并行的具体实现。它和 Graph Compiler 的配合方式是理解编译链路的关键。仓库地址:https://atomgit.com/cann/ge
-
最后看一个具体的算子如何从 Python 到 PTO 到机器码。ops-transformer 仓库里的 FlashAttentionScore 算子是个好例子——它有完整的 Ascend C 实现、Graph Compiler 的融合注册逻辑、PTO 指令生成路径。跑一遍 debug 流程,编译链路的每个环节就都串起来了。
CANN 的开源仓库都在 https://atomgit.com/cann/ ,直接 clone 下来看源码比读文档有效得多。
更多推荐




所有评论(0)