Ascend C算子开发(高级)笔记

Ascend C算子开发(高级)笔记

type: Post

status: Published

date: 2026/08/20

slug: AscendC-Advanced

summary: Ascend C算子开发(高级)笔记,包含 算子调试概述;CPU 域调试;NPU 域调试;矩阵乘基础知识;矩阵乘核函数;矩阵乘 Tiling;矩阵乘运行验证;AI Core 架构与融合算子;融合算子基础知识;Matmul + LeakyRelu 融合算子实现;融合算子代码精讲与运行验证;数据搬运优化;内存优化;Ascend C API 使用优化

tags: Ascend, 学技术, 推荐, 文字, 机器学习

category: Ascend

@ZZHow(ZZHow1024)

参考课程:

【Ascend C算子开发(高级)】

【Ascend C系列教程(高级)】

1-算子调试

1-1 算子调试概述

为什么需要 CPU/NPU 孪生调试

  • 传统算子开发如果只依赖 NPU 实机调试,通常会遇到两类问题:
    • 调试时间长:NPU 环境调试成本较高,且编译器编译过程会隐藏部分并行实现细节。
    • 问题定位难:并行执行时序、同步死锁、地址越界、数值精度以及数据溢出等问题,在 NPU 上不容易快速定位。
  • Ascend C 提供 CPU/NPU 孪生调试能力。同一份 Ascend C 算子源码可以分别用于 CPU 域和 NPU 域调测:
    • CPU 域更适合做功能和精度调试,可以使用 GDB、printf、断言等常规调试手段,主要定位逻辑错误、数值错误和内存问题。
    • NPU 域更适合做真实硬件上的功能和性能调试,可以使用数据打印、仿真、Profiling 等手段,主要定位性能问题和算子同步问题。

CPU/NPU 孪生调试概览

调试域 主要调试手段 更适合定位的问题
CPU 域 GDB、printf/cout、ASSERT 逻辑错误、数据计算错误、内存问题
NPU 域 Ascend C 打印、DumpTensor、仿真调试、Profiling、上板调试 性能问题、同步问题、真实硬件行为
  • 推荐的调试思路是:先在 CPU 域把功能和精度问题调通,再到 NPU 域验证真实运行行为和性能。

1-2 CPU域调试

printf 与 LocalTensor 打印

  • CPU 域最直接的调试方式是打印中间变量。

  • 对于标量,可以直接使用 printf:

    cpp 复制代码
    printf("xLocal size:%d\n", xLocal.GetSize());
    printf("tileLength:%d\n", tileLength);
  • 对于 LocalTensor,可以使用其 Print() 接口输出数据。该接口按照 datablcok 打印数据,每个 datablock 为 32 Bytes,例如:

    cpp 复制代码
    // input_local: [0, 1, 2, ..., 15]
    input_local.Print();
    
    // 0000: 0 1 2 3 4 5 6 7 8
    // 0008: 9 10 11 12 13 14 15
  • 这种方式适合快速确认:

    • Tiling 参数是否正确。
    • Tensor 长度、偏移是否正确。
    • CopyIn 后的数据是否符合预期。
    • Compute 前后的中间结果是否正确。

GDB 单步调试

  • CPU 调测可以借助 GDB 做断点和单步调试。CPU 调测模型可能会为不同 AI Core/计算单元拉起子进程,因此调试时需要切换为跟随子进程的方式。

  • 典型调试流程如下:

    bash 复制代码
    gdb --args add_custom_cpu
    set follow-fork-mode child
    break add_custom.cpp:45
    run
    list
    backtrace
    print i
    break add_custom.cpp:56
    continue
    display xLocal
    quit
  • 常用命令含义:

    命令 作用
    set follow-fork-mode child 跟随并调试新创建的子进程
    break file:line 在指定源文件行号处设置断点
    run 启动程序并运行到断点
    list 查看断点附近源码
    backtrace 查看当前调用栈
    print var 打印变量值
    continue 继续执行到下一个断点
    display var 在程序暂停时持续显示变量
    quit 退出 GDB
  • CPU 域调试的重点不是模拟 NPU 的真实性能,而是充分利用成熟的软件调试能力,把逻辑、边界、数据和精度问题尽量提前解决。

1-3 NPU域调试

NPU 域打印

  • 在 NPU 上也可以打印标量信息:

    cpp 复制代码
    AscendC::printf("xLocal size:%d\n", xLocal.GetSize());
    AscendC::printf("tileLength:%d\n", tileLength);
  • 对于 LocalTensor 或 GlobalTensor,可以使用 DumpTensor 输出指定 Tensor 的数据,同时还可以附带一个 uint32_t 类型的自定义描述信息:

    cpp 复制代码
    DumpTensor(srcLocal, 5, dataLen);
  • Dump 输出中会包含额外的头信息:

    • 每个 block 的 dump 信息前会增加 DumpHead,记录核号、资源使用等信息。
    • 每次输出 Tensor 数据前会增加 DumpTensorHead,记录 Tensor 的地址、数据类型、存储位置等信息。
  • 因此,NPU 数据打印不仅可以查看数值,还可以辅助确认当前核、Tensor 存储位置和实际数据布局。

