温州市网站建设_网站建设公司_表单提交_seo优化
2025/12/24 5:25:01 网站建设 项目流程

NVCC版本:12

关于GPU你需要了解的那些事

从硬件视角看GPU

之前在CUDA并行规约那篇文章中提到过,在进行CUDA开发时,我们是以Grid,Block和Thread三级的层次结构来组织线程的,那么这三者是如何对应到具体的硬件实现的呢?

从硬件视角下看,一张显卡里有一个GPU,GPU内部有多个流式多处理器(Streaming Multiprocessor,以下简称SM),如下图所示:

image

接下来把视角转向SM内部,每个SM有多个处理器,线程就是在这些处理器上具体执行的。

除此之外,每个SM还有一块共享内存区域,之前文章里提到的共享内存(Shared Memory,以下简称SMEM)就是在这个区域,这个区域只能是SM内部的处理器访问。SM内部的每个处理器又有着只能是自己访问的寄存器(REGS)。

从这里也可以总结出GPU上内存访问的速度排序,寄存器(REGS)是最快的,但是只能线程自身访问;其次是SMEM,但是只能是Block内的线程访问;最慢的是GMEM,GMEM就是我们通过cudaMalloc申请到的内存,所有线程均可访问。

那么上述的线程层级架构又是怎么和这个硬件架构相对应的呢? 而且Warp在其中又是怎么体现的呢?这就得从线程调度的角度来看一个内核被启动的过程了。

在启动内核时,我们会指定GridDim和BlockDim,这就使得内核有了一定数量的线程块(Block)需要执行,每个Block里有若干个Thread。在进行调度时,GPU会以Block为单位,把一个Block分给一个SM,这时候一个SM可能会被分到多个Block。

接下来就是SM的工作了,一个Block里连续的32个线程为一个Warp,SM会以Warp为单位进行调度,即SM会选择32个连续的线程,然后放到32个处理器上运行。上述过程如下图所示。

image

全局内存访问合并

同一个Warp里的线程有很多有意思的特性,对这些特性加以利用就能够达成不错的优化效果,全局内存访问合并(Global Memory Coalescing)就是其中之一。

这个特性是:如果一个Warp里的线程访问的内存恰好是连续的32个4B的浮点数,那么GPU就只会做一次长度为128B的访存操作, 把128B的数据读取之后分发给32个线程。这里参考资料作者的精美的手绘图可以很形象地说明这一点:

image

一些约定

在开始正式实现前,首先需要把一些容易混淆的设定给明确了。

本文默认所有矩阵都是行优先存储的

本文中的x都是指行下标,y都是指列下标

如下图所示:

image

(注:本文所有的内核实现都只是在大小为4096的方阵下进行了正确性验证,如果要适配任意形状需要考虑很多corner case,这有点偏离主线了,所以本文就暂时不做这方面的适配工作了。)

V42:cuBLAS

这里先放出cuBLAS实现的性能数据,供后续比较和参考

image

V1:Naive Kernel

对于矩阵乘法

,一个最朴素的想法就是让每个线程都计算C中的一个元素,所以只需要一个Block,使用Thread的x和y表示要计算的C的元素坐标,然后2个for循环计算即可。

这个想法没问题,只是在实现的时候,由于一个Block里面最多有1024个线程,所以需要进行一次分块。

具体而言,可以把C分成若干个

的块(Tile),每个Tile交给一个Block进行计算,如下图所示:

image

对于每个线程而言,首先需要根据计算出当前线程需要处理的C的坐标,然后用2个for循环计算结果并写回即可。

至于布局,每个Block里自然是

个线程,而Grid则是需要用向上取整的除法来分配尽量多的Block。

(注:理清楚每个线程需要计算哪些C的元素是不被后面更复杂的分块绕晕的关键)

最终得到的源代码如下所示:

__global__ void MatmulKernelV1(const scalar_t *a, const scalar_t *b, scalar_t *out, uint32_t M, uint32_t N, uint32_t P) {

uint32_t x = blockIdx.x * blockDim.x + threadIdx.x;

uint32_t y = blockIdx.y * blockDim.y + threadIdx.y;

if (x < M && y < P) {

scalar_t tmp = 0;

for (uint32_t k = 0; k < N; k++) {

// out[i][j] = a[i][k] * b[k][j]

tmp += a[x * N + k] * b[k * P + y];

}

out[x * P + y] = tmp;

}

}

