前言

你觉得写算子最难的是什么。是算法本身吗。不完全是。真正的难点在于:同一份计算逻辑,要在昇腾NPU的不同代际芯片上都能跑出好成绩。直接写机器码。换个芯片代际就得重写。用高层接口。性能又不够极致。CANN给出的解法是PTO-ISA——一套面向tile级操作的虚拟指令集体系结构。它处在自定义算子编译链路的中间层,往上对接框架和编译器前端,往下生成适配具体昇腾硬件的二进制代码。本文把这个中间层拆开,用几个日常类比把它的设计逻辑讲清楚。

直接写二进制代码为什么行不通

先问一个直白的问题。如果你要给三种不同架构的CPU写程序,你会选择哪种方式。

选项一:直接写二进制。0101那种。
选项二:写汇编。用mov、add这些助记符。
选项三:写C语言。让编译器帮你处理底层差异。

大多数人会选选项三,至少也会选选项二。直接写二进制不是不可能,是在工程意义上不可维护。

昇腾NPU的算子开发曾经面临类似的困境。不同代际的昇腾芯片(A2、A3、A5)在硬件执行单元、流水线深度、buffer大小上都有差异。如果开发者针对A2芯片手写了一套深度优化的二进制代码,到了A3上,这些代码可能无法发挥硬件的全部算力,甚至需要大幅改写。

这就是PTO-ISA要解决的第一个问题:硬件差异性。

用一个类比来理解。假设你是一家餐饮集团的技术总监,集团旗下有三家厨房,分别位于北京、上海、广州。三家厨房的灶台规格不同,北京用燃气灶,上海用电磁炉,广州用混合灶。你现在要设计一套"做菜指令",让同一份菜单在三家厨房都能顺利执行。

如果你直接写"大火爆炒30秒",北京厨房的厨师可能把火开得太大,上海厨房的电磁炉根本达不到那个温度,广州厨房又介于两者之间。这道菜做出来,三家味道不一样。

PTO-ISA扮演的角色,就是这套"做菜指令"的标准化层。它不规定"大火"具体是多少摄氏度,而是定义一套所有厨房都能理解的"中间指令"。各家厨房再根据自身设备,把这些中间指令翻译成自己的"操作手册"。

在编译链路中,PTO-ISA位于上层框架(如PyPTO、TileLang Ascend)和底层二进制代码之间。开发者用PTO指令描述tile级的计算和数据流,编译工具链再把这些指令翻译成目标硬件的机器码。这样,同一份PTO代码可以在A2、A3、A5上运行,同时保留性能调优的空间。

Tile是什么:从瓷砖到计算单元

PTO-ISA的核心抽象是tile。但tile这个词对不熟悉的读者来说可能有些抽象。

换个说法。你有没有铺过瓷砖。铺瓷砖的时候,你不会一次性把整间屋子的地面浇上一层水泥。而是用一块一块规格统一的瓷砖,逐块铺满。每块瓷砖的大小是固定的(比如30cm×30cm),铺的时候只需要关心当前这块砖放在哪个位置,和周围的砖怎么衔接。

tile在计算中的含义与此高度相似。

在深度学习的算子实现中,矩阵和计算任务往往规模很大。一个Attention层的query矩阵可能有几千行几千列。如果一次性处理整个矩阵,对芯片的片上内存(L1 Buffer、Local Buffer)是巨大挑战。芯片的片上内存没那么大。

tile的思路是分块。把大矩阵切成固定大小的小块,比如64×64的tile。每次只把一到两个tile加载到片上内存中做计算,算完再处理下一批tile。这样,片上内存的压力从"装下整个矩阵"降到了"装下几个tile"。

PTO-ISA把tile作为一等公民。它定义的90余条标准指令,操作的都是tile级的数据块。比如TADD指令做两个tile的逐元素加法,TGEMM指令做两个tile的矩阵乘法。

