大模型中经常出现depthwith卷积,本质是个分组卷积,这类卷积的特点是groups=cin=cout,这类卷积如果按照group作为外循环的普通方案实现,cube的利用率会很低,因为每个group内的卷积的cin核cout只有1,转换为矩阵乘后,K和N非常小,而且昇腾硬件上每cycle可计算161616大小的矩阵乘,最坏情况每次算力只能发挥1/256。我们提出了group-opt的方案,一次性计算多个groups,每次计算前对weight进行预处理改造,把weight扩充成对角矩阵,只有对角线上值有效,其他点都为0,大幅度提高cube利用率,对比group作为外循环的方案平均性能提升30%


当前昇腾为了满足端侧/云侧/大模型推理/训练已经推出了多种芯片型号,soc的核数也越来越多样化,之前我们矩阵类算子的分核策略亲核于2的幂次方,对于非2的幂次方的核经常出现绑不满核或者分核不均的情况,无法充分发挥算力,借此背景我们推出任意核上负载均衡的策略。把MN的大矩阵按M0N0分成多个小矩阵,每个核按顺序处理一小块,当块数是核数的倍数时可以很好的负载均衡,但如果不是倍数就会存在拖尾。假设一共5块,核数为4,每个核处理完一个后,会剩一个尾块需要一个核处理,这样会造成拖尾,导致负载不均衡。我们会对最后一个尾块再一次进行切分,动态调整基本快大小,尽可能最后一轮每个核上处理的数据量一致。我们还进行了蛇形走位的l2缓存优化,如果一行处理完后我们可以选择从下一行的0号位置开始或者从下一行的尾部开始,我们选择从尾部开始处理,这样可以复用上一行的A矩阵或者B矩阵的l2缓存,提高核间的l2缓存命中率。


搬运bound做对应的优化:
(1)消除重复搬运
(2)消除搬运流水中指令间的空隙
(3)消除bank冲突
(4)cacheLine对齐
(5)增大L2/L1/L0利用率,减少GM搬运
(6)整网中数据预取
(7)量化输入数据,减少搬运数据量,计算换搬运
(8)更快的硬件指令
(9)每次指令搬运的数据量要足够多,实现连续搬运
计算bound做对应的优化:
(1)消除计算流水中指令间的空隙
(2)使用算力更强的计算单元
(3)计算指令配置数据量对齐
(4)增大每次tile块的有效计算量
(5)量化
(6)数学上等价变换
scalar bound做对应的优化:
(1)减少LD和ST
(2)常量传播
(3)头开销优化
(4)一次多载,比如l0多载几份数据进来
无bound
(1)打开乒乓
(2)preload策略
(3)指令合并计算
多核
(1)是否用满核
(2)每个核处理数据量是否均分
mix算子理论时间估计
(1)按vector时间、搬运时间、cube时间最大的来估计理论时间


