做算子优化的人大概都有过这种体验:一段向量计算,逻辑上清清爽爽七八个算子一路算下去,可真跑起来,时间全耗在了"算子和算子之间"。

这篇文章聊的就是这件事——在 Ascend 950 上,编译器如何用 VF 融合把这些缝隙缝起来。

先说 VF 是什么

VF,Vector Function,可以理解成"Vector 核被启动一次所执行的一段向量计算"。

关键在于独立启动这四个字。Vector 核由控制侧 Launch,每一次 Launch 都有固定的启动开销。更要命的是,两个相邻的 Vector Function 之间,数据要交接——前一个把结果写回 UB,后一个再从 UB 读回来。

于是一条看起来很干净的算子链,实际执行是三次 Launch、四趟 UB 往返。真正的计算可能只占很小一部分,剩下的都在搬运和启动上。

VF 融合要做的,就是把它合成一次 Launch,中间结果不再落回 UB:

图片

VF 融合前后

这里有个容易误解的地方要先讲清楚:首尾仍然走 UB。数据还是从 UB 载入,结果还是写回 UB。融合改变的是中间结果的存放位置,不是让所有数据都绕开 UB。

为什么是 Ascend 950

这个"中间结果不落回 UB"的想法,在 A2/A3 上其实很难实现。

A2/A3 是 memory-based 的。Vector 单元算完一轮,结果没有别的地方可去,只能回 UB。两个向量计算之间的数据交换成本天然就高,想做深层融合,没有硬件支撑。

Ascend 950 在这条路径上插进来一层 Vector Register File:

图片

A2/A3 与 Ascend 950 的向量数据通路

两边的结构完全对称,差别只在第三层和第五层。从 memory-based 转向 register-based,中间结果有了一个"停留的地方"。

这一层就是 VF 融合的硬件基础。没有它,前面说的那个"一次 Launch 把三个 VF 串起来"根本无从谈起。

一个 Tile 是怎么跑完的

把粒度再放细一点,看一块数据具体怎么流动。

图片

一个 Tile 的执行过程

第一步,整块搬进 UB。 这一趟由 MTE 完成,Tile 的形状保持不变。

第二步,按 VL 分块送进寄存器。 VL 是一个向量寄存器能装下的元素个数。注意这里不是整块进 Vector——UB 里的 Tile 被切成一段一段连续的数据,每次取一个 VL。

第三步,计算。 Vector 单元从寄存器取操作数,算完写回寄存器。

第四步,推进到下一段。 循环回到第二步,直到整个 Tile 处理完。

A2/A3 和 Ascend 950 的区别就藏在第三步和第四步之间,也就是图下方那两行。A2/A3 上,每做完一个算子,这一段数据就得回一趟 UB,下一个算子再捞出来,循环里反复出现 Store → Load 这对动作。Ascend 950 上,如果两个算子被融合进同一个 VF,中间结果直接留在寄存器里,那对动作就消失了。

Tile 有多少个 VL,这对动作就省掉多少次。收益就是这么攒出来的。

下面说说编译器为了把融合做成,都做了些什么。

一、代码移动:先把机会腾出来

举一个很典型的小问题:一个 Store 挡住了融合。

一个归约算子在循环里累加,然后在循环之外把累加结果写出去。它和下一个算子的循环本来形状一致,完全可以融合,但中间横着这条 Store,两个循环不再相邻。

有意思的是——这个 Store 写的是归约结果,而下一个算子读的是原始输入。它根本不影响后面的计算。

图片

Sink 与 Hoist

那就把它下沉。编译器在尝试融合之前先做一次 Store Sink,把这条没有实际依赖的 Store 挪到后一个循环之后,两个循环随即相邻。Store 仍然只执行一次,语义没有改变。

对称的还有 Hoist:多个循环里反复计算同一组 row/col 地址,编译器把它统一提前算好,循环内直接用。

这类代码移动本身不产生收益,它们的作用是把融合的机会腾出来。

二、循环融合与寄存器化