这里有个值得注意的设计细节。PTO-ISA中的tile shape是静态的。也就是说,一个tile有多少行多少列,在编译时就要确定。但tile mask是动态的,可以在运行时根据实际数据大小决定哪些位置需要参与计算。

为什么这样设计。静态shape让编译器能做更准确的寄存器分配和流水线调度。动态mask则保留了处理任意尺寸输入的灵活性。类比来说,静态shape就像瓷砖的规格是固定的(30cm×30cm),但你可以决定用多少块砖、哪些位置铺砖、哪些位置留空。

PTO-ISA的指令分类:不只是计算

如果你打开PTO-ISA的指令列表,会发现它不只是定义了矩阵乘法、逐元素加法这些"看得见的计算指令"。它还定义了几类同样关键的指令。

第一类是计算指令。包括基础的逐元素操作(TADD、TMUL等)、矩阵乘加(TGEMM、TGEMM_FUSE等)、卷积类指令、量化类指令。这些是算子实现的核心。

第二类是数据搬运指令。tile计算需要数据,数据在芯片上的位置有层次:HBM(高带宽内存)→ L2 Buffer → L1 Buffer → Local Buffer。不同层级之间的搬移需要显式指令来描述。PTO-ISA提供了TLOAD、TSTORE等指令,描述tile数据在不同存储位置之间的移动。

第三类是同步指令。昇腾NPU的計算流水线是分阶段的,CUBE单元做矩阵运算,Vector单元做逐元素运算,DMA引擎做数据搬运。这些单元可以并行工作,但需要同步点来保证数据正确性。PTO-ISA用TSET_FLAG和TWAIT_FLAG这样的指令描述事件同步。

第四类是通信指令。这是PTO-ISA的一个扩展方向。当多个昇腾NPU协同工作时,需要NPU之间的数据传输和同步。PTO-ISA定义了点对点通信(TGET、TPUT)、信号同步和集合通信三类通信原语,让计算与通信可以在同一个kernel中融合。

用类比理解这四类指令。还是用厨房的例子。

计算指令是"做菜"本身——切菜、炒菜、调味。数据搬运指令是"备料"——把食材从冰箱(HBM)拿到操作台(L1 Buffer),再拿到灶台边(Local Buffer)。同步指令是" timing"——告诉配菜员"主厨这边还需要两分钟,你那边慢一点",保证各工序衔接顺畅。通信指令是"跨厨房协作"——北京厨房缺某种调料,让上海厨房送过来,同时保证两边的出菜节奏不乱。

这四类指令共同描述了一个完整的tile级kernel。只写计算指令,数据搬不到位,算不了。只写数据搬运,没有同步,并行流水线会出数据竞争。只写单机kernel,没有通信指令,多卡场景发挥不出集群优势。

从PTO指令到二进制代码:编译管线的中间表示

现在进入本文的核心问题。PTO指令是如何变成昇腾NPU能执行的二进制代码的。

在编译管线中,PTO-ISA扮演的是中间表示(Intermediate Representation, IR)的角色。更精确地说,PTO-ISA是一套有确定语义的指令集规范,它定义了每条指令做什么、操作数格式是什么、副作用是什么。

编译过程大致分为几个阶段。

第一阶段:算子定义。开发者用C++模板或Python前端(如PyPTO)描述一个算子的计算过程。这个描述是高层级的,可能用TileExpr这样的抽象数据类型表达tile之间的计算关系。

第二阶段:指令选型与调度。编译工具链根据目标硬件(A2/A3/A5)和tile shape,把高层的tile计算描述 lowering 成具体的PTO指令序列。这个阶段会做指令选型(选TGEMM还是TGEMM_FUSE)、流水线调度(计算和数据搬运怎么重叠)、寄存器分配。

