news 2026/4/26 15:39:29

从朴素实现到40倍性能提升的优化之旅

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
从朴素实现到40倍性能提升的优化之旅

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

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/4/25 19:15:37

BG3ModManager完全攻略:从下载到精通的全流程指南

BG3ModManager完全攻略&#xff1a;从下载到精通的全流程指南 【免费下载链接】BG3ModManager A mod manager for Baldurs Gate 3. 项目地址: https://gitcode.com/gh_mirrors/bg/BG3ModManager &#x1f3ae; 还在为《博德之门3》模组管理而烦恼吗&#xff1f;BG3ModMa…

作者头像 李华
网站建设 2026/4/25 12:21:27

8、Visual Studio for Mac 开发实用技巧与功能介绍

Visual Studio for Mac 开发实用技巧与功能介绍 在软件开发过程中,高效地使用开发工具能够显著提升开发效率和代码质量。Visual Studio for Mac 提供了一系列强大的功能和实用的技巧,帮助开发者更轻松地进行代码编写、调试和管理。下面将详细介绍这些功能和技巧。 1. 代码重…

作者头像 李华
网站建设 2026/4/26 1:10:00

掌控暗影精灵的终极利器:OmenSuperHub全面评测

掌控暗影精灵的终极利器&#xff1a;OmenSuperHub全面评测 【免费下载链接】OmenSuperHub 项目地址: https://gitcode.com/gh_mirrors/om/OmenSuperHub 在游戏笔记本领域&#xff0c;惠普暗影精灵系列以其出色的性价比赢得了众多玩家的青睐。然而&#xff0c;官方控制软…

作者头像 李华
网站建设 2026/4/24 3:24:53

消息永不消失:微信防撤回补丁的终极使用手册

在数字沟通的时代&#xff0c;重要信息的丢失往往带来无法弥补的损失。RevokeMsgPatcher作为一款专业的微信防撤回工具&#xff0c;能够有效防止消息被撤回&#xff0c;确保你永远掌握对话的完整内容。 【免费下载链接】RevokeMsgPatcher :trollface: A hex editor for WeChat/…

作者头像 李华
网站建设 2026/4/24 2:45:41

本地部署也能高性能?anything-llm镜像优化技巧全公开

本地部署也能高性能&#xff1f;anything-llm镜像优化技巧全公开 在企业知识管理日益智能化的今天&#xff0c;一个现实问题正不断浮现&#xff1a;我们能否既享受大语言模型的强大能力&#xff0c;又不必把敏感文档上传到云端&#xff1f;尤其当一份合同、一份技术白皮书或内部…

作者头像 李华
网站建设 2026/4/24 18:35:14

DBAN终极数据擦除指南:简单三步彻底销毁硬盘数据

在数字信息时代&#xff0c;数据安全擦除已成为每个技术爱好者和IT从业者的必备技能。当你需要处理旧硬盘、报废设备或紧急销毁敏感数据时&#xff0c;DBAN&#xff08;Dariks Boot and Nuke&#xff09;提供了最可靠的数据安全销毁解决方案。这款开源工具能够自动检测并彻底擦…

作者头像 李华