Files
project_6/upstream_ref/nvidia_sgemm_practice/README.md
dylan 284804ac53 data: cat 3 SGEMM repos — siboehm, wangzyon, edtallison (full clone, no --depth)
Sources:
  siboehm/SGEMM_CUDA        → upstream_ref/sgemm_siboehm/     (25 files)
  wangzyon/NVIDIA_SGEMM_PRACTICE → upstream_ref/nvidia_sgemm_practice/ (23 files, filled gaps)
  edtallison/sgemm-cuda      → upstream_ref/sgemm_edtallison/  (41 files)

All files cat'd one by one from git clone (no --depth).
These are the 3 public SGEMM repos that can compile on CUDA 10.2 + CoreX ivcore10.

Key files for BI-V100 porting:
  kernel 10 (warp tiling) — already proven on device with WARPSIZE=64
  kernel 11/12 (double buffering) — next optimization target
  sgemm.cu + runner.cu — complete build+benchmark harness
  CMakeLists.txt — build system reference
2026-08-15 06:58:07 +00:00

432 lines
19 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

![](images/head.png)
![](https://img.shields.io/badge/build-passing-brightgreen) ![](https://img.shields.io/badge/ubuntu-18.04-blue) ![](https://img.shields.io/badge/cuda-10.2-blue) ![](https://img.shields.io/badge/nvidia-RTX3090-blue) ![](https://img.shields.io/badge/cmake-3.21-blue)
# 概述
面向NVIDIA GPU使用CUDA编程逐步优化矩阵乘法运算性能
| 核函数 | 描述 | GFLOPS | 自定义核函数/CUBLAS% |
| -------- | ----------------------- | -------- | ------------------------ |
| CUBLAS | 官方库函数 | 14448.69 | 基准 |
| kernel_1 | 朴素实现 | 2262.168 | 15.65657 |
| kernel_2 | 共享内存缓存 | 4216.536 | 29.18283 |
| kernel_3 | 一维Thread Tile并行优化 | 7809.629 | 54.05078 |
| kernel_4 | 二维Thread Tile并行优化 | 12251.3 | 84.79179 |
| kernel_5 | 寄存器缓存 | 12177.95 | 84.28412 |
| kernel_6 | FLOAT4向量访存 | 13161.49 | 91.09125 |
| kernel_7 | 双缓存预取 | 13634.98 | 94.36832 |
> NVIDIA GeForce RTX 3090矩阵尺寸5120
# 配置
- 编译采用 `gcc 7.5.0` under Ubuntu 18.04.5 LTS
- NVIDIA CUDA version: `CUDA 10.2`
# 目录
```
NVIDIA_SGEMM_PRACTICE # 根目录
├── images # 图片结果
│ ├── describe_kernel_1.png
│ ├── describe_kernel_x.png
│ └── kernel_x_vs_y.png
├── test # 测试结果
│ ├── test_kernel_0.txt
│ ├── test_kernel_1.txt
│ └── test_kernel_x.txt
└── src # 源文件
│ ├── kernel
│ │ ├── kernel_1.cuh # 声明和定义
│ │ ├── kernel_2.cuh
│ │ └── kernel_x.cuh
│ ├── kernel.cuh
│ ├── utils.cuh # 辅助函数
│ └── utils.cu
├── plot.py # 根据test结果绘图
├── run.sh # 运行编译后可执行文件
├── sgemm.cu # 主程序
└── CMakeLists.txt # 编译相关
```
# 运行
1. 配置NVCC编译参数
> 在CMakeLists.txt中修改`set(CUDA_NVCC_FLAGS -arch=compute_70;-code=compute_70)`
2. 配置矩阵计算最大尺寸
> 在`sgemm.cu:16`中修改`size_len`建议初次运行设置为16过大尺寸可能导致电源超负荷主机重启
3. 编译
`cd build && cmake .. && make`
4. 运行run.sh统计各个核函数计算效率结果保存在test目录
5. 计算效率折线绘图
> `python plot.py 0 1`表示绘制CUBLAS和kernel_1计算效率对比图
# 逐步优化
## kernel 1
**Naive基础版矩阵乘法实现**
将每个逻辑线程与矩阵C的每一个元素相对应每个线程负责C中一个元素的计算
![](./images/describe_kernel_1.png)
```cpp
__global__ __launch_bounds__(1024) void
mysgemm_v1(int M, int N, int K, float alpha, float *A, float *B, float beta, float *C) {
int gx = blockIdx.x * blockDim.x + threadIdx.x; // 全局x
int gy = blockIdx.y * blockDim.y + threadIdx.y; // 全局y
float tmp = 0.;
for (int i = 0; i < K; i++) {
tmp += A[gy * K + i] * B[i * N + gx]; // 两次全局内存访问和一次FMA累加乘
}
C[gy * N + gx] = alpha * tmp + beta * C[gy * N + gx];
}
```
![](./images/kernel_culas_vs_1.png)
未经过优化的矩阵乘法性能不足CUBLAS的1/10具体分析如下
- 计算访存比每次迭代需要进行一次FMA乘累加和两次全局内存读取计算访存比1/2
- 访存量访问全局内存C矩阵每个元素计算需要访问`2K`个单精度浮点数,完成全部计算需要` 2*K*M*N`
全局内存访问延迟高几百cycle同时相同位置元素被重复读取C中同一行元素计算共享A中同一行元素C中同一列元素计算共享B中同一列元素另一方面较低的计算访存比无法有效隐藏访存延迟因此访存延迟和计算访存比是导致kernel 1效率低下的原因。
## kernel 2
**利用共享内存缓存减少全局内存访存量和访存延迟**
访存延迟来自于全局内存的高延迟和全局内存的重复访问。共享内存是片上内存具有较低的访存延迟几十cycle,使用共享内存进行缓存可降低访存延迟;
![](./images/describe_kernel_2.png)
> BM和BN表示block tile的高和宽BK表示待缓存的全局内存的步长即一个block的计算需要缓存K/BK次
共享内存缓存全局内存A tile和B tile完成C block中所有元素的FMA计算不断滑动缓存区域更新block
```cpp
/*
dim3 blockDim(1024);
dim3 gridDim(CEIL_DIV(M, 32), CEIL_DIV(N, 32));
mysgemm_v2<32><<<gridDim, blockDim>>>(M, N, K, alpha, A, B, beta, C);
*/
template<const int BLOCK_SIZE>
__global__ void mysgemm_v2(int M, int N, int K, float alpha, float *A, float *B, float beta, float *C) {
int bx = blockIdx.x;
int by = blockIdx.y;
const int BM = BLOCK_SIZE;
const int BN = BLOCK_SIZE;
const int BK = BLOCK_SIZE;
int tx = threadIdx.x % BN;
int ty = threadIdx.x / BN;
// 申请共享内存空间
__shared__ float As[BM * BK];
__shared__ float Bs[BK * BN];
// 移动到当前block
A = &A[by * BM * K];
B = &B[bx * BN];
C = &C[by * BM * N + bx * BN];
float tmp = 0.;
for (int k = 0; k < K; k += BK) {
// 缓存A_tile和B_tile
As[ty * BK + tx] = A[ty * K + tx];
Bs[ty * BN + tx] = B[ty * N + tx];
// 同步所有线程缓存完成
__syncthreads();
A += BK;
B += BK * N;
for (int i = 0; i < BK; i++) {
tmp += As[ty * BK + i] * Bs[i * BN + tx];
}
// FMA计算需要读取缓存数据在新一轮写入缓存前进行同步确保所有线程计算完成
__syncthreads();
}
C[ty * N + tx] = alpha * tmp + beta * C[ty * N + tx];
}
```
![](./images/kernel_1_vs_2.png)
- 访存量每个block需要从global memory中读取`(K/BK)*(BM*BK+BK*BN)`个单精度浮点数整个C存在`(M/BM)*(N/BN)`个block因此完成C中所有元素计算需要读取`(M/BM)*(N/BN)*(K/BK)*(BM*BK+BK*BN)`个单精度浮点数
kernel 1受限于全局内存的访存延迟和重复访问优化前全局访存量为`2*K*M*N`,共享内存缓存优化后,访存量减少为原来的`1/2*(1/BN)*(1/BM)`,当`BN=BM=32`访存减少至1/32另一方面shared memory访存延迟远低于全局内存因此计算效率得到了一定程度的提升。
## kernel 3
**利用一维thread tile优化**
已知可以通过增加block大小BMBN进一步降低全局内存的访问量因此将BM和BN从32提升至64
> **是否能通过无限增加block size降低全局访存**
>
> 不能一方面block分块矩阵尺寸过大block数量减少这样会造成大量 SMStreaming Multiprocessor的闲置浪费另一方面BN和BM的增加需要申请更多的共享内存单线程内共享内存占用越多活跃线程束越少不利于隐藏指令延迟
因此在增加BM和BN值的同时为了减少共享内存占用一方面减小BK值降低为8
> 当增加block size时应尤其注意共享内存的消耗限制共享内存尺寸和block中线程的数量避免因资源不足无法启动核函数
![](./images/describe_kernel_3_1.png)
另一方面通过共享内存缓存减少了全局内存访存量和FMA乘累加的访存延迟但计算访存比没有得到改善每次迭代计算都需要两个访存指令和一个计算指令因此引入thread tile即一个线程负责block中多个元素的计算TM和TN分别表示thread tile的高和宽。
![](./images/describe_kernel_3_2.png)
```cpp
/*
dim3 blockDim(512);
dim3 gridDim(CEIL_DIV(M, 64), CEIL_DIV(N, 64));
mysgemm_v3<64, 64, 8, 8><<<gridDim, blockDim>>>(M, N, K, alpha, A, B, beta, C);
*/
template<const int BM,
const int BN,
const int BK,
const int TM>
__global__ void mysgemm_v3(int M, int N, int K, float alpha, float *A, float *B, float beta, float *C) {
int bx = blockIdx.x;
int by = blockIdx.y;
int thread_num = BM * BN / TM; // 一个线程负责block中计算TM个元素
int tx = threadIdx.x % BN;
int ty = threadIdx.x / BN * TM;
__shared__ float As[BM * BK];
__shared__ float Bs[BK * BN];
// 移动到当前block
A = &A[by * BM * K];
B = &B[bx * BN];
C = &C[by * BM * N + bx * BN];
/*
当前线程负责搬运全局内存中第a_tile_row行第a_tile_col列元素至共享内存第a_tile_row行第a_tile_col列
a_tile_stride表示block中线程可搬运a_tile_stride行至共享内存
若BM=64,BK=8,thread_num=512,则a_tile_stride=64,a_tile_stride=BM表示每个线程搬运一轮即可完成所需元素的搬运;
若BM=128,BK=8,thread_num=512,则a_tile_stride=64,表示每个线程搬运两轮即可完成所需元素的搬运;
*/
int a_tile_row = threadIdx.x / BK;
int a_tile_col = threadIdx.x % BK;
int a_tile_stride = thread_num / BK;
int b_tile_row = threadIdx.x / BN;
int b_tile_col = threadIdx.x % BN;
int b_tile_stride = thread_num / BN;
float tmp[TM + 1] = {0.}; // 每个线程负责TM个元素则需要申请TM个寄存器保存累加值额外的一个寄存器用于缓存
#pragma unroll
for (int k = 0; k < K; k += BK) {
#pragma unroll
for (int i = 0; i < BM; i += a_tile_stride) {
As[(a_tile_row + i) * BK + a_tile_col] = A[(a_tile_row + i) * K + a_tile_col];
}
#pragma unroll
for (int i = 0; i < BK; i += b_tile_stride) {
Bs[(b_tile_row + i) * BN + b_tile_col] = B[(b_tile_row + i) * N + b_tile_col];
}
__syncthreads();
A += BK;
B += BK * N;
#pragma unroll
for (int i = 0; i < BK; i++) {
tmp[TM] = Bs[tx + i * BN]; // 额外的一个寄存器避免反复从共享内存中读取Bs[tx + i * BN]
#pragma unroll // 循环展开,增加指令并行度
for (int j = 0; j < TM; j++) {
tmp[j] += As[(ty + j) * BK + i] * tmp[TM];
}
}
__syncthreads();
}
#pragma unroll
for (int j = 0; j < TM; j++) {
C[(ty + j) * N + tx] = alpha * tmp[j] + beta * C[(ty + j) * N + tx];
}
}
```
![](./images/kernel_2_vs_3.png)
本例从两方面进行优化:
- 全局内存访存量:相比于初始版本,通过对`64*64`block size进行缓存访存量降至1/64
- 计算访存比引入thread tile利用单个线程负责多个元素计算增加计算访存比当TM=8时每执行共享内存As的8个次访存指令和共享内存Bs的1个访存指令可执行8次计算指令相比初始版本的计算访存比1:2提高至8:9有效隐藏访存延迟
通过本例的两方面优化,矩阵乘法计算效率显著提高近一倍;
## kernel 4
**利用二维thread tile优化**
将thread tile设置为二维即一个线程负责一小块元素的计算从而进一步增加block尺寸减少全局访存数量
> 增加thread tile尺寸可以在相同的线程数量或更少的线程数量下计算更大的block size;
更重要的是单线程负责计算更多的C元素区域可以增加指令级并行程度
> 为什么可以提高指令并行程度?
>
> 单线程处理的指令数量越多,流水线级越长,由于单线程流水线可并行处理多条指令,虽然单条指令执行变慢,但单位时间内处理的指令数量变多,提高了吞吐量,隐藏指令延迟;指令级并发相比与线程级并发更具优势。
![](./images/describe_kernel_4.png)
设置一个线程负责8×8区域内元素计算即thread tile=8×8TM=8,TN=8
```cpp
// BM=BN=128BK=8TM=TN=8共享内存大小128*8
dim3 blockDim(256);
dim3 gridDim(CEIL_DIV(M, 128), CEIL_DIV(N, 128));
mysgemm_v4<128, 128, 8, 8, 8><<<gridDim, blockDim>>>(M, N, K, alpha, A, B, beta, C);
int a_tile_row = threadIdx.x / BK;
int a_tile_col = threadIdx.x % BK;
int a_tile_stride = thread_num / BK; // 128*8/256=4需要所有线程搬运4轮可将全局内存中128*8大小区域搬运至共享内存
int b_tile_row = threadIdx.x / BN;
int b_tile_col = threadIdx.x % BN;
int b_tile_stride = thread_num / BN;
// 每个线程负责TM*TN个元素则需要申请TM*TN个寄存器保存累加值
float tmp[TM][TN] = {0.};
// 单个线程循环TMTN完成thread tile内元素的乘累加
for (int j = 0; j < TM; j++) {
for (int l = 0; l < TN; l++)
tmp[j][l] += As[(ty + j) * BK + i] * Bs[tx + l + i * BN];
}
```
全局访存量:相比未引入共享内存缓存版本,全局内存访存量减少至`1/2*(1/BM+1/BN)=1/128`,访存量显著降低。
![](./images/kernel_3_vs_4.png)
实际测试发现相比与一维thread tile由于二维thread tile进一步降低了全局访存量、提升计算访存比矩阵乘法效率显著提升一倍。
## kernel 5
**寄存器缓存共享内存**
![](./images/describe_kernel_5.png)
由下方代码可知单个线程计算thread tile元素乘累加时共享内存会被重复访问。
```cpp
for (int j = 0; j < TM; j++) {
for (int l = 0; l < TN; l++)
tmp[j][l] += As[(ty + j) * BK + i] * Bs[tx + l + i * BN]; //内层循环中 As[(ty + j) * BK + i] 重复访问TN次
}
```
共享内存相比全局内存能够大大减少访存延迟但共享内存延迟几十cycle相比于计算延迟几cycle仍然较大因此采用寄存器对共享内存As、Bs进行缓存避免共享内存的重复访问
```cpp
float a_frag[TM] = {0.};
float b_frag[TN] = {0.};
for (int i = 0; i < BK; i++) {
for (int j = 0; j < TM; j++) {
a_frag[j] = As[(ty + j) * BK + i]; // 采用a_frag寄存器数组缓存thread tile所需的As共享内存数据
}
for (int l = 0; l < TN; l++) {
b_frag[l] = Bs[tx + l + i * BN]; // 采用b_frag寄存器数组缓存thread tile所需的Bs共享内存数据
}
for (int j = 0; j < TM; j++) {
for (int l = 0; l < TN; l++)
tmp[j][l] += a_frag[j] * b_frag[l];
}
}
```
当TM=TN=8时经过寄存器缓存每个thread tile需要执行8个As共享内存访存指令和8个Bs共享内存访存指令可进行8×8=64个计算指令计算访存比相比于初始版本的1/2提升至64:16可有效隐藏访存延迟
![](./images/kernel_4_vs_5.png)
实际测试发现,经寄存器缓存实际性能并未发生明显变化,原因可能是当前性能瓶颈并非共享内存的重复访问;
## kernel 6
**向量内存指令FLOAT4优化**
- 计算指令GPU是以4维向量为基本单位进行计算的4个浮点数组成的float4向量是GPU最基本的类型使用GPU对两个float4进行向量计算与对两个整数或两个浮点数进行计算一样只需要一个指令即可完成
- 内存指令:与发出单个指令生成单独的内存事务获取相同数量的字节相比,通过向量内存指令所需的内存事务更少,减少了内存控制器的争用;另一方面,使用矢量加载每个字节需要更少的索引计算;
![](./images/describe_kernel_6.png)
例如BM=128BK=8线程数量为256若每个线程每次取1个浮点数每个线程需要消耗4次内存指令才能将全局内存搬运至共享内存若采用float4向量内存指令每个线程每次可以搬运4个浮点数则每个线程仅需要执行一次内存指令即可完成搬运。
关键代码示例如下:
```cpp
#define OFFSET(row, col, ld) ((row)*(ld)+(col))
#define FETCH_FLOAT4(pointer) (reinterpret_cast<float4*>(&(pointer))[0])
float ldg_a_reg[4 * ldg_a_num] = {0.}; // 每个线程搬运ldg_a_num轮寄存器缓存ldg_a_num个float4元素用于转置As矩阵
// 共享内存缓存全局内存
for (int i = 0; i < BM; i += a_tile_stride) {
int ldg_index = i / a_tile_stride * 4; // 第ldg_index轮
FETCH_FLOAT4(ldg_a_reg[ldg_index]) =
FETCH_FLOAT4(A[OFFSET(a_tile_row + i, a_tile_col, K)]);
// As转置存其中ldg_a_reg做中间缓存目的是读取时可以按FLOAT4读取
As[OFFSET(a_tile_col, i + a_tile_row, BM)] = ldg_a_reg[ldg_index];
As[OFFSET(a_tile_col + 1, i + a_tile_row, BM)] = ldg_a_reg[ldg_index + 1];
As[OFFSET(a_tile_col + 2, i + a_tile_row, BM)] = ldg_a_reg[ldg_index + 2];
As[OFFSET(a_tile_col + 3, i + a_tile_row, BM)] = ldg_a_reg[ldg_index + 3];
}
for (int i = 0; i < BK; i += b_tile_stride) {
FETCH_FLOAT4(Bs[OFFSET(b_tile_row + i, b_tile_col, BN)]) =
FETCH_FLOAT4(B[OFFSET(b_tile_row + i, b_tile_col, N)]); // 不需要转置
}
// 寄存器缓存共享内存
// ty,tx为当前线程对应thread tile的左上角元素在block中的位置
#pragma unroll
for (int m = 0; m < TM; m += 4) {
FETCH_FLOAT4(a_frag[m]) = FETCH_FLOAT4(As[OFFSET(i, ty + m, BM)]); // 偏移到当前thread tile
}
#pragma unroll
for (int n = 0; n < TN; n += 4) {
FETCH_FLOAT4(b_frag[n]) = FETCH_FLOAT4(Bs[OFFSET(i, tx + n, BN)]); // 偏移到当前thread tile
}
```
全局内存无法直接写入共享内存需要寄存器做中介其中As写入将全局内存->将寄存器->共享内存过程显示的描述出来而Bs写入并不是不需要寄存器参与只是编译器隐藏了这段代码As缓存显示运用寄存器的目的在于将As进行转置转置前的一列在转置后变成一行内存连续便于float4读取
![kernel_1](./images/kernel_5_vs_6.png)
实际测试,整体计算效率增加;
## kernel 7
**数据预取**
单缓存是指申请单块共享内存,缓存全局数据,申请单块寄存器内存,缓存共享数据,单块缓存不能实现读取和存储并行进行,因为数据之间存在依赖。例如单缓存场景,计算依赖共享内存数据,为保证计算前全局内存完全存入共享内存,需要进行一次同步;同样因为计算依赖共享内存数据,所以在存新一轮全局内存到共享内存前也需要进行一次同步,保证上一轮计算完成。
双缓存通过申请双倍存储空间,将读和写分开,计算数据读取一块存储空间同时,可以同时向另一块内存写入下一轮依赖的数据,因此,只需要保证计算前待读取共享内存完成写入,即一次同步即可。
> 双缓存使读写同步进行,实现数据预取,隐藏内存延迟。
![](./images/describe_kernel_7.png)
![](./images/kernel_6_vs_7.png)
采用双缓存技术实现数据预取,计算效率得到了进一步提升;
![](./images/kernel_culas_vs_7.png)
基本可以接近CUBLAS官方矩阵乘法的计算效率