msSanitizer 内存与竞争检测

  • msSanitizer 是面向昇腾 AI 处理器的异常检测工具,主要包含:

    • 内存检测。
    • 竞争检测。
    • CANN 软件栈内存检测。
  • 常见使用方式:

    bash 复制代码
    # 内存检测
    mssanitizer --tool=memcheck ./add_custom_npu
    
    # 竞争检测
    mssanitizer --tool=racecheck ./add_custom_npu
  • 编译和链接时需要开启相应的 sanitizer 选项:

    复制代码
    编译阶段:--cce-enable-sanitizer -g
    链接阶段:--cce-enable-sanitizer --cce-aicore-arch={ARCH}
  • 内存检测可以定位的典型问题包括:

    异常 含义 主要位置 支持地址空间
    非法读写 访问未分配或无权限的内存 kernel、host GM
    多核踩踏 多个 AI Core 对重叠内存进行错误访问 kernel GM
    非对齐访问 DMA 地址或长度没有满足最小访问粒度要求 kernel GM、UB、L0A/L0B/L0C、L1
    分配内存未使用 申请后未真正使用的内存 kernel、host GM
    内存泄漏 申请内存后未释放 host GM
    非法释放 释放未分配或已经释放的地址 host GM
  • 竞争检测重点关注多个内存事件之间的访问顺序:

    类型 含义
    WAW(Write-After-Write) 两次写同一块内存,最终结果依赖实际写入顺序
    WAR(Write-After-Read) 写操作过早执行,破坏尚未完成的读取
    RAW(Read-After-Write) 读操作过早执行,读到写入完成之前的旧值

msProf 性能分析

  • msProf 用于采集和分析运行在昇腾 AI 处理器上的算子性能数据,可以辅助定位软件和硬件性能瓶颈。

  • 常见能力包括:

    • Block 级数据采集。
    • 多类 AIC Metrics 性能指标。
    • 指令流水图。
    • 代码行和指令耗时。
    • 代码热点图。
    • 指令与代码行映射。
  • 典型命令:

    bash 复制代码
    # 上板调优
    msprof op ./add_custom_npu
    
    # 仿真调优
    msprof op simulator ./add_custom_npu
  • 使用 msProf 时重点观察:

    • 计算单元是否存在长时间空闲。
    • 搬运和计算是否形成有效流水。
    • Scalar、Vector、Cube、DMA 之间是否存在明显等待。
    • 某条指令、某一代码区间是否成为热点。

2-矩阵编成(高级API)

2-1 矩阵乘基础知识

MatMul 的数学定义

  • 矩阵乘计算可表示为:

    复制代码
    C = A × B + Bias
  • 其中:

    • A 为左矩阵,形状为 [M, K]。
    • B 为右矩阵,形状为 [K, N]。
    • C 为输出矩阵,形状为 [M, N]。
    • Bias 为偏置,形状为 [N],在结果矩阵的 M 方向进行广播。
  • 矩阵乘的核心是沿 K 维做乘加归约,因此实现时通常会围绕 M、N、K 三个维度分别考虑多核切分、单核切分以及片上存储空间约束。

ND 与 NZ 分形格式

  • 矩阵数据常见两类格式:

    • ND:普通 N 维 Tensor 的连续存储格式。
    • NZ:为了适配 Cube 高性能矩阵计算而使用的特殊分形格式。
  • NZ 会对 Tensor 最低两维进行 Pad、拆分和转置。其思路可以表示为:

    复制代码
    (..., B, M, N)
    -> Pad
    -> (..., B, M1*M0, N1*N0)
    -> Reshape
    -> (..., B, M1, M0, N1, N0)
    -> Transpose
    -> (..., B, N1, M1, M0, N0)
  • 也就是说,矩阵不再简单按照原始二维顺序连续排布,而是先切分为适合 Cube 计算的小分形,再按照硬件访问更友好的方式重排。

矩阵计算中的逻辑位置

  • 矩阵乘的输入、切分、中间结果分别存放在不同逻辑位置:

    逻辑位置 主要用途 可类比理解
    A1 存放整块 A 矩阵 二级缓存
    B1 存放整块 B 矩阵 二级缓存
    C1 存放 Bias 二级缓存
    A2 存放切分后的 A 小块 一级缓存
    B2 存放切分后的 B 小块 一级缓存
    C2 存放切分后的 Bias 小块 一级缓存
    CO1 存放矩阵乘局部结果 Cube Out
    CO2 存放聚合后的矩阵结果 Cube Out
    VECCALC Vector 计算中的临时数据 Vector 临时计算区
  • 矩阵乘的典型数据流可以概括为:

    复制代码
    CopyIn -> Split -> Compute -> Aggregate -> CopyOut

矩阵乘数据流

  • 典型路径包括:
    • A:GM -> A1 -> A2,也可以直接 GM -> A2。
    • B:GM -> B1 -> B2,也可以直接 GM -> B2。
    • 计算:A2 × B2 -> CO1。
    • 聚合:CO1 -> CO2。
    • 输出:CO2 -> GM 或 CO2 -> VECIN。
  • 这种逻辑位置设计的目的,是让矩阵数据尽可能按照硬件流水逐级进入计算单元,而不是频繁返回 GM。

2-2 矩阵乘的核函数

