请添加图片描述
个人主页:ujainu

前言

刚接触昇腾 CANN 编译栈那会,我被一个问题卡了一周:Graph Compiler 生成的 IR,是怎么变成昇腾 NPU 能执行的机器码的?中间的算子实现,既不是直接写 Ascend C,也不是直接怼硬件指令——而是先落到一个叫 PTO 的东西上。

PTO 全称 Program Tile Operator,是 pto-isa 仓库定义的虚拟指令集架构。它不绑定具体硬件,不暴露 Cube/Vector 单元的寄存器,也不要求你手写 .sic 汇编。但它又不是"抽象接口"那种虚无缥缈的东西——PTO 有 90+ 条标准 Tile 级操作,每条都有确定的语义、确定的数据流约束、确定的可组合性规则。

这篇文章拆解一个问题:为什么 AI 编译需要虚拟 ISA 这一层抽象?没有它会怎样?以及 Graph Compiler 是怎么把计算图翻译成 PTO 指令序列,再映射到昇腾 NPU 物理指令的。

仓库地址:https://atomgit.com/cann/pto-isa


直接生成物理指令不行吗?

先说结论:行,但代价是编译器要为每个硬件版本重写整个后端。

昇腾 NPU 的指令集不是固定不变的。Ascend 910 的达芬奇架构、后续迭代的硬件,指令编码格式、寄存器文件大小、Cube/Vector 单元的调度约束都在变。如果 Graph Compiler 直接生成物理指令,每换一代硬件就要重写一个 codegen——这不是假设,是 2018 年之前业界真实走过的路。

看一段伪代码,感受一下"直接生成物理指令"的编译器要处理什么:

# ❌ 没有虚拟 ISA 时,编译器后端要干的事(伪代码)
class DirectCodegen:
    def __init__(self, target_arch):
        # 每个硬件架构一套参数
        if target_arch == "Ascend910":
            self.cube_regs = 64       # Cube 单元寄存器文件大小
            self.vector_lanes = 256   # Vector 单元并行宽度
            self.sram_size = 1_048_576  # SRAM 容量(字节)
            self.inst_encoding = "v1"  # 指令编码格式版本
        elif target_arch == "Ascend950":
            self.cube_regs = 128
            self.vector_lanes = 512
            self.sram_size = 2_097_152
            self.inst_encoding = "v2"
        # ... 每出新硬件就加一个分支

    def codegen_matmul(self, node):
        # MatMul 的 codegen 要为每种硬件单独调优
        # 调度策略、分块大小、寄存器分配全部硬编码
        if self.target_arch == "Ascend910":
            return self._emit_matmul_v1(node)
        elif self.target_arch == "Ascend950":
            return self._emit_matmul_v2(node)

这段代码的核心问题:算子逻辑和硬件参数耦合在一起了。MatMul 的计算语义(C = A × B)是稳定的,但上面的实现让它跟着硬件变。

PTO 的解法:插一层虚拟指令集,让编译器前端和硬件后端解耦。


PTO 是什么:90 条 Tile 级操作构成的虚拟 ISA

pto-isa 仓库定义了 PTO 指令集的完整规范。核心数字:90+ 标准 Tile 级操作。

"Tile 级"是理解 PTO 的关键。Tile 是一小块矩阵/向量数据,通常驻留在 SRAM 上,大小适合一次运算。PTO 的每条指令都围绕 Tile 的操作设计:Tile 加载、Tile 计算、Tile 搬运、Tile 同步。

列出一部分 PTO 指令,感受一下它的抽象层次:

// PTO 指令集(C 风格伪代码,展示指令语义)
// 完整的 90+ 条指令见 pto-isa 仓库规范

/* === Tile 数据搬运 === */
PTO_LOAD_TILE(dst_tile, src_addr, tile_size, stride);
    // 从 HBM/内存加载一个 Tile 到 SRAM
PTO_STORE_TILE(dst_addr, src_tile, tile_size);
    // 将 SRAM 上的 Tile 写回内存
PTO_DMA_COPY(dst_tile, src_tile, size);
    // Tile 之间的 DMA 搬运(不占计算单元)

