前言

昇腾 NPU 有两个计算核心:Cube 单元做矩阵乘,Vector 单元做逐元素运算。这两者的分工不是偶然的——矩阵乘是计算密集型,要高密度算力;逐元素运算是内存密集型,要高带宽搬运。大多数深度学习模型里,两类算子缺一不可,区别在于用哪个单元来跑性价比更高。

atvc(AIC Vector Compute)仓是昇腾 NPU 的 Vector 算子模板库,专注于降低 Vector 单元上算子开发的门槛。与 catlass 仓(Cube 矩阵乘模板)对应,atvc 把 Vector 单元上的常用操作封装成模板,开发者只需填参数就能生成完整的 Ascend C 代码。这篇文章从向量计算的基本原理出发,先对比 Vector 和 Cube 的适用场景,再深入 atvc 的模板设计,最后用代码示例和性能数据说明 Vector 单元的实际表现。

Vector 单元 vs Cube 单元:适用场景对比

昇腾达芬奇架构的 NPU 包含三种计算单元:Scalar 单元处理控制流和简单标量运算,Cube 单元做矩阵乘法,Vector 单元做逐元素向量运算。搞清楚这三者的边界,是做算子开发的第一步。

Cube 单元的核心能力是矩阵乘(MatMul)。一条 Cube 指令同时处理两个 16×16 的矩阵块,输出一个 16×16 的结果。假设数据类型是 FP16,Cube 单元一次吞吐是 16×16×16 = 4096 次乘加运算。这个数字很大,所以矩阵乘类算子(GEMM、Conv、BatchMatMul)天然适合 Cube 单元。代价是 Cube 单元的灵活性差——它只认矩阵乘法,其他运算放上去要绕很大一圈才能用。

Vector 单元的核心能力是逐元素运算(Element-wise)。一条 Vector 指令同时处理 128 个元素(FP16 场景),输出也是 128 个元素。这比 Cube 的计算密度低很多,但覆盖的算子类型广得多:ReLU、GELU、SiLU、Sigmoid、Tanh、LayerNorm、Softmax、RMSNorm、Reduce 操作(Sum、Mean、Max)这些都落在 Vector 单元的主场。

两者的数据流也有本质区别。Cube 单元有专用的 Accumulator(累加器),中间结果不需要写回 HBM,可以直接在片上累积,延迟低、带宽省。Vector 单元没有这种结构,每次运算都要从 HBM 读数据,算完写回去,带宽压力大。这使得 Vector 单元对数据局部性更敏感——分块(Tiling)策略和向量化(Vectorization)做得好不好,直接决定算子性能。

选哪个单元,没有标准答案,要看算子的计算特征:

特征 推荐用 Cube 推荐用 Vector
计算类型 矩阵乘、卷积 逐元素运算、Reduce
计算密度 高(大量乘加运算) 低(单指令单操作)
数据复用 输入矩阵可复用多次 元素只参与一次运算
典型算子 GEMM、Conv、BatchNorm(部分实现) ReLU、GELU、LayerNorm、Softmax、Dropout
内存访问模式 固定 stride,可预取 不规则,需要分块管理

实际开发中,很多算子会把两个单元串起来用:Cube 做完矩阵乘,Vector 做 LayerNorm 或 GELU 的后处理。这种协同模式在 Transformer 类模型里极为常见,也是 atvc 模板库存在的重要背景——Cube 部分交给 catlass,Vector 后处理部分交给 atvc,开发者各司其职。

atvc 模板结构:模板参数和实例化方式

atvc 仓的定位是 Vector 算子的模板库,从目录结构到模板设计都围绕这个目标展开。克隆仓库后,核心目录有以下几个:

atvc/
├── templates/              # 向量运算模板定义
│   ├── vector_ops.hpp      # 逐元素运算模板(abs/relu/gelu/silu...)
│   ├── reduce_ops.hpp     # Reduce 类模板(sum/mean/max/min)
│   └── norm_ops.hpp       # 归一化模板(layer_norm/rms_norm/softmax)
├── core/                   # 模板核心基础设施
│   ├── tensor.hpp          # 张量描述符
│   ├── memory.hpp          # 内存管理(HBM / LocalTensor)
│   └── tiling.hpp         # 分块策略
├── examples/               # 完整示例
└── README.md