机会腾出来之后,LoopFusion 把生产和消费放进同一个循环,mem2reg 再把配对的 Store/Load 消掉:

图片

LoopFusion 与 mem2reg

中间值 T 从一次内存往返,变成一个寄存器的复用。这是整条链路上真正吃掉 UB 往返的一步。

三、展开与调度

循环融合之后,编译器还要决定展开多少次、指令按什么顺序发射。同一个 A → B 的循环,展开 2 次还是 4 次、原序还是重排,会得到完全不同的指令序列。

图片

Unroll 与 CostModel

判据不是"展开越多越快"。代价模型要按依赖图模拟发射与延迟、估算整个 VF 的 Completion Cycle,还要用 Register Pressure 筛掉有 spill 风险的候选,再挑预测周期最低的那个。

四、版本选择:单算子最快的实现,未必是最优解

这一步最反直觉,值得多说两句。

以 ColSum 为例,它有两种实现思路。一种是二分规约,64 行两两相加变 32 行,一路折半到 1,教科书式的写法,单个算子跑起来又快、精度又好。另一种是逐行累加,从头到尾加一遍,单看性能不如二分。

问题来了。上游的 Exp 是逐行处理的,循环次数等于行数。二分规约的循环次数是 64、32、16……一路在变,和上游对不上,没法融进同一个循环,于是 Exp 的结果必须完整落回 UB 再读出来。换成逐行版本,单算子慢一点,但能和 Exp 融进同一个循环,中间那一整块数据的 Store 和 Load 直接省掉。算下来,整体更快。

这正是版本选择要解决的问题:选哪个实现,判据不是它单独跑多快,而是它的循环结构能不能和上下游对齐。

同样的逻辑也体现在循环形态上——1D 单层展开单算子更快,2D 保留行列边界则更容易和 Reduce、Expand 对齐。编译器据此判断:这个算子有没有进入 fusion_group,没进就选单算子最优版本,进了就选融合友好的版本。

需要说明的是,1D/2D 这一层的版本选择已经在编译器中落地;更深一层的实现选择(比如归约的二分与逐行写法)还需要精度建模的支撑,这是我们正在推进的方向。

五、指令调度:让同 Shape 的计算连续

最后一层是排布。一条 MulS → ColMax → Expand → Sub → Exp → ColSum → Cast 的链,哪些算子挨在一起、Shape 在哪里发生突变,直接决定了可融合的范围有多大。

调度的目标很明确:让同 Shape 的计算尽量连续,让 Shape 突变集中到两端。

图片

指令调度前后

比如把 MulS 下放到 ColMax 之后——代价是对一个小向量多算一次,换来的是 128 行的整块缩放进入融合群;再把 Cast 提到 ColSum 之前,Shape 突变就集中到了链条末端,中间一大段同 Shape 的计算连成一片。

一次顺序调整,可能凭空多出一大段融合区间。难点在于这本质是个 NP 困难的调度问题:变换要保证语义等价,收益还取决于后续算子的实现细节,而调度本身追求的流水 overlap 又可能和融合追求的同 Shape 聚合相冲突。

总结

本文讲到的代码移动、循环融合、展开调度与版本选择,只是 VF 融合用到的部分技术,背后还有别名分析、同步处理、精度建模等一系列工作在支撑。

硬件架构理解 + 代码移动与循环融合 + 指令调度 + CostModel 与版本选择

Ascend 950 把中间结果搬进了寄存器,但硬件只是提供了可能性。要把一条算子链真正连起来、连得足够长,靠的是编译器——VF 融合正是编译器把硬件能力兑现为算子性能的关键一环。

了解更多详情,请线下参与

主题:CANN 开发者 Meetup PyPTO 算子编程专场

时间:2026年10月24日(周六) 14:00-18:00 

地点:上海市杨浦区创智天地广场7号楼一楼会议室 

报名链接:报名开启|CANN 开发者 Meetup PyPTO 算子编程专场,解锁复杂算子开发新范式

Logo

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

更多推荐