/* === Tile 矩阵计算 === */
PTO_MATMUL_TILE(out_tile, a_tile, b_tile, M, N, K);
    // Tile 级矩阵乘:out = a × b
    // 映射到 Cube 单元,自动处理数据分块
PTO_RELU_TILE(out_tile, in_tile, size);
    // Tile 级 ReLU 激活,映射到 Vector 单元
PTO_ADD_TILE(out_tile, a_tile, b_tile, size);
    // Tile 级逐元素加法

/* === Tile 同步与控制 === */
PTO_SYNC_TILE(unit_mask);
    // 等待指定计算单元(Cube/Vector/DMA)完成
PTO_FENCE_TILE();
    // Tile 级内存栅栏,保证数据可见性

几条关键观察:

  1. 不暴露物理寄存器PTO_MATMUL_TILE 不需要你指定用哪 64 个寄存器存 A 子块——PTO runtime 自己管。
  2. 不绑定指令编码PTO_LOAD_TILE 在 Ascend 910 上可能翻译成 3 条物理指令,在另一代硬件上可能是 5 条——PTO 语义不变。
  3. Tile 是一等公民。每条指令的操作数都是 Tile,不是标量或向量——这和昇腾 NPU 的硬件执行模型直接对应。

Graph Compiler 如何生成 PTO:从计算图到指令序列

Graph Compiler 是 CANN 第三层(编译层)的核心组件。它的输入是上层框架(PyTorch/TensorFlow)导出的计算图,输出是 PTO 指令序列。

完整流程分四步:

计算图(ONNX/PyTorch IR)
  ↓  ① 图优化(算子融合、死代码消除、常量折叠)
优化后的计算图
  ↓  ② Tile 划分(把算子拆成 Tile 粒度的子任务)
Tile 任务 DAG
  ↓  ③ PTO 指令生成(每个 Tile 任务 → 一组 PTO 指令)
PTO 指令序列
  ↓  ④ 指令调度(安排 Tile 执行顺序,最大化 Cube/Vector 并行)
调度后的 PTO 指令序列 → 交给 PTO runtime 映射到物理指令

用一段 Python 伪代码展示第 ③ 步"PTO 指令生成"的核心逻辑:

# Graph Compiler 生成 PTO 指令的核心逻辑(伪代码)
class PTOInstructionGenerator:
    def __init__(self):
        self.instrs = []  # 生成的 PTO 指令序列

    def generate_matmul(self, node):
        """将 MatMul 算子翻译成 PTO 指令序列"""
        A, B = node.inputs
        C = node.output
        M, N, K = node.shape

        # Tile 大小:根据 SRAM 容量和 Cube 单元特性计算
        # 这里用典型值:TileM=128, TileN=128, TileK=64
        tile_m, tile_n, tile_k = 128, 128, 64

        # 双层 Tile 循环:遍历 M 和 N 维度
        for i in range(0, M, tile_m):
            for j in range(0, N, tile_n):
                # 1. 加载 A 的 Tile(从 HBM → SRAM)
                a_tile = self._alloc_tile(tile_m, tile_k)
                self.instrs.append(
                    f"PTO_LOAD_TILE(a_tile_{i}_{j}, A[{i}:{i+tile_m}, :], {tile_k})"
                )

                # 2. 加载 B 的 Tile
                b_tile = self._alloc_tile(tile_k, tile_n)
                self.instrs.append(
                    f"PTO_LOAD_TILE(b_tile_{i}_{j}, B[:, {j}:{j+tile_n}], {tile_k})"
                )

                # 3. 执行 Tile 级矩阵乘
                c_tile = self._alloc_tile(tile_m, tile_n)
                self.instrs.append(
                    f"PTO_MATMUL_TILE(c_tile, a_tile_{i}_{j}, b_tile_{i}_{j}, "
                    f"{tile_m}, {tile_n}, {tile_k})"
                )

                # 4. 写回结果 Tile
                self.instrs.append(
                    f"PTO_STORE_TILE(C[{i}:{i+tile_m}, {j}:{j+tile_n}], c_tile)"
                )

                # 5. 同步:确保写回完成后再继续
                self.instrs.append("PTO_SYNC_TILE(CUBE | DMA)")

        return self.instrs

