gemm学习笔记
写在前面
经过了一个寒假,发现之前自己学习的gemm都快忘的差不多了,好记性还是不如烂笔头嘛,于是开个新专栏,记录下自己的学习
- 参考:github
初始化
使用的环境是之前构建的docker,首先ssh连接上电脑(ssh siyuan@dell),然后在vscode中找到该docker并选择attach to terminal,然后输入 service start ssh,此后便可通过ssh siyuan@docker进入此docker
使用
1 |
|
查看显卡的基本信息,得到信息如下:
1 |
|
gemm
我的一些问题
为什么使用列索引:
个人理解是:
cublas为了适配Fortran,默认使用了列优先存储 。在test.cu的实际调用cublasSgemm(err, CUBLAS_OP_N, CUBLAS_OP_N, m, n, k, &alpha, dA, m, dB, k, &beta, dC_ref, m);中,根据api可推断确实使用的是列优先。
当把 C / C++ 中行优先保存的二维数组 A 以一维形式输入 cublas 时,会被 cublas 理解为列优先存储。
为了保证和cublas的一致性,且避免行列的混乱,统一使用了列主序,从kernel1.cu中的#define A(i,j) A[(i) + (j)*lda] #define B(i,j) B[(i) + (j)*ldb] #define C(i,j) C[(i) + (j)*ldc]中也可推断出kernel中的矩阵是列优先的。通俗的计算逻辑:
我认为可以分为大子块和小子块两个部分,每个grid完成大子块的计算,通常是一个32*32的矩阵,在大子块中,每个线程负责一小块矩阵的计算,这在kernel4以前都是1*1,在kernel5变成了4*1,kernel6变成了4*4。
源代码分析
原始实现
1 |
|
宏定义
lda是A矩阵的行数(M),ldb是b矩阵的行数(K)
1
2
3#define A(i,j) A[(i) + (j)*lda]
#define B(i,j) B[(i) + (j)*ldb]
#define C(i,j) C[(i) + (j)*ldc]由于是列索引,存储是A[0,0]- A[1,0]-A[2,0]这样先存储完一整列,再存储下一列,因此这样的define可以正确定位
移位
1
2
3A = &A((bx<<5),0);
B = &B(0,(by<<5));
C = &C((bx<<5),(by<<5));每一个block负责32行A的子矩阵和32列B子矩阵的乘法,因此通过A = &A((bx<<5),0);
B = &B(0,(by<<5));直接更改A和B的地址。对于C矩阵,C = &C((bx<<5),(by<<5));也是直接定位到此block计算对应的子矩阵位置计算
1
2
3
4
5float tmp=0.;
for (int k_count = 0; k_count<K; k_count++){
tmp += A(tx, k_count) * B(k_count, ty);
}
C(tx,ty) = alpha * tmp + beta*C(tx,ty);在每个block的内部,32*32个thread,每个线程各负责一个子矩阵中元素的计算。宏观上讲,该block中的第(tx,ty)个线程负责C(bx*32+tx,by*32+ty)元素的计算。
实现全部计算。
引入共享内存
1 |
|
宏定义
1
2#define sa(i,j) sa[((i)<<5) + (j)]
#define sb(i,j) sb[((i)<<5) + (j)]完成共享内存的宏定义,表示sa和sb使用行优先存储,与ABC都不同!
共享内存
1
2__shared__ float sa[MS*KS];
__shared__ float sb[KS*NS];开了两块共享内存
内存分配
1
2
3for (int k_count = 0; k_count<K; k_count+=KS){
sa(tx,ty)=A(tx,ty);
sb(ty,tx)=B(tx,ty);}此部分的分配主要为了方便后续计算,
tmp += sa(tx,inner_k_count) * sb(ty,inner_k_count)
能够按存储顺序读取sa和sb元素,减小读取的延迟。
后续的计算与kernel1保持一致。
省一个寄存器
1 |
|
与上一版本相比,区别仅仅在于使用int tx = threadIdx.x;int row = tx&31, col = tx>>5;
说是省了一个寄存器,但是row和col变量的引入可能又占用了别的内存,具体效果有待用nsight验证。
另外sb4处的col、row指代的是B中的row和col,在后续sb4(col,inner_k_count)
部分需要注意区分以免引起歧义
Memory coalescing
Memory coalescing on shared memory 是并行计算(如 GPU 编程)中的一种优化技术,主要目的是提高内存访问效率,尤其在处理共享内存时。在 GPU 等并行计算架构中,多个线程同时访问共享内存。如果这些访问能够合并为更少的内存事务,就能显著提升性能。
在kernel2和3中,就已经正确的使用了Memory coalescing,此部分的代码只是为了展示没有完成内存对齐的效果如何。
1 |
|
使用micro kernel提升计算强度(Arithmetic Intensity)
1 |
|
此kernel与前几版相比,区别在于之前的每个thread都计算C矩阵中1个元素的值,现在的thread需要计算4个,以此提高了计算强度。
具体到代码实现上,区别主要在于:
row的确定
1
int row1 = (tx&7)<<2, row2 = row1+1, row3 = row1+2, row4 = row1+3, col = tx>>3;
此部分需要重点理解的是
- tx&7:取 tx 的低 3 位(tx % 8),用于确定线程在 8x8 的子块中的行偏移。
- (tx&7)<<2:将行偏移乘以 4,得到 row1,表示线程负责的起始行。
- row2, row3, row4:线程负责的另外 3 行(row1 + 1, row1 + 2, row1 + 3)。
- tx>>3:将 tx 右移 3 位(tx / 8),用于确定线程在 8x8 子块中的列偏移,赋值给 col。
在完成此操作后,row1的值可能为0,4,8,12…28,row2为1,5…29,以此覆盖32行,对于列由于tx的范围为0…256,故col的范围为0…32。
在此条件下,以tx=133为例,row1=(133%8)*4=20,col=(133/8)=16,因此tx=133的线程负责小矩阵块中(20,16)、(21,16)…(23,16)四个元素的计算
变量定义
1
float Cres[4] = {0., 0., 0., 0.};
原本的C变成了Cres,配合后面的
1
2
3
4
5Cres[0] += sa5(row1,inner_k_count) * b00;
Cres[1] += sa5(row2,inner_k_count) * b00;
Cres[2] += sa5(row3,inner_k_count) * b00;
Cres[3] += sa5(row4,inner_k_count) * b00;
}完成了单个线程的计算
共享内存赋值
1
2
3
4
5
6
7
8
9sa5(row1,col)=A(row1,col);
sa5(row2,col)=A(row2,col);
sa5(row3,col)=A(row3,col);
sa5(row4,col)=A(row4,col);
sb5(col,row1)=B(row1,col);
sb5(col,row2)=B(row2,col);
sb5(col,row3)=B(row3,col);
sb5(col,row4)=B(row4,col);这块没什么特别的,只是把之前的赋值展开成了四个,后续计算同理
向量化存储/读取
好处:
- 内存带宽利用率高:一次性加载或存储4个浮点数,减少了内存访问次数,提高了内存带宽的利用率。
- 并行性增强:GPU的SIMD(单指令多数据)架构适合处理向量化数据,float4 可以更好地利用硬件的并行计算能力。
- 减少指令开销:相比于逐个加载或存储浮点数,向量化操作可以减少指令的数量,从而降低指令开销。
float4定义:
1 |
|
1 |
|
与之前的核的区别在于使用了float4 Av, Bv, Cv, Cres;
来向量化计算,核心代码:
1 |
|
对于sa6,直接按照4个一组的顺序把A矩阵存入,sb则进行了转置,此处逻辑较为混乱,但整体思想很好理解,后续对Cres的计算更是非常简单。
增加线程工作量
1 |
|
DeepSeek版解析:
核心思想:增加每个线程的工作量
背景:GPU 线程块(TB)的寄存器资源充足(假设 64K),而每个 TB 仅分配了 256 个线程。此时单个线程的寄存器使用量较低,存在资源浪费。
优化策略:让每个线程计算更大的子矩阵(例如 4×4 的 C 子矩阵)。
优势:
寄存器级数据重用:每个线程在寄存器中保留更多中间计算结果,减少对共享内存或全局内存的访问次数。
减少内存延迟:寄存器访问速度远快于共享内存和全局内存,从而降低计算延迟。
提高计算强度(Arithmetic Intensity):每个线程执行更多计算操作,减少内存访问与计算的比率。参数调整:Ms、Ns 和 Ks
原始参数:Ms = 32, Ns = 32, Ks = 32。
新参数:Ms = 64, Ns = 64, Ks = 16。
目标:
保持共享内存(Shared Memory)的总消耗不变。
通过增大 Ms 和 Ns,让每个线程块处理更大的数据块。
通过减少 Ks,抵消 Ms 和 Ns 增大带来的共享内存压力。
共享内存计算共享内存占用公式:Shared Memory Size = Ms × Ks + Ns × Ks。
原始值:32×32 + 32×32 = 2048 单位(例如字节)。
新值:64×16 + 64×16 = 2048 单位(保持不变)。
意义:在共享内存资源受限的情况下,通过调整 Ks,允许每个线程块处理更大的输入矩阵块(Ms 和 Ns 增大)。性能优势分析
(1) 输入矩阵较大时的优势
更多线程块(TBs)映射到流式多处理器(SMs):
增大 Ms 和 Ns 后,每个线程块处理的数据量增加,但 GPU 的 SMs 仍然可以调度足够的线程块来覆盖整个输入矩阵。
例如:若输入矩阵尺寸为 4096×4096,增大 Ms 和 Ns 可以减少所需的线程块总数,同时保持高并行度。
减少全局内存访问:
每个线程块处理更多数据,减少了从全局内存加载数据的频率(数据局部性更好)。
(2) 线程级优化更高的寄存器利用率:
每个线程计算更大的子矩阵(如 4×4),充分利用寄存器资源,减少共享内存访问。
减少线程同步开销:
每个线程完成更多独立计算,减少线程间同步(如 __syncthreads())的频率。适用场景
大规模矩阵乘法:当输入矩阵足够大时,参数调整的优化效果更明显。
共享内存受限的 GPU 架构:通过平衡 Ms、Ns 和 Ks,避免共享内存成为瓶颈。总结
这段描述的核心是通过以下策略优化 GPU 矩阵乘法:
增加单个线程的计算量(4×4 子矩阵),利用寄存器重用数据,减少内存访问。
调整分块参数(Ms、Ns、Ks),在共享内存资源不变的前提下,扩大每个线程块处理的数据规模。
提升计算强度,更好地利用 GPU 的并行计算能力,尤其适用于大规模矩阵运算。
这种优化方法在 计算密集型任务(如深度学习、科学计算)中非常典型,其核心是平衡内存带宽与计算资源,最大化硬件利用率。
事实上,根据一开始代码运行出来的结果:Maximum number of registers per block: 64 K,每个block确实有64k寄存器,因此确实可以增大每个线程的计算量。
Deepseek版解析:
共享内存布局
sa7 的布局:
sa7 是一个64行×16列的共享内存数组,用于缓存矩阵A的当前分块数据。
每个线程负责将A的4×1(4行1列)元素加载到sa7中。
由于每个线程块有256个线程,每个线程处理A的一列中的4个元素,因此可以完整加载A的64行×16列分块。sb7 的布局:
sb7 是一个16行×64列的共享内存数组,但以转置形式存储为64行×16列。
每个线程负责将B的1×4(1行4列)元素加载到sb7中。
由于每个线程块有256个线程,每个线程处理B的一列中的4个元素,因此可以完整加载B的16行×64列分块(转置后为64行×16列)。大循环:加载数据到共享内存
大循环的作用:
沿K维度分块处理,每次处理16个K维度元素(KS_7=16)。
在每个大循环迭代中,将A的64行×16列分块和B的16行×64列分块加载到共享内存。数据加载逻辑:
每个线程根据tx(线程索引)确定自己负责加载A和B的哪部分数据。
对于A:
row_a = (tx & 15) << 2:每个线程负责A的4行。
col_a = tx >> 4:每个线程负责A的某一列。
加载A的4×1元素到sa7。对于B:
row_b = (tx & 3) << 2:每个线程负责B的4列。
col_b = tx >> 2:每个线程负责B的某一行。
加载B的1×4元素并转置存储到sb7。更新全局内存指针:
A += lda16:A按列主序,每次移动16列。
B += 16:B按行主序,每次移动16行。
为下一次大循环加载新的分块数据做准备。线程同步:
__syncthreads():确保所有线程完成共享内存的写入,避免数据竞争。小循环:乘累加计算
小循环的作用:
对当前K分块(16次迭代)进行乘累加计算。
每个线程从共享内存加载A的4×1元素和B的1×4元素,进行外积计算,累加到4×4的局部结果Cres中。计算逻辑:
从sa7加载A的4×1元素(行向量):1
vload(Av, &sa7(row_a, inner_k_count));
从sb7加载B的1×4元素(列向量,已转置为行):
1
vload(Bv, &sb7(col_c, inner_k_count));
进行外积计算并累加到Cres:
1
2
3
4vscal(Cres[0], Av, Bv.x); // Cres[0] += Av * Bv.x
vscal(Cres[1], Av, Bv.y); // Cres[1] += Av * Bv.y
vscal(Cres[2], Av, Bv.z); // Cres[2] += Av * Bv.z
vscal(Cres[3], Av, Bv.w); // Cres[3] += Av * Bv.w每个线程负责计算C中一个4×4的子块。
结果导出
结果累加:
将局部结果Cres与C的当前值结合(alpha * Cres + beta * C)。写回全局内存:
使用float4将4×4结果写回C的对应位置,保持合并写入。总结
sa7 和 sb7 的布局:
sa7 是64行×16列,缓存A的分块数据。
sb7 是16行×64列,但以转置形式存储为64行×16列,缓存B的分块数据。大循环:
根据tx确定索引,将A的64行×16列和B的16行×64列加载到共享内存。小循环:
依次计算4行×1列的A和1行×4列的B的外积,得到4行×4列的结果,并累加到Cres。结果导出:
将Cres的结果写回全局内存C。
1 |
|
个人认为这段代码初看还是有一定难度的:
- row_a:通过(tx & 15) << 2计算线程在A子块中的起始行。取tx的低4位(0-15),乘以4得到0,4,8,…,60。每个线程负责加载A的4行数据。
- col_a:通过tx >> 4计算线程在A子块中的列。将tx右移4位,得到0-15的列索引。每个线程处理A的某一列。
- row_b:通过(tx & 3) << 2计算线程在B子块中的起始行。取tx的低2位(0-3),乘以4得到0,4,8,12。每个线程加载B的4行数据。
- col_b:通过tx >> 2计算线程在B子块中的列。将tx右移2位,得到0-63的列索引。每个线程处理B的某一列。
对于256个线程的线程块,就是依次处理A矩阵的(1-4行,第1列)、(5-8行,第1列)…(60-64行,第1列)然后(1-4行,第2列)和B矩阵的(1-4…12-16行,1-64列)。即把64行16列的A的子矩阵和16行64列的B的子矩阵导入共享内存。
然后A = &A((bx<<6),0); B = &B(0,(by<<6)); C = &C((bx<<6),(by<<6));
就很好理解了,表示A往下移64行,B移动64列。
接下来的就是大循环,先把A和B中的子矩阵移入sa7和sb7,然后进入小循环,小循环中4*1的小矩阵和1*4的小矩阵相乘,形成4*4的矩阵存入Cres中,最后把Cres的值导入C,完成计算。
继续增大线程工作量
1 |
|
本质和上一个写法没什么差别,不过是计算强度更强了:
- 原共享内存的64*16变成了128*8
- 原本是4*1的小矩阵和1*4的小矩阵相乘,现在变成了4*2和2*4的小矩阵相乘
- 现在每个线程块负责128*128个元素的C的子矩阵
添加warp-level tiling和 warp-level parallelism
1 |
|
前面的宏定义以及后面的共享内存赋值和计算部分和上一个核没有任何区别,主要的区别在于一开始的索引计算部分
kernel8的写法为:
1 |
|
而kernel9的写法为:
1 |
|
看了下知乎上的内存访问介绍,这篇文章讲bank conflict中的4byte和8byte两种情况讲的非常好
贴一段deepseek对这部分的解析:
这段代码实现了一个优化的矩阵乘法核函数(SGEMM),利用共享内存和线程束(warp)的合并访问特性,以提高计算效率。以下是代码的关键点解析:
线程与块的配置
- 线程块(Block):每个线程块包含256个线程(threadIdx.x范围0-255)。
网格划分:网格中的块(blockIdx.x和blockIdx.y)对应输出矩阵C的划分,每个块负责计算C的一个128x128子块。 - Warp划分:每个线程块分为8个warp(256线程/32线程每warp)。通过warp_id = tx >> 5计算warp索引,lane_id = tx & 31确定线程在warp内的位置。
- 线程块(Block):每个线程块包含256个线程(threadIdx.x范围0-255)。
内存访问优化
- 共享内存(Shared Memory):声明sa9和sb9各1024个float,用于缓存全局内存中的A和B矩阵子块,减少重复访问全局内存的开销。
- 合并访问:同一warp内的线程访问共享内存时,通过{Mw, Nw} = {4xMr, 8xNr}的布局(如4x8的子块),使得内存访问可合并,提高带宽利用率。
索引计算
- C矩阵的起始位置:每个线程块处理C的128x128子块,起始位置由块索引bx和by确定:
C = &C[(bx << 7) + (by << 7) * ldc]; // 每个块处理128x128的C子块 - 线程在C子块中的坐标:
row_c和col_c通过warp内部分解(warp_row, warp_col, row_w, col_w)计算,每个warp负责C子块内的多个8x8区域。
例如,row_c = (warp_row << 5) + (row_w << 3),表示warp在行方向划分成4部分(每部分32行),每个线程进一步处理8行的数据。
- C矩阵的起始位置:每个线程块处理C的128x128子块,起始位置由块索引bx和by确定:
全局内存到共享内存的加载
- A和B的指针调整:全局内存中的A和B矩阵指针被偏移到当前块处理的起始位置:
A = &A[(bx << 7)]; // A的起始行调整为bx128
B = &B[(by << 7) * ldb]; // B的起始列调整为by128 - 共享内存布局:线程通过row_a, col_a, row_b, col_b等索引,将A和B的数据加载到共享内存。例如:
row_a = (tx & 31) << 2表示每个线程加载A矩阵中连续的4行。
col_b = tx >> 1结合row_b = (tx & 1) << 2,用于B矩阵的列优先布局,减少共享内存的bank冲突。
- A和B的指针调整:全局内存中的A和B矩阵指针被偏移到当前块处理的起始位置:
计算逻辑
- 分块乘累加:虽然代码未展示完整计算循环,但典型SGEMM优化会分块加载A和B的子块到共享内存,进行局部乘累加。每个线程从共享内存读取数据,计算局部结果,并累加到C中。
- Warp级并行:通过将线程划分为warp,并设计共享内存访问模式(如4x8子块),利用warp内32线程的SIMD并行性,同时合并内存访问。
性能优化
- 减少Bank冲突:共享内存的布局设计(如row_b和col_b)确保同一warp内的线程访问不同bank,避免冲突。
- 寄存器重用:通过循环展开和寄存器缓存中间结果,减少共享内存访问次数。
总结
这段代码通过精细的线程划分、共享内存布局优化和索引计算,实现了高效的矩阵乘法。每个线程块处理128x128的C子块,利用共享内存缓存数据,减少全局内存访问,并通过warp级并行和合并访问提升性能。设计核心在于平衡线程工作量、内存访问模式与并行度,充分发挥GPU的计算能力。