一、 搬运 Bound (Memory Bound) 优化迁移
GPU 是极其依赖带宽的架构,这一部分的迁移率几乎是 100%。
- (1)消除重复搬运:对应 GPU 中的 Kernel Fusion(算子融合)。减少中间结果写回 Global Memory (GM) 再读出的过程。
- (3)消除 Bank 冲突:在 GPU 中特指 Shared Memory Bank Conflict。GPU 共享内存分为 32 个 bank,如果多个线程访问同一个 bank 的不同地址,会造成串行化。
- (4)CacheLine 对齐:对应 GPU 的 Memory Coalescing(显存合并访问)。确保一个 Warp 内的 32 个线程访问的地址落在同一个 128-byte 的显存块内。
- (5)增大 L2/L1 利用率:GPU 开发者通常通过 Tiling(分块) 策略,将数据留在 Shared Memory(相当于程序员控制的 L1/L0)中重复使用。
- (7)量化输入数据:对应使用 FP16/BF16/INT8。减少比特数直接翻倍带宽利用率,同时 GPU 的 Tensor Core 对低精度有极高的吞吐量。
- (9)连续搬运(Vectorized Load/Store):在 CUDA 中使用
float4或uint4指令,一次读取 128 bit,能更有效地利用内存总线带宽。
二、 计算 Bound (Compute Bound) 优化迁移
GPU 的计算优化重心在于“喂饱”众多的流处理器(SM)。
- (1)消除计算流水空隙:对应 GPU 的 Latency Hiding(延迟隐藏)。GPU 不靠高频率,而是靠高并行度(足够的 Warp 数量)来掩盖指令延迟。
- (2)更强的计算单元:对应使用 Tensor Cores。在 GPU 上,如果能用 Tensor Core 跑矩阵运算,效率会比普通的 CUDA Core(Vector Unit)高出数倍。
- (3)&(4)数据量对齐与 Tile 块大小:在 GPU 中,Tile 的大小直接影响 Occupancy(活跃线程束比例)。Tile 过大会导致寄存器资源枯竭,过小则无法隐藏搬运延迟,需要寻找平衡点。
- (5)量化:GPU 的低精度计算(如 INT8/FP8)吞吐量远高于 FP32。
三、 Scalar Bound 优化迁移
GPU 虽然是典型的 SIMT(单指令多线程)架构,但每个 SM 都有标量单元。
- (1)减少 LD 和 ST:在 GPU 中体现为 Register Spilling(寄存器溢出) 优化。避免编译器将局部变量放入慢速的 Local Memory(实则在显存中)。
- (2)常量传播:使用
__constant__内存。GPU 有专门的 Constant Cache,对于 Warp 内所有线程访问同一地址的情况有加速。 - (3)头开销优化:对应 Kernel Launch Overhead。通过 CUDA Graph 或把小算子合并来减少 CPU 侧下发指令的开销。
四、 无 Bound (General/Pipeline) 优化迁移
- (1)打开乒乓(Double Buffering):在 CUDA 中通过
shared memory配合cp.async(异步拷贝指令)实现。在计算当前 Tile 时,异步加载下一个 Tile。 - (2)Preload 策略:对应
__prefetch_global_l1指令,或者在计算循环开始前先做一次异步加载。 - (3)指令合并计算:如 FMA (Fused Multiply-Add)。一个周期完成乘加运算,GPU 编译器通常会自动处理。
五、 多核 (Multi-SM) 优化迁移
GPU 的“多核”体现为数百个 SM。
- (1)是否用满核:对应 Grid Size。如果生成的线程块(Thread Blocks)太少,会导致 GPU 大量 SM 闲置(特别是现在的 H100/H20).
- (2)均分数据(Load Balance):避免 Tail Effect(长尾效应)。如果某个线程块处理的数据远多于其他块,整个 Kernel 的结束时间取决于最慢的那个块。在 GPU 动态任务分配中常用
Atomic Add实现任务池(Task Queue)。
总结:GPU 独有的额外优化点
虽然 NPU 的经验迁移性很强,但 GPU 有几个特有的性能陷阱需要额外注意:
- Warp Divergence(分支分叉):
if-else逻辑如果导致同一 Warp 内线程走不同分支,会造成严重的计算性能下降(NPU 某些架构对标量逻辑处理较好,GPU 则非常忌讳)。 - Register Pressure(寄存器压力):GPU 的寄存器是有限资源,每个线程用的寄存器越多,能并行跑的线程就越少(Occupancy 下降)。
- Unified Memory / PCIe 瓶颈:如果数据在 CPU 和 GPU 之间频繁同步,搬运 Bound 会变成整个系统的致命瓶颈。
你的总结已经触及了高性能计算的本质,只要把 NPU 的 L0/L1/L2 缓存概念 映射到 GPU 的 Registers/Shared Memory/L2 Cache,逻辑是完全相通的。


如何从头实现一个高性能算子?
1、审视数学公式,进行等价化简,确定对硬件最优的数据排布格式,分析使用哪些指令可以完成功能,选用最高效的指令。根据理论计算指令吞吐、多级缓存带宽与延迟去进行理论建模,根据理论建模评估不同场景下的 bound 类型,简单算子使用roofline模型评估,融合算子按耗时最长的流水进行评估。
2、根据理论建模去推导最优tiling切块。
3、配合tiling实现算子kernel。
4、完成算子开发后使用性能分析工具查看能否达到理论bound类型,如果不能先明确为什么达不到,是否是代码bug?是否某种场景有局限性无法达到理论性能?根据实际情况刷新bound类型,再根据bound类型去优化tiling和kernel。