这段伪代码展示了一个关键点:PTO 指令生成是 Tile 粒度的。Graph Compiler 不需要知道 MatMul 怎么映射到 Cube 单元的微架构细节——它只需要把计算图切成 Tile,然后调用 PTO_MATMUL_TILE。硬件相关的优化(寄存器分配、流水线调度、预取策略)全部下沉到 PTO runtime。


PTO runtime 如何映射到昇腾 NPU 物理指令

PTO 指令序列生成之后,还需要最后一步:映射成昇腾 NPU 能执行的物理指令(Kernel 生成)。

这一层由 PTO runtime(pto-isa 仓库的一部分)负责。它的输入是 PTO 指令序列,输出是昇腾 NPU 上可执行的 Kernel 机器码。

Kernel 生成是这一步的核心:PTO runtime 不是逐条翻译指令,而是做全局调度——把多条 PTO 指令组合成一个 Kernel,一次性下发给 NPU 执行。这样可以减少 Host→Device 的调度开销,让 AICore 持续满负荷运行。

用 C++ 伪代码展示映射逻辑的核心结构:

// PTO runtime:将 PTO 指令映射到昇腾 NPU 物理指令 + Kernel 生成(伪代码)
class PTORuntime {
public:
    // Kernel 生成:将一组 PTO 指令编译成一个可执行的 NPU Kernel
    NPUKernel* generateKernel(const std::vector<PTOInstruction>& pto_seq) {
        NPUKernel* kernel = new NPUKernel();
        
        // 第一步:指令调度——重排 PTO 指令,最大化 Cube/Vector 并行
        auto scheduled = scheduleInstructions(pto_seq);
        
        // 第二步:批量映射到物理指令(不是逐条翻译)
        for (const auto& instr : scheduled) {
            auto machine_code = emitMachineCode(instr);
            kernel->append(machine_code);
        }
        
        // 第三步:插入 Kernel  prolog/epilog(寄存器保存/恢复)
        kernel->prepend(emit_prolog());
        kernel->append(emit_epilog());
        
        return kernel;  // 返回可执行的 NPU Kernel
    }
    
    // 单条 PTO 指令 → 物理指令映射
    std::vector<uint32_t> emitMachineCode(const PTOInstruction& instr) {
        switch (instr.opcode) {
            case PTO_OPCODE_LOAD_TILE: {
                // PTO_LOAD_TILE → 昇腾 DMA 指令 + 地址计算指令
                // 实际映射可能生成 2-4 条物理指令
                auto dma_instr = emit_dma_load(instr.dst_tile, instr.src_addr, instr.size);
                auto fence_instr = emit_fence();  // 保证加载可见
                return {dma_instr, fence_instr};
            }
            case PTO_OPCODE_MATMUL_TILE: {
                // PTO_MATMUL_TILE → 昇腾 Cube 指令
                // 包括:数据预取、矩阵乘、结果写回
                auto prefetch = emit_cube_prefetch(instr.a_tile, instr.b_tile);
                auto matmul = emit_cube_matmul(instr.M, instr.N, instr.K);
                auto sync = emit_sync(CUBE_UNIT);
                return {prefetch, matmul, sync};
            }
            case PTO_OPCODE_SYNC_TILE: {
                // PTO_SYNC_TILE → 昇腾屏障指令
                return {emit_barrier(instr.unit_mask)};
            }
            // ... 其他 87+ 条 PTO 指令的映射
        }
    }
};

这里的工程价值:如果明天昇腾出了新硬件,指令编码格式变了,只需要改 PTORuntime::emitMachineCode 这一个地方。Graph Compiler 前端一行代码不用动。

对比一下"没有虚拟 ISA"时要做的事:

有 PTO(虚拟 ISA) 无虚拟 ISA
新硬件适配 改 PTO runtime(1 个仓库) 改 Graph Compiler 后端(N 个 pass)
算子优化 改 PTO 指令实现 每个硬件重写算子
编译器前端 稳定,不感知硬件 跟着硬件变

Transformer 推理中的完整编译链路

把上面几节串起来,看一个 Transformer 推理请求在 CANN 编译栈中的完整路径:

# Transformer 推理的编译 + 执行链路(Shell 命令视角)