Matmul 高阶 API 的整体流程

  • Ascend C 提供 Matmul 高阶 API,把常见的数据切分、搬运和矩阵计算逻辑封装起来。Kernel 侧使用 Matmul 高阶 API 的基本流程为:

    1. 创建 Matmul 对象。
    2. 初始化 Matmul 对象。
    3. 设置左矩阵 A、右矩阵 B 和 Bias。
    4. 执行矩阵乘。
    5. 结束 Matmul 计算。
  • 一个典型结构如下:

    cpp 复制代码
    typedef MatmulType<TPosition::GM, CubeFormat::ND, half> aType;
    typedef MatmulType<TPosition::GM, CubeFormat::ND, half> bType;
    typedef MatmulType<TPosition::GM, CubeFormat::ND, float> cType;
    typedef MatmulType<TPosition::GM, CubeFormat::ND, float> biasType;
    
    Matmul<aType, bType, cType, biasType> mm;
    mm.Init(&tiling, &tpipe);
    
    mm.SetTensorA(gm_a);
    mm.SetTensorB(gm_b);
    mm.SetBias(gm_bias);
    
    while (mm.Iterate()) {
        mm.GetTensorC(gm_c);
    }
    
    mm.End();
  • 也可以使用 IterateAll 一次完成当前单核范围内的完整矩阵计算。

Matmul 常用 API

API 功能
Init 初始化 Matmul 对象,传入 Tiling 参数和 TPipe 对象
SetTensorA 设置左矩阵 A
SetTensorB 设置右矩阵 B
SetBias 设置 Bias
Iterate 每次计算一个 baseM × baseN 的 C 矩阵分片
IterateAll 计算一个 singleCoreM × singleCoreN 范围的 C 矩阵
GetTensorC 配合 Iterate 获取一次迭代产生的 C 分片
SetTail 尾块场景下重设本次计算的 M/N/K 大小
End 结束一次 Matmul 计算并完成必要收尾

MatmulType

  • 创建 Matmul 对象前,需要通过 MatmulType 描述 A、B、C、Bias 的属性,主要包括:

    • 内存逻辑位置,例如 GM、VECCALC、TSCM。
    • 数据格式,例如 ND、NZ。
    • 数据类型,例如 half、float。
    • 是否转置。
  • 其抽象形式类似:

    cpp 复制代码
    template <TPosition POSITION,
              CubeFormat FORMAT,
              typename TYPE,
              bool ISTRANS = false>
    struct MatmulType;
  • Matmul 高阶 API 因此不仅知道数据类型,还知道数据存储在哪里、采用什么布局、是否需要转置。

设置 A、B 和 Bias

  • A、B 可以来自 GlobalTensor,也可以来自 LocalTensor:

    cpp 复制代码
    mm.SetTensorA(gm_a);
    mm.SetTensorB(gm_b);
    mm.SetBias(gm_bias);
  • 接口还支持是否转置的控制。对于融合算子,这一点很重要,因为前一个计算阶段产生的局部结果可以直接作为 Matmul 的输入,而不一定要先回写 GM。

Iterate、GetTensorC 与 IterateAll

Iterate
  • Iterate() 每次完成一个 baseM × baseN 的 C 分片。开发者可以通过循环自行决定执行多少次:

    cpp 复制代码
    while (mm.Iterate()) {
        mm.GetTensorC(gm_c);
    }
  • 这种模式控制更灵活,适合:

    • 需要逐分片处理 C 结果。
    • 需要把 C 分片直接送给后续 Vector 计算。
    • 需要自定义尾块或融合流水。
  • GetTensorC 可以将结果写入 GM,也可以输出到局部 Tensor,供后续计算继续使用。

IterateAll
  • IterateAll 一次计算当前单核分配到的 singleCoreM × singleCoreN 范围,适合不需要自行控制每个分片的场景。
  • Tiling 参数中的 iterateOrder 可以控制 M/N 方向的遍历顺序。

SetTail

  • 多核切分后,最后一个核可能得到不足一个完整 singleCoreM、singleCoreN 或 singleCoreK 的尾块,此时可以使用:

    cpp 复制代码
    mm.SetTail(tailM, tailN, tailK);
  • 特点:

    • 不需要重新生成一套 Tiling。
    • 未重新设置的维度继续使用原 Tiling 值。
    • 尾块值不能大于原始 singleCoreM/N/K。

End

  • 一次 Matmul 计算结束后必须调用:

    cpp 复制代码
    mm.End();
  • 特别是在融合算子中,如果多个 Matmul 对象或多个 Matmul 阶段交替使用而没有正确调用 End(),可能造成不可预期的执行错误。

2-3 矩阵乘的Tiling

Matmul Tiling 的作用

  • Local Memory 通常无法一次容纳完整输入输出,因此矩阵需要分块搬运和分块计算。对于 Matmul,Tiling 需要同时解决两层问题:
    • 多核切分:M/N 等维度如何分给多个 AI Core。
    • 核内切分 :单个 AI Core 内部如何继续切成 baseM/baseN/baseK 小块,使其满足 L1、L0A、L0B、L0C、UB 等空间约束。
  • 静态 Shape 场景下很多参数可以在编译期确定;动态 Shape 场景中输入 Shape 运行时才确定,因此需要由 Host 侧 Tiling 函数在运行时计算并下发参数。

Matmul Tiling 核心参数