第三阶段:二进制编码。PTO指令有确定的二进制编码格式(这也是ISA的一部分)。每条指令被编码成固定长度或变长的二进制字段,包括操作码、操作数索引、mask信息、flag信息等。这些二进制字段打包后,就是昇腾NPU的硬件能直接解码执行的机器码。

用类比理解这个过程。你有一份菜谱(算子定义),写着"做一份鱼香肉丝"。编译工具链是第一阶段的"菜单翻译官",把它翻译成后厨能理解的"标准操作指令"(PTO指令),比如"切肉丝→腌制→热锅→爆炒→调味"。第二阶段的"调度官"根据北京厨房的灶台情况,决定先做哪步、哪些步骤可以并行(指令调度)。第三阶段的"执行码生成器"把这些标准指令翻译成灶台边厨师能直接执行的"操作手册"(二进制代码),精确到"燃气灶开几档"“炒多少秒”。

这里有个关键的设计权衡。PTO-ISA是虚拟ISA,不是直接对应某款芯片的真实指令集。虚拟的意思是,同一份PTO指令序列,经过不同的后端编译,可以生成适配不同芯片的二进制代码。A2和A3的底层指令编码格式不同,但它们都支持PTO-ISA中定义的那90余条标准操作。这就实现了"写一次,多代际运行"。

但这种可移植性不是免费的。虚拟ISA引入了一个额外的编译层,指令选型和调度的质量直接决定最终性能。如果编译工具链不够聪明,生成的二进制代码可能没把硬件算力完全发挥出来。所以PTO-ISA在提升抽象层级的同时,也保留了tile size、tile shape、指令顺序等调优入口,让开发者可以手动干预编译结果。

下面看一段实际的PTO代码示例,具体理解指令是怎么组织的。

// PTO GEMM kernel 的核心循环(简化示意)
for (int m = 0; m < M_tiles; ++m) {
    for (int n = 0; n < N_tiles; ++n) {
        // 1. 从HBM加载A和B的tile到L1 Buffer
        TLOAD(A_tile, hbm_A + m * K_tiles * tile_m * tile_k, L1_BUF);
        TLOAD(B_tile, hbm_B + n * tile_k * tile_n, L1_BUF);
        
        // 2. 将A_tile从L1搬入CUBE单元的Local Buffer
        TMOVE(A_local, A_tile, CUBE_LBUF);
        TMOVE(B_local, B_tile, CUBE_LBUF);
        
        // 3. 发出TGEMM指令,做矩阵乘加
        TGEMM(C_local, A_local, B_local, C_local);
        
        // 4. 等待CUBE计算完成,再将结果搬回HBM
        TWAIT_FLAG(FLAG_CUBE_DONE);
        TSTORE(hbm_C + m * tile_m * N + n * tile_n, C_local, HBM);
    }
}

这段代码描述的是一个分块矩阵乘法的核心循环。它用PTO指令明确描述了数据在哪里、计算在哪里、同步在哪里。

分块(tiling)是NPU算子性能的基础。昇腾NPU的片上内存有限,无法一次装下整个矩阵,必须把M和N维度拆成tile粒度逐块计算。TLOAD/TMOVE/TSTORE描述了数据在存储层次间的显式搬移——这一点和CPU/GPU的隐式cache不同,NPU要求开发者或编译器显式管理数据搬移,因为这样能更精确地控制带宽利用。TGEMM是CUBE单元的专用指令,做tile级的矩阵乘加,延迟低、吞吐高。TWAIT_FLAG则是同步点,保证CUBE单元的结果写回Local Buffer后再触发后续搬移,避免读写竞争。这些设计都源于昇腾NPU的硬件约束:计算快、存储层级深、并行单元多,需要显式同步来协调。

手动模式与自动模式:两条开发路径

PTO-ISA支持两条开发路径。这个设计背后有一个实用主义的考量。

手动模式(Manual Mode)让开发者显式地写PTO指令,精确控制tile size、指令顺序、pipeline调度。这条路径适合性能要求极高的算子,比如GEMM、Flash Attention的核心循环。手动写的代码能充分发挥硬件算力,但开发成本高,需要深入理解硬件架构。