# 第 1 步:PyTorch 模型导出为 ONNX
python export_onnx.py --model deepseek-v3 --output model.onnx

# 第 2 步:ATC(Ascend Tensor Compiler)将 ONNX 转换为 Graph Compiler IR
atc --model=model.onnx \
    --framework=5 \
    --output=model.ir \
    --soc_version=Ascend910

# 第 3 步:Graph Compiler 对 IR 做图优化 + Tile 划分 + PTO 指令生成
# (这一步是本文核心,生成 .pto 指令序列文件)
graph_compiler --ir=model.ir \
               --output=model.pto \
               --enable-tile-fusion \
               --enable-mem-reuse

# 第 4 步:PTO runtime 将 .pto 指令序列映射到昇腾 NPU 物理指令
pto_runtime --input=model.pto \
            --soc_version=Ascend910 \
            --output=model.bin

# 第 5 步:Runtime 加载 model.bin,在 NPU 上执行推理
ascendcl_runtime --model=model.bin \
                 --input=input_tokens.bin \
                 --output=output_tokens.bin

这条链路中,PTO 的角色是编译层和应用层之间的契约层。Graph Compiler 不需要知道昇腾 NPU 的物理指令格式,PTO runtime 不需要知道上层框架的计算图结构——双方只通过 PTO 指令集契约通信。


调试技巧:如何查看 PTO 指令序列

开发过程中经常需要确认 Graph Compiler 生成的 PTO 指令对不对。pto-isa 仓库提供了调试工具,可以打印 PTO 指令序列。

# 调试 PTO 指令生成(Python 伪代码)
from pto_isa import PTODisassembler

# 加载 Graph Compiler 生成的 .pto 文件
pto_file = "model.pto"
disasm = PTODisassembler(pto_file)

# 打印前 50 条 PTO 指令(通常包含模型前面的 Embedding + 前几层 Attention)
print("=== PTO Instruction Sequence (first 50) ===")
for i, instr in enumerate(disasm.instructions[:50]):
    print(f"  [{i:04d}] {instr.opcode:32s} "
          f"dst={instr.dst_tile:16s} "
          f"src=({instr.src_tile_a}, {instr.src_tile_b})")

# 统计各类型 PTO 指令的占比(用来判断 Tile 划分是否合理)
stats = disasm.instruction_stats()
print(f"\n=== PTO Instruction Stats ===")
print(f"  LOAD_TILE:  {stats['LOAD_TILE']:6d}  ({stats['LOAD_TILE']/stats['total']*100:.1f}%)")
print(f"  MATMUL_TILE: {stats['MATMUL_TILE']:6d}  ({stats['MATMUL_TILE']/stats['total']*100:.1f}%)")
print(f"  SYNC_TILE:  {stats['SYNC_TILE']:6d}  ({stats['SYNC_TILE']/stats['total']*100:.1f}%)")

如果 LOAD_TILE 占比过高(比如超过 40%),说明 Tile 划分太小,数据搬运开销淹没了计算——这时候要去调 Graph Compiler 的 Tile 大小参数。


性能分析:PTO 指令级 profiling

要知道模型推理时间花在哪条 PTO 指令上,可以用 pto-isa 自带的 profiling 工具:

# PTO 指令级 profiling(Python 伪代码)
from pto_isa import PTOProfiler

profiler = PTOProfiler()
profiler.start_capture()

# 运行推理(NPU 执行 PTO 指令序列)
output = model.run(input_data)

profiler.stop_capture()

# 输出热点指令(耗时 TOP 10)
hot_instrs = profiler.top_k_hotspots(k=10)
print("=== PTO Instruction Hotspots ===")
for rank, (instr, cycles) in enumerate(hot_instrs, 1):
    print(f"  #{rank:2d}  {instr.opcode:32s}  {cycles:10d} cycles")

# 分析 Cube/Vector/DMA 单元的利用率
util = profiler.unit_utilization()
print(f"\n=== Unit Utilization ===")
print(f"  Cube:  {util['cube']*100:.1f}%")
print(f"  Vector: {util['vector']*100:.1f}%")
print(f"  DMA:   {util['dma']*100:.1f}%")

