Ascend C 算子性能优化实践指南
作者:昇腾实战派
知识地图:https://blog.csdn.net/Lumos_Lovegood/article/details/161601003
1. 背景概述
在 AI 计算场景中,算子的执行效率直接影响模型训练与推理的整体性能。Ascend C 作为面向昇腾 AI 处理器的算子开发语言,提供了丰富的编程接口与硬件抽象能力。然而,要充分发挥硬件潜力,开发者需要系统性地识别并消除性能瓶颈。本文从搬运、内存、API 使用、流水、Tiling 及头开销等六大方向出发,结合实际案例,总结了一套可复用的性能优化方法论,帮助开发者快速定位问题并实施有效优化。
2. 优化总览与方法论
Ascend C 算子性能优化是一个持续迭代的过程,遵循四步循环:性能分析 → 识别瓶颈 → 实施优化 → 验证效果,不断重复直至达成性能目标。
优化手段分为六大方向:搬运优化、内存优化、API 使用优化、流水优化、Tiling 优化、头开销与指令优化。各建议按优先级分类——为大多数算子带来收益的建议具有最高优先级,仅影响特定场景的手段给予较低优先级。开发者应先使用 msProf(获取 Profiling 数据)和 CAModel(获取仿真流水图)定位瓶颈,再选择对应方向开展优化。
3. 搬运优化
3.1 尽量一次搬运较大的数据块
原理说明
搬运不同大小的数据块时,对带宽的利用率(有效带宽 / 理论带宽)不同。硬件的 DMA 引擎启动一次搬运存在固定开销,小数据块搬运时该开销占比高,带宽利用率低。根据实测经验,单次搬运数据长度达到 16KB 以上时,通常能较好地发挥出带宽的最佳性能。
优化路径
- 在 Tiling 阶段合理设计切分大小,确保每次搬运的数据量足够大(≥16KB)。
- 对于 Matmul 场景,使能大包搬运(设置 depthA1/depthB1),一次将多个基本块从 GM 搬入 L1。
- 合并多个小张量的搬运操作,减少搬运次数。
简单案例
Matmul 算子中,默认每次从 GM 搬运一个基本块(baseM×baseK)到 L1。使能大包搬运后,一次搬入 depthA1 个基本块,搬运数据量从几 KB 提升到几十 KB,MTE2 搬运效率提升约 20%+。
3.2 GM 地址尽量 512B 对齐
原理说明
AI 处理器的 DMA 引擎在从 GM 向 Local Memory 搬运数据时,针对 512B 对齐的地址有硬件加速路径。若地址仅满足 32B 最低对齐要求,DMA 引擎需要额外的地址转换和拼接操作,导致有效带宽下降。实测显示,32B 对齐场景下带宽最差仅为 512B 对齐场景的 70%。
优化路径
- 在算子设计阶段,规划输入输出 Tensor 的内存分配策略,使起始地址 512B 对齐。
- Tiling 切分时,确保每个数据块的起始偏移量为 512B 的整数倍。
- 必要时对数据进行 padding,以满足对齐要求。
简单案例
某 Vector 算子输入 Tensor 起始地址为 32B 对齐,每次从 GM 搬运 64KB 数据。调整内存分配使起始地址 512B 对齐后,同样搬运 64KB 数据,搬运耗时降低约 30%,整体算子性能提升约 15%。
3.3 高效使用搬运 API
原理说明
DataCopy 等搬运 API 提供了 srcStride、dstStride、blockLen、blockCount 等参数,可在一次 API 调用中完成多块数据的批量搬运。DMA 引擎内部对这种批量搬运做了流水优化。若使用 for 循环逐块调用 DataCopy,每次调用都有独立的指令发射和启动开销,且无法利用 DMA 内部流水,效率远低于批量搬运。
优化路径
- 分析数据在 GM 上的排布,识别是否存在固定间隔的搬运模式。
- 将 for 循环搬运改写为带 stride 参数的单次 DataCopy 调用。
- 验证搬运结果的正确性(stride 计算容易出错)。
简单案例
从 GM 上搬运图像数据,每行 16KB 中取前 2KB,共 16 行。
// 反例:for 循环,每次仅搬 2KB,共 16 次
for (int i = 0; i < 16; i++) {
DataCopy(tensorIn[i * copyWidth], tensorGM[i * imgWidth], copyWidth);
}
// 正例:stride 参数,一次搬完 32KB
DataCopyParams params;
params.blockLen = copyWidth / 8;
params.blockCount = 16;
params.srcStride = (imgWidth - copyWidth) / 8;
params.dstStride = 0;
DataCopy(tensorIn, tensorGM, params);
正例只产生一条 DMA 指令,DMA 引擎内部流水处理 16 个块,总体搬运效率远高于 16 次独立调用。
4. 内存优化
4.1 UB Buffer 融合实现连续 Vector 计算
原理说明
当算子包含多次串联的 Vector 计算(前一次输出是后一次输入)时,如果每次计算后都将中间结果搬回 GM、再从 GM 搬入 UB 供下一次使用,会产生大量冗余搬运。由于这些中间数据只在核内使用,完全可以暂存在 UB 上直接传递,省去中间搬运环节。
优化路径
- 识别算子中连续的 Vector 计算链。
- 为中间结果分配 UB Buffer(使用 TBuf 而非 TQue)。
- 将中间搬运环节去掉,前一次输出 Tensor 直接作为后一次输入 Tensor。
- 仅保留首次 CopyIn 和末次 CopyOut。
简单案例
算子计算逻辑为 z = ReLU(x + y),包含 Add 和 ReLU 两步。优化前:CopyIn(x,y) → Add → CopyOut(tmp) → CopyIn(tmp) → ReLU → CopyOut(z),共 4 次搬运。优化后:CopyIn(x,y) → Add(结果留在 UB) → ReLU → CopyOut(z),仅 2 次搬运,搬运耗时减少约 50%。
4.2 L0C Buffer 暂存实现矩阵乘结果累加
原理说明
矩阵乘计算结果存放在 L0C(CO1)上。在需要多次矩阵乘并累加结果的场景中,如果每次计算后都将 L0C 结果搬出到 GM 再搬回,搬运开销巨大。L0C 支持原地累加能力(Mmad 指令的 initVal 参数),可直接在 L0C 上对多次矩阵乘结果进行累加,最终一次性搬出。
优化路径
- 识别循环内的矩阵乘累加模式。
- 第一次 Mmad 使用
initVal=0(清零后计算),后续迭代使用initVal=1(累加模式)。 - 所有迭代完成后,通过 FixPipe 一次性将 L0C 结果搬出。
简单案例
计算 C = A1×B1 + A2×B2,K 方向分两次迭代。优化前每次 Mmad 后搬出到 GM 再加,共 4 次搬运。优化后利用 L0C 累加:第一次 Mmad(initVal=0),第二次 Mmad(initVal=1) 直接在 L0C 上累加,最后一次 FixPipe 搬出,搬运次数减少 75%。
4.3 较小矩阵长驻 L1 Buffer
原理说明
在矩阵乘 C = A × B 的场景中,若 A 矩阵较小、B 矩阵较大,对 B 进行分块处理时每次迭代都需要重新搬入 A。由于 L1 容量远大于 L0A/L0B,可以将 A 一次性搬入 L1 并常驻,后续迭代只需搬运 B 的不同分块,从而减少 A 的重复搬运。
优化路径
- 评估较小矩阵是否能完整放入 L1(注意预留空间给大矩阵分块)。
- 在首次迭代前将小矩阵搬入 L1,后续迭代复用。
- 仅对大矩阵按 L1 剩余空间分块搬运。
简单案例
A 矩阵大小 64KB,B 矩阵大小 2MB,L1 容量 1MB。将 A 常驻 L1(占 64KB),剩余 960KB 用于 B 的分块搬运。B 分 3 次搬入,A 的搬运次数从 3 次降为 1 次,总搬运量减少约 10%。
4.4 TPipe 对象创建位置优化
原理说明
TPipe 对象初始化时会设置全局变量的 TPipe 指针,若 TPipe 定义在 Kernel 类内部,编译器认为 Kernel 对象的内存有被外部修改的风险,会对类内 Scalar 变量采取保守策略,放弃常量折叠和常量传播等编译优化,导致 Scalar 指令数增多、耗时增加。
优化路径
- 将 TPipe 对象从 Kernel 类内部移至类外部(如全局变量或作为独立对象传入)。
- 确保 TPipe 的内存空间独立于 Kernel 类对象。
- 验证编译后 Scalar 指令数是否减少。
简单案例
// 反例:TPipe 在类内部
class KernelAdd {
TPipe pipe; // 阻碍编译器优化
int32_t tileLength; // 无法被常量折叠
};
// 正例:TPipe 在类外部
TPipe pipe; // 独立内存空间
class KernelAdd {
int32_t tileLength; // 编译器可做常量折叠
};
实测某算子将 TPipe 外移后,Scalar 指令耗时降低约 15%。
5. API 使用优化
5.1 优先使用高阶 API
原理说明
高阶 API(如 Matmul、Conv2d 等)由昇腾官方针对硬件特性深度优化,内部集成了分块策略、数据搬运调度、流水线并行等多层优化逻辑。手动用底层 API 实现同等功能,不仅开发工作量大,而且难以达到同等的优化深度。
优化路径
- 优先检查昇腾是否提供对应的高阶 API。
- 使用高阶 API 替代手写的底层实现。
- 通过高阶 API 提供的模板参数(如 MDL 模板)进一步调优。
简单案例
实现矩阵乘 C = A × B,手动使用 Mmad 底层 API 编写分块循环、搬运调度,总代码约 200 行,实测耗时 120us。替换为 Matmul 高阶 API 后,代码缩减至 30 行,实测耗时 75us,性能提升约 37%。
5.2 Vector 算子灵活运用 Counter 模式
原理说明
Vector 指令通过 mask 控制每次迭代处理哪些元素。Normal 模式下需要手动计算并设置 bitmap 形式的 mask,对于非对齐数据量容易出错且有额外的 mask 计算开销。Counter 模式(SetMaskCount)直接指定处理的元素个数,硬件自动生成 mask,更灵活高效。
优化路径
- 将
SetMaskNorm切换为SetMaskCount。 - 用
SetVectorMask<T>(0, elementCount)直接指定元素个数。 - 计算完成后调用
ResetMask恢复默认。
简单案例
处理 15000 个 half 元素的 Add 计算:
uint32_t totalLen = 15000;
uint32_t numPerRepeat = 128; // half 类型每次处理 128 个
uint32_t mainRepeat = totalLen / numPerRepeat; // 117 次
uint32_t tailNum = totalLen % numPerRepeat; // 24 个尾元素
AscendC::SetMaskCount();
AscendC::SetVectorMask<half>(0, numPerRepeat);
AscendC::Add<half, false>(z, x, y, AscendC::MASK_PLACEHOLDER, mainRepeat, params);
if (tailNum > 0) {
AscendC::SetVectorMask<half>(0, tailNum);
AscendC::Add<half, false>(z[mainRepeat*numPerRepeat],
x[mainRepeat*numPerRepeat], y[mainRepeat*numPerRepeat],
AscendC::MASK_PLACEHOLDER, 1, params);
}
AscendC::ResetMask();
Counter 模式自动处理尾块对齐,无需手动计算 bitmap,代码更简洁且避免了 mask 计算开销。
5.3 针对不同场景合理使用归约指令
原理说明
数据归约(如 ReduceSum)有多种实现方式:WholeReduceSum 对整个 Tensor 做全局归约,BlockReduceSum 先做 32B 块内归约。对于大数据量的归约,先块内再全局的两级策略比直接全局归约的指令效率更高,因为块内归约充分利用了 Vector 单元的并行能力。
优化路径
- 评估数据量大小,选择合适的归约层级。
- 大数据量:先
BlockReduceSum将数据缩减到块级别,再WholeReduceSum做全局归约。 - 小数据量:直接
WholeReduceSum即可。
简单案例
对 4096 个 float 元素求和。方案一:两次 WholeReduceSum,实测 100 次循环总耗时 85us。方案二:一次 BlockReduceSum + 一次 WholeReduceSum,实测同样 100 次循环总耗时仅 52us,性能提升约 39%。
6. 流水优化
6.1 基于编程范式实现流水并行
原理说明
AI Core 内部的执行单元(MTE2 搬入、Vector/Cube 计算、MTE3 搬出等)是独立的流水线节点,可以异步并行执行。Ascend C 编程范式将核内逻辑划分为 CopyIn、Compute、CopyOut 三个阶段,通过 Queue 队列机制实现数据在不同阶段间的传递和同步。正确实现此范式后,搬入单元处理第 N+1 批数据时,计算单元可同时处理第 N 批数据。
优化路径
- 将算子逻辑拆分为 CopyIn、Compute、CopyOut 三个函数。
- 使用
TQue的AllocTensor/EnQue/DeQue/FreeTensor管理数据流转。 - 在主循环中依次调用三个阶段函数,Queue 机制自动处理流水依赖。
简单案例
for (int i = 0; i < loopCount; i++) {
CopyIn(i); // MTE2 搬入第 i 批数据到 VECIN 队列
Compute(i); // Vector 从 VECIN 取数据计算,结果放入 VECOUT 队列
CopyOut(i); // MTE3 从 VECOUT 取数据搬出到 GM
}
虽然代码看似串行调用,但由于 MTE2、Vector、MTE3 是独立硬件单元且通过 Queue 解耦,实际执行时第 i+1 次 CopyIn 与第 i 次 Compute 可并行,实现流水掩盖。
6.2 使能 Double Buffer
原理说明
基本流水范式中,若 Queue 仅有一个 Buffer,当计算单元正在使用该 Buffer 时,搬入单元必须等待其释放后才能写入下一批数据,造成搬运和计算无法完全重叠。Double Buffer 为 Queue 分配两个 Buffer(Ping/Pong),搬入单元写入 Buffer A 的同时计算单元可以读取 Buffer B,实现搬运与计算的完全并行。
优化路径
- 评估 UB 空间是否足够容纳双份 Buffer(Double Buffer 需要 2 倍内存)。
- 将
InitBuffer的内存块个数从 1 改为 2。 - 如果 UB 空间不足,需减小每块的数据量(减小 tileLength)后再使能。
- 用 Profiling 工具验证流水线是否确实实现了并行。
简单案例
// 使能前:单 buffer,搬运与计算串行
pipe.InitBuffer(inQueueX, 1, tileLen * sizeof(half));
// 使能后:双 buffer,搬运与计算并行
pipe.InitBuffer(inQueueX, 2, tileLen * sizeof(half));
某 Vector 算子使能 Double Buffer 后,从 Profiling 流水图可见 MTE2 和 Vector 流水线交替执行无空隙,算子耗时从 50us 降至 32us,性能提升约 56%。
6.3 使能 Iterate 异步接口避免 AIC/AIV 同步依赖
原理说明
在 MIX 编程模式中,AIV(Vector 核)调用 Matmul 的 Iterate 接口向 AIC(Cube 核)发送计算任务。同步模式下(sync=true),每次调用都会阻塞 AIV 等待 AIC 完成,产生大量同步等待时间。异步模式下(sync=false),AIV 发送任务后立即返回继续执行后续指令,AIC 独立完成计算,两者并行工作。
优化路径
- 将
Iterate<sync=true>替换为Iterate<sync=false>。 - 仅在真正需要使用 Cube 计算结果的位置插入同步指令。
- 用仿真流水图验证 AIC 和 AIV 的并行度是否提升。
简单案例
某 Matmul+BiasAdd 融合算子中,Matmul 用 Cube 核完成,BiasAdd 用 Vector 核完成。同步模式下 AIV 空闲等待 AIC,总耗时 100us。切换为异步模式后,AIV 在 AIC 做 Matmul 的同时预先搬入 Bias 数据,总耗时降至 78us,性能提升约 22%。
6.4 减少 PipeBarrier 的使用
原理说明
PipeBarrier<PIPE_ALL> 会强制所有流水线同步等待,即所有已发射的指令全部完成后才能继续。这会彻底打断流水并行,造成所有执行单元短暂停滞。实际上多数场景只需要在特定流水线之间同步,不需要全局同步。
优化路径
- 审查代码中所有
PipeBarrier<PIPE_ALL>,评估是否可以替换为更精细的同步。 - 仅在确实涉及多条流水线数据依赖时使用
PIPE_ALL。 - 仅涉及 Vector 流水时使用
PipeBarrier<PIPE_V>,仅涉及 MTE2 时使用PipeBarrier<PIPE_MTE2>。
简单案例
某算子在连续两次 Vector 计算之间插入了 PipeBarrier<PIPE_ALL>,导致 MTE2 也被迫等待。将其改为 PipeBarrier<PIPE_V> 后,MTE2 可在 Vector 同步期间继续搬运下一批数据,算子耗时降低约 10%。
7. Tiling 优化
7.1 L2 Cache 切分策略
原理说明
AI 处理器的 L2 Cache(典型大小约 192MB)读写带宽约 7TB/s,远高于 GM 的约 1.6TB/s。当所有核同时处理的数据总量超过 L2 Cache 容量时,数据无法命中 L2 Cache,被迫从 GM 读写,带宽利用率大幅下降。通过在 Tiling 中将数据按 L2 Cache 大小分批处理,确保每批数据能驻留在 L2 Cache 中,可大幅提升搬运效率。
优化路径
- 计算算子输入输出数据的总量,与 L2 Cache 大小比较。
- 若总量超过 L2 Cache,将数据按 L2 Cache 容量切分为若干份。
- 核函数接收切分索引参数,Host 侧循环调用核函数处理每份数据。
- 确保每份数据量 ≤ L2 Cache 大小。
简单案例
输入数据 384MB,L2 Cache 为 192MB,总核数 20。将数据切分为 2 份,每份 192MB:
constexpr int32_t TILE_NUM = 2;
for (int i = 0; i < TILE_NUM; i++) {
KernelSample op;
op.Init(x, y, i); // 第 i 份数据的偏移
op.Process();
}
每次 20 个核处理 192MB 数据,全部可命中 L2 Cache,避免了 GM 访问瓶颈。
7.2 核间负载均衡
原理说明
L2 Cache 切分后,每份数据需要分配给多个核处理。若数据量不能被核数整除,会出现"拖尾"现象——部分核需要处理额外的数据块,而其他核已经完成并空闲等待。算子的总耗时取决于最慢的核,因此负载不均衡会直接浪费计算资源。
优化路径
- 计算每份数据量与核数的整除关系,调整切分份数使其能被核数整除。
- 对于 causal 等不规则场景,按有效计算块的数量(而非行数)来分核。
- 可适当增加切分份数,牺牲少量 L2 Cache 命中率换取更好的负载均衡。
简单案例
FlashAttention causal 场景下,atten_mask 为下三角。若按行分核(8 行分 9 核),第 1 个核仅计算 1 个有效块,第 8 个核需要计算 8 个有效块,负载极度不均。改为统计有效块总数(共 36 个),按块均匀分给各核(每核 4 个块),负载均衡后整体性能提升显著。
7.3 基本块大小调整
原理说明
每次循环迭代有固定的指令发射、流水启停和同步开销,迭代次数越多总开销越大。在 UB 空间允许的范围内,增大基本块意味着减少迭代次数,从而减少这些固定开销。同时更大的基本块也有利于 Vector/Cube 单元的计算效率。
优化路径
- 用 Profiling 数据检查 UB 空间是否有富余(是否用满)。
- 在 UB 空间允许范围内,增大 tileLength 或基本块尺寸。
- 验证增大基本块后算子功能正确性。
- 比较优化前后的迭代次数和总耗时。
简单案例
FlashAttention 算子优化前按 (64, 128) 切分基本块,总共需循环 32 次。检查发现 UB 空间远未用满,将基本块调整为 (128, 128),循环次数减至 16 次,算子性能提升约一倍。
7.4 Matmul 基本块与 MDL 模板优化
原理说明
Matmul 计算中,baseM、baseN、baseK 参数决定了搬运数据的方式和总量。搬运总数据量为:totalCnt = (N/baseN)×M×K + (M/baseM)×K×N。选择不同的 baseM/baseN 组合直接影响搬运效率。此外,MDL(Multi-Data Loading)模板可使 MTE2 一次搬入多个基本块到 A1/B1,后续 MTE1 复用缓存数据,减少搬运次数和流水等待。
优化路径
- 根据搬运量公式,选择使
(1/baseM + 1/baseN)最小的基本块组合。 - 在 Matmul 高阶 API 中使能 MDL 模板。
- 用 Profiling 数据重点分析 MTE2 流水耗时变化。
简单案例
M=4096, N=5120, K=4096 的 Matmul 算子。使能 MDL 模板后,MTE2 一次搬入多个基本块,搬运次数减少,MTE2 平均耗时从 75.64us 降至 46.24us。一个基本块的计算延迟从 10.899us 降至 5.198us(流水等待大幅减少),算子总耗时从 83.68us 降至 53.4us,性能提升约 36%。
8. 头开销与指令优化
8.1 设置合适的核数和 Kernel 类型
原理说明
每个核在启动时有固定的初始化开销(核启动、TLB 加载、变量初始化等),且核数越多总头开销越大。对于整体耗时在微秒级、单核计算量很小的算子,头开销在总耗时中占比可能很高。同时,若纯 Vector 算子以混合模式启动,Cube 核虽无实际计算却仍产生启动开销,造成浪费。
优化路径
- 用 Profiling 数据分析头开销占总耗时的比例。
- 若头开销占比高,尝试减少启动核数(增加单核计算量)来找到最佳平衡点。
- 纯 Vector 算子设置为 Vector Kernel 类型,纯 Cube 算子设置为 Cube Kernel 类型,避免启动不必要的核。
- 通过多次实测不同核数配置,确定性能最优的核数设置。
简单案例
某纯 Vector 算子总耗时 8us,其中头开销 5us(占 62%)。将启动核数从 20 减少到 10,单核计算量翻倍,头开销降至 3us,总耗时降至 6us(计算量 3us),性能提升约 25%。同时将 Kernel 类型从混合模式改为纯 Vector 模式,避免 Cube 核的无效启动,头开销进一步降低 0.5us。
8.2 数据类型与编译优化
原理说明
低精度数据类型(如 half 代替 float)可以减半搬运数据量并提高计算吞吐量,但类型转换本身有开销。编译器在编译 Scalar 代码时,若能识别常量和确定性变量,会进行常量折叠、传播和循环展开等优化,减少 Scalar 指令数。使用 constexpr 和 inline 等关键字可以辅助编译器做出更好的优化决策。
优化路径
- 评估算法精度容忍度,在可接受范围内使用低精度类型。
- 若 half 直接计算精度不达标,可尝试通过变换计算公式避免精度敏感的运算。
- 用
constexpr标记编译期可确定的常量。 - 对热路径上的函数使用
__aicore__ inline标记。
简单案例
某 Activation 算子中间计算使用 float32 类型,数据量 1MB。改为 half 类型后,搬运数据量减至 512KB,MTE2 耗时降低 45%。同时将 tileLength 等参数改为 constexpr,编译器将多条 Scalar 运算在编译期完成,Scalar 指令数减少约 20%。
9. MC² 通算融合优化(高级场景)
原理说明
MC² 通算融合算子将分布式训练中的通信任务(如 AllReduce)与计算任务(如 Matmul)融合为一个算子。核心思想是将输入数据沿某一维度切分为多个子块,子块的计算和通信形成两条独立的流水线。当第一块数据做完计算进入通信阶段时,第二块数据可以同时开始计算,通过这种流水掩盖使通信开销被计算所覆盖(或反之),从而提升整体性能。
优化路径
- 分别测量未融合时计算和通信的独立耗时,判断算子是计算 bound 还是通信 bound。
- 根据 bound 类型制定切分策略——计算 bound 时让通信被掩盖,通信 bound 时让计算被掩盖。
- 在 Tiling 代码中设置切分数量和子块大小。
- 结合实测微调切分策略(过多切块会带来额外膨胀开销)。
简单案例
MatmulAllreduce 算子,左矩阵 [M, K],右矩阵 [K, N],输入 half 类型。未融合时 Matmul 耗时 1200us,AllReduce 耗时 674us,串行总耗时 1874us。融合后将 M 轴切分为 4 块,计算和通信流水并行执行,融合算子总耗时 1262us,性能收益 32.7%。
参考资料
更多推荐



所有评论(0)