自动模式(Auto Mode)让编译工具链(如BiSheng编译器)自动分配tile buffer、自动插入同步指令、自动做指令选型。开发者只需要描述计算逻辑(比如用TileExpr或Python前端),工具链帮你生成PTO指令序列。这条路径开发效率高,但目前主要在CPU仿真路径上可用,NPU后端的自动生成质量还在持续演进。

用类比理解这两条路径。手动模式就像"主厨亲自写菜单+亲自盯后厨"——每道工序、每个灶台的温度、每个配菜员的节奏都由主厨精确指定。自动模式就像"给后厨管理系统输入菜品标准,系统自动生成后厨执行计划"——效率高,但生成出来的计划可能不如主厨亲手安排那么精细。

实际开发中,推荐的路径是先用手动模式写一个参考实现,验证正确性;再用自动模式快速迭代其他算子;对性能瓶颈算子则回到手动模式精细调优。

下面看一个更完整的手动模式kernel示例,包含事件同步和流水线重叠。

// Flash Attention kernel 中的流水线组织(简化示意)
// 目标:让Q×K^T的计算、Softmax、V的加权这三个阶段部分重叠

// 阶段1:计算Q×K^T,结果存入S_tile
TGEMM(S_tile, Q_tile, K_tile, S_tile);
TSET_FLAG(FLAG_S_READY);  // 通知下游:S_tile已就绪

// 阶段2(与阶段1部分并行):对S_tile做Softmax
TWAIT_FLAG(FLAG_S_READY);  // 等待S_tile
TSOFTMAX(S_tile, S_tile);  // 原地做Softmax
TSET_FLAG(FLAG_SOFTMAX_DONE);

// 阶段3(与阶段2部分并行):用Softmax结果乘V
TWAIT_FLAG(FLAG_SOFTMAX_DONE);
TGEMM(O_tile, S_tile, V_tile, O_tile);

这段代码展示了事件同步如何把三个阶段串起来,同时让数据和计算在一定程度上重叠。

Flash Attention的性能关键不在于某一个计算步骤特别快,而在于计算和数据搬运的重叠度高。Q×K^T的矩阵乘法(CUBE密集)和Softmax(Vector密集)可以部分并行,因为CUBE单元和Vector单元是独立的硬件模块。TSET_FLAG/TWAIT_FLAG用来表达这种生产者-消费者关系:阶段1算完S_tile就发信号,阶段2不需要等阶段1完全结束才开始,只要S_tile的对应数据块就绪即可。这种设计把NPU的并行硬件能力显式暴露给开发者,代价是同步逻辑需要手动保证正确,flag用错会导致数据竞争或死锁。

使用PTO-ISA前后的效率对比

说到这里,需要正面回答一个问题。用PTO-ISA重写算子,到底值不值。对比的基准是什么。

基准是"不用PTO-ISA"的开发方式。在PTO-ISA出现之前,开发者要写昇腾NPU的自定义算子,主要有几种路径。一是直接用底层硬件指令(非公开的、代际相关的指令编码),这种方式性能可以很高,但代际迁移成本大。二是通过CANN上层API(如TBE DSL)描述算子,让编译器自动生成二进制代码,这种方式开发效率高,但对复杂算子的性能控制不够精细。

PTO-ISA位于这两者之间。它比底层硬件指令抽象层级高(可移植),比上层DSL抽象层级低(可控性强)。

下面的表格从几个维度对比使用PTO-ISA前后的差异。

