ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

asc-devkit Matmul 高阶 API 开启 L2 Cache 切分:大数据量矩阵乘的搬运瓶颈优化实战

asc-devkit Matmul 高阶 API 开启 L2 Cache 切分:大数据量矩阵乘的搬运瓶颈优化实战
  • 人工智能
  • 深度学习
  • 算子库
  • CANN
  • Ascend

【免费下载链接】asc-devkit

本项目是CANN 推出的昇腾AI处理器专用的算子程序开发语言,原生支持C和C++标准规范,主要由类库和语言扩展层构成,提供多层级API,满足多维场景算子开发诉求。

项目地址:https://gitcode.com/cann/asc-devkit
点击查看免费下载

本文基于 asc-devkit 开源仓库中的 Matmul 高阶 API 调优案例,讲解当矩阵乘的输入与输出数据总量超过 L2 Cache 容量时,如何通过 L2 Cache 数据切分提升 L2 命中率、压减 MTE2 搬运开销。读完本文,你将掌握 L2 Cache 切分的适用场景判断、基于核间负载均衡的切分份数计算、错位分核消除同地址冲突三大优化要点,以及完整的代码实现路径与编译运行验证方法,可直接对照仓库中的 matmul_l2cache 算子样例 落地实践。

一、案例背景:当矩阵乘数据量超过 L2 Cache 容量

在昇腾 AI 处理器上,L2 Cache 是 AI Core 与 Global Memory(GM)之间的一层共享高速缓存。从系统缓存概述可以看出,搬运单元 MTE2 通过 L2 Cache 读取 GM 数据:优先从 L2 Cache 命中,未命中时才从 GM 加载数据并更新到 L2 Cache。

本案例使用的 AI 处理器 L2 Cache 大小为 192MB,其纯读带宽约为 GM 的 3~4 倍,两者存在较大差距。因此:

  • 命中 L2 Cache 时:搬入或搬出相同数据量,访问 L2 Cache 内的数据比访问 GM 更快;
  • 未命中 L2 Cache 时:需要访问 GM 进行读写,带宽利用效率较低,算子搬入/搬出数据成为整个运行过程的性能瓶颈。

适用场景:当算子输入和输出的数据量超过 L2 Cache 的大小时,即可考虑开启 L2 Cache 切分。本案例的算子规格如下:

输入ShapeData typeFormat
a30720, 1024float16ND
b4096, 1024float16ND

从样例说明(README.md)中可以进一步确认本样例的具体规格:M = 30720、N = 1024、K = 4096,左矩阵 a 为[M, K]、右矩阵 b 为[K, N],均为 half 类型、ND 格式、非转置;输出 c 为[M, N]的 float 类型;核函数名为matmul_l2cache_custom。按此规格估算,输入输出的总数据量约为:

((30720 * 4096) + (4096 * 1024) + (1024) + (30720 * 1024)) * 2 = 322963456 字节(约 308M)

该数据量(约 308M)已明显超过 L2 Cache(192M),计算前读取的数据无法保证命中 L2 Cache,搬运数据成为样例运行的性能瓶颈——这正是开启 L2 Cache 切分的典型前提。

二、获取性能数据:用 msOpProf 盯住 MTE2 流水

性能优化必须建立在准确的性能数据之上。本案例使用 msOpProf 工具获取两类数据:

  • 算子仿真流水图:仿真环境下采集并自动解析的指令流水视图;
  • 上板 Profiling:真实硬件上的算子级性能剖析数据。

由于 L2 Cache 切分功能主要利用带宽更大的 L2 Cache 来减少 MTE2 数据搬运开销,因此性能分析的重点应放在 MTE2 流水上,关注aic_mte2_time等与数据搬运相关的字段,判断搬运是否成为算子执行的主瓶颈。

三、瓶颈分析:MTE2 搬运占比高达 99%

本案例是在 Tiling 全量常量化基础上进一步优化的,Tiling 全量常量化请参考 Matmul 高阶 API 开启 Tiling 全量常量化 案例。