参数 作用
usedCoreNum 参与计算的 AI Core 数量
M, N, K 原始矩阵乘问题规模
singleCoreM/N/K 单核负责的矩阵范围
baseM/N/K 单次矩阵乘指令处理的分块大小
depthA1/depthB1 A/B 在 A1/B1 中能够缓存的分块份数
stepM/stepN A1/B1 缓冲区沿 M/N 方向一次缓存多少个 base 块
iterateOrder C 分片的遍历顺序
  • baseM/baseN/baseK 需要满足片上空间约束,例如:

    复制代码
    baseM × baseN × sizeof(OutputDtype) <= L0C_size
    baseM × baseK × sizeof(InputDtype)  <= L0A_size
    baseK × baseN × sizeof(InputDtype)  <= L0B_size
  • 同时还需要满足矩阵分形对齐要求。

多核与核内切分关系

Matmul API 切分示意

  • 可以把整个 Matmul Tiling 理解成两级切分:

    复制代码
    原始 M/N/K
        ↓ 多核切分
    singleCoreM / singleCoreN / singleCoreK
        ↓ 核内切分
    baseM / baseN / baseK
  • 多核切分决定每个 AI Core 的工作量,核内切分决定一次 Cube 计算的数据块大小。

iterateOrder

  • 一次 Iterate 会输出一个 baseM × baseN 的 C 分片。多次迭代时,Matmul 会自动移动到下一个输出位置。
  • iterateOrder 用来描述自动偏移顺序:
    • 0:先沿 M 轴方向移动,再沿 N 轴移动。
    • 1:先沿 N 轴方向移动,再沿 M 轴移动。
  • 合理选择遍历顺序有利于复用 A1/B1 中已经缓存的数据。

Matmul Tiling API

  • Ascend C 提供 Matmul Tiling API 来生成 Kernel 所需参数,常用接口包括:

    API 作用
    SetAType/SetBType 设置 A/B 的逻辑位置、格式、数据类型以及是否转置
    SetCType 设置 C 的逻辑位置、格式和数据类型
    SetBiasType 设置 Bias 的位置、格式和数据类型
    SetShape 设置本次 Matmul 计算的 singleM/singleN/singleK
    SetOrgShape 设置原始矩阵 M/N/K
    SetBias 设置是否参与 Bias 计算
    SetFixSplit 手工固定 baseM/baseN/baseK
    SetBufferSpace 设置 Matmul 可使用的 L1/L0C/UB 空间
    SetTraverse 设置 M 或 N 优先的遍历方式
    GetBaseM/N/K 读取计算出的 baseM/baseN/baseK
    GetTiling 生成并获取 Tiling 参数
    SetDim 设置多核 Matmul 可参与计算的核数
    SetSingleShape 设置多核场景下单核范围 singleCoreM/N/K
    GetSingleShape 获取计算得到的单核 Shape
    GetCoreNum 获取多核切分后实际使用的 BlockDim
  • 典型获取过程:

    1. 创建单核或多核 Tiling 对象。
    2. 设置 A/B/C/Bias 的类型信息。
    3. 设置 M/N/K Shape 和可用片上空间。
    4. 调用 GetTiling,生成 TCubeTiling。
    5. 将生成的 Tiling 参数下发给 Kernel。

单核 Tiling

  • 单核场景一般直接围绕 SetShape 和 SetOrgShape 配置:

    cpp 复制代码
    MatmulApiTiling tiling;
    
    tiling.SetAType(...);
    tiling.SetBType(...);
    tiling.SetCType(...);
    tiling.SetBiasType(...);
    tiling.SetShape(singleM, singleN, singleK);
    tiling.SetOrgShape(M, N, K);
    tiling.SetBufferSpace(...);
    
    TCubeTiling tilingData;
    int ret = tiling.GetTiling(tilingData);
  • 在单核场景中,singleM/N/K 会进一步生成 Matmul 内部所需的 singleCoreM/N/K 和 baseM/N/K。

  • 对于融合算子,如果 L1、L0C、UB 的一部分需要留给其他计算阶段,可以通过 SetBufferSpace 显式限制 Matmul 可使用的片上空间。

多核 Tiling

  • 多核场景需要先指定可用核数,再对每个核的工作范围进行约束:

    cpp 复制代码
    MultiCoreMatmulTiling tiling;
    tiling.SetDim(coreNum);
    tiling.SetSingleShape(singleCoreM, singleCoreN, singleCoreK);
  • 多核 Tiling 会先把整体问题切成多份 singleCoreM/N/K,再把每一份继续切成 baseM/N/K。

  • SetSingleShape 可用于限制部分单核维度;如果某个维度不设置或设置为 -1,则由 Tiling 策略自行选择。

2-4 运行验证

  • 示例代码:

    Ascend/samples: 本仓库已停止维护,请前往https://gitcode.com/cann获取代码样例

  • 运行验证时重点检查:

    • A、B、Bias 的数据类型和格式是否与 MatmulType 一致。
    • Host 侧生成的 TCubeTiling 是否成功。
    • blockDim 是否与多核 Tiling 结果一致。
    • SetTensorA/B/Bias 是否指向正确的数据区域。
    • Iterate/GetTensorC 或 IterateAll 的输出是否完整覆盖 C 矩阵。
    • 尾块场景是否正确使用 SetTail。
    • 每次 Matmul 计算结束后是否调用 End()。