维度 使用前(直接写硬件指令或仅用上层DSL) 使用后(基于PTO-ISA开发) 差异来源
代际迁移成本 换一代芯片需大幅改写或重写,接口和指令编码都不同 同一份PTO代码经不同后端编译即可运行于A2/A3/A5 PTO-ISA作为虚拟ISA屏蔽了代际差异,编译工具链处理后端适配
性能调优粒度 写硬件指令:粒度最细,但开发效率极低;用上层DSL:粒度粗,难以精细控制tile调度 支持tile size、tile shape、指令顺序等中层粒度的调优,兼顾控制力和开发效率 PTO-ISA的tile级抽象恰好匹配NPU硬件的执行单元粒度,不过高也不过低
通信与计算融合 多卡场景下计算kernel和通信kernel通常是分离的,需要手动管理同步 PTO通信原语允许在同一个kernel中融合计算与通信,减少kernel启动开销 PTO-ISA的通信扩展指令集提供了NPU间数据传输的tile级抽象,可与计算指令统一调度
功能验证效率 需要在真实NPU硬件上验证,环境依赖重,迭代周期长 支持CPU Simulator仿真,在x86/AArch64上即可验证功能正确性 PTO-ISA的语义在CPU上有参考实现,行为可预测,支持快速功能验证
复杂算子开发门槛 Flash Attention、MoE等复杂算子的手动实现需要深入理解每一代硬件的微架构 PTO-ISA提供了90余条标准操作,覆盖计算、数据搬移、同步、通信,开发者用组合这些指令的方式描述算子 标准指令集降低了"从零开始写机器码"的门槛,同时保留了手动调优的入口

需要说明一点。上表中的"使用前"不是一个单一的基准点,因为"使用前"本身就有多种开发方式,各自的优劣不同。PTO-ISA的价值不在于在某一个维度上做到极致,而在于在可移植性、性能控制力、开发效率这三个常被此消彼长地权衡的维度之间,找到了一个可持续的工程平衡点。

通信指令:当计算遇到多卡

前面的讨论主要集中在单卡场景。但当模型大到一张卡放不下的时候,多卡协同是必然选择。

多卡协同的传统做法是:计算kernel做本地计算,完成后把数据搬出来,调用通信库(如HCCL)做NPU间数据传输,再启动下一个计算kernel。这种方式的问题是:计算和通信是串行的,通信的时候计算单元闲置,计算的时候通信链路闲置。

PTO-ISA的通信扩展指令集试图改变这个局面。它提供了三类通信原语:点对点通信(TGET、TPUT及它们的异步版本TGET_ASYNC、TPUT_ASYNC)、信号同步指令、集合通信指令(覆盖AllReduce、AllGather等常用模式)。

这些通信原语和compute指令一样,都是tile级抽象。TGET指令的含义是"从远程NPU的指定buffer读取一个tile的数据,存入本地的tile buffer"。因为tile是PTO-ISA的一等公民,通信操作也以tile为粒度,这样计算和通信的粒度自然对齐——计算在tile上做,通信也以tile为单位搬数据,两者可以在同一个流水线中调度。

用一个具体场景说明。GEMM AllReduce融合算子。传统做法是:先做本地GEMM,得到部分结果,再启动AllReduce把多卡的部分结果求和。PTO-ISA允许把"GEMM的部分结果生成"和"AllReduce的数据传输"融合到同一个kernel中,让CUBE单元在算后续tile的同时,DMA引擎已经在搬前面tile的AllReduce数据。

这种融合的收益取决于算子和集群拓扑。对于大shape的GEMM,通信时间占比高,融合带来的重叠收益更明显。对于小shape的GEMM,kernel启动开销可能反而让融合版本变慢。这也是为什么PTO-ISA保留了手动控制的能力——开发者可以根据实际shape决定要不要融合、怎么融合。

下面看一段通信指令的使用示例。

// 使用TGET_ASYNC做异步远程tile读取(简化示意)
// 场景:本NPU需要读取远程NPU上的B_tile数据来做GEMM

// 1. 发起异步远程读请求,DMA引擎后台传输,不阻塞AIV
TGET_ASYNC(remote_B_tile_handle, local_B_tile, NOTIFY_NONE);