for batch:
for N:
for s1_loop: // q
lse_max = -inf;
lse_sum = 0;
acc_out = 0;
for s2_loop:
qk_tile = mm(q, k, transposeB=true); // cube
// l0c_to_ub
new_lse_max = max(lse_max, max(qk_tile)); // ub
p_qk_tile = exp(qk_tile - new_lse_max); // ub
new_lse_sum = lse_sum * exp(lse_max - new_lse_max) + sum(p_qk_tile); // ub
new_acc_out = mm(p_qk_tile, v); // cube
// l0c_to_ub
acc_out = acc_out * exp(lse_max - new_lse_max) + new_acc_out; // ub
lse_max = new_lse_max; // ub
lse_sum = new_lse_sum; // ub
acc_out /= lse_sum; // ub
output[offset] = acc_out;// ub_to_gm
for s1_loop:
lse_max = -inf;
lse_sum = 0;
lse_out = 0;
for d_loop:
new_lse_max = max(lse_max, max(d_tile));
new_d_tile = exp(d_tile - new_lse_max);
new_lse_sum = lse_sum * exp(lse_max - new_lse_max) + sum(new_d_tile);
lse_out = lse_out * exp(lse_max - new_lse_max);
lse_out[offset] = new_d_tile;
lse_max = new_lse_max;
lse_sum = new_lse_sum;
lse_out /= lse_sum;
output[offset] = lse_out;


mte2头开销减少
1、在L1空间够的情况下,weight切K分块载入,但不换出,保持复用。
2、增加重复载入量,强行切K,cubebound场景可做。