3-融合算子

3-1 再谈架构

AI Core 基本组成

  • AI Core 负责标量、向量和矩阵等计算密集型任务,主要包括:
    • Cube 矩阵计算单元。
    • Vector 向量计算单元。
    • Scalar 标量计算单元。
    • 片上存储单元。
    • 数据搬运单元。
    • 控制单元。
  • 根据 Cube 和 Vector 是否部署在同一个核内,把硬件形态划分为耦合架构 和分离架构。

耦合架构

耦合架构

  • 耦合架构中,Cube 和 Vector 位于同一个 AI Core:
    • Cube 与 Vector 可以围绕共享的片上存储形成流水。
    • Cube 结果可以通过 CO2 等路径直接进入 Vector。
    • Vector 结果也可以进一步作为矩阵计算输入。
    • MTE1/MTE2/MTE3 等搬运单元负责不同存储层级之间的数据移动。
  • 耦合架构产品包括 Atlas 推理系列(Ascend 310P)、Atlas 训练系列以及 Atlas 200/500 A2 推理产品。

分离架构

分离架构

  • 分离架构把矩阵计算核 AIC 和向量计算核 AIV 分开:

    • AIC 负责 Cube 计算,并拥有自身 Scalar 和矩阵相关存储。
    • AIV 负责 Vector 计算,并拥有自身 Scalar 与 UB。
    • AIC 与 AIV 之间需要通过 Global Memory 完成跨核数据交换。
    • AIC 额外包含 BT Buffer 和 Fixpipe Buffer,用于 Bias、量化参数等特殊数据。
  • Atlas A2 训练系列/Atlas 800I A2 推理产品采用分离架构。

  • 在分离架构下,典型路径为:

    复制代码
    Vector:GM -> UB -> Vector -> UB -> GM
    
    Cube:
    GM -> L1 -> L0A/L0B -> Cube -> L0C -> FixPipe -> GM
    或
    GM -> L1 -> L0A/L0B -> Cube -> L0C -> FixPipe -> L1
  • 理解这些路径是后续设计融合算子的基础,因为融合的本质之一就是尽可能减少不必要的跨层级、跨核和回 GM 的数据搬运。

3-2 融合算子基础知识

什么是融合算子

  • 融合算子是把多个原本独立的小算子合并成一个大的 Kernel。融合后的算子在功能上与原算子序列等价,但可以通过统一的数据流和流水调度获得更高性能。
  • 例如 Flash Attention 可以把多个 MatMul、Scale、Mask、Softmax 等阶段组合为一个整体实现,而不是让每个算子都独立完成一次完整的 GM 输入输出。

融合带来的收益

  • 与多个独立算子串行执行相比,融合算子主要有四类收益:

    • 减少计算和调度开销:多个小算子合并为一个 Kernel,减少多次算子调度。
    • 减少内存占用:中间结果尽量保留在片上存储,而不是为每个中间结果单独分配 GM 空间。
    • 优化数据流:减少 GM 与 Local Memory 之间的往返搬运。
    • 简化代码组织:把紧密关联的数据流放到同一个实现中,便于统一控制流水与资源。
  • 对于 Cube + Vector 融合,如果仍然按照独立算子方式执行,通常是:

    复制代码
    Cube -> GM -> Local Memory -> Vector -> GM
  • 而合理融合后,可以对数据进行分片和流水调度,使 Cube 与 Vector 在不同数据分片上重叠执行,并尽量减少中间结果回 GM 的次数。

融合算子的编程范式

  • 融合算子仍然围绕数据在逻辑位置之间的流动来设计:
    • Cube 输出可以作为 Vector 输入:CO2 -> VECIN。
    • Vector 输出可以作为 Cube 输入:VECOUT -> A1 -> A2 或 VECOUT -> B1 -> B2。
  • 一个基础的 MatMul + Vector 数据流如下:

MatMul + Vector 数据流

  • 基本步骤:
    1. 将输入从 Global Memory 搬入 Cube 侧。
    2. 执行 MatMul。
    3. 将 MatMul 结果送入 Vector 侧。
    4. 执行 Vector 计算。
    5. 最终结果写回 Global Memory。
  • 设计融合算子时,重点不是简单地把两个函数写在一个 Kernel 里,而是让切分策略、片上内存和不同执行单元之间真正形成流水。

3-3 以Matmul+LeakyRelu为例

算子定义

  • 本例融合 MatMul 与 LeakyReLU:

    复制代码
    C = LeakyRelu(A × B + Bias, alpha)
  • LeakyReLU 的定义为:

    复制代码
    f(x) = x        , x >= 0
    f(x) = alpha*x  , x < 0
  • 设计流程分为三部分:

    1. 算子分析。
    2. 数据流分析。
    3. Tiling 策略设计。

输入输出规格

类型 名称 Shape 数据类型 Format
输入 a [M, K] half ND
输入 b [K, N] half ND
输入 bias [1, N] float32 -
属性/输入 alpha 标量 float32 -
输出 c [M, N] float32 ND
  • Kernel 名称为:

    复制代码
    matmul_leakyrelu_custom
  • Kernel 参数需要能够访问 a、b、bias、alpha 与输出 c 对应的 Global Memory 地址。

