本文主要记录了使用Nsight Compute排查CUDA矩阵乘法性能瓶颈的过程。
本文首先简单介绍了Nsight Compute这一工具,然后使用一个实际案例演示了如何使用该工具精确排查是哪一行代码造成的Bank Conflict,并展示了该问题解决后的结果。
本文是CUDA矩阵乘法系列文章的一个副产物,主要是记录一下使用NVIDIA的Nsight Compute工具进行性能瓶颈定位以及修复的全过程。
本文的故事还得从我在实现Kernel 6时说起(相关上下文见本公众 号内的系列文章上篇),参考文章作者在Kernel 6里用了两个技巧:
一个是向量化加载,另一个是把部分数据读取到REGS里减少SMEM的读取次数,于是我在实现了向量化加载后测了一波数据;
又在实现了加载REGS测了一波数据,发现两个优化叠加之后性能反而不如前者。当时初步排查了一下,确认不是REGS空间不够导致的问题,于是这个问题就成为了一个遗留问题。
再到后面实现Kernel 10时,遇到的问题更棘手了:参考文章作者并没有解释清楚Kernel 10的性能优化来源,我在按照自己的理解实现了一个版本后,发现性能反而不如Kernel 6了,很奇怪,但是运行参考文章作者的Kernel 10,却又很大的性能提升。
在这几个问题解决之前,是肯定不能开始动笔写下篇的,所以我就开始了各种尝试,包括怀疑是求坐标的取模运算导致的性能损耗,尝试换掉取模运算等,但是最终问题都没能解决,于是就这样被卡了两周。
这里问题的关键在于:我们不知道自己的实现和参考文章作者相比慢在了哪里,现在已知的信息就只有一个端到端耗时。
这时候就需要一个Profiling工具来拆解内核执行时每个阶段的耗时数据,从而来协助我们定位具体的问题所在。
Nsight Compute正是这样一个工具,这一工具能够为开发者带来非常详尽的性能数据,这一数据能具体到每条指令的执行次数,整体的访存次数,甚至Bank Conflict的发生次数都能看到。
这里真得夸一下NVIDIA,这是我见过的数据最详细的性能分析工具,而且GUI的可视化也做的非常精美,比如像下面这张内存访问示意图,这确实是很惊艳了。
本文会简单介绍一下这一工具的使用方法,然后会以本次矩阵乘法内核性能优化为例子,展示一下使用这一工具来实现CUDA Kernel性能优化的全过程。
Nsight Compute是NVIDIA官方开发的一个CUDA性能分析套件,可以进行指令级别的性能数据分析。
逻辑上讲,Nsight Compute这个工具可以被分为采集端和分析端,采集端会实际执行Kernel代码,采集数据,然后把数据保存到一个文件中;分析端则是读取数据,然后对数据进行可视化展示。
Nsight Compute在全平台上都能安装,但是在没有NVIDIA显卡的平台,例如Mac平台,就只能使用分析端软件;如果是安装在有NVIDIA显卡的平台,则可以使用ncu指令来进行数据采集。
如果本机没有NVIDIA显卡(例如本文所面临的情况,显卡在云服务器上),可以使用这样一个工作流:
首先在服务器上使用ncu指令完成数据采集,然后把数据文件下载到本机,使用本机的Nsight Compute GUI打开数据文件,进行分析。
ncu指令支持我们通过--set参数指定采集的数据类型,这里我们的Kernel比较小,运行时间不长,可以选择使用--set full全部采集。
直接使用sudo ncu --set full -o my_kernel_prof ./cuda_perf即可开始对cuda_perf进行数据采集。
在程序运行结束后,下载当前目录下的my_kernel_prof.ncu-rep,使用GUI打开即可开始分析。
如果遇到下图所示的情况,提示No section files found in search paths,那就是ncu没能找到配置section的路径
此时使用GUI打开只能看到很少量的数据,如下图所示
此时打开Details也是看不到任何信息的
这时候需要手动指定section文件夹,这个文件夹一般在Nsight Compute的安装目录下,例如我的云服务器上就是/usr/lib/nsight-compute/sections,这个目录下有很多.section文件,如下图所示
使用指令sudo ncu --set full -o my_kernel_prof -section-folder=/usr/lib/nsight-compute/sections ./cuda_perf就能够愉快的开始采集数据啦。
这里再分享几个在使用ncu指令采集数据时遇到的坑:
ncu采集数据的,比如本文实验用的Quadro K620ncu采集数据的,可能是因为容器部署的关系如果手里没有合适的环境,最方便的解决方法就是上阿里云或者腾讯云买一个按量付费的GPU服务器,成本大概是9块一小时,测完数据就关机的话实测开销也不会很高。
Nsight Compute GUI能够展示的数据种类非常丰富,碍于篇幅原因,本文仅介绍和此次内核优化相关的部分。
这一小节会简单展示一下工具的操作方法,以便于想要复现的朋友上手实操一下。
为了保障阅读思路的连贯性,具体的数据含义和分析会放到后面的实战部分展开介绍。
目前网上已经有了一些比较完善的Nsight Compute使用教程,感兴趣的朋友可以参考一下这些资料:
打开NVIDIA Nsight Compute,把获取到的.ncu-rep文件拖到窗口内即可打开。
在一开始的Summary界面可以看到ncu采集到的所有Kernel,由于我们只运行了一次矩阵乘法,所以只采集到了一个名为MatmulKernelV10的Kernel
双击这个列表项就能进入到下图所示的Details界面
这里面展示了很多的数据,可以点击Tab栏左边的展开符号展开这一列表,查看更详细的信息。
例如,点击Memory Workload Analysis左边的展开按钮后,能看到如下所示的图表。这个图表很清晰的展示了每个内存区域的数据转移量,以及相应的访存请求数量
甚至图表上方的警告处还会贴心的告诉你当前访存方式可能存在哪些问题,以及该如何优化
展开Warp State Statistics还能看到参考文章作者所展示的Warp State图
(注:如果需要关联指令和源代码,则需要在使用nvcc编译时添加-lineinfo参数)
这个界面就是对某一条具体的指令进行性能分析的地方了,初始的Source界面如下图所示:
左边是展示源代码的地方,点击Resolve选择源代码文件后就能完成加载
Resolve之后,点击源代码的某一行,其右边对应的汇编代码就会高亮。
可以发现界面上除了指令还有一系列数据,可以看到现在的数据都是以百分比展示的,点击这个按钮可以切换展示方式
以Instructions Executed这个指标为例,鼠标指针移动到这个Tab栏上能够看到这个指标的定义
一行代码会被翻译成多条汇编指令,源代码窗口里Instructions Executed的值就是这些汇编指令的值的和
所以左右两个窗口可以认为是等价的,因此我们可以直接只看左边源代码窗口里的数据
例如这里的2.15B表示这行代码对应的指令被执行了2.15 Billion次。
接下来我们会通过一个实际的例子来展示Nsight Compute这一强大工具的用法。
这里再更具体地描述一下我们需要解决的问题:我们自己实现了一个矩阵乘法内核,记为V10,然后还有一个参考文章作者实现的一个矩阵乘法内核Author_V10。
Author_V10的性能是要显著高于V10的,现在需要弄清楚V10究竟是哪里慢了,并尝试修复这些性能瓶颈,让两者的性能一致。
(具体而言,V10版本的性能是470 GFLOPS,而Author_V10的性能是582 GFLOPS,V10版本慢了接近20% )
首先使用ncu在服务器上分别对V10和Author_V10进行数据采集,然后在本地打开采集到的数据文件。
由于我们已经知道CUDA矩阵乘法慢大概率是访存没做好导致的,所以我们首先就打开Details界面的Memory Load Analysis,对比两个版本的数据,果然发现了端倪:
在Shared Memory那一块,V10的Load Bank Conflicts数量有2亿多
而Author_V10的,是惊人的0
那么怎么确定具体是哪条指令导致了Bank Conflict呢?在网上搜索时发现了NVIDIA开发者论坛里的这两个帖子:
大致的意思是可以看Source面板里的L1 Wavefronts Shared和L1 Wavefronts Shared Ideal的差值,如果差别特别大,那大概率就是发生了Bank Conflict。那么这两个指标的含义是什么呢?这里贴一段官方的解释:
memory_I1_wavefronts_shared
Number of wavefronts in L1 from shared memory instructions.
wavefronts: Unique "work package" generated at the end of the processing stage for requests.
All work items of a wavefront are processed in parallel, while work items of different wavefronts are serialized and processed on different cycles.
At least one wavefront is generated for each request.
这里涉及到两个陌生概念,一个是L1,另一个是Wavefront,我们来分别看看这两个分别是什么。
L1就是处理器的L1 Cache,这个可以和CPU类比:
我们在计算机组成原理课程里学过,CPU如果要对内存里的两个数字进行运算,就需要按顺序把数据从内存加载到L2 Cache,L1 Cache,Regs中,然后才能调用ALU等单元对Regs里的数据进行计算操作。
GPU里同样有这样的分级缓存机制,和CPU稍微有些不同的是:GPU里的Shared Memory是可以直接和L1 Cache进行数据交换的。
所以这里的L1 Wavefronts Shared其实就是指的L1 Cache和Shared Memory之间数据交换产生的Wavefronts。
那么什么是Wavefront呢?Wavefront可以理解为在硬件视角下,实际要执行的操作数量。
GPU会对一些特定的内存访问做优化,例如上一篇文章里提到的Memory Coalescing,即如果一个Warp里访问的内存是连续的32个字节,那么处理器只会做一次访存操作,此时就只会产生一个Wavefront;
但是如果不是连续的32个字节,那么最多就会产生32个Wavefront。
再例如,如果一个Warp里的32个线程都访问同一个内存区域,那么硬件在执行时也只会做一次访存操作,此时我们看到的Wavefront也是只有1个。
在理解了L1 Wavefronts Shared的含义之后,L1 Wavefronts Shared Ideal的含义也就明确了:
前者是实际产生的Wavefront数量,后者是理想情况下的Wavefront数量。
对于这个“理想情况”,官方的描述是:
Ideal number of wavefronts in L1 from shared memory instructions, assuming each not predicated-off thread performed the operation.
这里的描述只提到了对于if-else谓词的假设,并没有提到内存访问模式相关的假设;AI给出的解释是:这里是以完全没有Bank Conflict为前提进行的计算。
所以L1 Wavefronts Shared Ideal可以理解为没有Bank Conflict的情况下的Wavefront数量。
所以,L1 Wavefronts Shared和L1 Wavefronts Shared Ideal的差值就代表Bank Conflict发生的数量,差值越大的地方Bank Conflict就越严重。
那么接下来的任务就很简单了,只需要打开Source界面,查看每行代码对应的L1 Wavefronts Shared和Ideal。
注意到,在加载Bs到Regs时,实际的Wavefront比理想情况多了一倍,说明这里存在Bank Conflict。
相对应的,参考文章作者在加载Bs的时候并没有遇到Bank Conflict,说明确实有方法可以避免这一情况。
紧接着还有一个更关键的问题:把这里的Bank Conflict优化掉了能提升多少性能呢?
这里确实很难根据现有信息得出一个结论,但是可以猜测:优化掉了这个Bank Conflict之后会有较大的性能提升。
这个猜测主要是基于Warp State图做出的,如下图所示:
这里Stall MIO Throttle的周期非常大,这个Stall在本场景下大概率就是SMEM访问引起的。
此外,在Details -> Memory Workload Analysis里面可以发现,V10版本的SMEM访存Wavefronts一共有670M,如果解决掉这个Bank Conflict,可以直接变到400M左右,这个优化带来的性能提升应该是比较大的。
当然,真实的性能提升数据只有实际运行之后才能知道了,所以接下来我们就着手解决这一问题。
对V10的详细解析会在后面的文章里展开,这里只需要知道以下两个设定就能够不影响接下来的阅读:
TILE_ROW_SIZE=TILE_COL_SIZE=8我们先简单分析一下V10的访存模式,关键的SMEM访问代码如下所示:
for (uint32_t tile_row = 0; tile_row < TILE_ROW_SIZE; tile_row += 4) {
reinterpret_cast<float4 *>(&a_reg[tile_row])[0] =
reinterpret_cast<float4 *>(&As[asIdxTranspose(i, thread_x_in_block * TILE_ROW_SIZE + tile_row)])[0];
}
for (uint32_t tile_col = 0; tile_col < TILE_COL_SIZE; tile_col += 4) {
reinterpret_cast<float4 *>(&b_reg[tile_col])[0] =
reinterpret_cast<float4 *>(&Bs[bsIdx(i, thread_y_in_block * TILE_COL_SIZE + tile_col)])[0];
}
在访问As时,以tile_row=0,i=0为例。由于thread_x_in_block只有0,1,2,3这四个取值(因为Warp线程布局是4*8的,只有4行)
所以访问As时实际上只会有4次访存,分别为As的第0,8,16,24号元素。
(注:实际上这里每次访存会访问4个元素是因为代码里使用了float4这个数据类型,这会导致访存时使用128位的向量化加载,但是容易验证这对最终的结论没有影响,为了不影响阅读这里就暂时忽略这个float4这个设定了。)
可以注意到,上述内存访问完全没有Bank Conflict发生,这也能和Nsight Compute里展示的数据相对应。
但是在访问Bs时,情况就有所不同了,还是以tile_col=0,i=0为例,由于Warp的布局是4*8,所以thread_y_in_block会有8种取值。
这就导致访问Bs时会有8次访存,分别访问Bs的0,8,16,24,32,40,48,56号元素,其中0和32产生了Bank Conflict,8和40产生了Bank Conflict,......。
总共有4对Bank Conflict发生,最理想的情况下也需要进行分两批才能完成。
所以Nsight Compute里显示的Wavefronts比理想情况多了一倍,这也是能和这里的理论分析相对应上的。
这里最佳的解决方案是把TILE_COL_SIZE改成4,这样的话,访问Bs时访问的元素就是0,4,8,12,16,20,24,28,完全没有了Bank Conflict。
这也是我认为Author_V10的性能得到提升的根本原因,个人认为参考文章作者在文章里提到的Warp Tiling本质上就是为了把TILE_COL_SIZE改成4,从而避免访问Bs时的Bank Conflict,这一点会在后面的文章中分析Kernel 10时展开阐述。
最终的性能数据如下图所示,可以看出,修改后的V10性能虽然有了较大的提升。
由此可见Bank Conflict带来的性能损耗有多严重。