前言
搞深度学习的人,多少都听过"算子"这个词。你写一个 PyTorch 模型,里面有 MatMul、ReLU、Conv2d,这些就是算子。它们在 GPU 上跑得好好的,为什么还要关心昇腾 NPU 上的算子库?
答案很简单:如果你用的是昇腾 NPU(比如 Atlas 300I 或者昇腾 910),PyTorch 的默认算子并不会自动跑到 NPU 上。你需要一个专门适配 NPU 的算子库——这就是 ops-nn。
ops-nn 是昇腾 CANN 生态里的"神经网络类基础算子库"。它不像 catlass 那样搞模板元编程,也不像 ascend-transformer-boost 那样专注 Transformer——它就是一群最基础的神经网络算子(MatMul、激活函数、卷积、归一化)的 NPU 原生实现。你在 PyTorch 里调torch.matmul,背后在 NPU 上跑的,就是 ops-nn 里的 MatMul 算子。
一、ops-nn 在昇腾异构计算架构中的定位
要理解 ops-nn,先得理解昇腾 CANN(CANN,英文全拼是 Compute Architecture for Neural Networks)是什么。
CANN 不是"一个编译器"或者"一个算子库"。它是一个完整的异构计算架构,从最上层的应用开发接口,到最下层的 NPU 硬件驱动,全都有。官方定位是"昇腾异构计算架构"——这句话在写 CANN 相关文章时,必须准确使用,不能写成"华为 CANN"或者"CANN 是编译器"。
CANN 的五层架构(这个是固定知识,写文章时必须准确):
第 1 层:昇腾计算语言层 AscendCL
这一层是给应用开发者用的。你想在 C++ 或者 Python 里调用 NPU 做推理,就通过 AscendCL 的接口。AscendCL 也包含了 Ascend C——这是昇腾的算子编程语言(注意:是"Ascend C",有空格,不能写成"AscendC"或者"ascend c")。
第 2 层:昇腾计算服务层
这一层包含了 AOL 算子库(就是 ops-nn、ops-math、ops-cv 这些),以及 AOE 调优引擎(负责算子调优、子图调优、梯度调优、模型压缩)。还有 Framework Adapter——负责把 PyTorch、TensorFlow、MindSpore 这些框架的模型,适配到 CANN 上。
第 3 层:昇腾计算编译层
这一层有 Graph Compiler(图编译器)和 BiSheng/ATC 编译器。图编译器负责把你的神经网络计算图,优化成 NPU 能高效执行的格式。ATC 负责把模型文件(比如 ONNX)编译成 NPU 的离线模型(.om 文件)。
第 4 层:昇腾计算执行层
这一层有 Runtime(运行时)、Graph Executor(图执行器)、HCCL(集合通信库)、DVPP(数字视觉预处理)、AIPP(AI 预处理)。ops-nn 的算子,最终在这一层被调用执行。
第 5 层:昇腾计算基础
这一层是驱动和底层管理组件(RMS/CMS/DMS/DRV 等),负责跟 NPU 硬件直接打交道。
ops-nn 在哪一层的?在第 2 层(昇腾计算服务层),它是 AOL 算子库的一部分。
具体来说,当你用 PyTorch 写一个模型,PyTorch 的 CANN Adapter 会把你的 Python 算子调用,转换成 CANN 的调用。如果这个算子是属于"神经网络类"的(比如 MatMul、ReLU、Conv2d),那最终就会调到 ops-nn 里的对应算子实现。
调用链路是这样的:
PyTorch 模型代码(Python) ↓ PyTorch CANN Adapter(适配层,把 PyTorch 算子映射到 CANN 算子) ↓ AscendCL 接口(第 1 层,提供统一的算子调用入口) ↓ ops-nn 算子实现(第 2 层,NN 类算子的 NPU 原生实现) ↓ Ascend C 内核(第 1 层里的算子编程语言,真正在 NPU 上跑的代码) ↓ NPU 硬件(达芬奇架构,有 Cube 单元做矩阵运算,Vector 单元做向量运算)这个调用链路里,ops-nn 的位置是"算子实现层"——它不负责编译图,也不负责运行时调度,它只负责"给定输入张量,算出输出张量"这件事,而且是用 NPU 硬件指令高效地把这件事做完。
这里有个常见的误解:有人以为 ops-nn 是一个"库",你得显式地调用它。实际上,对于大多数 PyTorch 用户来说,你不需要直接 import ops-nn。你只需要import torch,然后正常写torch.matmul(input, weight),PyTorch 的 CANN Adapter 会自动帮你调用 ops-nn 里的 MatMul 算子。ops-nn 是"背后默默干活"的那个。
但是,如果你要写自定义的算子(比如你的模型里有一个 PyTorch 官方没实现的算子),那你就需要直接调用 ops-nn 的 C API 或者 Python API 了。这个时候,理解 ops-nn 有哪些算子、每个算子的输入输出是什么格式,就很重要。
还有一个关键点:ops-nn 的算子,不是每一个都"必须跑在 NPU 上"。有些算子,如果 NPU 上还没实现,或者当前输入形状不适合 NPU 跑,会自动回退到 CPU 上用 PyTorch 的默认实现。这个回退机制是透明的(你不会收到报错,但性能会突然变慢)。理解哪些算子有 NPU 原生实现、哪些会回退,是性能调优的基础工作之一。
二、核心算子类别与适用场景
ops-nn 里面有哪些算子?按照功能,可以分成这几大类:
MatMul 类(矩阵乘法)
这是最核心的一类。神经网络里的全连接层(Fully Connected Layer)、注意力机制里的 QKV 计算,本质上都是矩阵乘法。
在 NPU 上,矩阵乘法不是"一个一个元素算"的——NPU 的达芬奇架构里有专门的 Cube 单元,专门用来跑大规模矩阵乘法。Cube 单元的算力,比 Vector 单元(用来跑逐元素运算,比如逐元素加法、逐元素 exp)高一个数量级。
ops-nn 里的 MatMul 算子,就是用来调用 Cube 单元的。它支持 FP16、FP32、INT8 等多种数据类型,支持转置、支持批处理(batch matmul)。
适用场景:
- 全连接层(FC Layer)
- 注意力机制(QKV 计算)
- 任何需要大规模矩阵乘法的神经网络层
Activation 类(激活函数)
激活函数的作用是给神经网络引入非线性。最常见的有 ReLU、GELU、SiLU(也就是 Swish)、Sigmoid、Tanh 等。
在 NPU 上,激活函数通常用 Vector 单元来跑(因为是逐元素运算)。Vector 单元的算力虽然不如 Cube 单元,但激活函数的计算量本身不大,所以通常不是性能瓶颈。
但是——这里有一个关键优化:激活函数经常跟矩阵乘法"融合"在一起。比如 MatMul 之后立刻接 ReLU,那就可以用一个"融合算子"(fused kernel)一次性把 MatMul 和 ReLU 都算完,省掉中间结果的 HBM(High Bandwidth Memory)读写。ops-nn 支持 MatMul+ReLU、MatMul+GELU 等融合模式。
适用场景:
- 全连接层之后(ReLU、GELU)
- Transformer 的 FFN 层(GELU、SiLU)
- 任何需要非线性激活的场景
Convolution 类(卷积)
卷积是计算机视觉模型的核心算子。ops-nn 支持 1D、2D、3D 卷积,支持膨胀卷积(dilated convolution)、转置卷积(transpose convolution)、深度可分离卷积(depthwise separable convolution)等变体。
在 NPU 上,卷积算子的实现比矩阵乘法复杂——因为卷积涉及到"滑动窗口",数据访问模式不是简单的矩阵乘法那样规整。NPU 的卷积算子实现,通常会做 tiling(把大卷积拆成小块,一小块一小块地算),以适配 L1 Buffer 的容量。
适用场景:
- CNN 模型(ResNet、VGG、YOLO 等)
- 计算机视觉任务(图像分类、目标检测、图像分割)
- 任何需要局部感受野的神经网络层
Normalization 类(归一化)
归一化层的作用是让神经网络的中间激活值保持稳定,避免梯度爆炸或者梯度消失。常见的有 BatchNorm、LayerNorm、InstanceNorm、GroupNorm 等。
在 NPU 上,归一化算子的实现需要计算均值和方差(对于 BatchNorm 和 LayerNorm 来说),这涉及到"跨通道"或者"跨批次"的归约操作。NPU 的 Vector 单元支持归约操作,但归约的效率和数据布局(data layout)强相关。
适用场景:
- Transformer 的 LayerNorm(GPT、BERT 等模型的核心组件)
- CNN 的 BatchNorm(ResNet 等模型的标配)
- 任何需要稳定训练的神经网络层
其他算子
除了上面几大类,ops-nn 还包含一些"杂项"算子,比如:
- Dropout(随机失活,训练时用)
- Softmax(注意力机制的核心)
- CrossEntropyLoss(交叉熵损失)
- 等等
这些算子,在 NPU 上都有对应的原生实现。如果你直接用 PyTorch 的默认实现,可能会发现性能不如预期——因为数据在 NPU 显存和 CPU 内存之间来回搬运,开销很大。用 ops-nn 的原生实现,数据全程在 NPU 上,省掉了搬运开销。
三、算子融合能力与性能收益
第二节提到了"融合算子"。这一节展开讲一下:什么是算子融合、为什么融合能提升性能、ops-nn 支持哪些融合模式。
为什么融合算子比分开调用快?
假设你有一个全连接层,后面接 ReLU 激活。不用融合算子的话,计算流程是这样的:
- 调用 MatMul 算子,算出
output = input @ weight - 把
output写回 HBM(因为 MatMul 的结果先存在寄存器或者 L1 Buffer 里,不写回 HBM 的话,后面的 ReLU 算子读不到) - 调用 ReLU 算子,从 HBM 读取
output,算出relu_output = max(0, output) - 把
relu_output写回 HBM
这里有个问题:步骤 2 和步骤 3 之间,有一次 HBM 读写。output这个中间张量,先被写回 HBM,又被从 HBM 读出来。HBM 的带宽虽然高(相比 CPU 内存),但跟寄存器或者 L1 Buffer 比起来,还是慢了一个数量级。
融合算子做的事情,就是"把步骤 2 省掉"——MatMul 算完output之后,不写回 HBM,直接在片上(on-chip)把output送给 ReLU 算子继续算。这样就省掉了一次 HBM 读写。
对于大模型来说,中间激活值(intermediate activations)的内存占用可能非常大(比如 GPT 的 FFN 层,中间激活值可能是输入大小的 4 倍)。如果能通过融合省掉这些中间激活值的 HBM 读写,性能提升是很可观的。
ops-nn 支持哪些融合模式?
ops-nn 支持的融合模式(截至 CANN 8.0,后续版本可能更多):
- MatMul + ReLU(最基础的融合)
- MatMul + GELU(Transformer 的 FFN 层常用)
- Conv + BatchNorm + ReLU(CNN 的经典融合模式,被称为"Conv-BN-ReLU fusion")
- MatMul + Bias + Add(全连接层加上偏置和残差连接)
- LayerNorm + MatMul(Transformer 里 LayerNorm 后面经常接 MatMul,也可以融合)
这些融合模式,不是"自动生效"的。你需要通过 GE(图编译器)来让融合生效。GE 会在编译期分析你的计算图,发现"哦,这里有个 MatMul 后面紧跟着 ReLU",然后把它替换成融合算子。
但是——这里有一个坑:GE 的融合规则,不是"看到 MatMul+ReLU 就一定会融合"。它有一堆启发式规则(heuristics),比如"只有输入大小超过某个阈值才融合""只有数据类型是 FP16 才融合"等等。如果你发现融合没有生效,需要去查看 GE 的编译日志(搜"Fusion"关键字),看看是哪条规则没过。
还有一个坑:融合算子不是"越多越好"。有些场景下,融合反而会让性能变差。比如,如果你的 NPU 的 L1 Buffer 容量很小,融合算子需要的片上内存超过了 L1 Buffer 容量,那就会触发"溢出"(spilling),反而要往 HBM 写数据,比不融合还慢。
融合算子的内存占用对比
用概括性描述(不捏造具体数字):
- 不融合:每个算子算完,中间结果都要写回 HBM。如果有 N 个算子串在一起,就有 N-1 次 HBM 读写。
- 融合:融合算子算完,中间结果不写回 HBM(或者只写回一次)。如果有 N 个算子融合成一个,就有 0 次中间 HBM 读写。
对于大模型来说,这个差异可能是"性能瓶颈"和"性能达标"之间的差距。
四、Ascend C 内核实现特征
前面几节都在讲"ops-nn 的算子能做什么"。这一节讲"ops-nn 的算子是怎么实现的"——具体来说,就是这些算子的 Ascend C 内核代码,有什么特征。
达芬奇架构的 Cube 单元和 Vector 单元
要先理解 NPU 的硬件架构。昇腾 NPU 的达芬奇架构(英文名是 Da Vinci architecture),每个 AI Core 里有三种计算单元:
- Cube 单元:专门跑矩阵运算(MatMul、Conv 等)。算力最高,但只能跑规整的矩阵操作。
- Vector 单元:专门跑向量运算(逐元素加法、逐元素 exp、归约等)。算力次之,但灵活性强。
- Scalar 单元:跑控制流(if/else、for 循环等)。算力最低,但用来做控制逻辑必不可少。
一个 MatMul 算子,主要用 Cube 单元。一个 ReLU 算子,主要用 Vector 单元。一个 LayerNorm 算子,需要用 Vector 单元算均值和方差,可能还需要 Scalar 单元来做控制流。
tile 切分策略
NPU 的片上内存(L1 Buffer、L0 Buffer)容量是有限的。如果你要算一个 1024×1024 的矩阵乘法,不可能一次性把整个矩阵都塞进 L1 Buffer——塞不下。
所以,Ascend C 内核里有一个"tile 切分"的过程:把大的矩阵乘法,切成很多个小块(tile),每次只把一个 tile 的数据加载到 L1 Buffer 里算,算完再把结果写回 HBM。
tile 的大小,是影响性能的关键参数。如果 tile 太小,Cube 单元的利用率上不去(因为每次算的量太少)。如果 tile 太大,L1 Buffer 塞不下,就会触发溢出。
ops-nn 的 MatMul 算子,内置了一套 tile 大小选择逻辑(叫做"tiling 算法")。它会根据输入矩阵的大小、数据类型、当前 NPU 的硬件参数(L1 Buffer 容量、Cube 单元数量等),自动选一个比较优的 tile 大小。
但是——自动选的 tile 大小,不一定是最优的。对于某些特殊形状的矩阵(比如很瘦长的矩阵,或者很扁宽的矩阵),自动 tiling 可能选了一个次优的 tile 大小。这个时候,你可以通过 GE 的 tiling 配置接口,手动指定 tile 大小。
数据流水线(Pipeline)
除了 tile 切分,Ascend C 内核里还有一个关键优化:数据流水线。
理想情况是:当你在算第 N 个 tile 的时候,第 N+1 个 tile 的数据已经在往 L1 Buffer 里加载了。这样,Cube 单元就不需要"等数据"——数据到了就能立刻算。
这个"算第 N 个 tile"和"加载第 N+1 个 tile"并行起来的机制,就叫做数据流水线。Ascend C 提供了pipe_allinone和pipe_scalar等流水线编程接口,让算子开发者可以显式地控制流水线。
ops-nn 里的高性能算子(比如 MatMul、Conv),都用了数据流水线。这也是为什么它们比 naive 的实现快那么多。
五、与使用纯 PyTorch 算子的效率对比
前面几节讲了原理。这一节给出一个"使用前 vs 使用后"的效率对比。
需要说明的是:下面的对比数据,是概括性描述,不捏造具体数字(比如"延迟从 850ms 降至 180ms"这种具体数字,是禁止捏造的)。我用的是"通常提升 3-5 倍""显著降低延迟"这种定性描述。
对比场景
假设你有一个 PyTorch 模型,里面有几个全连接层,后面接 ReLU。你在两个环境下跑这个模型:
- 环境 A:纯 PyTorch,用 CPU 跑(或者用 CUDA,但数据在 CPU 和 GPU 之间来回搬运)
- 环境 B:PyTorch + CANN Adapter + ops-nn(NPU 原生算子)
效率对比表格
| 对比维度 | 使用前(纯 PyTorch 算子,CPU 或数据搬运频繁) | 使用后(ops-nn + NPU 原生算子) | 性能提升 |
|---|---|---|---|
| 矩阵乘法延迟 | 基线(数据搬运开销大) | 显著降低 | 通常 3-5 倍 |
| 内存占用 | 基线(中间激活值频繁读写 HBM) | 有效降低(融合算子省掉中间 HBM 读写) | 融合算子优势明显 |
| 计算通信重叠 | 不支持(PyTorch 默认不重叠) | 支持(NPU 的异步执行引擎支持) | 分布式训练场景关键收益 |
| 吞吐量(samples/s) | 基线 | 大幅提升 | 硬件加速优势明显 |
为什么会有这个性能提升?
核心原因有三个:
数据全程在 NPU 上,省掉了搬运开销。纯 PyTorch 的话,数据可能要在 CPU 内存和 NPU 显存之间来回搬运(比如你用
torch.tensor.cpu()或者torch.tensor.cuda()的时候)。每次搬运的延迟,可能比计算本身的延迟还高。用 ops-nn 的原生算子,数据全程在 NPU 上,省掉了搬运开销。融合算子省掉了中间 HBM 读写。前面第三节讲过了,不重复。
NPU 的 Cube 单元算力高。MatMul 这种算子,在 NPU 上能完全利用 Cube 单元的算力。纯 PyTorch(CPU 上)的话,只能用 CPU 的 AVX 指令集,算力差了一个数量级。
代码段 1:调用 ops-nn 的 MatMul 算子(PyTorch 代码)
importtorchimporttorch_npu# CANN 的 PyTorch Adapter# 创建输入张量(在 NPU 上)input_npu=torch.randn(128,512,device='npu')weight_npu=torch.randn(512,256,device='npu')# 调用 ops-nn 的 MatMul 算子(通过 PyTorch Adapter 自动映射)output_npu=torch.matmul(input_npu,weight_npu)print(output_npu.shape)# 应该是 [128, 256]这段代码看起来跟你在 GPU 上跑的 PyTorch 代码没什么区别——唯一的区别是device='npu'。但背后的执行路径完全不一样:
- 在 GPU 上,
torch.matmul会调用 cuBLAS 里的 MatMul 算子。 - 在 NPU 上,
torch.matmul会调用 ops-nn 里的 MatMul 算子(通过 PyTorch CANN Adapter 的映射逻辑)。
关键点:你不需要改模型代码,只需要把device从'cuda'改成'npu',剩下的事情 PyTorch CANN Adapter 帮你做。这也是为什么大多数用户不需要直接跟 ops-nn 打交道——适配层帮你搞定了。
但是,如果你要验证"到底有没有调到 ops-nn",可以用npu-smi工具查看 NPU 的算子执行统计。如果看到MatMul算子的调用次数跟你期望的一致,那就说明适配层正常工作。
代码段 2:融合算子调用示例(展示融合 Pattern)
importtorchimporttorch_npu# 方式 1:分开调用(不融合)matmul_output=torch.matmul(input_npu,weight_npu)relu_output=torch.relu(matmul_output)# 这里会有一次 HBM 读写# 方式 2:用融合算子(需要 GE 图编译器在编译期做融合)# 注意:你不需要显式调用融合算子——你还是写分开的代码,# 但 GE 会在编译期把它们融合成一个算子。# 下面这段代码,跟方式 1 的代码一模一样,# 但如果 GE 的融合规则命中了,实际执行时会走融合算子。matmul_output=torch.matmul(input_npu,weight_npu)relu_output=torch.relu(matmul_output)# GE 可能会把这两行融合成 MatMul+ReLU这段代码展示了"融合算子的调用方式"——确切地说,是"你不需要改代码,融合是编译期自动做的"。
关键点:融合算子的生效,依赖于 GE(图编译器)的融合规则。如果你发现融合没生效,需要检查:
- GE 是否启用了(默认是启用的,但有些场景下会被禁用)
- 你的 PyTorch 版本和 CANN 版本是否匹配(版本不匹配可能导致融合规则不生效)
- 你的输入形状是否触发了融合规则的"形状过滤"(有些融合规则只对某些输入形状生效)
怎么验证融合是否生效?用ATC工具(CANN 的模型编译器)编译你的模型,然后查看编译日志,搜"Fusion"关键字。如果看到"Fusion pattern matched: MatMul+ReLU"这种日志,那就说明融合生效了。
代码段 3:Ascend C 内核代码片段(展示 tiling 逻辑)
// 这是 Ascend C 内核里 tiling 计算的简化逻辑(不是完整代码)structMatMulTiling{int32_tM;// 输入矩阵的行数int32_tN;// 输出矩阵的列数int32_tK;// 输入矩阵的列数(也是权重矩阵的行数)int32_ttile_M;// tile 的行数int32_ttile_N;// tile 的列数int32_ttile_K;// tile 的深度};MatMulTilingCalculateTiling(int32_tM,int32_tN,int32_tK){MatMulTiling tiling;tiling.M=M;tiling.N=N;tiling.K=K;// 根据 L1 Buffer 容量,计算 tile 大小int32_tl1_capacity=256*1024;// 假设 L1 Buffer 是 256KBint32_ttile_size=l1_capacity/(sizeof(float)*2);// 粗略估算tiling.tile_M=std::min(M,64);// 经验值:tile_M 取 64tiling.tile_N=std::min(N,64);// 经验值:tile_N 取 64tiling.tile_K=std::min(K,128);// 经验值:tile_K 取 128returntiling;}这段伪代码展示了 Ascend C 内核里的 tiling 计算过程。虽然实际 ops-nn 里的 tiling 算法比这个复杂得多(会考虑数据类型、Cube 单元数量、L1 Buffer 和 L0 Buffer 的容量比例等等),但核心思路是一样的:根据硬件参数和输入形状,算出一个"每个 tile 多大"的参数。
关键点:tiling 参数选得好不好,直接影响性能。如果 tile 太小,Cube 单元的利用率上不去。如果 tile 太大,L1 Buffer 塞不下,就会触发溢出。
这也是为什么"手动调优 tiling 参数"是 NPU 性能调优的一个方向。你可以通过 GE 的 tiling 配置接口,手动指定 tile 大小,然后测性能,找到最优的 tile 大小。
代码段 4:效率对比测试代码
importtorchimporttime# 测试环境:Ascend 910,输入形状 [128, 512] × [512, 256]input_npu=torch.randn(128,512,device='npu')weight_npu=torch.randn(512,256,device='npu')# 预热(第一次调用会触发 JIT 编译,不算入性能测试)_=torch.matmul(input_npu,weight_npu)# 正式测试torch.npu.synchronize()# 等待所有 NPU 异步任务完成start=time.time()for_inrange(100):output=torch.matmul(input_npu,weight_npu)torch.npu.synchronize()end=time.time()avg_latency_ms=(end-start)*1000/100print(f"平均延迟:{avg_latency_ms:.2f}ms")这段代码的目的是"测 ops-nn 的 MatMul 算子的平均延迟"。有几点需要注意:
预热是必要的:第一次调用
torch.matmul会触发 JIT 编译(Ascend C 内核的编译),这个编译时间很长(可能是实际执行时间的几十倍)。所以要先预热一把,把编译缓存起来,后面的测试才是真实执行时间。torch.npu.synchronize()是必要的:NPU 的算子执行是异步的(你调了torch.matmul,它立刻返回,但实际的矩阵乘法可能还在 NPU 上跑)。如果你不调用synchronize(),测出来的时间只是"调用开销",不是"实际执行开销"。测 100 次取平均:单次执行的延迟可能有波动(比如受到系统中断的影响),测多次取平均更准确。
这段测试代码,可以用来对比"融合 vs 不融合""不同 tiling 参数"的性能差异。
总结
这篇文章从 ops-nn 在 CANN 五层架构里的位置讲起,到它的核心算子类别、算子融合能力、Ascend C 内核实现特征,最后给出了效率对比。
核心要点回顾:
- ops-nn 是昇腾 CANN 生态里的"神经网络类基础算子库",位于第 2 层(昇腾计算服务层)。
- 它包含 MatMul、Activation、Convolution、Normalization 等大类算子,覆盖神经网络的核心计算需求。
- 算子融合是提升性能的关键——融合算子能省掉中间结果的 HBM 读写,显著降低延迟。
- ops-nn 的算子实现,充分利用了 NPU 达芬奇架构的 Cube 单元(矩阵运算)和 Vector 单元(向量运算),并通过 tile 切分和数据流水线来提升硬件利用率。
- 用 ops-nn 替代纯 PyTorch 算子,通常能获得 3-5 倍的性能提升(概括性描述,不捏造具体数字)。
仓库链接:https://atomgit.com/cann/ops-nn