数据流分析

  • 未经高阶 API 简化时,可以把整个过程理解为五个阶段:

    1. 将 A、B、Bias 从 Global Memory 搬入 Cube 相关存储。
    2. 执行 MatMul。
    3. 把 MatMul 结果送到 Vector 侧。
    4. 执行 LeakyReLU。
    5. 将最终结果写回 Global Memory。
  • 由于 Matmul 高阶 API 已经封装了前面矩阵搬运、切分和计算细节,因此实际 Kernel 可以进一步简化为三个 Stage:

    复制代码
    Stage1:MatMul Compute
    Stage2:LeakyReLU Compute
    Stage3:CopyOut
  • 其中:

    • Stage1 使用 Matmul 高阶 API 获取矩阵乘结果。
    • Stage2 在 Vector 侧对 Matmul 输出做 LeakyReLU,并通过 Queue 管理局部 Tensor。
    • Stage3 使用 DataCopy 将最终结果写回 GM。
  • 涉及的主要接口包括:

    • Matmul 高阶 API。
    • LeakyReLU Vector 计算接口。
    • DataCopy。
    • EnQue / DeQue。

Tiling 策略

  • Tiling 同时考虑多核和核内切分。
多核切分
  • 根据可用核数和输入 M/K/N,得到每个核负责的:

    复制代码
    singleCoreM
    singleCoreK
    singleCoreN
核内切分
  • 再根据 Local Memory 空间,把单核范围切分为:

    复制代码
    baseM
    baseK
    baseN
  • 如果 GetTensorC 的结果直接保存在 Local Memory/UB 中供 LeakyReLU 使用,那么 baseM × baseN 对应结果必须满足 UB 空间约束。

  • 因此融合算子中的 Matmul Tiling 不能只追求 Matmul 本身的最大分块,还必须给 LeakyReLU、Queue 和其他临时数据预留空间。

3-4 代码精讲&运行验证

Host 侧与 Kernel 侧配合

  • 融合算子的整体实现可以分成两部分:

    • Host 侧

      1. 创建 Matmul Tiling 对象。
      2. 设置 A/B/C/Bias 的数据类型和格式。
      3. 设置矩阵 Shape。
      4. 设置 Matmul 可使用的片上空间。
      5. 生成 Matmul 所需 Tiling 参数。
      6. 结合 LeakyReLU 的需求生成融合算子的其他 Tiling 参数。
      7. 将 Tiling 下发给 Kernel。
    • Kernel 侧

      复制代码
      Stage1:Matmul Compute
        - 将输入送入 Cube
        - 完成 Matmul
        - 获取矩阵乘局部结果
      
      Stage2:LeakyRelu Compute
        - 获取 Matmul 输出
        - 执行 Vector LeakyReLU
        - 将结果 EnQue
      
      Stage3:CopyOut
        - DeQue 输出 Tensor
        - DataCopy 到 Global Memory
  • 融合实现的关键是让 Matmul 高阶 API 的输出能够直接进入后续 Vector 处理,而不是退化为"Matmul 写 GM、LeakyReLU 再从 GM 读取"的两个独立算子。

  • 示例代码:

    Ascend/samples: 本仓库已停止维护,请前往https://gitcode.com/cann获取代码样例

  • 运行验证时应重点确认:

    • Matmul 输出的数据类型与 LeakyReLU 输入是否匹配。
    • Matmul Tiling 给后续 Vector 阶段预留的局部内存是否足够。
    • Queue 的 EnQue/DeQue 是否一一对应。
    • 跨 Cube/Vector 数据流是否符合目标芯片的架构特点。
    • 多核尾块的 M/N 范围是否正确。
    • 最终输出是否与 LeakyRelu(A × B + Bias, alpha) 的参考结果一致。

4-性能优化(理论)

4-1 搬运优化

理论性能评估:先判断 Compute Bound 还是 Memory Bound

  • 做算子性能优化前,应先估计理论上限,而不是直接从局部代码开始修改。
  • 需要的输入信息主要包括:
    • 芯片通路带宽。
    • Buffer 大小。
    • 各类计算指令的 cycle 数据。
    • 算法 FLOPs。
    • 总数据搬运量。
  • 可以分别估算:
    • 计算所需时间 tc。
    • 数据搬运所需时间 tb。
  • 判断原则:
    • tc > tb:更偏 Compute Bound,理论基准主要由计算时间决定。
    • tb > tc:更偏 Memory Bound,理论基准主要由搬运时间决定。
  • 经验目标:实际性能能够达到理论基准的大约 80% 时,通常已经比较接近该实现的合理上限。
  • 需要注意:达到某个执行单元的 bound 并不等于整个算子已经最优。还要检查算法是否存在重复计算、是否已经接近最小数据搬运量,以及是否存在设计本身造成的额外 bound。

尽量一次搬运更大的数据块

  • 搬运带宽对单次搬运长度非常敏感。小块搬运难以充分利用通路带宽;实测经验是,单次搬运达到较大的连续数据块后,带宽利用率才趋于稳定。
  • 优化原则:
    • 尽量把多个小 DataCopy 合并为一次较大的搬运。
    • 避免在 Kernel 中使用大量循环逐小块搬运。
    • 让一次搬运覆盖尽可能多的有效数据。