atvc 的模板设计有两个层次:基础设施层算子模板层。基础设施层处理内存分配、分块调度、数据搬运这些通用问题;算子模板层定义具体的向量运算逻辑。两层分离的好处是:改写一个算子的计算逻辑,不需要动内存管理的代码。

模板参数

atvc 模板用 C++ 模板参数来描述算子的静态特性,这是昇腾Ascend C 算子开发的惯用做法。静态参数在编译期确定,生成代码时不需要运行时判断,执行效率更高。

核心模板参数有三类:

第一类是数据类型(DataType)。指定输入和输出的精度,决定了 Vector 指令处理元素的位宽。常用选项包括 DT_FLOAT16(FP16,128 元素/指令)、DT_FLOAT(FP32,64 元素/指令)和 DT_INT8(INT8,256 元素/指令)。不同数据类型直接影响 Vector 单元的吞吐:INT8 吞吐最高,FP16 次之,FP32 最低,选什么类型要权衡精度需求和性能目标。

第二类是张量形状(TensorShape)。描述输入输出的维度布局,包括 Batch 维度、Channel 维度、Height 维度和 Width 维度。Shape 决定了 tiling 的起点——一个 3D 张量和一个 1D 向量,在 Vector 单元上的切分方式完全不同。atvc 模板根据 Shape 自动推导分块参数,开发者不需要手动计算每个 Block 处理多少元素。

第三类是分块参数(TilingParams)。这是 Vector 算子性能的关键。Vector 单元没有 Cube 那种专用 Accumulator,每次计算都要访问 HBM。如果一次把整个张量都读进来,HBM 带宽会成为瓶颈。分块策略是把大张量切成若干小块,每块的大小要能塞进 LocalTensor 的容量,同时保持足够的计算粒度。atvc 在 core/tiling.hpp 里提供了几种预设的分块策略:按固定块大小切、按 Core 数均分、自适应切(根据张量总大小动态选择)。

实例化方式

模板定义好了,接下来要实例化——即用具体的参数替换模板参数,生成可编译的 Ascend C 代码。atvc 支持两种实例化方式:

第一种是直接实例化(Compile-time),用 C++ 模板语法直接写死参数。这种方式生成的代码没有分支,性能最优,但换一个形状就要重新编译一次。适合形状固定的生产环境算子。

// 直接实例化:输入 FP16、形状 1024×1024
using AbsKernel = atvc::VectorKernel<
    atvc::Op::kAbs,           // 算子类型:Abs
    atvc::DataType::kFloat16, // 数据类型:FP16
    atvc::TensorShape<1024, 1024>, // 张量形状:1024×1024
    atvc::TilingPolicy::kDefault // 默认分块策略
>;

第二种是参数化实例化(Runtime Config),把形状和分块参数放到运行时配置结构里,一次编译可以处理多种形状。代价是代码里要加 if-else 判断,略有性能损耗。适合形状不固定的自定义算子或调参阶段。

// 参数化实例化:形状由 runtime 配置决定
struct KernelConfig {
    int64_t rows;
    int64_t cols;
    int block_size;
    int num_blocks;
};

using AbsKernel = atvc::VectorKernel<
    atvc::Op::kAbs,
    atvc::DataType::kFloat16,
    atvc::TensorShape<atvc::Runtime, atvc::Runtime>, // 运行时确定形状
    atvc::TilingPolicy::kRuntime // 运行时选择分块策略
>;

// 调用时传入配置
KernelConfig cfg{1024, 1024, 128, 64};
AbsKernel kernel;
kernel.Init(cfg);
kernel.Execute();

两种实例化方式没有绝对优劣,关键是看使用场景。生产推理时形状固定,直接实例化性能更好;训练调参或做研究时形状经常变,参数化实例化更灵活。

向量运算的优化:向量化 + 分块

Vector 单元上跑算子,性能瓶颈主要来自两方面:带宽指令效率。向量化(Vectorization)和分块(Tiling)是解决这两个问题的核心手段,atvc 模板在底层把这两件事自动化了。

向量化

向量化是指利用 Vector 指令一次处理多个元素的能力,提升单指令吞吐。昇腾 910 的 Vector 单元,FP16 场景每次处理 128 个元素,这意味着如果代码里写的是循环逐元素处理(for i in range(N): y[i] = abs(x[i])),实际只用了 1/128 的算力。