Cube 利用率低通常意味着 Tile 划分没有喂饱计算单元——可以尝试增大 TileM/TileN。DMA 利用率高但 Cube 利用率低,说明瓶颈在计算的等待数据搬运——这时候要去优化 PTO_LOAD_TILEPTO_MATMUL_TILE 的重叠度。


编译选项配置:如何控制 PTO 生成策略

Graph Compiler 生成 PTO 指令时,有很多策略可以调。这些策略通过编译配置文件传入:

# graph_compiler.conf —— Graph Compiler 编译选项
# 控制 PTO 指令生成策略

[pto]
# Tile 大小(M/N/K 维度)
tile_m = 128
tile_n = 128
tile_k = 64

# 是否启用 Tile 级算子融合(减少 PTO_SYNC_TILE 的数量)
enable_tile_fusion = true

# 是否启用双缓冲(DMA 搬运和计算重叠)
enable_double_buffering = true

# PTO 指令调度策略:"greedy" | "harmonic" | "memory_aware"
schedule_policy = "memory_aware"

[pto.codegen]
# 是否为 MatMul 生成 PTO_MATMUL_TILE 的融合变体(包含 BiasAdd + ReLU)
fuse_activation = true

# 是否生成 PTO_DMA_COPY 预取指令(减少 HBM 访问延迟)
enable_prefetch = true

[pto.debug]
# 是否输出 PTO 汇编文件(方便人工检查)
emit_pto_asm = true
output_asm_path = "./model.pto.asm"

enable_double_buffering = true 这个选项值得展开:它让 PTO runtime 在计算当前 Tile 的同时,用 DMA 预取下一个 Tile。在 Transformer 推理场景下,这块优化能省 15-25% 的端到端延迟——前提是 Graph Compiler 生成的 PTO 指令序列中有足够的 PTO_DMA_COPY 指令来覆盖预取窗口。


虚拟 ISA 为什么是编译器工程的必然选择

回到开头的问题:为什么 AI 编译需要虚拟 ISA?

三个理由:

第一,硬件迭代速度 > 编译器适配速度。 昇腾 NPU 的硬件迭代周期大概是 18-24 个月,如果每次都要重写编译器后端,软件栈永远追不上。虚拟 ISA 把"稳定的计算语义"和"变化的硬件实现"切开,硬件变的时候只改一半。

第二,AI 编译器的优化空间在 Tile 级。 现在业界做算子融合、内存复用、多流并行,操作的粒度都是 Tile——不是逐算子,也不是逐指令。PTO 的 Tile 级抽象刚好和这个优化粒度对齐。如果直接用物理指令,优化 pass 要处理寄存器分配、指令调度、流水线填充这些琐事,根本没精力做架构级优化。

第三,跨平台。 pto-isa 的 90+ 条标准操作,理论上可以映射到其他 AI 加速器的指令集——不只是昇腾 NPU。这是虚拟 ISA 的天然优势:指令语义是平台无关的,映射层可以针对不同硬件定制。


继续深入:Graph Compiler 的 IR 设计

PTO 指令集是虚拟 ISA——但它不是凭空生成的,它的输入是 Graph Compiler 的 IR(中间表示)。

如果你想继续深入理解 CANN 编译栈,下一个值得读的代码是 Graph Compiler 的 IR 设计:它如何用多层 IR(HLO → Tile IR → PTO 指令)做渐进式 lowering,以及每一层 IR 保留了哪些语义信息供后续优化 pass 使用。

pto-isa 仓库的 README 中有 PTO 指令的完整定义,包括每条指令的操作数约束、数据流规则、和可组合性限制。配合 Graph Compiler 的 Tile 划分策略一起看,能完整理解"计算图 → Tile 任务 → PTO 指令 → 物理指令"这条链路。

仓库地址:https://atomgit.com/cann/pto-isa

Graph Compiler 相关仓库(用于深入编译栈):https://atomgit.com/cann/ge

Logo

鲲鹏昇腾开发者社区是面向全社会开放的“联接全球计算开发者,聚合华为+生态”的社区,内容涵盖鲲鹏、昇腾资源,帮助开发者快速获取所需的知识、经验、软件、工具、算力,支撑开发者易学、好用、成功,成为核心开发者。

更多推荐