GM 地址尽量 512B 对齐

  • 在针对的 Atlas A2 训练系列/Atlas 800I A2 推理产品中,从 GM 向 Local Memory 搬运数据时,如果 GM 地址满足 512 Bytes 对齐,可以更高效地利用带宽。
  • 测试表明,在部分数据长度下,32 Bytes 对齐场景的有效带宽可能只有 512 Bytes 对齐场景的大约 70%。
  • 因此在可控的数据布局下,优先考虑:
    • 起始 GM 地址对齐。
    • Tile 起点对齐。
    • 批量数据结构按较大对齐粒度规划。

高效使用 DataCopy 参数

  • 如果数据在 GM 中呈规则间隔分布,应优先利用 DataCopy 自带的参数完成二维/跨步搬运,而不是手写 for 循环。
  • 常见参数包括:
    • blockCount:需要搬运多少个 block。
    • blockLen:每个 block 的连续搬运长度。
    • srcStride:源数据相邻 block 之间的间隔。
    • dstStride:目的数据相邻 block 之间的间隔。
  • 例如,每行 16 KB,但只需要每行前 2 KB,共 16 行。如果用循环,需要发起 16 次 2 KB 搬运;利用 stride 参数后,可以通过一次 DataCopy 描述整个二维搬运任务。
  • 优化原则是:让搬运 API 描述数据布局,而不是让 Scalar 使用循环一块块发起搬运。

4-2 内存优化

UB Buffer 融合

  • 如果一个 Vector 计算的输出就是下一次 Vector 计算的输入,则不应把中间结果写回 GM 后再重新搬入 UB。

  • 反例:

    复制代码
    GM -> UB -> Exp -> UB -> GM
    GM -> UB -> Abs -> UB -> GM
  • 优化后:

    复制代码
    GM -> UB -> Exp -> UB/VECCALC -> Abs -> UB -> GM

UB Buffer 融合

  • 这种方式:
    • 减少 CopyOut/CopyIn 次数。
    • 降低 GM 带宽压力。
    • 提高 UB 使用效率。
    • 是算子融合最基础的数据流优化之一。

使用 BT Buffer 做 Bias 融合

  • 带 Bias 的矩阵乘可以直接把 Bias 搬到 C2/BT(Bias Table Buffer),在 Mmad 计算阶段完成 Bias 融合。

  • 不推荐的流程:

    复制代码
    Matmul -> CO1 -> GM/workspace
                       ↓
                  GM -> UB
                       ↓
                     Add Bias
                       ↓
                      GM
  • 推荐流程:

    复制代码
    A/B -> Mmad
    Bias -> C2(BT)
          \  /
          Mmad -> CO1 -> GM
  • 这样可以避免矩阵结果为了加 Bias 额外经历一次 GM 往返和 Vector Add。

Fixpipe Buffer 与随路量化

  • 如果需要对矩阵乘结果做量化,可以把量化参数放入 Fixpipe Buffer,并在 Fixpipe 阶段完成量化。
  • 相比先把矩阵乘结果写回 GM、再读到 UB 做 Vector 量化,随路量化可以减少:
    • CO1 -> workspace 搬运。
    • workspace -> UB 搬运。
    • 额外的 Vector 量化计算流程。
  • 这一优化手段主要面向 Atlas A2 训练系列/Atlas 800I A2 推理产品。

LOC 中保留中间矩阵乘结果

  • 如果需要累加多次矩阵乘结果,例如:

    复制代码
    A1 × B1 + A2 × B2 + ...
  • 不应每次都把局部矩阵乘结果写回 GM 再做累加。更合理的方式是:

    • 将前一次结果保留在 CO1/LOC。
    • 下一次调用 Mmad 时继续在 LOC 上累加。
    • 通过类似 cmatrixInitVal 的参数控制是否从 0 初始化。
    • 所有局部累加完成后再一次性输出。
  • 这可以显著减少多次 CO1 -> GM -> UB 的往返搬运和额外 Add。

4-3 API使用优化

Scalar 优化

  • Scalar 主要负责:
    • 地址计算。
    • 循环控制。
    • 条件控制。
    • 指令发射。
  • 如果 Scalar 工作量过大,会使 Vector、Cube 或 DMA 单元因为等待指令而空闲,导致整个异步流水被 Scalar 阻塞。
  • 常见优化手段:
    • 循环变量不要频繁外提,消除循环内重复计算。
    • 将循环中的乘法、复杂偏移计算尽量转化为递增加法。
    • 能在 Host/Tiling 阶段提前算出的值,不要放到 Kernel Scalar 中重复计算。
    • 适当增加单次 Tile 工作量,减少循环次数,使 Scalar 发射开销被更多计算覆盖。
    • 小函数在合适时使用 inline,但大函数被多处调用时不要盲目 inline,否则会增大指令代码尺寸。
  • 发现 Scalar Bound 的主要手段:
    • 上板 PMU 统计。
    • 仿真流水图。
    • 指令/代码日志分析。