正确的做法是一次性把数据送进 Vector 单元,让它自己并行处理。atvc 模板内部直接调用 Vector 指令(比如 vec_absvec_reluvec_gelu),编译器把循环展开成向量化指令,不需要开发者手动做 SIMD 优化。

但向量化有个前提:数据的内存布局要对齐到 Vector 指令的宽度。如果一个 FP16 向量有 127 个元素,Vector 单元第一次处理 128 个元素时会越界。atvc 在 core/tensor.hpp 里内置了自动对齐逻辑:读取数据时自动 padding 到 Vector 宽度的整数倍,计算完成后裁掉多余部分,对外暴露的接口仍然是无 padding 的原始形状。

分块策略

分块解决的是 HBM 带宽瓶颈。Vector 单元处理的每个元素都要从 HBM 读进来、算完写回去,HBM 带宽是有限的。如果一次性处理整个张量,HBM 带宽会成为瓶颈,Vector 单元大部分时间在等数据。

分块的思想是把张量切成若干 Block,每个 Block 的数据量与 LocalTensor 的容量匹配。LocalTensor 是 Vector 单元可以直接访问的片上高速缓存(类似 GPU 的 Shared Memory),容量有限但带宽极高。把数据先搬到 LocalTensor,在上面做计算,再写回 HBM,可以显著减少 HBM 访问次数。

atvc 的分块策略考虑了三个因素:张量总大小(决定分几块)、LocalTensor 容量(决定每块多大)、并行 Core 数(决定块怎么分配给不同 Core)。TilingPolicy::kDefault 策略的逻辑大致如下:

  1. 先根据张量总大小估算需要的 Block 数,保证每个 Block 至少处理 512 个元素(否则启动开销不划算)。
  2. 再根据 LocalTensor 容量上限约束单 Block 大小,确保数据能完整放进 LocalTensor。
  3. 最后按 Core 数目均分 Block,每个 Core 处理相同数量的 Block(负载均衡)。

这个策略在大多数场景下表现良好,但并不是最优的。精细的性能调优需要根据具体算子的计算/带宽比来手动调整分块参数。比如 LayerNorm 算子需要两遍扫描(第一遍算均值,第二遍算方差),每遍的数据搬运模式不同,分块策略也要相应调整。atvc 模板暴露了 TilingParams 结构,开发者可以直接改参数做实验:

// 自定义分块参数:适合大张量场景
atvc::TilingParams params;
params.block_size = 256;   // 增大单块大小,减少 HBM 访问次数
params.num_blocks = 8;     // 8 个 Block 并行
params.enable_double_buffer = true; // 开启双缓冲:计算和搬运并行

using AbsKernel = atvc::VectorKernel<
    atvc::Op::kAbs,
    atvc::DataType::kFloat16,
    atvc::TensorShape<8192, 8192>,
    params  // 传入自定义分块参数
>;

双缓冲(Double Buffer)是另一个常用的优化手段。当一个 Block 在 Vector 单元上计算时,DMA 控制器同时预取下一个 Block 的数据到 LocalTensor,计算和搬运两个阶段完全 overlap,Vector 单元不再等待数据。代价是 LocalTensor 容量要翻倍(同时容纳当前 Block 和下一个 Block),适合内存充裕的场景。

代码示例:atvc 模板实例化

光讲原理不过瘾,这一节给出一个完整的代码示例,展示用 atvc 模板开发一个自定义 GELU 算子的全过程。GELU(Gaussian Error Linear Unit)在 Transformer 类模型里大量使用,公式是 gelu(x) = 0.5 * x * (1 + tanh(sqrt(2/pi) * (x + 0.044715 * x^3)))。原生 Ascend C 实现这段计算要分好几步,用 atvc 模板可以把核心逻辑压缩到几行。

步骤一:创建项目并引入 atvc

# 克隆 atvc 仓库
git clone https://atomgit.com/cann/atvc.git
cd atvc

# 在 templates/ 下创建自己的算子目录
mkdir -p my_ops/gelu

步骤二:定义 GELU 算子模板

新建 my_ops/gelu/gelu_op.hpp,继承 atvc 的向量算子基类,填入 GELU 的计算逻辑。atvc 模板把 Vector 指令封装成了可组合的算子对象,计算流程是链式的——每个中间结果的 LocalTensor 直接传给下一个算子,不需要显式管理内存:

// gelu_op.hpp — 用 atvc 模板定义 GELU 算子
#pragma once
#include "atvc/core/tensor.hpp"
#include "atvc/core/memory.hpp"
#include "atvc/core/tiling.hpp"
#include "atvc/templates/vector_ops.hpp"
#include "atvc/templates/math_ops.hpp"

namespace atvc {
namespace my_ops {

/**
 * GELU 算子
 *
 * 公式: gelu(x) = 0.5 * x * (1 + tanh(sqrt(2/pi) * (x + 0.044715 * x^3)))
 *
 * Vector 单元上分两步执行:
 *   第一步:计算 x^3 和中间项 sqrt(2/pi) * (x + 0.044715 * x^3)
 *   第二步:算 tanh,再乘 0.5 * x
 * 这里利用了 Vector 单元的链式指令能力,
 * 中间结果不写回 HBM,直接在 LocalTensor 上流转,
 * 省掉一次 HBM 读写。
 */
template <DataType kType, TensorShape kShape, TilingParams kTiling>
class GeluOp : public VectorKernel<Op::kCustom, kType, kShape, kTiling> {
public:
    using Base = VectorKernel<Op::kCustom, kType, kShape, kTiling>;
    using Base::Base;  // 继承构造

    // GELU 的计算图(算子链)
    void Compute(LocalTensor<half> output, LocalTensor<const half> input, int numel) {
        constexpr float k = 0.044715f;
        constexpr float sqrt_2_over_pi = 0.7978845608f;

        // 临时 LocalTensor,容量从 Base 类的 workspace 里分配
        // Workspace 由 atvc 在 Init 阶段自动规划,无需手动指定大小
        auto tmp1 = this->AllocTmp<half>(numel); // x^3
        auto tmp2 = this->AllocTmp<half>(numel); // 中间项
        auto tmp3 = this->AllocTmp<half>(numel); // tanh 结果

        // 第一步:计算 x^3 = x * x * x
        // 这里两次乘都用 vec_mul,不额外开临时变量,直接覆盖 tmp1
        vec_mul(tmp1, input, input, numel);     // x * x
        vec_mul(tmp1, tmp1, input, numel);      // (x * x) * x = x^3

        // 第二步:计算中间项 sqrt(2/pi) * (x + k * x^3)
        // 先算 k * x^3,结果放 tmp2
        half k_half(k);  // 标量乘法,atvc 自动广播到所有元素
        vec_mul(tmp2, tmp1, k_half, numel);     // k * x^3

        // 再算 x + k * x^3,覆盖 tmp2
        vec_add(tmp2, input, tmp2, numel);      // x + k * x^3

        // 最后乘 sqrt(2/pi)
        half sqrt_k(sqrt_2_over_pi);
        vec_mul(tmp2, tmp2, sqrt_k, numel);     // sqrt(2/pi) * (x + k*x^3)

        // 第三步:tanh,得到中间结果
        vec_tanh(tmp3, tmp2, numel);             // tanh(...)

        // 第四步:计算最终结果 0.5 * x * (1 + tanh(...))
        // 先算 1 + tanh(...)
        vec_add(tmp2, tmp3, half(1.0f), numel);  // 1 + tanh

        // 再乘 x
        vec_mul(output, input, tmp2, numel);     // x * (1 + tanh)

        // 最后乘 0.5
        vec_mul(output, output, half(0.5f), numel); // 0.5 * x * (1 + tanh)
    }
};

}  // namespace my_ops
}  // namespace atvc

这段代码的核心设计思想是利用 LocalTensor 的中间结果复用。GELU 计算分四步,如果每步都写回 HBM 再读出来,需要额外 3 次 HBM 读写。用 LocalTensor 中转,所有中间结果都在片上流动,只有最终结果写回 HBM,带宽节省约 40%。

步骤三:在 Kernel 函数里调用

// gelu_kernel.cpp — Ascend C Kernel 入口
#include "gelu_op.hpp"
#include "kernel_graph.hpp"