优化前的 Profiling 数据如下:

  • C 列的aic_time为867us(算子整体执行时间);
  • K 列的aic_mte2_time为861.9us(MTE2 数据搬运时间);
  • MTE2 占比 99%,即算子几乎全程都在等待数据搬运。

由此可以明确:MTE2 数据搬运是当前算子性能的瓶颈。由于数据量超过 L2 Cache 容量,MTE2 搬运的数据无法命中 L2 Cache,只能以较低的 GM 带宽反复读写,导致搬运时间被显著拉长。

四、优化方案:三个优化点

优化点一:调整切块大小和计算次数

  • 优化前:输入数据不进行切分,所有核一次计算全部数据。以下图为例,图中数字表示核 id,24 个核一次计算 A 和 B 矩阵的所有数据;
  • 优化后:输入数据被切分为多块,所有核分多次计算,每个核单次计算只依赖切分后的数据量。L2 Cache 切分方案确保单次计算的数据都落在 L2 Cache 缓存中,搬运输入数据的效率更高。

优化点二:选择拖尾较小的 L2 Cache 切分方案

结合核间负载均衡的原理,AI 处理器的物理核数是固定的。当数据进行 L2 Cache 切分之后,可能出现部分核有计算拖尾的情况,即每次所有核总计算量除以每个核单次处理的数据量不能被核数整除,导致每次计算的最后需要部分尾核计算剩余数据;而在尾核计算时,部分核始终处于空闲状态,算子整体性能变差。

下图中标黄的数据块就是尾块数据:左边方案由于拖尾,每次计算中 0、1、2、3 号核多执行一次处理剩余数据;为达到全局负载最优,调整拖尾核的位置,如右边方案所示,完成所有计算时 0 到 7 号核均多一次数据块的计算,负载分布更加均衡。

在实际场景中,满足切分后的数据量小于 L2 Cache 大小的前提下,拖尾越小越好。基于这一原则即可确定 L2 Cache 切分块数。

优化点三:错位分核,减少左右矩阵同地址冲突

同地址冲突:多核并发执行 Matmul 计算时,如果多核同时访问输入矩阵的相同地址,会导致地址冲突,影响性能。

在 M 和 N 方向,将矩阵数据 L2 Cache 切分为大数据块,然后在数据块间错位分核——即将每个数据块依次沿对角线分配给不同的核处理,从而有效减少同地址冲突。例如,在处理同一行的尾块数据 0、1、2、3 时:

  • 顺序分配:多核会同时读同一行左矩阵数据,导致读读冲突;
  • 对角线分配:在对角线上的尾块数据被分配给核 0、1、2、3 计算,多核访问不同行的左矩阵数据,同地址冲突次数显著减少。

五、代码实现:L2 Cache 切分的四个关键步骤

Matmul API 开启 L2 Cache 切分的完整样例位于 matmul_l2cache。切分算法的核心实现封装在 l2_cache_optimizer.h 的L2CacheOptimizer模板类中(当前在 Kernel 侧完成切分份数计算,也可自行迁移至 Host 侧),Kernel 主流程位于 matmul_l2cache.asc。实现的关键步骤如下:

步骤 1:判断是否需要进行 L2 Cache 切分

如果数据总量未超过设定的 L2 Cache 阈值,则不需要切分,直接返回;否则进入切分计算流程:

bool smallDim = mTileNum_ < L1_MIN_UST_DIM && nTileNum_ < L1_MIN_UST_DIM; if (smallDim || (!EnableL2Tile())) { // 判断计算数据总量是否小于L2Cache阈值 mL2TileNum_ = mTileNum_; nL2TileNum_ = nTileNum_; mL2BlockNum_ = 1; nL2BlockNum_ = 1; return; // 不需要切分,提前返回 } InitL2TileTail(); // 计算L2切分

对应的底层实现(l2_cache_optimizer.h 中InitL2Tile)包含两重判断:

  • smallDim:M、N 方向上的 L1 块数均小于L1_MIN_UST_DIM(源码中定义为 4)时,维度本身很小,无需切分;
  • EnableL2Tile():内部调用GetTotalSize(m_, n_, k_) > L2_TILE_THRESHOLD判断总数据量是否超过阈值。源码中L2_TILE_THRESHOLD = 100 * 1024 * 1024(约 100MB),明显小于 192MB 的 L2 Cache 容量,为缓存命中预留了余量。

其中GetTotalSize按 A、B、C 三块数据分别估算搬运量:

__aicore__ inline int64_t GetTotalSize(int64_t mL2, int64_t nL2, int64_t kL2) { int64_t sizeA = mL2 * kL2 * sizeof(half); int64_t sizeB = kL2 * nL2 * sizeof(half); int64_t sizeC = mL2 * nL2 * sizeof(half); return sizeA + sizeB + sizeC; }

步骤 2:基于负载均衡原则计算 L2 Cache 切分份数

计算 m 方向 L2 Cache 切分数mL2TileNum_与 n 方向切分数nL2TileNum_。核心思想是:遍历可能的切分组合,在"分块总数据量不超过 L2 阈值"的前提下,选择使核间负载冲突值最小的组合(对应优化点二"拖尾越小越好"):

int64_t mConflict = INT64_MAX; int64_t nConflict = INT64_MAX; constexpr bool isNMajor = l1N > l1M; // 根据shape大小,判断主维度 for (int64_t i = maxMajor; i >= L1_MIN_UST_DIM; i--) { for (int64_t j = maxMinor; j >= minMinor; j--) { if (GetTotalSize(j * l1M, i * l1N, k_) <= L2_TILE_THRESHOLD) { // 确保分块小于L2Cache阈值 uint64_t mConflictTmp = AscendC::Std::ceil_div(blockNum_, mL2TileNumTailTmp); // 计算负载冲突值 uint64_t nConflictTmp = AscendC::Std::ceil_div(blockNum_, nL2TileNumTailTmp); if (mConflict >= mConflictTmp && nConflict >= nConflictTmp) { // 若冲突值更小,更新分块数量 mConflict = mConflictTmp; nConflict = nConflictTmp; mL2TileNum_ = curMajorDim; nL2TileNum_ = curMinorDim; } } } }

结合源码(InitL2TileTail)可以更完整地理解这一过程:

  • l1M = baseM * stepM、l1N = baseN * stepN,由常量化 Tiling 参数推导而来(样例中baseM=128、baseN=256、stepM=1、stepN=1,见 matmul_l2cache.asc 的GetCFG与GetL1Tiling);
  • 以isNMajor判断主维度后,两层循环在maxMajor到L1_MIN_UST_DIM、maxMinor到minMinor的范围内枚举切分份数;
  • 每个候选组合通过GetTotalSize校验分块数据量是否超出L2_TILE_THRESHOLD,超出则continue跳过;
  • 用GetTail(除尽返回除数、除不尽返回余数)计算尾块后的实际切分数,再以ceil_div(blockNum_, tailNum)计算负载冲突值,冲突值越小表示拖尾越少、核间负载越均衡;
  • 遍历结束后若mL2TileNum_/nL2TileNum_仍为 0(未找到可行组合),则回退为不切分,保持mTileNum_/nTileNum_。

最终在InitL2Tile中计算两个方向的 L2 大块数:mL2BlockNum_ = ceil_div(mTileNum_, mL2TileNum_)、nL2BlockNum_ = ceil_div(nTileNum_, nL2TileNum_)。

步骤 3:错位分核,获取按对角线分配的核下标

输入当前数据块的下标,通过取模与最小公倍数(CalcLcm)运算,将块索引映射到对角线错位分布的核上(对应优化点三):

__aicore__ inline BlockCoord GetBlockCoord(int64_t tileIdx) { GetCommonTileIndex(tileIdx); int64_t mTileIdx = newBlockIdx_ % mL2TileNumTmp_; mTileIdx = mTileIdx + mL2Idx_ * mL2TileNum_; int64_t nTileIdx = 0; if (mL2TileNumTmp_ != 0 && nL2TileNumTmp_ != 0) { int64_t tmp = newBlockIdx_ / CalcLcm(mL2TileNumTmp_, nL2TileNumTmp_); nTileIdx = (newBlockIdx_ + tmp) % nL2TileNumTmp_; } nTileIdx = nTileIdx + nL2Idx_ * nL2TileNum_; return {mTileIdx * l1M, nTileIdx * l1N, 0}; }

其中GetCommonTileIndex负责把全局块下标解析为 L2 大块索引(mL2Idx_、nL2Idx_)与 L2 大块内部的相对块下标(newBlockIdx_),并处理尾块尺寸(GetTail);返回的坐标即当前切分块在 M、N 方向上相对矩阵起点的块坐标。

步骤 4:设置左右矩阵,循环多次计算 Matmul

根据前序步骤计算的 L2 Cache 切分数和执行核的下标,每个核以blockNum为步长遍历所有切分块,循环调用 Matmul 完成计算:

L2CacheOpt l2Opt(shapes, blockNum); matmulObj.SetOrgShape(shapes.m, shapes.n, shapes.k); for (int64_t tileIdx = curBlockIdx; tileIdx < l2Opt.GetTileNum(); tileIdx += blockNum) { auto blockShape = l2Opt.GetBlockShape(tileIdx); // 获取单次计算L2切分块大小 if (Get<0>(blockShape) <= 0 || Get<1>(blockShape) <= 0){ return; } auto blockCoord = l2Opt.GetBlockCoord(tileIdx); // 获取当前执行计算的核的下标blockCoord matmulObj.SetTail(Get<0>(blockShape), Get<1>(blockShape), Get<2>(blockShape)); const auto& offsetCoord = CalcOffset(shapes, blockCoord); // 基于下标计算矩阵偏移 int64_t offsetA = Get<0>(offsetCoord); int64_t offsetB = Get<1>(offsetCoord); int64_t offsetC = Get<2>(offsetCoord); matmulObj.SetTensorA(aGlobal[offsetA], false); matmulObj.SetTensorB(bGlobal[offsetB], false); if (shapes.isBias) { matmulObj.SetBias(biasGlobal); } matmulObj.IterateAll(cGlobal[offsetC]); // 计算L2切分块 } matmulObj.End();

这段循环与 matmul_l2cache.asc 中MatmulKernel::Process的实现完全一致。几个关键点:

  • 接口语义:GetTileNum()返回左右矩阵总的 L2 切分份数(mTileNum_ * nTileNum_);GetBlockShape(tileIdx)返回切分后 M、N、K 轴的长度(尾块返回实际尾长);GetBlockCoord(tileIdx)返回对应切块的位置坐标;
  • 负载均衡循环:tileIdx从curBlockIdx开始、以blockNum为步长递增,保证每个核拿到的数据块数量最多相差一块,与优化点二的拖尾控制目标一致;
  • 偏移计算:CalcOffset基于 ND 布局(MakeLayout(MakeShape, MakeStride))把块坐标换算为 A/B/C 三个矩阵在线性存储上的元素偏移,再通过aGlobal[offsetA]等切片方式传入对应数据块;
  • 常量 Tiling 配合:本样例在 Kernel 侧通过GetMatmulApiTiling推导常量化 Tiling(REGIST_MATMUL_OBJ的 Tiling 入参使用空指针(TCubeTiling*)nullptr),运行时仅需 Host 侧传入MatmulProblemShape(m/n/k/isBias 四个字段),其余切分参数均在 Kernel 侧推导,这也是在 Tiling 常量化案例基础上的进一步优化。

六、样例编译运行与结果验证

在样例根目录examples/01_simd_cpp_api/04_advanced_api/00_matmul/matmul_l2cache下执行如下步骤即可编译并验证:

source ${install_path}/cann/set_env.sh # 配置 CANN 开发套件包环境变量 mkdir -p build && cd build # 创建并进入 build 目录 cmake -DCMAKE_ASC_ARCHITECTURES=dav-2201 ..; make -j; # 编译工程,默认 npu 模式 python3 ../scripts/gen_data.py # 生成测试输入数据 ./demo # 执行编译生成的可执行程序 python3 ../scripts/verify_result.py output/output.bin output/golden.bin # 验证输出结果

其中${install_path}为 CANN 包安装目录,未指定安装目录时默认安装至/usr/local/Ascend下。编译选项说明如下:

选项可选值说明
CMAKE_ASC_RUN_MODEnpu(默认)、cpu、sim运行模式:NPU 运行、CPU 调试、NPU 仿真
CMAKE_ASC_ARCHITECTURESdav-2201(默认)、dav-3510NPU 架构:dav-2201 对应 Atlas A2 系列和 Atlas A3 系列产品,dav-3510 对应 Ascend 950PR&950DT 系列产品

使用 CPU 调试或 NPU 仿真模式时,添加-DCMAKE_ASC_RUN_MODE=cpu或-DCMAKE_ASC_RUN_MODE=sim参数即可;切换编译模式前需清理 build 目录下的 cmake 缓存后重新 cmake。上述编译配置与 CMakeLists.txt 中CMAKE_ASC_RUN_MODE、CMAKE_ASC_ARCHITECTURES的默认定义保持一致。

样例配套脚本 gen_data.py 负责生成随机 float16 输入与基于np.matmul的真值(golden)数据;verify_result.py 按相对误差 1e-3、绝对误差 1e-3、错误率阈值 1e-3 逐元素比对输出。验证通过时打印:

test pass!

按照 README.md 的说明,本样例支持的硬件产品及 CANN 版本为:Ascend 950PR&950DT 系列产品(>= CANN 9.2.0)、Atlas A3 系列产品(>= CANN 9.2.0)、Atlas A2 系列产品(>= CANN 9.2.0)。

七、验证优化方案性能收益

优化后的 Profiling 数据显示:

  • C 列aic_time为805.6us;
  • 相比优化前的 867us,算子总执行时间降低约 7.1%;
  • MTE2 搬运时间降低约 10.7%。

结合瓶颈分析可以看到:L2 Cache 切分使得单次计算的数据量能够命中 L2 Cache,MTE2 搬运更多地发生在带宽更高、延迟更低的 L2 Cache 上,搬运开销被有效压缩,整体执行时间随之下降。

八、总结

在 Matmul 计算数据量超过 L2 Cache 大小的场景下,可以考虑开启 L2 Cache 切分,通过"切分输入矩阵 + 控制拖尾实现负载均衡 + 错位分核减少同地址冲突"的组合策略,提高 L2 Cache 命中率,利用 L2 Cache 高带宽特性提升算子性能。

如需进一步深入,建议继续阅读仓库内以下内容:

  • 切分算法完整实现:l2_cache_optimizer.h;
  • Kernel 主流程与常量 Tiling 配置:matmul_l2cache.asc;
  • 前置优化基础:Matmul 高阶 API 开启 Tiling 全量常量化;
  • 拖尾与负载均衡原理:核间负载均衡;
  • 性能数据采集方法:性能数据采集。
  • 人工智能
  • 深度学习
  • 算子库
  • CANN
  • Ascend

【免费下载链接】asc-devkit

本项目是CANN 推出的昇腾AI处理器专用的算子程序开发语言,原生支持C和C++标准规范,主要由类库和语言扩展层构成,提供多层级API,满足多维场景算子开发诉求。

项目地址:https://gitcode.com/cann/asc-devkit
点击查看免费下载

相关推荐

上一篇:GhostTrack 三步快速查 IP 位置、手机号归属地与社交注册情况完整实战指南
下一篇:Ice 完整指南:三步让 Mac 菜单栏告别杂乱图标

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进