避免 Scalar 直接参与数据计算

  • 如果一个数值本身来自 UB/Vector 计算结果,再由 Scalar 读取并参与后续运算,会引入 Scalar 与 Vector 之间的同步依赖。

  • 例如:

    复制代码
    S = vreducesum(TensorA)
    S = S + 1.0           # Scalar 参与
    A1 = muls(TensorB, S)
  • 这种写法可能产生 Vector 等 Scalar、Scalar 再等 Vector 的同步链。

  • 更好的方式是让该数值继续留在 Vector 数据路径中,即使只计算一个元素,也使用 Vector 指令完成:

    复制代码
    S = vreducesum(TensorA)
    S = vadds(S, 1.0, mask=1)
    A1 = muls(TensorB, S)
  • 核心思想:Scalar 尽量只负责"控制",不要进入大数据计算依赖链。

Ascend C API 分级

  • 计算 API 的核心数据结构是 LocalTensor。把 Vector/Cube 计算接口按抽象程度分为多个层级:

    API 层级 特点 示例
    3 级 运算符重载,表达最简单 dst = src1 + src2
    2 级 一维连续计算,可以指定连续元素数量 Add(dst, src1, src2, count)
    1 级 多维 Slice 计算 开发中
    0 级 功能最丰富,最贴近硬件,可控性最高 Add(..., repeatTimes, repeatParams)
  • 抽象层级越高,代码越简单;越接近 0 级,开发者越能直接控制:

    • repeatTimes。
    • repeatStride。
    • blockStride。
    • mask。
  • 因此追求极致性能时,可以在明确理解硬件数据布局后使用更低层级 API。

0 级 API 中的 block、repeat 和 mask

  • 以 Vector abs 类接口为例:

    复制代码
    abs(src1, dst1, mask, repeatNum, repeatStride, src1BlockStride, dst1BlockStride)
  • 其基本概念:

    • 内部 SIMD 以固定 32B block 为基础。
    • 一个 repeat 通常由 8 个 block 组成,即 256B 宽度。
    • blockStride 描述同一 repeat 内相邻 block 的地址步长。
    • repeatStride 描述相邻 repeat 的起始地址步长。
    • repeatNum 控制 repeat 次数。
    • mask 控制一个 repeat 中哪些元素真正参与计算。
  • 对于 FP16,一次 256B repeat 对应 128 个元素,因此可以使用 128-bit Mask 精细控制每个元素是否参与计算。

  • 0 级 API 的优势在于,它能避免额外的数据重排和多次 API 调用,但也要求开发者自己保证地址、步长和 Mask 的正确性。

iCache 优化

  • 如果 Kernel 代码体积过大、分支很多、循环和长跳转复杂,可能导致 iCache miss。
  • iCache miss 时,AIC 需要重新从更远的存储层级取指令,造成计算中断。
  • 常见原因:
    • Kernel 代码尺寸超过 iCache 容量。
    • 大量复杂条件分支。
    • 循环和长距离跳转。
    • 过度 inline 导致代码膨胀。
  • 优化思路:
    • 减小 Kernel 代码体积。
    • 必要时把大型互斥分支拆成不同 Kernel。
    • 把执行频率更高的分支放在更有利的位置。
    • 避免无意义的函数展开。
  • 是否存在 iCache miss,可以通过上板 PMU 的 ICACHE miss 指标辅助判断。

用 Cube 实现大型 ReduceSum

  • 当 ReduceSum 的归约规模超出 Vector 单元适合的并行范围时,可以把归约转换为矩阵乘问题。

  • 例如对 [16, 32] Tensor 的最后一维做 ReduceSum,可以构造一个 [32, 1] 的全 1 矩阵:

    复制代码
    [16, 32] × [32, 1] = [16, 1]
  • 这样就可以把最后一维求和转化为 Matmul,由 Cube 单元完成。

  • 给出的思路是:

    • Vector FP16 一次并行处理能力有限。
    • 通过构造辅助全 1 矩阵,把 ReduceSum 改写为矩阵乘。
    • 只要矩阵 M/K 维度足够大,就可以利用 Cube 更高的并行能力。
  • 这类优化说明:性能优化不仅是"同一个 API 调参数",还可以通过计算单元转换和算法等价变换,把任务改写成更适合硬件的形式。

相关推荐
llgdwuhan1 小时前
光制造突围 | 国产耦合设备的替代与迭代
人工智能·光模块·耦合测试设备·光模块耦合设备·芯片测试设备·双透镜自动耦合设备
秦先生在广东1 小时前
Microsoft BitNet b1.58: 重新定义本地化 AI 推理的性能与能效极限
人工智能
秦先生在广东1 小时前
去中心化与全能化:深入剖析 Open WebUI 的自托管架构与工程实践
人工智能
IvorySQL1 小时前
PostgreSQL 日报|堆表选择性索引更新(10 月 13 日)
数据库·人工智能·postgresql·开源·区块链
蜗牛互联网1 小时前
Java 17 调用视觉 Responses API:JSON 约束与人工复核路由
java·人工智能·后端
秦先生在广东1 小时前
Agent 驱动的逆向工程工作流:REA 全栈二进制与多格式分析工具深度解析
人工智能
2603_968817341 小时前
AI视频怎么超清增强
人工智能
aqiu1111111 小时前
【C++算法打怪专栏】LeetCode 217. 存在重复元素(解法总结)
c++·算法·leetcode
frankzch1 小时前
微软 137B 编码模型搬到 PC:端侧推理与 Agent 权限防线
人工智能·深度学习·microsoft·机器学习·语言模型