scalar常见问题
现象:分析算子执行时PC(Program Counter)地址随cycle的变化,可识别icache miss的位置。当Scalar需要从I-Cache加载指令但缓存未命中时,会产生数十到数百cycle的等待。
分析:代码段过大,一次性无法全部载入导致缓存不命中。
现象:ConstParam保存了FlashAttention计算过程中所需的参数,在算子执行期间会被频繁访问。它默认是类的成员变量——这对编译器来说意味着:(1) 必须通过this指针+偏移访问;(2) 别名分析无法排除其他指针修改它的可能;(3) 跨函数调用后必须重新从内存加载。这些因素共同导致大量Load指令。
分析:少使用类变量,多使用局部变量。
现象:代码中使用一个"上帝类"(God Class)保存所有参数,且在函数入口处很早就创建了类对象。变量生命周期贯穿整个函数,编译器倾向于将其溢出到栈上。
分析:使用局部变量且定义尽量贴近使用位置时,编译器更容易将变量缓存在寄存器中,显著减少Load/Store指令数。硬件上的通用寄存器有限,提前分配了大量暂时未使用的变量,就可能导致寄存器数量占满从而触发寄存器Spill。
PS:
1、消除tiling的多级指针解引用
2、长生命周期的代价:变量长期占用一个寄存器,当编译器遇到其他变量时可能寄存器不够用,被迫进行寄存器Spill。
贴近使用的效果:变量在真正需要前才定义,用完后活跃状态立即结束。短生命周期的变量可能直接使用临时寄存器,甚至被优化掉。
3、显式的for循环有两方面优势:
硬件层面:for循环的回跳不经过分支预测器(使用专用的循环硬件),不会发生flush
编译器层面:编译器更容易对显式循环进行循环展开、循环无关变量外提等优化
4、将单一循环拆分为三段:
Prologue(首块):仅执行1次,处理初始化逻辑
Hot Loop(中间段):循环主体,零分支,仅包含纯计算和参数更新
Epilogue(尾块):仅执行1次,处理收尾逻辑
5、结构体内的数组成员,如果通过动态下标(运行时变量)访问,会导致编译器的别名分析(Alias Analysis)失败,进而阻断常量传播(Constant Propagation)和常量折叠(Constant Folding)优化。
具体机制:当编译器无法确定动态下标的具体值时,必须考虑最坏情况——下标可能越界或经过计算后恰好指向结构体中的其他成员。为了保证正确性,编译器会放弃对该结构体所有成员的常量传播,改为从内存重新读取。
如果其他结构体包含该结构体(作为成员或继承),别名分析的失败会向上传播,污染父结构体。
6、局部变量与成员变量在编译器优化层面存在三方面本质差异:
(1)寄存器分配的难易度
局部变量:活跃范围通常不跨越函数调用(除非取地址),编译器能精确分析其使用点。许多局部变量可能从不进入内存,完全在寄存器中运算。
成员变量:属于某个对象,编译器必须通过this指针+偏移访问。即使this在寄存器中,每次读写成员变量仍需至少一次内存访问(除非编译器能证明该成员可以长期驻留寄存器——但别名分析通常不允许这种假设)。
(2)别名分析(Alias Analysis)的保守性
编译器必须保证:两次读取同一个变量,中间如果有未知的写入,则不能重用第一次读取的值。
局部变量:如果未取地址,编译器知道没有任何其他指针能指向它,可以大胆地将多次读取优化为寄存器复用。例如x = a + b; y = a * 2;中的a不会在中间被意外修改,可以一直驻留寄存器。
成员变量:其他代码可能通过其他指针或引用修改同一对象。编译器通常无法证明两个指针不指向同一对象(即"指针别名"问题),因此每次读取成员变量都可能需要重新从内存加载。
void foo(Point* p1, Point* p2) {
p1->x = 10;
p2->x = 20; // 若 p1 == p2,则 p1->x 被修改
// 编译器无法确定 p1 和 p2 是否指向同一对象
int v = p1->x; // 必须重新从内存读取
}
(3)跨函数调用的行为
局部变量(未取地址):编译器知道被调用函数没有该变量的地址,因此局部变量可以安全地跨函数调用保持在寄存器中。
成员变量:编译器通常假定任何外部函数都可能修改对象的任意成员(除非有const成员函数标记或严格别名规则)。调用一个普通函数后,成员变量的缓存值被丢弃,需重新从内存加载。
struct S { int x; };
void use(S* s) {
s->x = 1;
some_func(); // 编译器不知道 some_func 是否会修改 s->x
int y = s->x; // 必须重新从内存读取 s->x
}
7、编译器需要将变量映射到寄存器。变量的活跃范围(Live Range)——即从定义到最后一次使用的代码范围——越长,该变量占用寄存器的时间就越长。
长生命周期的代价:变量长期占用一个寄存器,当编译器遇到其他变量时可能寄存器不够用,被迫进行寄存器Spill。
贴近使用的效果:变量在真正需要前才定义,用完后活跃状态立即结束。短生命周期的变量可能直接使用临时寄存器,甚至被优化掉。
8、多级指针(如T**、Struct* -> member*)带来三方面问题:
(1)更多的访存延迟
每一级解引用本质上是一条Load指令,且这些Load之间存在数据依赖(前一条Load的结果是下一条Load的地址),延迟串行累加。
(2)破坏数据访问局部性
多级指针的各级地址可能散落在堆内存的不同位置,每一级解引用都可能触发一次D-Cache miss。而单级数组(连续内存布局)可以通过一个cacheline预取多个元素,局部性好得多。
(3)编译器优化困难
指针别名问题:编译器不确定指针的某一级内容是否会被其他写操作修改,每次解引用都必须从内存重新加载
跨函数调用:多级指针作为参数传递时,函数内部的解引用通常不会被优化掉
9、避免使用超大结构体
(1)缓存局部性差
D-Cache的cacheline大小为64字节。结构体过大时,访问不同成员可能落在不同的cacheline上,导致D-Cache miss增多。一般建议将结构体大小控制在64字节以内(1个cacheline),最大不超过128字节。
(2)寄存器溢出
编译器无法将超大结构体完全放入寄存器,必须分配到栈内存上。每次访问成员都生成Load/Store指令。
(3)指针传递引入别名问题
为避免拷贝开销,大结构体通常通过指针传递,但这引入了别名问题——编译器无法确定两个指针是否指向同一块内存,从而无法激进优化。
void compute(struct Huge *a, struct Huge *b) {
a->data[0] = 1;
b->data[0] = 2; // 如果 a == b,则上一条赋值被覆盖
int x = a->data[0]; // 编译器必须重新从内存读取(b可能别名a)
}


Welcome to ask questions and discuss with other members.
问题描述
ifa是pd分离场景中,decode阶段专用的attention算子,特点是q的s恒为1。ifa负载均衡方案,背景是input中有多个batch,batch会分到不同核上处理,每个batch中kvcache历史的token长度是不一样的,如果按照batch均分会导致核间负载不均衡。我们使用贪心算法,先根据每个batch中的历史token长度进行从大到小排序,然后每次取最长的batch的放入负载最小的核上处理,实现负载均衡,对比初始按batch均分的方案平均性能提升10%。
其他优化点softmax除sum放到最后而不是每一轮循环都除