昇腾算子地图:编排层、自研算子、CANN 三层逐层展开
一层前向的每一行,到了 NPU 上都是一个或一串算子——这些算子不是散沙,它们分三层住:编排层不出算子、插件自研层藏着 DSA 特化、CANN 层有两户人家。先看三层全景,再对照组件×楼层矩阵,然后逐层展开族谱,最后拿真实 profiling 数据认亲
读前须知:四个名词 30 秒(更多见文末术语速查)
- 算子(operator/kernel):NPU 上一次下发的计算任务——用 PyTorch 写推理时的每一行(矩阵乘、归一化、softmax)到设备上都是一个或一串算子;
- CANN:昇腾的算力栈与算子库——大量算子的"本体"住在这里,版本随 CANN 包走;
- _C_ascend:vllm-ascend 插件自己编译安装的原生算子扩展(源码在插件仓库的 csrc/ 目录)——"算子住在哪"三看口诀里的一看;
- op_statistic.csv:profiling 导出的算子聚合统计(谁调了多少次、耗时占比)——本文所有实测占比的出处(GLM-5.1,A3,prefill 窗口)。
1. 全景:三层算子楼
从基础已知出发:用 PyTorch 写一层前向(②),LN、attention、残差、MoE 四五行代码——每一行到了 NPU 上都要变成具体的算子。这些算子住在哪、谁管它们?答案是一栋三层楼(③的目录结构就是这栋楼的门牌):顶层是 vllm-ascend 的 Python 编排层,中间是插件自研的原生算子层,底层是 CANN 算子层。先看全景:
图 1 · 三层算子楼全景:编排层出调用、自研层出特化融合、CANN 层出本体——每层的聚集点、策略与特点
一栋楼三层的分工一句话:编排层决定"调谁、怎么调";自研层放"别处没有、必须自己写"的特化算子;CANN 层是"别人早就写好、拿来就用"的算子本体库。写下的一行 torch.matmul 不会经过顶两层的代码,但它最终执行的内核一定住在层三。
楼层不等于组件:没有任何一个模型组件只住一层——attention 的算子散在 3a(投影矩阵乘、KV 融合)与层二(稀疏注意力本体、索引器);MoE 散得更开(层二的路由 hash 算子、3a 的重排与激活、3b 的大融合)。图 2 的矩阵把这张多对多关系摆全:
图 2 · 组件 × 楼层矩阵:attention 散住 3a 与层二、MoE 散住层二·3a·3b——多对多,层一整列不出算子
读矩阵的三点:一、多对多是常态——排查"attention 为什么慢"时,答案可能同时在 3a 的矩阵乘与层二的稀疏注意力,别只盯一层;二、层一整列空着不是漏画——编排层不出算子,四类组件的调用序列都由它编排,这正是它的工作方式;三、每格都有出处——图内下方列了源码文件与行号。DispatchFFNCombine 尤其特殊:调用走 _C_ascend 注册下发(moe_comm_method.py:485),内核本体却住在 3b 自定义算子包(staticKey 对比的对象)——两户联办。
下面逐层展开——每层先讲这一层的算子聚集于什么、什么策略,再看这一层的族谱。
2. 层一 · 编排层:不出算子,出调用
编排层怎么干活,拿 attention 一条调用链看最直观(②:用 PyTorch 实现推理时认识的那种前向)——这条链在 vllm-ascend 里被编排层一步步接手。这一层一个算子都不出:它出的是"调用与备料"——选哪个后端、元数据怎么建、张量按什么布局递下去。拿链上的五步看编排层每天在干什么:
图 3 · 编排层展开:五步流水线里它只出调用——投影走 3a 矩阵乘、KV 融合走 3a、稀疏注意力走层二、索引器双家二选一
这一层的策略与特点:聚集于调度与备料,策略是"数学全部下沉"——所以它的改动(选路、开关、元数据)都是纯 Python,看得见、改得动、不用重编译;两个 PR 的改动落点也全在这一层(#18870 的选路窄门、#18961 的备料复用)。顺带把一件容易混的事拆开:attention/ 目录管"注意力怎么算"(编排注意力的调用链),ops/ 目录管"算子怎么接进昇腾"(custom op 注册与分发)——两回事,别合成一个框记。
3. 层二 · 插件自研算子层:_C_ascend 族谱
有些算子别处没有、必须自己写:DSA 的稀疏注意力语义、索引器的量化选块、Q 侧动态量化——CANN 标准库里没有现成对应物。vllm-ascend 把它们写在自己的 csrc/ 里,编译成 _C_ascend 扩展随插件安装。按功能分四族:
图 4 · _C_ascend 族谱:稀疏注意力、索引器、归一化·旋转·量化、融合特化四族——每族"住着谁、为什么自研"
这一层的策略与特点:聚集于 DSA/MLA 特化与融合大算子;策略是"源码进仓库"——AscendC 内核写在 csrc/ 里、按 CANN 自定义算子格式编译加载,跟插件同版本发布;特点是重量级(MIX_AIC,单次毫秒级)且数量可控(四族几十个)。认它的口诀:代码里看到 torch.ops._C_ascend.xxx 就是这一层。
4. 层三 · CANN 算子层:两户人家,各有族谱
最底下一层住着两户人家:3a 标准算子库——torch_npu 暴露的 npu_* 接口、aclnn 前缀的标准算子、以及普通 matmul 自动到达的内核;3b 自定义算子包——libcust_opapi.so 那几个文件(Profiling 排查时对 staticKey 的对象),MoE 的大融合算子住这里。3a 内部再分族:
图 5 · CANN 层两户族谱:3a 标准库内分五族(矩阵乘/融合预处理/elementwise/写回/通信),3b 自定义包住 MoE 大融合
这一层的策略与特点:3a 聚集于通用数学——朴素前向里的每一行几乎都能在这里找到对应内核,入口有三种(显式 torch_npu 调用、aclnn 标准下发、普通 matmul 自动到达);3b 聚集于 MoE 大融合——代价是黑盒。两户共同的特点:内核本体随 CANN 包版本走——这正是 Profiling 排查时对 staticKey 而不是对代码的根本原因。
5. 认亲:trace 里的名字对回代码
拿真实 profiling 数据(GLM-5.1,A3,prefill 窗口 op_statistic.csv)把 top10 逐个对回三层楼——这张表就是"拿到一份 trace 怎么读"的练习:
| trace 名(op_statistic) | 代码里的名字 / 来源 | 住哪层 | 占比 |
| DispatchFFNCombine | 同名(fused_mc2 开关选的融合形态) | 层三 · 3b 自定义包 | 40.5% |
| QuantBatchMatmulV3 | 量化矩阵乘下发(名字常带 WeightNz——NZ 权重布局) | 层三 · 3a 矩阵乘族 | 12.2% |
| SparseFlashAttention | torch.ops._C_ascend.npu_sparse_attn_sharedkv | 层二 · 稀疏注意力族 | 11.4% |
| AivKernel | 聚合名:Fill / IndexCheck / foreach / cast 一类 vector 小算子 | 层三 · 3a elementwise 族 | 8.5% |
| TransposeBatchMatMul | 普通 torch.matmul 的转置形态 | 层三 · 3a 矩阵乘族 | 4.9% |
| allgatherAicpuKernel | torch.distributed 集合通信下发 | 层三 · 3a 通信族(AI_CPU) | 4.1% |
| MatMulV2 | nn.Linear → torch.matmul | 层三 · 3a 矩阵乘族 | 3.6% |
| AscendQuantV2 | W8A8 的激活量化下发 | 层三 · 3a elementwise 族 | 3.2% |
| ForeachAddList | 残差/列表加(torch foreach 下发) | 层三 · 3a elementwise 族 | 1.9% |
| reduce_scatterAicpu | torch.distributed 集合通信下发 | 层三 · 3a 通信族(AI_CPU) | 1.5% |
三看口诀(判断一个算子住哪)
- 看
torch_npu.npu_xxx 或普通 matmul → 层三 3a(CANN 标准库);
- 看
torch.ops._C_ascend.xxx → 层二(插件自研;其中薄适配的内核仍在层三);
- 看纯 Python 流程 → 层一(编排,不下内核)。
名字对不上怎么办(trace 名 ≠ 代码名是常态)
图融合会把多个小算子合成一个名字(AivKernel 就是这么来的);aten 层算子下发的 kernel 名也和 Python 名不同。对不上时用 shape + 调用次数去认:ForeachAddList 12008 次 ≈ DispatchFFNCombine 12007 次——成对出现的就在同一条执行路径上(MoE 的随从);ScatterNdUpdate 10611 次 == SparseFlashAttention 10611 次——KV 写入跟着 attention 走。别死磕名字。
同一算子两个家的实例
lightning indexer(DSA 选块的"闪电索引器")在 torch_npu(层三 3a)和 _C_ascend(层二)各有一份;代码按模型二选一——GLM(glm_moe_dsa)走 torch_npu 那份,其余走 _C_ascend 那份。所以"这个算子住哪"有时要加一句:住哪家还看跑的是什么模型。
6. 与我们排查问题的关系
- 给 Profiling 三板斧加了"户口"维度:排行→分布→按 shape 拆(姊妹篇《Profiling 实战解读》)之外,先问"这个算子住哪层"——层三的问题对 CANN 版本账(staticKey),层二的问题查插件源码与版本,层一的问题看 Python 调用与配置;
- 参数四分类的地基:姊妹篇《算子与 vLLM 参数》讲参数怎么改算子清单——本文反过来讲算子清单本身住在哪,两篇合成一张完整地图;
- 归因要落到物:耗时对比只能定位"谁慢",本文的三层楼给出下一步去哪找"为什么慢"——3b 找算子包版本与算子团队,层二查插件仓库 changelog,层一看调用方式;
- 硬件代次敏感:层二有 arch32/arch35 两版 tiling、A5 走 kv_quant 变体(#18961 的条件二也是 A5)——换硬件代次时族谱成员会变;
- 典型闭环案例:TTFT 劣化钉到 DispatchFFNCombine(层三 3b)→ 对 staticKey 全 DIFF → 算子包实现变更——三层楼的地图就是这条排查路径的底图。
7. 术语速查
| 术语 | 本文语境下的含义 |
| 三层楼 | 编排层(vllm-ascend Python)/ 插件自研算子层(_C_ascend)/ CANN 算子层(3a 标准库 + 3b 自定义包) |
| _C_ascend | vllm-ascend 编译安装的原生算子扩展;注册表在 csrc/torch_binding.cpp,调用形如 torch.ops._C_ascend.xxx |
| torch_npu | PyTorch 的昇腾适配层——npu_* 接口的绑定在此,内核本体在 CANN |
| 3a / 3b | CANN 标准算子库 / CANN 自定义算子包(libcust_opapi.so)——后者是 staticKey 对比的对象 |
| MIX_AIC | Cube+Vector 混合执行的大算子核心类型——融合算子多为它,top 榜重量级 |
| AivKernel | trace 里 vector 小算子的聚合名(Fill/IndexCheck/foreach/cast 一类),看明细要回 kernel_details 按 Name+shape 拆 |
| staticKey | 自定义算子包里标识 kernel 实现版本的键——两版本全 DIFF 即实现变更 |
| NZ 布局 | 矩阵单元友好的权重存放格式——量化矩阵乘名字里常带 WeightNz |
| AscendC | 写昇腾内核的语言/工具链——层二的 op_host 源码与 3b 的内核都是它写的 |
来源:算子名单与调用点以本地 vllm-ascend 源码实测(torch_npu 调用、csrc/torch_binding.cpp 注册表、device_op.py 选路);占比为 GLM-5.1(A3)prefill 窗口 op_statistic.csv 实测(姊妹篇《Profiling 实战解读》的数据同源)· 行号以当期源码为准