void MatmulCoreV1(const scalar_t *a, const scalar_t *b, scalar_t *out, uint32_t M, uint32_t N, uint32_t P) {

dim3 grid(std::ceil(M / 32.0), std::ceil(P / 32.0), 1);

dim3 block(32, 32, 1);

MatmulKernelV1<<<grid, block>>>(a, b, out, M, N, P);

}

实验数据

最终的性能数据如下所示:

image

性能只有cuBLAS的0.39%,可以说是相当拉垮了。

理论分析

这里先插入一段理论分析,来分析一下Naive Kernel可能的性能瓶颈在哪。

首先计算一下理论最快的运行时间,进行一个4096方阵的乘法所需要浮点运算次数为

(因为C有

个元素,每个元素需要进行4096次乘法和加法),大约为137GFLO;

而内存读取最低需要

,约134MB,写入需要

,约67MB。

实验用的显卡浮点数计算性能为870GFLOPS,显存带宽为29GB/s,所以理论上计算最快需要157ms,访存共需要6.9ms,也就是说,理论上来讲,矩阵乘法应该是计算瓶颈的。

但是我们的Naive Kernel似乎并不是这样的,下面来详细分析一下。

在计算次数上,如果不考虑计算下标的开销,那它的计算次数就是和理论最低值相等的;

在内存访问上,实际上每个线程都会访问

次全局内存(GMEM),如果这些访问没有经过任何优化,那么这个内核一共就会有

的访存,需要耗时18.9s,已经是计算的120倍了,所以很显然,目前的首要任务是优化内存访问。

(注:这里内存访问事实上并没有计算的那么多,因为有一些Warp层的自动优化,这个后面马上会提到)

V2:Global Memory Coalescing

这里可以像防止Bank Conflict那样,通过调整每个线程负责的区域来实现Coalescing。

我们首先分析一下V1里面每个线程都在计算C的哪一个元素。通过代码可以知道,线程计算的C的x坐标就是threadIdx.x,y坐标就是threadIdx.y,并且threadIdx是x先变化的,所以第一个线程计算的是(0, 0),第二个是(0, 1),以此类推,如下图所示(用背景色来区分T0和T1加载的数据):

image

可以发现,一个Warp里计算的其实是C中的某一列,那么同一时刻,Warp里的线程访问的A一定不是连续的,所以访问A的部分一个Warp需要

次访存,而访问B的部分,由于一个Warp里的线程在同一时刻访问的都是同一个B,所以这里只会有一次访存开销,那么访问B总共就会有4096次访存。

这里如果我们让一个Warp计算C中的一行会怎么样呢?那么访问A就只需要4096次访存,但是访问B的时候,在同一时刻,线程们访问的数据是连续的,此时就可以触发Global Memory Coalescing,把32次访存压缩为1次,如下图所示:

image

实验数据

image

这里仅仅是对换一下x和y,性能就提升了接近8倍。

但是和cuBLAS相比,还是有不小的差距,目前也还只有cuBLAS性能的2%。

关于实现方式

具体实现时,只需要把x和y对换一下就行了。

参考资料作者在这里的实现是取消了threadIdx.y这个维度,然后把x维度的大小扩展为了1024,之后在线程内部根据threadIdx.x以及BlockSize来计算当前线程对应到C的坐标,如下所示:

constexpr uint32_t BLOCK_SIZE = 32;

__global__ void MatmulKernelV2(const scalar_t *a, const scalar_t *b, scalar_t *out, uint32_t M, uint32_t N, uint32_t P) {

// 相当于首先把OUT分成若干个32*32的Block,V1和V2都是如此,它们的区别在于Block内部的分配方式

// 这里blockIdx.x * BLOCK_SIZE, blockIdx.y * BLOCK_SIZE 就是在定位Block的起始x和y

const uint32_t x = blockIdx.x * BLOCK_SIZE + threadIdx.x / BLOCK_SIZE;

const uint32_t y = blockIdx.y * BLOCK_SIZE + threadIdx.x % BLOCK_SIZE;

......

个人认为这种实现肯定是不如直接对换x和y的,因为这种实现引入了除法和求余数这种非常昂贵的操作,但是实际测试下来两种实现的性能是几乎一致的。

于是我看了下两种实现对应的PTX汇编,如下图所示

image

image

发现两者指令数量几乎都差不多,可能是因为这里BlockSize为2的整数次幂的关系吧,这种特性使得编译器可以对求余数指令做优化,一般的求余数指令是需要用除法+减法来实现的。

如下图所示,可以看到,在把BlockSize换成34之后,确实多出来了一条sub指令。

image

在把BlockSize改成31然后实际运行后,确实出现了性能的损失。

image

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询