// 2. 在等待B_tile到达的同时,先做A_tile的本地预处理
TLOAD(A_tile, hbm_A + offset, L1_BUF);
TMOVE(A_local, A_tile, CUBE_LBUF);
TVEC_MUL(A_local, A_local, scale);  // 对A做逐元素缩放

// 3. 等待远程数据到达
TWAIT_FLAG(FLAG_DMA_B_ARRIVED);

// 4. 此时local_B_tile已就绪,可以发起GEMM
TGEMM(C_tile, A_local, local_B_tile, C_tile);

这段代码展示了异步通信如何与本地计算重叠。

TGET_ASYNC和TGET的关键区别在于:TGET是同步的,调用后AIV(Vector单元的控制核心)会等待数据到达;TGET_ASYNC是异步的,调用后立刻返回,AIV可以继续做其他事情,DMA引擎在后台完成数据传输。这个设计让"通信延迟"可以被"计算时间"隐藏——这是提升多卡算子性能的核心思路之一。但异步也带来了同步复杂度:开发者必须保证在真正使用local_B_tile之前,FLAG_DMA_B_ARRIVED已经置位,否则会读到无效数据。这种复杂性的存在,正是PTO-ISA选择同时提供同步和异步两种通信指令的原因:简单场景用同步版本,正确性容易保证;高性能场景用异步版本,但需要手动管理同步。

PTO-AS:当指令集需要自己的汇编器

指令集定义好了,有人会用C++模板来写PTO指令序列,也有人希望用更贴近汇编的方式来直接写PTO指令。PTO-AS就是这个需求的解答。

PTO-AS是一个面向PTO指令集的汇编器。它允许开发者用汇编语法直接描述PTO指令序列,汇编器再把这些汇编代码转换成PTO的二进制编码(bytecode)。这个bytecode可以被运行时加载和执行。

为什么要提供汇编级入口。有几个实际原因。

一是调试。当编译工具链生成的二进制代码行为不符合预期时,开发者可能需要手工写一份最小复现用例。用C++模板构造这个用例可能引入很多编译期细节,用汇编写则更直接。

二是指令级优化实验。PTO-ISA在持续演进,新的指令、新的调度策略需要快速验证。汇编级入口让指令设计者可以绕过高层编译管线,直接测试新指令的编码和执行行为。

三是教学和理解。读汇编代码比读C++模板实例化后的展开代码更容易看清指令序列的全貌。对于想深入理解PTO-ISA的开发者,汇编语法是一个更友好的入口。

PTO-AS的汇编语法和PTO-ISA的指令定义一一对应。每条PTO指令在汇编中有一条对应的汇编语句,操作数格式和指令定义中的一致。汇编器做的工作包括:指令解析、操作数编码、依赖分析(检查flag的使用是否正确)、最终打包成bytecode。

这个工具和PTO-ISA的关系是:PTO-ISA定义"指令长什么样、做什么",PTO-AS提供"把这些指令写成汇编并变成可执行格式"的工具链支持。两者共同构成了PTO生态中的"ISA定义+汇编工具"这一基础层。

结尾

PTO-ISA是CANN体系中连接上层算子描述和底层昇腾NPU二进制代码的关键中间层。它用tile作为核心抽象,用90余条标准指令覆盖计算、数据搬移、同步和通信四类操作,用虚拟ISA的设计同时兼顾了代际可移植性和性能调优空间。对于算子开发者来说,理解PTO-ISA的指令语义和编译管线,是在昇腾NPU上实现高性能自定义算子的基础能力。CPU Simulator降低了功能验证的门槛,通信扩展指令集打开了计算与通信融合的优化空间,PTO-AS汇编器则提供了指令级调试和实验的底层工具。这些组件共同构成一个仍在快速演进的工具链生态。

https://atomgit.com/cann/pto-isa

Logo

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

更多推荐