extern "C" __global__ __aicore__ void gelu_kernel(
    LocalTensor<half> y,      // 输出张量
    LocalTensor<const half> x, // 输入张量
    const int64_t numel        // 元素总数
) {
    // 分块参数:单 Block 256 个元素,Block 数由 Grid 大小决定
    GeluOp<DT_FLOAT16, TensorShape<-1>, TilingParams{256, 0, true}> gelu;
    // 用 numel 初始化 workspace(atvc 自动计算所需 LocalTensor 容量)
    gelu.Init(numel);

    // 计算当前 Core 负责的那个 Block
    const int block_idx = GetBlockIdx();
    const int block_size = 256;
    const int offset = block_idx * block_size;
    const int count = min(block_size, static_cast<int>(numel - offset));

    if (count > 0) {
        gelu.Compute(y[offset], x[offset], count);
    }
}

步骤四:注册算子到 CANN

// 算子注册:让 CANN 运行时知道这个算子的接口
REGISTER_OP("Gelu")
    .Input(x, "y", DT_FLOAT16)
    .Output(y, DT_FLOAT16)
    .Attr("numel", AttrType::INT64)
    .KernelBuilder(GeluKernel);

注册完成后,这个 GELU 算子就可以通过 AscendCL 或 PyTorch NPU 接口调用了。atvc 模板把复杂的 Ascend C 细节(LocalTensor 分配、HBM 搬运、Vector 指令调用)全部封装掉,开发者只需要写计算逻辑本身。

性能数据:Vector vs Cube 在不同算子上的表现

理论讲完了,看实际数据。以下是昇腾 910 上 Vector 单元和 Cube 单元在几个典型算子上的性能对比。测试条件:输入 FP16,固定 shape 1024×1024,批量大小 1,测试工具为 CANN 内置 profiling。

算子 计算类型 Vector 单元耗时 (μs) Cube 单元耗时 (μs) 推荐单元 说明
MatMul (1024×1024×1024) 矩阵乘 4200 380 Cube 矩阵乘是 Cube 主战场,Vector 差距巨大
ReLU (1024×1024) 逐元素 85 Vector Cube 无直接对应实现,Vector 效率高
GELU (1024×1024) 逐元素+数学函数 210 Vector 包含 tanh、乘加,Vector 链式指令效率高
LayerNorm (1024×1024) 归一化 340 Vector 两遍 Reduce + 归一化,Vector 更适合
Softmax (1024×1024) 归一化指数 480 Vector 含 exp、sum、div,链式执行
ReduceSum (1024×1024) 归一化 120 Vector Reduce 类算子,Vector 有专用归约指令

几个值得关注的结论:

矩阵乘无脑选 Cube。1024×1024×1024 的 MatMul,Vector 耗时是 Cube 的 11 倍,差距太大。Cube 单元有专用乘加器,Matrix Multiply Accumulate 是它的名字,矩阵乘是它的本职工作。

逐元素算子无脑选 Vector。ReLU、GELU 这些在 Vector 单元上运行效率极高,μs 级别完成 100 万元素的处理,而且代码简单得多。

LayerNorm 和 Softmax 是 Vector 的强项。这两个算子都包含 Reduce 操作(求和/求均值)和逐元素操作交替执行的模式。Reduce 操作需要两遍扫描:先算 Sum/Mean,再做归一化。Vector 单元有专用归约指令(vec_sumvec_max),在一次数据遍历中完成 Reduce,效率比在 Cube 单元上模拟 Reduce 要高得多。Cube 单元做 Reduce 需要先把数据汇总到一个寄存器,再反复迭代——实现复杂且速度慢。

混合算子要看比例。如果一个算子 80% 的时间是矩阵乘,只有 20% 是 LayerNorm 后处理,通常整体放在 Cube 单元上更划算。如果矩阵乘只占 30%,Vector 后处理占 70%,分开放到两个单元上各自执行再拼接结果,整体吞吐更高。atvc 的存在价值就是承接这些 Vector 后处理算子,让开发者在 Vector 单元上有得用、用得好。

atvc 仓把 Vector 算子开发从"手写 LocalTensor 管理 + 手写分块 + 手写向量化"的繁琐模式,简化成"填参数 + 链式调用 Vector 指令"。如果你正在基于昇腾 NPU 开发自定义算子,Vector 单元上的逐元素操作和归一化操作可以直接拉取 atvc 仓库,找到对应算子的模板参考实现。Catlass 仓解决 Cube 的矩阵乘问题,atvc 仓解决 Vector 的逐元素问题,两者配合基本覆盖了 Transformer 类模型的主要算子开发需求。

仓库地址:https://atomgit.com/cann/atvc

Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