
个人主页:ujainu
文章目录
-
- 前言
- [为什么 PTO 存在:编译器的"中间协议"](#为什么 PTO 存在:编译器的"中间协议")
- [为什么 AI 编译需要虚拟 ISA:从指令集碎片化说起](#为什么 AI 编译需要虚拟 ISA:从指令集碎片化说起)
-
- [没有虚拟 ISA 的世界](#没有虚拟 ISA 的世界)
- [引入 PTO 虚拟 ISA 之后](#引入 PTO 虚拟 ISA 之后)
- [PTO 指令集:90+ 标准 Tile 级操作](#PTO 指令集:90+ 标准 Tile 级操作)
-
- [PTO 指令的基本格式](#PTO 指令的基本格式)
- [Graph Compiler 如何生成 PTO:从计算图到指令序列](#Graph Compiler 如何生成 PTO:从计算图到指令序列)
- [昇腾 NPU 如何执行底层指令:从 PTO 到硬件](#昇腾 NPU 如何执行底层指令:从 PTO 到硬件)
- [Transformer 推理中的编译链路:完整走读](#Transformer 推理中的编译链路:完整走读)
- [调试技巧:如何查看 PTO 指令序列](#调试技巧:如何查看 PTO 指令序列)
-
- [打印 PTO 指令](#打印 PTO 指令)
- 性能分析方法
- [配置文件:编译选项对 PTO 生成的影响](#配置文件:编译选项对 PTO 生成的影响)
- [构建与运行:验证 PTO 指令生成](#构建与运行:验证 PTO 指令生成)
- 总结
前言
第一次看 CANN 五层架构图时,很多人会漏掉第 3 层编译层和底层硬件之间的那个"灰色地带"------编译完了,指令是怎么真正跑到昇腾 NPU 上的?
答案就藏在 pto-isa 这个仓库里。
pto-isa 定义了一套 PTO(Parallel Tile Operation)虚拟指令集架构。它不直接对应昇腾达芬奇架构的底层指令,而是给 Graph Compiler 提供一个稳定的中间目标------让编译器只管生成 PTO 指令,让运行时去操心怎么映射到真实硬件。
这篇文章把这条链路拆开讲清楚:为什么需要虚拟 ISA、Graph Compiler 怎么生成 PTO、昇腾 NPU 怎么执行底层指令、以及 Transformer 推理里这条编译链路长什么样。
为什么 PTO 存在:编译器的"中间协议"
直接说结论:没有 PTO,Graph Compiler 就要直接面对每一代昇腾 NPU 的底层指令变化。
昇腾 NPU 的底层指令集是硬件相关的。Ascend 910、Ascend 950PR、Ascend 950DT 的指令编码格式不同,Cube 单元和 Vector 单元的指令格式也不同。如果 Graph Compiler 直接生成底层指令,每出一款新硬件就要改一遍编译后端------这个维护成本,没有人扛得住。
PTO 干的事就是插进去做"中间协议":
Graph Compiler(硬件无关)
↓ 生成
PTO 虚拟指令(pto-isa 定义)
↓ 指令映射
底层 NPU 指令(硬件相关)
这个设计的核心收益有三条:
- 编译器与硬件解耦------Graph Compiler 只需要面向 PTO 指令集编程,不需要关心底层指令怎么编码
- 跨代兼容------新硬件只需要新增一套 PTO→底层指令的映射规则,编译器侧零修改
- 跨平台算子开发------pto-isa 定义了 90+ 标准 Tile 级操作,开发者面向 PTO 写算子,自动获得多硬件后端支持
用一句大白话讲:PTO 是编译器和硬件之间的"翻译协议",让两边各自演化,不再互相绑架。
为什么 AI 编译需要虚拟 ISA:从指令集碎片化说起
通用 CPU 世界里有 x86 和 ARM 两条指令集,大家忍忍也就过去了。AI 加速芯片的世界里,每一家的指令集都是私有的,甚至连同一家的不同代芯片都会改指令编码格式。
If you write a compiler that directly generates hardware instructions, you're signing up for a maintenance nightmare.
没有虚拟 ISA 的世界
Graph Compiler ──→ Ascend 910 指令格式(硬编码)
↑ 换硬件?重写编译器后端
这是早期 AI 编译器的做法。问题在于:
- 昇腾 NPU 的指令是 Tile 级 的------每次操作处理一个 tile(小块数据),而不是逐元素操作。Tile 的大小、排布方式、Cube/Vector 单元协同方式,每一代都会优化调整
- 底层指令格式是 编码级 的------opcode 占几位、寄存器索引占几位、立即数怎么编码,都是硬件细节
- 融合算子需要 指令级协同------FlashAttention 的 tiling 逻辑需要在指令层面做流水编排,直接生成底层指令会让编译器变得极其复杂
引入 PTO 虚拟 ISA 之后
Graph Compiler ──→ PTO 虚拟指令(pto-isa 标准定义)
↓ 指令映射层(运行时负责)
Ascend 910 指令格式
Ascend 950PR 指令格式
...(新增硬件只需加映射规则)
PTO 的"虚拟"体现在三件事上:
- 不绑定具体硬件编码------PTO 指令描述的是"做什么操作",不是"怎么编码成二进制"
- Tile 级语义 ------每条 PTO 指令对应一个标准 Tile 操作(如
tile_matmul、tile_softmax),编译器用 Tile 粒度思考,不用管底层怎么拆分 - 可扩展的映射规则------同一份 PTO 指令序列,通过不同的映射配置,可以生成不同硬件的底层指令
PTO 指令集:90+ 标准 Tile 级操作
pto-isa 仓库的核心产物是一份 PTO 指令集规范,定义了 90+ 条标准 Tile 级操作。
这些操作按功能可以分成几大类:
| 类别 | 代表指令 | 说明 |
|---|---|---|
| 矩阵运算 | tile_matmul、tile_mmad |
Tile 级矩阵乘,映射到 Cube 单元 |
| 向量运算 | tile_relu、tile_gelu、tile_layernorm |
Tile 级向量计算,映射到 Vector 单元 |
| 数据搬运 | tile_load、tile_store、tile_broadcast |
Tile 间数据搬运,映射到 MTE |
| 归约操作 | tile_reduce_sum、tile_reduce_max |
Tile 内归约,常用于 Softmax |
| 融合操作 | tile_matmul_gelu、tile_attention_score |
多操作融合为单条 PTO 指令 |
PTO 指令的基本格式
每条 PTO 指令包含:
- 操作码(opcode) :标识指令类型(如
TILE_MATMUL) - 操作数描述:源 Tile 索引、目标 Tile 索引、Tile 形状
- 数据类型:FP16、BF16、FP32、INT8 等
- 融合标志:是否与其他指令融合执行
下面是一个 PTO 指令序列的真实样子(伪代码格式):
python
# PTO 指令序列示例:FlashAttention 的 attention score 计算
# 这段 PTO 指令由 Graph Compiler 生成,描述了一个完整的 attention score 计算
pto_program = [
# 1. Q 和 K 做矩阵乘,得到 attention score
PTOInstruction(
opcode="TILE_MATMUL",
src_tiles=[tile_q, tile_k], # Q 和 K 各是一个 tile
dst_tile=tile_score,
dtype="FP16",
fuse_next=True # 和下一指令融合
),
# 2. 对 attention score 做 Softmax(融合执行,省一次 Tile 写回)
PTOInstruction(
opcode="TILE_SOFTMAX",
src_tiles=[tile_score],
dst_tile=tile_softmax,
dim=-1, # 沿最后一维做 softmax
fuse_next=False
),
# 3. attention score 和 V 做矩阵乘,得到输出
PTOInstruction(
opcode="TILE_MATMUL",
src_tiles=[tile_softmax, tile_v],
dst_tile=tile_output,
dtype="FP16"
)
]
这段 PTO 指令序列描述了一个完整的 attention score 计算流程。注意第 1 条指令的 fuse_next=True------这个标志告诉指令映射层:把 TILE_MATMUL 和 TILE_SOFTMAX 融合为一次底层指令发射,省掉中间 Tile 的写回开销。
Graph Compiler 如何生成 PTO:从计算图到指令序列
Graph Compiler 是 CANN 第 3 层编译层的核心组件,负责把计算图编译为可执行的指令序列。PTO 是 Graph Compiler 的直接输出目标。
生成流程拆解
计算图(GE 输出)
↓ 图优化(算子融合、内存复用)
优化后的计算图
↓ 算子调度(Tile 切分、流水编排)
调度计划(Tile 执行顺序)
↓ 指令选择(每条 Tile 操作 → PTO 指令)
PTO 指令序列
↓ 指令映射(PTO → 底层 NPU 指令)
底层 NPU 指令流
关键步骤详解
步骤 1:图优化
Graph Compiler 接收 GE 优化后的计算图,进一步做算子融合。比如 MatMul → GELU 融合为单算子,对应的 PTO 输出就是一条融合指令 TILE_MATMUL_GELU,而不是两条独立指令。
步骤 2:Tile 切分
大模型推理的矩阵经常大过 Tile 的大小(典型 Tile 形状:128×128 或 256×256)。Graph Compiler 把大矩阵切分成多个 Tile,为每个 Tile 生成对应的 PTO 指令。
步骤 3:指令选择
每条 Tile 操作映射为 PTO 指令。这个映射是 模式匹配 的过程------Graph Compiler 内部维护了一张"Tile 操作模式 → PTO 指令"的映射表。
下面是一段 Graph Compiler 生成 PTO 指令的伪代码:
python
# Graph Compiler 内部:将融合算子映射为 PTO 指令
# 文件:graph_compiler/fusion_mapper.py(伪代码)
def map_fusion_pattern_to_pto(pattern, tile_shape, dtype):
"""
将算子融合模式映射为 PTO 指令序列
Args:
pattern: 融合模式,如 "matmul_gelu"、"attention_score"
tile_shape: Tile 形状,如 (128, 128)
dtype: 数据类型,如 "FP16"
Returns:
List[PTOInstruction]: PTO 指令序列
"""
if pattern == "matmul_gelu":
# MatMul + GELU 融合 → 单条 PTO 融合指令
return [PTOInstruction(
opcode="TILE_MATMUL_GELU",
src_tiles=["tile_a", "tile_b"],
dst_tile="tile_out",
dtype=dtype,
tile_shape=tile_shape
)]
elif pattern == "attention_score":
# Q @ K^T + Softmax → 两条指令,标记融合执行
return [
PTOInstruction("TILE_MATMUL", ["tile_q", "tile_k"],
"tile_score", dtype, fuse_next=True),
PTOInstruction("TILE_SOFTMAX", ["tile_score"],
"tile_softmax", dtype, fuse_next=False)
]
else:
# 未知模式 → 拆成多条基础指令
return decompose_to_basic_ops(pattern, tile_shape, dtype)
步骤 4:指令调度
PTO 指令序列生成后,Graph Compiler 会对指令做调度优化:
- Tile 间并行------独立的 Tile 计算可以并行发射
- 流水编排------Cube 单元和 Vector 单元交替工作,隐藏延迟
- 内存复用------相邻 Tile 复用同一块片上内存,减少 MTE 搬运
昇腾 NPU 如何执行底层指令:从 PTO 到硬件
PTO 指令序列生成后,并没有直接扔给硬件。中间还有一层 指令映射(Instruction Mapping),由 CANN 运行时(Runtime)负责。
指令映射层
PTO 指令序列(硬件无关)
↓ 指令映射层(Runtime 负责)
opcode 映射(TILE_MATMUL → 底层 matmul opcode)
操作数映射(Tile 索引 → 物理寄存器/片上内存地址)
数据类型映射(FP16 → 硬件支持的编码格式)
↓
底层 NPU 指令流(硬件相关)
↓ 指令发射
昇腾 NPU 执行(Cube / Vector / MTE)
指令映射的核心数据结构是一张 映射表,描述每条 PTO 指令在不同硬件上的底层实现:
cpp
// 指令映射配置(伪代码)
// 文件:runtime/instruction_mapper.h(伪代码)
struct PTOInstructionMapping {
PTOOpcode pto_opcode; // PTO 操作码
HardwareType hw_type; // 目标硬件类型
// 映射结果
LowLevelOpcode ll_opcode; // 底层操作码
ExecutionUnit exe_unit; // 执行单元(CUBE / VECTOR / MTE)
bool support_fusion; // 是否支持融合执行
int latency_cycles; // 执行延迟(周期数)
};
// Ascend 910 上的映射表示例
PTOInstructionMapping mapping_table[] = {
{TILE_MATMUL, ASCEND_910, LL_MATMUL, CUBE, true, 128},
{TILE_SOFTMAX, ASCEND_910, LL_SOFTMAX, VECTOR, false, 64},
{TILE_MATMUL_GELU, ASCEND_910, LL_MATMUL_GELU, CUBE, true, 192},
// ... 90+ 条 PTO 指令各自的映射配置
};
底层指令执行流程
映射生成的底层指令通过以下路径到达硬件:
Runtime 指令队列
↓ 指令发射(Task 下发)
任务调度器(Task Scheduler)
↓ 分配执行单元
Cube 单元 / Vector 单元 / MTE
↓ 执行
达芬奇架构 AI Core
关键设计:指令映射是延迟绑定的。Graph Compiler 编译时只生成 PTO 指令,真正的映射发生在模型加载到具体硬件时。这意味着同一份编译结果(PTO 指令序列)可以在不同代的昇腾 NPU 上运行,只要运行时支持对应的映射规则。
Transformer 推理中的编译链路:完整走读
把前面的所有环节串起来,看一个 Transformer 推理请求在 CANN 上的完整编译+执行链路。
端到端链路
PyTorch 模型
↓ TorchAir(图模式接入)
ONNX 计算图
↓ GE 图引擎(图优化、算子融合)
优化后的计算图
↓ Graph Compiler(编译层)
PTO 指令序列 ← pto-isa 定义的标准格式
↓ Runtime 指令映射
底层 NPU 指令流
↓ Task 下发
昇腾 NPU 执行(Attention + FFN)
↓
推理结果返回
FlashAttention 在链路中的具体形态
以 Transformer 中最核心的 FlashAttention 算子为例,看它在各层的具体形态:
| 层级 | 形态 | 格式 |
|---|---|---|
| 框架层 | PyTorch 算子调用 | F.scaled_dot_product_attention(Q, K, V) |
| GE 层 | 计算图节点 | FlashAttentionScore 节点(已融合) |
| Graph Compiler 层 | PTO 指令序列 | TILE_MATMUL → TILE_SOFTMAX → TILE_MATMUL |
| Runtime 层 | 底层指令流 | Ascend 910 的 matmul + softmax + matmul 指令 |
| 硬件层 | AI Core 执行 | Cube 单元执行矩阵乘,Vector 单元执行 Softmax |
性能关键点
这条链路上有三个性能关键点,都是 PTO 指令集设计直接影响的地方:
-
融合粒度 ------FlashAttention 的
Q@K^T和Softmax能否融合为一次指令发射,取决于 PTO 是否定义了对应的融合指令(TILE_MATMUL_SOFTMAX)。pto-isa 的 90+ 指令里包含了常见的融合模式,减少了 Tile 中间结果的写回次数。 -
Tile 大小匹配------PTO 指令的 Tile 形状参数需要和硬件的片上内存大小匹配。Graph Compiler 在生成 PTO 指令时会查询硬件的 Tile 配置(通过 Runtime 获取),选择合适的 Tile 形状。
-
指令映射延迟------PTO 到底层指令的映射需要在模型加载时完成。mapping table 的查找是 O(1) 的(哈希表实现),但融合指令的拆解(当硬件不支持某个融合 PTO 指令时,需要拆成多条底层指令)会引入额外开销。
调试技巧:如何查看 PTO 指令序列
开发过程中经常需要确认 Graph Compiler 生成的 PTO 指令是否符合预期。pto-isa 仓库提供了调试工具,可以打印 PTO 指令序列。
打印 PTO 指令
python
# 调试技巧:打印 Graph Compiler 生成的 PTO 指令序列
# 需要设置环境变量开启 PTO dump
import os
# 开启 PTO 指令打印
os.environ["DUMP_PTO_INSTRUCTIONS"] = "1"
os.environ["PTO_DUMP_FILE"] = "/tmp/pto_dump.txt"
# 正常运行推理,PTO 指令会自动 dump 到指定文件
# 文件格式:每条指令一行,包含 opcode、操作数、Tile 形状
dump 出来的文件内容大致如下:
# /tmp/pto_dump.txt
[PTO] op=TILE_MATMUL, src=[tile_q:128x128, tile_k:128x128], dst=tile_score:128x128, dtype=FP16, fuse=1
[PTO] op=TILE_SOFTMAX, src=[tile_score:128x128], dst=tile_softmax:128x128, dtype=FP16, fuse=0
[PTO] op=TILE_MATMUL, src=[tile_softmax:128x128, tile_v:128x128], dst=tile_out:128x128, dtype=FP16, fuse=0
性能分析方法
除了直接看 PTO 指令序列,还可以用性能分析工具确认每条 PTO 指令的底层执行效率:
python
# 性能分析:统计每条 PTO 指令的执行周期
# 需要 CANN 运行时开启 profiling
import torch
import torch_npu
# 开启 PTO 级别 profiling
with torch.autograd.profiler.profile(
activities=[torch.profiler.ProfilerActivity.NPU],
with_stack=True
) as prof:
output = model(input_ids)
# 打印 profiling 结果,可以看到每条 PTO 指令的耗时
print(prof.key_averages().table(sort_by="self_npu_time_total"))
配置文件:编译选项对 PTO 生成的影响
Graph Compiler 生成 PTO 指令时,行为受编译配置文件控制。关键配置项影响 Tile 大小、融合策略和指令映射方式。
# ${CANN_INSTALL_PATH}/config/graph_compiler.conf
[pto_generation]
# Tile 形状:影响 PTO 指令的 src_tiles/dst_tile 形状参数
tile_m=${TILE_M:128} # Tile M 维度大小
tile_n=${TILE_N:128} # Tile N 维度大小
tile_k=${TILE_K:128} # Tile K 维度大小
# 融合策略:控制是否生成融合 PTO 指令
enable_op_fusion=${ENABLE_OP_FUSION:true}
max_fusion_depth=${MAX_FUSION_DEPTH:3} # 最多融合几个算子
# 指令映射:选择映射规则版本
instruction_mapping_version=${MAPPING_VER:ascend_910_v2}
enable_late_mapping=${LATE_MAPPING:true} # 延迟绑定:加载模型时才做指令映射
enable_late_mapping=true 是跨代兼容的关键配置。设为 true 时,Graph Compiler 编译结果里只存 PTO 指令序列,底层指令映射推迟到模型在具体硬件上加载时执行。
构建与运行:验证 PTO 指令生成
如果要基于 pto-isa 仓库做二次开发(比如新增自定义 PTO 指令),需要构建并验证。
bash
# 克隆 pto-isa 仓库
git clone https://atomgit.com/cann/pto-isa.git
cd pto-isa
# 构建(使用 CANN 提供的构建脚本)
bash build.sh
# 构建产物:
# - libpto_isa.so:PTO 指令集动态库
# - pto_tools/pto_dump:PTO 指令打印工具
# - include/pto_isa.h:PTO 指令集头文件(供 Graph Compiler 引用)
# 验证:用 pto_dump 工具查看示例 PTO 程序
./build/pto_tools/pto_dump ./examples/flash_attention.pto
总结
PTO 在 CANN 五层架构里的定位,用一句话说就是:编译层输出 PTO,执行层消费 PTO,中间靠指令映射衔接。
理解这一层,才能真正看懂 CANN 的编译链路为什么能做到"编译一次,多硬件运行"------虚拟 ISA 是最核心的抽象层。
如果这篇文章帮你理清了 PTO 的位置,下一步建议直接去看 Graph Compiler 的源码------看它怎么把计算图节点映射为 PTO 指令,比读十篇文档都管用。