一、基础知识
1.并行
GPU是多核心并行计算架构,与CPU不同,CPU是单核计算能力强,GPU是多核支持大量线程同时进行计算,但是单个核心计算能力弱。
warp:warp内部是32个线程,这32个线程由调度器统一调度,执行相同的任务。
GPU并行原理:在GPU内部有多个SM,SM内部包含调度器和计算核心,当有任务分配过来,调度器会以warp为单位将任务分配到计算区域由CUDA Core进行统一计算,每个线程分配一个计算核心,SM内有大量计算核心,从而保证多个线程可以同时进行计算,从而保证并行性。
2.GPU框架
不同架构之间的区别主要是SM不同,这里主要用Fermi架构展示一下SM布局及GPU工作过程。
如下图,是一个简单的SM结构图,内部包含多个部件,每个部件包含不同的功能。
①宏观解释:一个SM类似一台"小型GPU",里边包含计算单元、调度器、寄存器、缓存和其他部件,大量SM组合起来构成一整块GPU。
②Instruction Cache:指令缓存,保存GPU程序指令。GPU编程后的指令存放到这里,目的是避免每次都从显存拿数据很慢。
③Warp Scheduler:warp调度器,以warp为单位进行调度,负责决定哪个warp去工作,因为申请内存的速度很慢,计算的速度很快,当申请内存的时候,可以切换其他的已经准备就绪的warp,这就叫延迟隐藏。
④Dispatch Unit:派发单元,调度器决定执行哪个warp,dispatch unit负责把指令送给执行单元。
⑤Register File:寄存器文件,存放每个线程的寄存器
⑥Core:进行数学计算的地方,可以多个Core同时进行计算
⑦LD/ST:load store unit:LD负责访问内存加载数据,ST负责将数据写回内存
⑧SFU:特殊函数计算单元,比如sin、cos...
⑨Interconnect NetWork:内部互联,负责连接SM内部部件
⑩Shared Memory:共享内存,块内共享,可由程序员控制,与L1 Cache共享同一片区域
⑪L1 Cache:L1缓存,GPU包含多级缓存,L1 Cache是离计算单元最近的缓存,从全集内存中拿的数据会保存在其中,再次获取相同数据无需再跑到显存中获取,快。
⑫Uniform Cache:用来保存所有线程都一样的数据

整体流程:当CUDA kernel启动时,线程块被分配到可用的SM上,SM将线程划分为多个Warp,Warp Scheduler从当前驻留的多个Warp中选择一个准备好的Warp,由Dispatch Unit根据指令类型将任务发送到对应执行单元,如需获取数据需要使用LD访问Global Memory获取数据,通过计算单元计算将数据保存到寄存器中,如果需要将数据写回Global Memory则使用ST将数据写回。注:当Warp等待数据的时候Warp Scheduler可以切换Warp。
3.Warp
Warp是GPU为提高执行效率,把32个线程绑在一起作为最小调度单位。
一个Warp内的32个线程共享一个指令流,Warp Scheduler一次只能发射一条指令,一个Warp虽然有32个线程,但是硬件看到的是Warp执行那条指令不是线程执行哪条指令,所以一个Warp不能执行不同的语句。
线程束分支:由于一个Warp只能执行相同的指令,所以如果遇到分支语句将线程分成了两部分分别执行A指令和B指令,那么Warp就会执行两次,这两次执行是串行的,不是并行的,会降低速度。
4.资源分配
当Block被分配到SM后,会占用SM的硬件资源,这些资源有限决定了一个SM能同时驻留多少Block和Warp
①Register:一个SM内的寄存器数量是有限的,现在的GPU中一个SM内的寄存器个数是65535个,硬件限制每个线程最多255个寄存器,单个线程占用过多的寄存器会导致SM内部的block数减少。
②Shared Memory限制:一个SM上最大共享内存大小是100KB,每个Block最大共享内存默认是48KB,当你使用过多的共享内存,那一个SM内就没多少block了。
5.延迟隐藏
前面说过了,在一个warp在等待数据的过程中由warp scheduler切换warp进行计算,从而达到隐藏延迟的目的,如果想要达到延迟隐藏的目的,首先要考虑的是占用率。
6.占用率
为了达成延迟隐藏就需要SM上有足够的Warp能够让你去切换;占用率=当前SM内活跃的Warp数÷SM最大支持的Warp数量。
决定占用率的就是上边的资源分配,资源分配的合理占用率就高。
占用率也不是越高越好,因为在GPU中有很多SM,如果你把block全部放到一个SM内部进行计算,那这个SM的占用率是很高了,但是很多空闲的SM没有用到,反而没有发挥出GPU高并发的优势。
二、优化
1.线程束分支优化
当核函数出现分支,就会导致一部分执行,另一部分暂停,严重影响GPU并行。
优化思路:尽量让一个Warp内的32个线程执行相同的路径,减少分支导致的串行执行。
2.并行规约
并行规约用于把一个大数组通过某种操作变成一种操作。
使用GPU对数组进行求和的时候,如果不使用并行规约的话,线程是无法自动进行相加的。
①相邻结构:相邻的两个元素配对,最终求出总和
问题:在进行计算的时候会产生大量的线程分支,在一个warp内第一轮就会有一半的线程是不工作的,第二轮有四分之三的线程不工作,并且它还会将相邻线程分割开,在归约迭代的过程中,线程访问间隔不断扩大,降低访存合并效率。

cpp
__global__ void reduceNeighbored(int* d_idata, int* d_odata, int n)
{
unsigned int tid = threadIdx.x;
unsigned int idx = blockIdx.x * blockDim.x +threadIdx.x;
int* idata = d_idata + blockIdx.x * blockDim.x;
if(idx >= n) return;
for(int s = 1; s < blockDim.x; s *= 2)
{
if(tid % (s*2) == 0)
idata[tid] += idata[tid + s];
__syncthreads();
}
if(tid == 0) d_odata[blockIdx.x] = idata[0];
}
②交错结构:元素与一定距离的元素配对
问题:它同样会产生大量线程束分支,随着不断归约迭代它也会有大量空闲线程,但是它相较于相邻结构的优势是,保持了更规整的内存访问模式,提高了GPU的执行效率

cpp
__global__ void reduceNeighbored(int* d_idata, int* d_odata, int n)
{
unsigned int tid = threadIdx.x;
unsigned int idx = blockIdx.x * blockDim.x +threadIdx.x;
int* idata = d_idata + blockIdx.x * blockDim.x;
if(idx >= n) return;
for(int s = blockDim.x/2; s > 0; s >>= 1)
{
if(tid < s)
idata[tid] += idata[tid + s];
__syncthreads();
}
if(tid == 0) d_odata[blockIdx.x] = idata[0];
}
流程:这段代码是计算一个block内的数组的和的,对block内部进行归约最终将结果保存到block的0号线程内。如果数据量非常大,需要使用多个block,那么就可以先开启多个block将结果保存到每个block内的0号线程中,然后再次调用核函数,不过需要计算好启动多少个block和block内的线程数。
这里的问题是并行规约的天然限制,当多个线程需要把多个结果合并成一个结果时,不可能让多有现成一直保持独立计算,随着结果数量减少,必然会有线程处于空闲状态。
3.循环展开
循环展开就是把循环中的多次迭代手动展开,让编译器减少循环控制开销,并提高指令级并行。
cpp
//展开前
for (int i=0; i<32; i++) {
a[i] = b[i] + c[i];
}
cpp
//展开后
for (int i=0; i<32; i+=4) {
a[i+0] = b[i+0] + c[i+0];
a[i+1] = b[i+1] + c[i+1];
a[i+2] = b[i+2] + c[i+2];
a[i+3] = b[i+3] + c[i+3];
}
两段代码对比,展开后循环控制指令减少到了原来的四分之一。
①展开的归约
思路:一个线程控制2个或多个数据。
优势:如果一个线程控制2个数据,那么for循环就可以少一半,block的数量也可以少一半,只需要在归约之前将数据两个数据加起来放入一个线程的寄存器里。

cpp
__global__ void reduceUnroll2(int* g_idata, int* g_odata, unsigned int n)
{
unsigned int tid = threadIdx.x;
unsigned int idx = blockIdx.x * blockDim.x * 2 + threadIdx.x;
if(tid >= n) return;
int* idata = g_idata + blockIdx.x * blockDim.x * 2;
if(idx+blockDim.x < n)
g_idata[idx] += g_idata[idx+blockDim.x];
__syncthreads();
for(int s = blockDim.x/2; s > 0; s >>= 1)
{
if(tid < s)
idata[tid] += idata[tid + s];
__syncthreads();
}
if(tid == 0)
g_odata[blockIdx.x] = idata[0];
}
注意:idx使用×2代表一个线程计算两个数据。
②完全展开的归约
思路:完全展开的归约目标是32个线程,因为最后32个线程刚好组成一个Warp,而Warp内部线程本身就是同步执行的,不再需要块级别同步。
cpp
__global__ void reduceUnrollWarp(int* g_idata, int* g_odata, unsigned int n)
{
unsigned int tid = threadIdx.x;
unsigned int idx = blockIdx.x * blockDim.x * 8 + threadIdx.x;
if(idx >= n) return;
int* idata = g_idata + blockIdx.xblockDim.x8;
if(idx+7*blockDim.x < n)
{
int a1 = g_idata[idx];
int a2 = g_idata[idx+blockDim.x];
int a3 = g_idata[idx+2*blockDim.x];
int a4 = g_idata[idx+3*blockDim.x];
int a5 = g_idata[idx+4*blockDim.x];
int a6 = g_idata[idx+5*blockDim.x];
int a7 = g_idata[idx+6*blockDim.x];
int a8 = g_idata[idx+7*blockDim.x];
g_idata[idx] = a1+a2+a3+a4+a5+a6+a7+a8;
}
for(int s = blockDim.x/2; s > 32; s >>=1)
{
if(tid < s)
idata[tid] += idata[tid+s];
__syncthreads();
}
if(tid < 32)
{
volatile int* vmem = idata;
vmem[tid]+=vmem[tid+32];
vmem[tid]+=vmem[tid+16];
vmem[tid]+=vmem[tid+8];
vmem[tid]+=vmem[tid+4];
vmem[tid]+=vmem[tid+1];
}
if(tid == 0)
g_odata[blockIdx.x] = idata[0];
}
三、内存结构
内存分为片上内存和片外内存,片上内存就是在SM内的内存,距离计算核心近,速度快,空间小;片外内存是在SM外的内存,距离计算核心远,速度慢,空间大。
1.结构
下边这两张图结合起来大概就是GPU的内存结构

全局内存:GPU中最大、延迟最高、最常使用的内存,我们说的显存大部分都是全局内存。我们从cudaMemcpy的数据通常都是放在全局内存中的,所以在GPU中计算的时候不可避免地需要访问全局内存,可是访问全局内存非常慢,所以GPU设置了各种机制来减少对全局内存的访问,如缓存机制、共享内存...减少对全局内存的访问也是我们后续优化的重点方向。
常量内存:需要使用__constant__声明,然后再使用cudaMemcpyToSymbol给常量变量赋值,变量会被放入GPU中的常量内存区域,当有线程访问的时候变量就会被从片外的常量内存区域加载到片内的常量缓存区域。注:常量内存只有64KB
纹理内存和表面内存:与全局内存类似,也有缓存机制,纹理内存一般只读,表面面内存可读可写,不过纹理内存和表面内存容量更大,使用方式也与常量内存不同。
寄存器:片上,寄存器是线程能独立访问的资源,用来存储一些线程暂存数据,访问速度最快,但是容量较小,所以当使用数量超过硬件限制的时候,数据会被保存到本地内存中。
本地内存:也叫局部内存,每个线程独有,延迟和空间比寄存器大,寄存器溢出时的数据会被放到这里,较大的数组也会放到这里,因为寄存器资源是很稀缺的。注:本地内存位于显存中,每个线程最多使用512KB的本地内存,我们访问显存是很慢的,所以要避免发生寄存器溢出。
L1和L2Cache:每个SM上都有一个L1Cache,所有 SM共享一个L2Cache。L1和L2缓存都被用来存储局部内存和全局内存中的数据,包括寄存器溢出的部分,减少延时。当从global memory获取数据的时候,数据会在L1和L2分别保存一份,下次再获取该数据,先从L1找,L1没有再从L2找,L2再没有就会去访问显存。注:在物理上,目前的GPU架构中,L1、纹理缓存、共享内存是统一的。
共享内存:块内共享,与L1位于相同的物理内存区域,它是程序员可控制的内存,且位于片上,所以后续写程序大多数会先将数据从全局内存拿到共享内存中,如果重复访问无需再走全局内存那一套逻辑,直接访问共享内存会快很多,共享内存存在bank冲突问题,这是我们后续要优化的重点方向。
2.机制
①统一虚拟地址(UVA)
简单来说就是主机(CPU)内存和设备(GPU)内存共享同一个虚拟地址空间,这意味着无论数据位于主机内存还是设备内存,都可以通过相同的指针访问,简化了内存管理和数据传输。
cpp
#include <stdio.h>
__global__ void test(float* factor)
{
printf("device factor's address = %p\n", factor);
}
int main()
{
float* d_factor = NULL;
cudaMalloc(&d_factor, sizeof(float));
cudaPointerAttributes attributes;
cudaPointerGetAttributes(&attributes, d_factor);
printf("pointer devicepointer = %p\n",attributes.devicePointer);
test<<<1,1>>>(d_factor);
cudaDeviceSynchronize();
printf("host factor's address = %p\n", d_factor);
cudaFree(d_factor);
return 0;
}

注:这是现代CUDA的统一机制,不需要手动配置
②统一内存空间
统一内存空间定义了一个托管内存空间,它是一种虚拟内存管理机制,它可以存在于CPU内存或者GPU显存中,数据会根据CPU/GPU的访问情况在二者之间迁移。
使用:
静态声明:__managed__
cpp
__managed__ float factor;
__global__ void test2(float* factor)
{
*factor+=3.0f;
printf("Device Factor = %f\n", *factor);
}
int main()
{
factor = 1.0f;
test2<<<1,1>>>(&factor);
cudaDeviceSynchronize();
printf("Host Factor = %f\n", factor);
return 0;
}

动态获取:cudaMallocManged()
cpp
__global__ void test2(float* factor)
{
*factor+=3.0f;
printf("Device Factor = %f\n", *factor);
}
int main()
{
float* factor = NULL;
cudaMallocManaged(&factor, sizeof(float), cudaMemAttachGlobal);
*factor = 1.0f;
test2<<<1,1>>>(factor);
cudaDeviceSynchronize();
printf("Host Factor = %f\n", *factor);
cudaFree(factor);
return 0;
}

③页锁定内存
页锁定内存是指主机内存中不会被操作系统分页或交换到磁盘的内存区域,与普通可分页内存不同,页锁定内存通过CUDA函数cudaMallocHost或cudaHostAlloc分配,确保内存始终驻留在物理内存中,释放时使用cudaFreeHost。
注:页锁定内存分配过多会导致分页内存减少从而降低系统的整体性能。
④零拷贝内存
零拷贝内存是CUDA中一种让GPU直接访问CPU主机内存的技术,它的核心思想是:不需要把数据从CPU内存拷贝到GPU显存,GPU可以直接通过PCle访问CPU内存。
使用:
内存分配:cudaHostAlloc((void**)&ptr, size_t size, unsigned int flag)
cpp
__global__ void zerocpyMemory(float* data)
{
*data += 2.7;
printf("GPU zerocpyMemory data = %.2f\n", *data);
}
int main()
{
float* h_zerocpymem = NULL;
CHECK(cudaHostAlloc((void**)&h_zerocpymem, sizeof(float), cudaHostAllocDefault));
*h_zerocpymem = 15.23;
printf("Prev CPU zerocpyMemory data = %.2f\n", *h_zerocpymem);
zerocpyMemory<<<1,1>>>(h_zerocpymem);
cudaDeviceSynchronize();
printf("Next CPU zerocpyMemory data = %.2f\n", *h_zerocpymem);
cudaFreeHost(h_zerocpymem);
return 0;
}

3.共享内存
共享内存是块内共享,一个块内所有线程都能访问共享内存的数据;共享内存和L1 Cache公用64K的区域,可配置共享内存大小。
共享内存是程序员自己控制的告诉缓存,用来把Global Memory中会被多个线程重复使用的数据搬到SM内部,让线程访问更快。
①结构
共享内存内部由32个bank(存储体)构成,可以把它想象成一个32列的二维数组,每一列都属于一个bank。
②bank conflict
只有当一个warp内的不同线程同时访问同一个bank内的不同地址时才会发生bank conflict,当发生bank conflict的时候会导致线程串行化执行,极大的降低了执行效率。
注:只要不发生bank conflict那不管线程的指向有多不规律,那么性能都差不多。
③配置共享内存
cpp
cudaDeviceSetCacheConfig(cudaFuncCache config);
enum device_builtin cudaFuncCache
{
cudaFuncCachePreferNone = 0,
cudaFuncCachePreferShared = 1,
cudaFuncCachePreferL1 = 2,
cudaFuncCachePreferEqual = 3
};
三、线程束洗牌
1.基础概念
线程束洗牌指令作用在线程束内,允许两个线程相互访问对方的寄存器,使线程数内束程相互访问不通过共享内存或全局内存,通信效率更快。
2.指令
①__shfl_sync(unsigned mask, int var, int lane, int width=warpSize)
功能:将lane内的var变量给mask所选择的线程,width可以将warp分组。
参数:
mask:决定当前warp内哪些线程参与通信,如0xffffffff表示全部参加,0x0000ffff表示后半部分参加
var:变量名
lane:线程号,表示要读取lane中的var变量给mask选择的线程
width:默认32,将warp分组,16表示将warp分为两组,lane的编号会从0~31变成两个0~15
使用:将warp分为了两组,分别将两组内的线程的value的值变为每组中的1号线程的value值。
cpp
__global__ void shuffer()
{
int value = threadIdx.x;
int x = __shfl_sync(0xffffffff,value,1,16)
printf("threadID: %d, x: %d\n", threadIdx.x);
}
int main()
{
shuffer<<<1,32>>>();
cudaDeviceSynchronize();
return 0;
}

②__shfl_up_sync(unsigned mask, int var, unsigned int delta, int width = warpSize)
功能:每个线程拿到自己前delta的线程数据

参数:
delta:向前偏移多少个lane
其他参数与shfl_sync相同
使用:每个线程都拿自己前1个线程的value值(如果前边有线程的话)
cpp
__global__ void shuffle()
{
intt value = threadIdx.x;
int x = __shfl_up_sync(0xffffffff,value,1);
printf("threadID: %d, x: %d\n", threadIdx.x, x);
}

③__shfl_down_sync(unsigned mask, int var, unsigned int delta, int width = warpSize)
功能:每个线程拿到自己后delta的线程数据。

参数:与__shfl_up_sync相同
使用:每个线程都拿自己后5个线程的value值
cpp
__global__ void shuffle()
{
int value = threadIdx.x;
int x = __shfl_down_sync(0xffffffff, value, 5);
printf("threadID: %d, x: %d\n", threadIdx.x, x);
}

④__shfl_xor_sync(unsigned mask, int var, int laneMask, int width = warpSize)
功能:目标lane=当前lane ^ laneMask,然后读取目标lane的数据
参数:
laneMask:异或掩码
其他参数与上边说过的相同
使用:01、23、45...号线程互相获取对方value值,这叫蝴蝶交换。

cpp
__global__ void shuffer()
{
int value = threadIdx.x;
int x = __shfl_xor_sync(0xffffffff,value,1);
printf("threadID: %d, x: %d\n", threadIdx.x, x);
}

Thread0:000 ^ 1 = 1
Thread1:001 ^ 1 = 0
Thread2:010 ^ 1 = 3
Thread3:011 ^ 1 = 2
............
Thread30:11110 ^ 1 = 11111 = 31
Thread31:11111 ^ 1 = 1 1110 = 30
还有更多更复杂的操作,不再细讲了。
四、流
CUDA允许包括CPU计算、GPU计算、主机到设备的数据传输等多种任务并发执行。这种并发使通过异步接口实现的,也就是函数调用后立即返回,CPU不会等待GPU真正做完,为了管理这些异步操作,CUDA提供了Stream这个概念。
流本质上就是一个工作队列:同一个流内的操作严格按照排队的先后顺序执行,不同流之间的操作,如果GPU资源允许,可以并发执行。
1.接口
①创建:cudaStreamCreate(cudaStream_t* stream1)
②使用:kernel<<<grid, block, 0, stream>>>(...)
③销毁:cudaStreamDestory(cudaStream_t stream)
④显式同步:cudaStreamSynchronize(cudaStream_t stream),等待流中所有任务执行完再往下走。
⑤查询:cudaStreamQuery(cudaStream_t stream):查询Stream是否完成,之询问一次,成功返回cudaSuccess,失败返回错误码
⑥异步数据传输:cudaMemcpyAsync(dst, src, size, kind, stream)
参数与cudaMemcpy类似,流程是CPU->发起拷贝->直接返回->执行其他代码
注:使用异步数据传输时主机端内存必须时固定的而非分页的,也就是说开辟内存需要使用cudaMallocHost或cudaHostAlloc
2.底层硬件
Hyper-Q机制:kepler架构新增机制,增加了多个硬件队列,最多32个硬件连接,GPU可以看到多个任务,它允许CPU多个任务队列同时提交GPU工作,而不是所有任务挤在一个硬件队列中。
与Stream的联系:当创建多个CUDA Stream后,CUDA驱动会根据硬件情况,将这些Stream映射到底层硬件工作队列中,从而允许多个任务并发提交给GPU。

3.事件
事件用于记录GPU中某个任务执行到的位置,并提供同步和计时功能。
创建:cudaEventCreate(cudaEvent_t* event)
销毁:cudaEventDestroy(cudaEvent_t event)
插入:cudaEventRecord(cudaEvent_t event,cudaStream_t stream) 将event插入到指定stream的任务队列中,当GPU执行到该event时,就会把这个event标记为完成,通常用于计时
同步:cudaEventSynchronize(event) CPU等待,知道指定event被GPU标记为完成
查询:cudaEventQuery(event) 查询事件是否完成
计时:cudaEventElapsedTime(float* millisecond, event_t start, event_t end) 计算start与end之间的时差
等待:cudaStreamWaitEvent(stream, event, 0) stream等待event完成后再继续
4.流同步
流同步解决GPU中不同操作之间谁必须等谁的问题。
①案例
cpp
cudaMemcpyAsync(..., stream);
kernel1<<<..., stream>>>();
kernel2<<<..., stream>>>();
cudaMemcpyAsync(..., stream);
逻辑上是
H2D->kernel1->kernel2->D2H
后边操作不能越过前边的操作
②cudaStreamSynchronize(stream)
让CPU等待指定Stream中之前提交的任务全部完成
cpp
kernel<<<grid, block, 0, stream>>>();
cudaStreamSynchronize(stream);
printf("kernel 已经执行完\n");
执行关系:
CPU端提交kernel,GPU端执行,在这个过程中,CPU需要等待kernel执行完才能执行打印操作。
5.阻塞流与非阻塞流

流分为同步流和异步流,未声明的流也就是默认流也叫空流就是同步流,同步流存在隐式同步,会阻塞阻塞流,不会阻塞非阻塞流。
注:空流影响的是GPU内部stream之间的执行顺序,不影响CPU与GPU之间的同步异步。
下图展示的是被空流阻塞的情况

下图展示的是我们期待的流并行的情况

阻塞流与非阻塞流的核心区别就是它们和默认流的隐式同步关系,阻塞流会被默认流阻塞,非阻塞流不会被默认流阻塞。
将流设置为非阻塞流:cudaStreamCreateWithFlags(stream, Flag)
Flag:
cudaStreamDefault:默认阻塞流
cudaStreamNonBlocking:非阻塞流
6.重叠内核执行和数据传输
GPU内部包含多个CopyEngine,用于处理CPU与GPU显存之间的数据传输,不同Copy Engine可以负责不同方向的数据传输,因此Host到Device的数据传输和Device到Host的数据传输是可以同时执行的,实现数据传输方向上的并行,但同方向的数据传输是否能够并行取决于GPU拥有的Copy Engine数量以及硬件能力。
注:大多数普通GPU只有两个Copy Engine,这两个Copy Engine可以反向但不能同向。

接口:cudaMemcpyAsync(void* dst, const void* src, size_t count, cudaMemcpyKind kind, cudaStream_t stream = 0)
作用:异步拷贝
限制:开辟空间的时候需要使用固定内存,cudaHostAlloc
由stream执行拷贝工作,可以与其他流并行执行。
7.深度优先和广度有限的调度重叠
①深度优先
cpp
#include <stdio.h>
#include "freshman.h"
#define N_REPEAT 10
#define N_SEGMENT 4
void sumArray(float *a, float *b, float *res, int size)
{
for(int i = 0; i < size; i += 4)
{
res[i] = a[i] + b[i];
res[i + 1] = a[i + 1] + b[i + 1];
res[i + 2] = a[i + 2] + b[i + 2];
res[i + 3] = a[i + 3] + b[i + 3];
}
}
global void sumArrayGPU(float *a, float *b, float *res, int size)
{
int idx = blockIdx.x * blockDim.x + threadIdx.x;
if(idx < size)
{
for(int i = 0; i < N_REPEAT; i++)
{
res[idx] += a[idx] + b[idx];
}
}
}
int main()
{
initDevice(0);
double iStart, iElaps;
iStart = cpuSecond();
int nElem = 1 << 24;
printf("Vector size: %d\n", nElem);
int nByte = sizeof(float) * nElem;
float *a_h, *b_h, *res_h, *res_from_gpu_h;
CHECK(cudaHostAlloc(&a_h, nByte, cudaHostAllocDefault));
CHECK(cudaHostAlloc(&b_h, nByte, cudaHostAllocDefault));
CHECK(cudaHostAlloc(&res_h, nByte, cudaHostAllocDefault));
CHECK(cudaHostAlloc(&res_from_gpu_h, nByte, cudaHostAllocDefault));
cudaMemset(res_h, 0, nByte);
cudaMemset(res_from_gpu_h, 0, nByte);
float *a_d, *b_d, *res_d;
CHECK(cudaMalloc(&a_d, nByte));
CHECK(cudaMalloc(&b_d, nByte));
CHECK(cudaMalloc(&res_d, nByte));
initialData(a_h, nElem);
initialData(b_h, nElem);
sumArray(a_h, b_h, res_h, nElem);
dim3 block(512);
dim3 grid((nElem - 1) / block.x + 1);
int iElem = nElem / N_SEGMENT;
cudaStream_t stream[N_SEGMENT];
for(int i = 0; i < N_SEGMENT; i++)
{
CHECK(cudaStreamCreate(&stream[i]));
}
cudaEvent_t start, stop;
cudaEventCreate(&start);
cudaEventCreate(&stop);
cudaEventRecord(start, 0);
for(int i = 0; i < N_SEGMENT; i++)
{
int ioffset = i * iElem;
CHECK(cudaMemcpyAsync(&a_d[ioffset], &a_h[ioffset], nByte / N_SEGMENT, cudaMemcpyHostToDevice, stream[i]));
CHECK(cudaMemcpyAsync(&b_d[ioffset], &b_h[ioffset], nByte / N_SEGMENT, cudaMemcpyHostToDevice, stream[i]));
sumArrayGPU<<<grid, block, 0, stream[i]>>>(&a_d[ioffset], &b_d[ioffset], &res_d[ioffset], iElem);
CHECK(cudaMemcpyAsync(&res_from_gpu_h[ioffset], &res_d[ioffset], nByte / N_SEGMENT, cudaMemcpyDeviceToHost, stream[i]));
}
cudaEventRecord(stop, 0);
CHECK(cudaEventSynchronize(stop));
iElaps = cpuSecond() - iStart;
printf("GPU time: %f\n", iElaps);
for(int i = 0; i < N_SEGMENT; i++)
cudaStreamDestroy(stream[i]);
cudaFreeHost(a_h);
cudaFreeHost(b_h);
cudaFreeHost(res_h);
cudaFreeHost(res_from_gpu_h);
cudaFree(a_d);
cudaFree(b_d);
cudaFree(res_d);
cudaEventDestroy(start);
cudaEventDestroy(stop);
return 0;
}
如下图是一个深度优先调度重叠的结果图,可以发现,由不同的流执行H2D和D2H可以并行执行,节约了时间,并且同时只有一对H2D和D2H执行,所以我们的机器应该是只有两个Copy Engine的。

②广度优先
cpp
#include <stdio.h>
#include "freshman.h"
#define N_REPEAT 10
#define N_SEGMENT 4
void sumArray(float *a, float *b, float *res, int size)
{
for(int i = 0; i < size; i += 4)
{
res[i] = a[i] + b[i];
res[i + 1] = a[i + 1] + b[i + 1];
res[i + 2] = a[i + 2] + b[i + 2];
res[i + 3] = a[i + 3] + b[i + 3];
}
}
global void sumArrayGPU(float *a, float *b, float *res, int size)
{
int idx = blockIdx.x * blockDim.x + threadIdx.x;
if(idx < size)
{
for(int i = 0; i < N_REPEAT; i++)
{
res[idx] += a[idx] + b[idx];
}
}
}
int main()
{
initDevice(0);
double iStart, iElaps;
iStart = cpuSecond();
int nElem = 1 << 24;
printf("Vector size: %d\n", nElem);
int nByte = sizeof(float) * nElem;
float *a_h, *b_h, *res_h, *res_from_gpu_h;
CHECK(cudaHostAlloc(&a_h, nByte, cudaHostAllocDefault));
CHECK(cudaHostAlloc(&b_h, nByte, cudaHostAllocDefault));
CHECK(cudaHostAlloc(&res_h, nByte, cudaHostAllocDefault));
CHECK(cudaHostAlloc(&res_from_gpu_h, nByte, cudaHostAllocDefault));
cudaMemset(res_h, 0, nByte);
cudaMemset(res_from_gpu_h, 0, nByte);
float *a_d, *b_d, *res_d;
CHECK(cudaMalloc(&a_d, nByte));
CHECK(cudaMalloc(&b_d, nByte));
CHECK(cudaMalloc(&res_d, nByte));
initialData(a_h, nElem);
initialData(b_h, nElem);
sumArray(a_h, b_h, res_h, nElem);
dim3 block(512);
dim3 grid((nElem - 1) / block.x + 1);
int iElem = nElem / N_SEGMENT;
cudaStream_t stream[N_SEGMENT];
for(int i = 0; i < N_SEGMENT; i++)
{
CHECK(cudaStreamCreate(&stream[i]));
}
cudaEvent_t start, stop;
cudaEventCreate(&start);
cudaEventCreate(&stop);
cudaEventRecord(start, 0);
for(int i=0;i<N_SEGMENT;i++)
{
int ioffset=i*iElem;
CHECK(cudaMemcpyAsync(&a_d[ioffset],&a_h[ioffset],nByte/N_SEGMENT,cudaMemcpyHostToDevice,stream[i]));
CHECK(cudaMemcpyAsync(&b_d[ioffset],&b_h[ioffset],nByte/N_SEGMENT,cudaMemcpyHostToDevice,stream[i]));
}
for(int i=0;i<N_SEGMENT;i++)
{
int ioffset=i*iElem;
sumArrayGPU<<<grid,block,0,stream[i]>>>(&a_d[ioffset],&b_d[ioffset],&res_d[ioffset],iElem);
}
for(int i=0;i<N_SEGMENT;i++)
{
int ioffset=i*iElem;
CHECK(cudaMemcpyAsync(&res_from_gpu_h[ioffset],&res_d[ioffset],nByte/N_SEGMENT,cudaMemcpyDeviceToHost,stream[i]));
}
cudaEventRecord(stop, 0);
CHECK(cudaEventSynchronize(stop));
iElaps = cpuSecond() - iStart;
printf("GPU time: %f\n", iElaps);
for(int i = 0; i < N_SEGMENT; i++)
cudaStreamDestroy(stream[i]);
cudaFreeHost(a_h);
cudaFreeHost(b_h);
cudaFreeHost(res_h);
cudaFreeHost(res_from_gpu_h);
cudaFree(a_d);
cudaFree(b_d);
cudaFree(res_d);
cudaEventDestroy(start);
cudaEventDestroy(stop);
return 0;
}
通过图片可以观察到先将数据拷贝到设备才进行计算的,其实正常情况应该是第一个流拷贝完之后第一个流中的kernel就可以和其他拷贝共同运行了。

8.流回调
流回调是向某个CUDA Stream中插入一个CPU函数,当这个stream前面的GPU工作全部完成后,自动调用这个CPU函数。
①用法
cudaLaunchHostFunc(cudaStream_t stream,cudaHostFn_t func,void* userData)
参数:
stream:插入哪个CUDA流
fn:CPU回调函数
userData:传递给回调函数的数据
②案例
在程序中包含很多的流,如果想要在流结束后执行某些操作就要使用cudaStreamSynchronize,流同步,这样CPU就会一直等待,就无法处理其他任务了,流回调就是要解决这种问题的。
cpp
void callback(void *data)
{
int value = *(int *)data;
printf("Callback execute, value=%d\n", value);
}
__global__ void kernel()
{
printf("Kernel execute\n");
}
int main()
{
cudaStream_t stream;
cudaStreamCreate(&stream);
int value = 100;
kernel<<<1, 1, 0, stream>>>();
cudaLaunchHostFunc(
stream,
callback,
&value);
cudaStreamSynchronize(stream);
cudaStreamDestroy(stream);
return 0;
}

五、GEMM
GEMM就是通用矩阵乘法,C=A×B,A:M×K B:K×N C:M×N

1.朴素GEMM
思路:GPU中的一个线程负责计算一个C元素,每个线程获取A的一行和B的一列

实现:
cpp
__global__ void GEMM_GPU(int* A, int* B, int* C, int M, int N, int K)
{
int row = blockIdx.y * blockDim.y + threadIdx.y;
int col = blockIdx.x * blockDim.x + threadIdx.x;
if(row >= M || col >= N)
return;
int sum = 0;
for(int i = 0; i < K; i++)
{
sum += A[row*K+i]*B[i*N+col];
}
C[rowK+col] = sum;
}
对朴素GEMM分析:
朴素GEMM有一个很大的问题,就是它每个线程都需要获取A和一行和B的一列,所以A的同一行需要被取N次,B的同一列需要被取M次,而每次获取数据都需要从全局内存中拿数据,非常的慢,所以优化思路是将数据先放到共享内存中再进行计算。
补充:在内存角度分析,其实第一次从全局内存获取到的数据会放到L1 Cache中,后续再使用这些数据就会从缓存中拿,按道理来说在L1的速度和共享内存的速度是差不多的,那为什么还要引入共享内存呢?其实是因为L1 Cache是非常小的,且不受程序员控制的,你将数据放到L1中,后续还会有数据被放进去,当L1满了就会有部分数据被淘汰,再次访问就需要再次从L2、global memory中拿数据了,所以我们使用共享内存的原因是方便我们程序员自己控制,尽可能的少访问全局内存。
2.引入共享内存
思路:将矩阵乘法沿K维度进行分块,每个线程块每次从Global Memory中协作加载一块A Tile和一块B Tile到Shared Memory中,然后线程块内的多个线程反复复用这两个Tile进行计算,计算完成后加载下一组Tile,最终将各个Tile的部分计算结果累加到C Tile并写回Global Memory。这样可以例用Shared Memory的告诉访问和线程间数据复用,减少对Global Memory的重复访问,从而提高GEMM的性能。
注:一般一个block就是一个Tile

cpp
global void GEMM_Tiled(float* A, float* B, float* C, int M, int N, int K)
{
//当前线程在block中的位置
int tx = threadIdx.x;
int ty = threadIdx.y;
//当前线程负责计算C中的位置
int row = blockIdx.y * TILE_SIZE + ty;//一个块对应一个tile,TILE_SIZE相当于blockDim.y
int col = blockIdx.x * TILE_SIZE + tx;
//共享内存
shared float As[TILE_SIZE][TILE_SIZE];
shared float Bs[TILE_SIZE][TILE_SIZE];
float sum = 0.0;
//K方向分块循环
for(int i = 0; i < (K+TILE_SIZE-1)/TILE_SIZE; i++)
{
//加载ATile
if(row < M && i*TILE_SIZE + tx < K)
As[ty][tx] = A[row*K + i*TILE_SIZE + tx];
else
As[ty][tx] = 0.0f;
//加载BTile
if(i*TILE_SIZE +ty < N && col < K)
Bs[ty][tx] = B[(i*TILE_SIZE + ty)*N + col];
else
Bs[ty][tx] = 0.0f;
__syncthreads();
//计算
for(int j = 0; j < TILE_SIZE; j++)
{
sum += As[ty][j] * Bs[j][tx];
}
__syncthreads();
}
//写回C
if(row < M && col < N)
C[row*K + col] = sum;
}
对引入共享内存的GEMM进行分析:
通过引入共享内存,减少了对global memory的重复访问次数,从而提高了效率。但是它虽然解决了对Global Memory的大量重复访问,但是一个线程在计算自己的C元素时,仍然会反复从Shared Memory读取A、B,我们下一步要进行的优化就是需要提高数据的复用率
3.引入Register
思路:对于引入Shared Memory的版本,它仍然是一个线程计算一个C元素,通过引入寄存器实现线程级数据复用,让一个线程计算多个C元素,也就是一个线程计算一小块C而不是一个C元素,比如Register Tile:8×8,一个线程负责64个C元素,并将这些结果累加保存到寄存器中,这样可以让线程从Shared Memory加载的A、B数据被多个C元素重复使用,同时寄存器的访问速度更快,从而进一步减少了Shared Memory访问并提高计算效率。

cpp
__global__ void GEMM_Register_Tiled(float *A, float *B, float *C, int M, int N, int K)
{
int tx = threadIdx.x;
int ty = threadIdx.y;
// 每个线程负责2×2 C
int row = blockIdx.y * TILE_SIZE + ty * REGISTER_SIZE;
int col = blockIdx.x * TILE_SIZE + tx * REGISTER_SIZE;
// shared memory
__shared__ float As[TILE_SIZE][BLOCK_SIZE];
__shared__ float Bs[BLOCK_SIZE][TILE_SIZE];
float c[REGISTER_SIZE][REGISTER_SIZE];
for(int i = 0; i < REGISTER_SIZE; i++)
{
for(int j = 0; j < REGISTER_SIZE; j++)
{
c[i][j] = 0.0f;
}
}
// K方向移动tile
for(int tile = 0; tile < (K + BLOCK_SIZE - 1) / BLOCK_SIZE; tile++) //。。。
{
int k_base = tile * BLOCK_SIZE;
/*
加载A
A:
128×16
16×16线程
每个线程加载8个A
*/
for(int i = 0; i < REGISTER_SIZE; i++)
{
int a_row = ty * REGISTER_SIZE + i;
int a_col = tx;
if(row < M && k_base + a_col < K) //。。。
As[a_row][a_col] = A[row * K + k_base + a_col]; //。。。
else
As[a_row][a_col] = 0;
}
/*
加载B
每个线程加载8个B
*/
for(int j = 0; j < REGISTER_SIZE; j++)
{
int b_row = k_base + ty;
int b_col = tx * REGISTER_SIZE + j;
if(b_row < N && b_col < K) //。。。。
Bs[ty][b_col] =
B[b_row * N + b_col];
else
Bs[ty][b_col] = 0;
}
__syncthreads();
// register计算
for(int k = 0; k < BLOCK_SIZE; k++)
{
float a[REGISTER_SIZE];
float b[REGISTER_SIZE];
for(int i = 0; i < REGISTER_SIZE; i++)
{
a[i] = As[ty*REGISTER_SIZE + i][k];
b[i] = Bs[k][tx * REGISTER_SIZE + i];
}
for(int i = 0; i < REGISTER_SIZE; i++)
{
for(int j = 0; j < REGISTER_SIZE; j++)
c[i][j] += a[i] * b[j];
}
}
__syncthreads();
}
// 写回
// 64次
for(int i = 0; i < REGISTER_SIZE; i++)
{
for(int j = 0; j < REGISTER_SIZE; j++)
{
if(row+i < M && col+j < N)
C[(row+i) * N + col + j] = c[i][j];
}
}
}
下图是一个简单的流程图,由于我画的时候是按照A:M×N B:N×K画的,代码部分我已经改变了方向,所以图与代码并不完全对应,但大体意思一样。

对引入Register版本进行分析:
通过引入Register Tiling,扩大每个线程负责的C矩阵区域,使得一个线程可以计算多个C元素,并将多个累加结果保存在寄存器中。同时,从Shared Memory读取的A、B数据也可以在寄存器中被多个C重复使用,从而减少Shared Memory的访问次数,进一步提高数据复用率和计算效率。但当Register Tile越大,每个线程需要使用的寄存器通常也就越多,当寄存器资源成为限制因素的时候,会减少一个SM上能够同时主流的线程或Block数量,从而可能降低Occupancy;如果寄存器压力过大,还可能发生寄存器溢出,导致性能下降。
4.Warp级复用
Register Tile的局限:Register Tiling中一个线程计算多个数据,很好地利用了线程内部地数据复用,但是每个线程基本上都是独立计算自己地Register Tile,Warp内地32个线程之间地计算组织比较简单。
Warp Tiling思路:将一个Warp的32个线程作为一个计算整体,让它们按照特定的线程布局协同计算一个较大的Tile,从而进一步组织和例用Warp内的数据复用;同时这种Warp级的计算组织也是后续使用Tensor Core MMA指令的基础。
下图是Warp Tiling布局示例图:

cpp
#define M_SEGMENT 1024
#define K_SEGMENT 1024
#define N_SEGMENT 1024
#define TILE_SIZE 128 // 一个block计算128×128个C元素
#define BLOCK_SIZE 16 // 一次处理tile的边长
#define REGISTER_SIZE 8 // 每个线程计算8×8个C元素
__global__ void GEMM_Warp_Tiled(float *A, float *B, float *C, int M, int N, int K)
{
int tid = threadIdx.y * blockDim.x + threadIdx.x;//块内索引
/*
一个block:
256线程
8 warp
*/
int warp_id = tid / 32;//块内warp编号
int lane_id = tid % 32;//warp内线程编号
/*
warp布局
4行×2列
*/
int warp_row = warp_id / 2;//块内warp行号
int warp_col = warp_id % 2;//块内wapr列号
/*
warp内部:
4行×8列
每个线程8×8
*/
int lane_row = lane_id % 4;//warp内线程行号
int lane_col = lane_id / 4;//warp内线程列号
/*
当前线程负责C位置
Block
|
Warp
|
Thread
*/
//row为当前线程所处8×8矩阵左上角横坐标
int row = blockIdx.y * BLOCK_TILE_M + warp_row * WARP_TILE_M + lane_row * THREAD_TILE_M;
//col为当前线程所处8×8矩阵左上角纵坐标
int col = blockIdx.x * BLOCK_TILE_N + warp_col * WARP_TILE_N + lane_col * THREAD_TILE_N;
/*
Register Tile
8×8
*/
float c[THREAD_TILE_M][THREAD_TILE_N];
for(int i = 0; i < THREAD_TILE_M; i++)
{
for(int j = 0; j < THREAD_TILE_N; j++)
{
c[i][j] = 0.0f;
}
}
/*
Shared Memory
A:
128×16
B:
16×128
*/
//一个块内计算BLOCK_TILE_M×BLOCK_TILE_N个数据,
//所以需要BLOCK_TILE_M行A数据和BLOCK_TILE_N列B数据
shared float As[BLOCK_TILE_M][16];
shared float Bs[16][BLOCK_TILE_N];
/*
K方向分块
*/
for(int tile = 0; tile < (K + 15) / 16; tile++)
{
int k_base = tile * 16;//当前行/列的起始位置
/*
Load A
128×16
*/
for(int i = tid; i < BLOCK_TILE_M * 16; i += blockDim.x * blockDim.y)
{
int r = i / 16; //共享内存中行号
int c = i % 16; //共享内存中列号
if(blockIdx.y * BLOCK_TILE_M + r < M && k_base + c < K)
{
As[r][c] = A[(blockIdx.y * BLOCK_TILE_M + r) * K + k_base + c];
}
else
{
As[r][c] = 0;
}
}
/*
Load B
16×128
*/
for(int i = tid; i < 16 * BLOCK_TILE_N; i += blockDim.x * blockDim.y)
{
int r = i / BLOCK_TILE_N;
int c = i % BLOCK_TILE_N;
if(k_base + r < K && blockIdx.x * BLOCK_TILE_N + c < N)
{
Bs[r][c] = B[blockIdx.x * BLOCK_TILE_N + (k_base + r) * N + c];
}
else
{
Bs[r][c] = 0;
}
}
__syncthreads();
/*
Register Compute
*/
for(int k = 0; k < 16; k++)
{
float a[THREAD_TILE_M];
float b[THREAD_TILE_N];
/*
A:
当前线程负责8行
*/
for(int i = 0; i < THREAD_TILE_M; i++)
{
a[i] = As[warp_row * WARP_TILE_M + lane_row * THREAD_TILE_M + i][k];
}
/*
B:
当前线程负责8列
*/
for(int j = 0; j < THREAD_TILE_N; j++)
{
b[j] = Bs[k][warp_col * WARP_TILE_N + lane_col * THREAD_TILE_N + j];
}
/*
outer product
8×1 * 1×8
*/
for(int i = 0; i < THREAD_TILE_M; i++)
{
for(int j = 0; j < THREAD_TILE_N; j++)
{
c[i][j] += a[i] * b[j];
}
}
}
__syncthreads();
}
/*
Store C
*/
for(int i = 0; i < THREAD_TILE_M; i++)
{
for(int j = 0; j < THREAD_TILE_N; j++)
{
if(row + i < M && col + j < N)
{
C[(row + i) * N + col + j] = c[i][j];
}
}
}
}
对Warp Tiling进行分析:
Warp Tiling在Register的基础上,将Warp中的32个线程作为一个整体,协同计算一个更大的C Tile,通过合理的线程布局提高Warp内的数据复用和计算组织效率,同时为后续使用Tensor Core的Warp-level MMA操作建立基础。但Warp Tiling会增加线程映射和数据布局的复杂度,并且会带来比较高的寄存器使用、bank conflict等问题,因此使用Warp Tiling并不一定能获得性能提升
六、Tensor Core
Tensor Core是CUDA专门处理矩阵乘法的运算单元,下图为Turing TU的SM内部结构,分为4个processing blocks,每个Processing block包含16个FP32Cores、16INT32 Cores、2个Tensor Cores、1个Warp Scheduler和Dispatch Unit。

1.精度
CUDA设计了很多不同的精度,用来处理不同的场景,不同精度的计算对硬件的需求不同,所以要区分多种精度,不能封装到一个硬件上。
2.概念
Tensor Core是NVIDIA GPU中专门针对矩阵运算设计的计算单元,它的主要工作是做D=A×B+C这样的矩阵乘加,CUDA Core擅长一个一个计算,而Tensor Core山城一小块一小块的矩阵乘加,Tensor Core的矩阵运算是以Warp为单位进行的,一个Warp内的线程欧冠南通提供数据然后由Tensor Core统一计算。
3.Fragment
Fragment是一个Warp为Tensor Core的矩阵运算准备的一组数据,计算D=A×B+C就需要A fragment、B fragment、C fragment。准备好三个fragment就可以通过Tensor Core计算的到D fragment。一个Warp内的32个线程共同持有一个Fragment。在实际存储中,一个Fragment分散在32个线程各自的寄存器中,而Fragment在线程上的映射不一定是平均连续分配的,而是由具体架构和指令决定的。
4.接口
①wmma::fragment
wmma::fragment<wmma::matrix_a,16,16,16,half,wmma::row_major> a;
wmma::fragment<wmma::matrix_b,16,16,16,half,wmma::row_major> b;
wmma::fragment<wmma::accumulator,16,16,16,float> c;
a:为A矩阵分配Tensor Core Fragment,A fragment分配到warp内的32个线程里
b:为B矩阵分配Tensor Core Fragment
c:累加器
②mma_sync()
wmma::mma_sync(d,a,b,c)
d=a×b+c,进行计算
③load_matrix_sync()
wmma::load_matrix_sync(a, A, lda)
从地址A开始,按照lda指定的内存跨度,把矩阵的一块数据加载到Warp的a Fragment中,其中lda是大矩阵一行包含的数据数
5.mma shape
mma shape是Tensor Core一次矩阵乘加指令所操作的数据形状
wmma::fragment<wmma::matrix_a, 16,16,16, half,wmma::row_major> a;
其中(16,16,16)代表m=16 n=16 k=16,注:mma shape是由官方硬件设定的,wmma只支持下图的mma shape

6.线程协作
在使用mma进行矩阵乘加的时候,A、B、C都会加载很多数据,这些数据不会保存到一个线程的寄存器中,所以出现了fragment,warp中的32个线程各自持有mma数据的一小部分,32个线程内的数据合起来构成一个fragment。经过wmma::sync得到的结果也一样,会将结果分给warp内的32个线程中,32个线程各自保存一部分。

7.使用
①16×16×16
A:16×16 B:16×16 进行矩阵相乘得到C矩阵
思路:只需一个warp,先在各线程内创建A、B、C fragment,然后加载数据,就从A、B、C的起始位置加载一次即可,然后mma_sync进行计算。
cpp
#include <iostream>
#include <cuda_runtime.h>
#include <cstdio>
#include <mma.h>
using namespace nvcuda;
global void wmma_gemm(const half *A, const half *B, float *C)
{
// A fragment and B fragment
wmma::fragment<wmma::matrix_a, 16, 16, 16, half, wmma::row_major> a;
wmma::fragment<wmma::matrix_b, 16, 16, 16, half, wmma::row_major> b;
// accumulator fragment
wmma::fragment<wmma::accumulator, 16, 16, 16, float> c;
// initial C fragment
wmma::fill_fragment(c, 0.0f);
// load A fragment and B fragment
// 从A当前起始位置取16×16的逻辑矩阵装入warp的A fragment
wmma::load_matrix_sync(a, A, 16);
wmma::load_matrix_sync(b, B, 16);
// Tensor Core MMA
wmma::mma_sync(c, a, b, c);
// store C fragment
wmma::store_matrix_sync(C, c, 16, wmma::mem_row_major);
}
int main()
{
const int M = 16;
const int N = 16;
const int K = 16;
size_t size_A = M * K * sizeof(half);
size_t size_B = K * N * sizeof(half);
size_t size_C = M * N * sizeof(float);
half *d_A, *d_B;
float *d_C;
cudaMalloc(&d_A, size_A);
cudaMalloc(&d_B, size_B);
cudaMalloc(&d_C, size_C);
// 初始化
half h_A[M * K];
half h_B[K * N];
float h_C[M * N];
for(int i = 0; i < M * K; i++)
h_A[i] = __float2half(1.0f);
for(int i = 0; i < K * N; i++)
h_B[i] = __float2half(1.0f);
cudaMemcpy(d_A, h_A, size_A, cudaMemcpyHostToDevice);
cudaMemcpy(d_B, h_B, size_B, cudaMemcpyHostToDevice);
wmma_gemm<<<1, 32>>>(d_A, d_B, d_C);
cudaDeviceSynchronize();
cudaMemcpy(h_C, d_C, size_C, cudaMemcpyDeviceToHost);
for(int i = 0; i < M * N; i++)
{
std::cout << h_C[i] << " ";
if((i + 1) % N == 0)
std::cout << std::endl;
}
cudaFree(d_A);
cudaFree(d_B);
cudaFree(d_C);
}
②32×32×32
A:32×32 B:32×32进行矩阵乘法的到C矩阵
思路:由于MMA Shape的限制,不能一次计算32×32的元素,所以需要进行分块,将C分为c00、c01、c10、c11,一个块计算16×16的C元素,同样的加载数据也需要分块,需要注意起始位置的计算。

C00 = A00*B00+A01*B10
C01 = A00*B01+A01*B11
C10 = A10*B00+A11*B10
C11 = A10*B01+A11*B11
cpp
global void wmma_gemm2(const half *A, const half *B, float *C)
{
wmma::fragment<wmma::matrix_a, 16,16,16, half, wmma::row_major> a;
wmma::fragment<wmma::matrix_b, 16,16,16, half, wmma::row_major> b;
wmma::fragment<wmma::accumulator, 16,16,16, float> c00;
wmma::fragment<wmma::accumulator, 16,16,16, float> c01;
wmma::fragment<wmma::accumulator, 16,16,16, float> c10;
wmma::fragment<wmma::accumulator, 16,16,16, float> c11;
wmma::fill_fragment(c00, 0.0f);
wmma::fill_fragment(c01, 0.0f);
wmma::fill_fragment(c10, 0.0f);
wmma::fill_fragment(c11, 0.0f);
//K方向有两个tile
for(int k = 0; k < 32; k+=16)
{
const half* A_top = A + k;
const half* A_bottom = A + 16*32 + k;
const half* B_left = B + k*32;
const half* B_right = B + k*32 + 16;
//1.计算C00
wmma::load_matrix_sync(a, A_top, 32);
wmma::load_matrix_sync(b, B_left, 32);
wmma::mma_sync(c00, a, b, c00);
//2.计算C01
wmma::load_matrix_sync(a, A_top, 32);
wmma::load_matrix_sync(b, B_right, 32);
wmma::mma_sync(c01, a, b, c01);
//3.计算C10
wmma::load_matrix_sync(a, A_bottom, 32);
wmma::load_matrix_sync(b, B_left, 32);
wmma::mma_sync(c10, a, b, c10);
//4.计算C11
wmma::load_matrix_sync(a, A_bottom, 32);
wmma::load_matrix_sync(b, B_right, 32);
wmma::mma_sync(c11, a, b, c11);
//5.存储
wmma::store_matrix_sync(C, c00, 32, wmma::mem_row_major);
wmma::store_matrix_sync(C + 16, c01, 32, wmma::mem_row_major);
wmma::store_matrix_sync(C + 16*32, c10, 32, wmma::mem_row_major);
wmma::store_matrix_sync(C + 16*32 + 16, c11, 32, wmma::mem_row_major);
}
}
③引入共享内存
思路:开辟32×32的A和B的共享内存,在计算前将全局内存中的数据放入共享内存中,在计算时访问global memory,直接从shared memory拿数据,其他部分与上一个版本一样。
cpp
__global__ void mma_sgemm2(const half* A, const half* B, float* C)
{
wmma::fragment<wmma::matrix_a, 16,16,16, half, wmma::row_major> a;
wmma::fragment<wmma::matrix_b, 16,16,16, half, wmma::row_major> b;
wmma::fragment<wmma::accumulator, 16,16,16, float> c00;
wmma::fragment<wmma::accumulator, 16,16,16, float> c01;
wmma::fragment<wmma::accumulator, 16,16,16, float> c10;
wmma::fragment<wmma::accumulator, 16,16,16, float> c11;
wmma::fill_fragment(c00, 0.0f);
wmma::fill_fragment(c01, 0.0f);
wmma::fill_fragment(c10, 0.0f);
wmma::fill_fragment(c11, 0.0f);
shared half As[32][32];
shared half Bs[32][32];
int tid = threadIdx.x;
for(int i = tid; i < 32*32; i+=32)
{
int row = i/32;
int col = i%32;
As[row][col] = A[row*32 + col];
Bs[row][col] = B[row*32 + col];
}
for(int i = 0; i < 32; i+=16)
{
const half* A_top = &(As[0][i]);
const half* A_bottom = &(As[16][i]);
const half* B_left = &(Bs[i][0]);
const half* B_right = &(Bs[i][16]);
wmma::load_matrix_sync(a, A_top, 32);
wmma::load_matrix_sync(b, B_left, 32);
wmma::mma_sync(c00, a, b, c00);
wmma::load_matrix_sync(a, A_top, 32);
wmma::load_matrix_sync(b, B_right, 32);
wmma::mma_sync(c01, a, b, c01);
wmma::load_matrix_sync(a, A_bottom, 32);
wmma::load_matrix_sync(b, B_left, 32);
wmma::mma_sync(c10, a, b, c10);
wmma::load_matrix_sync(a, A_bottom, 32);
wmma::load_matrix_sync(b, B_right, 32);
wmma::mma_sync(c11, a, b, c11);
}
wmma::store_matrix_sync(C, c00, 32, wmma::mem_row_major);
wmma::store_matrix_sync(C+16, c01, 32, wmma::mem_row_major);
wmma::store_matrix_sync(C+16*32, c10, 32, wmma::mem_row_major);
wmma::store_matrix_sync(C+16*32+16, c11, 32, wmma::mem_row_major);
}
④引入多个warp
A:128×128 B:128×128
block:128×128 将block分为4个warp:2×2 每个warp计算64×64个元素

上面三个例子都是在一个warp上进行计算的,能够在一个warp上计算是因为我们的矩阵比较小,寄存器压力不大,但是真实情况下矩阵会比较大,如果使用一个warp那每个线程要保存大量的数据,寄存器压力很大,所以我们要使用多个warp平摊压力。

现在已经将四个warp控制区域划分好了,接下来就要考虑对将warp内的64×64的区域划分成多个16×16的MMA

在32×32的版本里我们的一个warp里有2×2个16×16的MMA,所以需要c00、c01、c10、c11,而在当前版本中一个warp里面有4×4个16×16的MMA,所以需要c00、c01、c02...c33,共16个accumulator fragment
共享内存布局如下:

实现思路:先由一个Block的128个线程协作,把当前K阶所需要的A的128×16子块和B的16×128子块从Global Memory搬到Shared Memory中,因为A(128×16)×B(16×128)恰好可以产生一个完整的128×128C Tile;随后Block中的128个线程组成4个Warp,每个Warp负责C的一个64×64区域,而每个64×64Warp Tile又进一步拆成4×4个16×16的MMA Tile,因此每个Warp维护16个accumulator fragment。接下来沿K维度以16为步长循环,每一次都从Shared Memory中取出当前Warp所需要的16×16A/B fragment,通过mma_sync()让整个Warp调用Tensor Core完成C+=A×B,并把结果累加到对应的cij中;因为K=128,所以一共需要8个K阶段,最终每个Warp得到自己完整的64×64结果,4个Warp合起来就是整个128×128的C。最后通过warp_m和warp_n确定每个Warp在C中的起始位置,再通过mma_row和mma_col定位16×16的具体输出块,使用store_matrix_sync()将各个accumulator fragment写回Global Memory。
cpp
#include <mma.h>
#include <iostream>
using namespace nvcuda;
#define M 128
#define N 128
#define K 128
#define BLOCK_M 128
#define BLOCK_N 128
#define WARP_M 64
#define WARP_N 64
#define MMA_M 16
#define MMA_N 16
#define MMA_K 16
#define NUM_WARPS 4
global void mma_gemm_128x128(const half* A, const half* B, float* C)
{
shared half As[BLOCK_M][MMA_K];
shared half Bs[MMA_K][BLOCK_N];
int tid = threadIdx.x;
int warp_id = tid/32;
int warp_row = warp_id/2;
int warp_col = warp_id%2;
int warp_m = warp_row*64;
int warp_n = warp_col*64;
wmma::fragment<wmma::accumulator, MMA_M,MMA_N,MMA_K, float> c[4][4];
for(int i = 0; i < 4; i++)
{
for(int j = 0; j < 4; j++)
wmma::fill_fragment(c[i][j], 0.0f);
}
wmma::fragment<wmma::matrix_a, MMA_M,MMA_N,MMA_K, half, wmma::row_major> a;
wmma::fragment<wmma::matrix_b, MMA_M,MMA_N,MMA_K, half, wmma::row_major> b;
for(int k = 0; k < K; k += MMA_K)
{
// 加载A
for(int idx = tid; idx < BLOCK_M*MMA_K; idx += blockDim.x)
{
int row = idx/MMA_K;
int col = idx%MMA_K;
As[row][col] = A[row*K+(k+col)];
}
// 加载B
for(int idx = tid; idx < BLOCK_N*MMA_K; idx += blockDim.x)
{
int row = idx/BLOCK_N;
int col = idx%BLOCK_N;
Bs[row][col] = B[(k+row)*N + col];
}
__syncthreads();
for(int mma_row = 0; mma_row < 4; mma_row++)
{
int a_row = warp_m + mma_row * MMA_M;
const half* A_ptr = &As[a_row][0];
wmma::load_matrix_sync(a, A_ptr, MMA_K);
// A fragment可以被同一行4个MMA复用
for(int mma_col = 0; mma_col < 4; mma_col++)
{
int b_col = warp_n + mma_col * MMA_N;
const half* B_ptr = &Bs[0][b_col];
wmma::load_matrix_sync(b, B_ptr, BLOCK_N);
wmma::mma_sync(c[mma_row][mma_col], a, b, c[mma_row][mma_col]);
}
}
__syncthreads();
}
for(int mma_row = 0; mma_row < 4; mma_row++)
{
for(int mma_col = 0; mma_col < 4; mma_col++)
{
int c_row = warp_m+mma_row*MMA_M;
int c_col = warp_n+mma_col*MMA_N;
float* C_ptr = C + c_row*N + c_col;
wmma::store_matrix_sync(C_ptr, c[mma_row][mma_col], N, wmma::mem_row_major);
}
}
}
int main()
{
const int size_A = M*K;
const int size_B = K*N;
const int size_C = M*N;
size_t bytes_A = size_A*sizeof(half);
size_t bytes_B = size_B*sizeof(half);
size_t bytes_C = size_C*sizeof(float);
half A[size_A];
half B[size_B];
float C[size_C];
for(int i = 0; i < size_A; i++)
A[i] = __float2half(1.0f);
for(int i = 0; i < size_B; i++)
B[i] = __float2half(1.0f);
for(int i = 0; i < size_C; i++)
C[i] = 0.0f;
half* d_A = nullptr, *d_B = nullptr;
float* d_C = nullptr;
cudaMalloc(&d_A, bytes_A);
cudaMalloc(&d_B, bytes_B);
cudaMalloc(&d_C, bytes_C);
cudaMemcpy(d_A, A, bytes_A, cudaMemcpyHostToDevice);
cudaMemcpy(d_B, B, bytes_B, cudaMemcpyHostToDevice);
dim3 block(128);
dim3 grid(1);
mma_gemm_128x128<<<grid, block>>>(d_A, d_B, d_C);
cudaMemcpy(C, d_C, bytes_C, cudaMemcpyDeviceToHost);
cudaFree(d_A); cudaFree(d_B); cudaFree(d_C);
return 0;
}
⑤引入多个block
和上一个版本整体思路相同,只需要注意store的时候要注意设置全局索引
cpp
#include <mma.h>
#include <cuda_fp16.h>
#include <iostream>
#include <vector>
using namespace nvcuda;
#define M 1024
#define N 1024
#define K 1024
#define BLOCK_M 128
#define BLOCK_N 128
#define WARP_M 64
#define WARP_N 64
#define MMA_M 16
#define MMA_N 16
#define MMA_K 16
#define NUM_WARPS 4
global void mma_gemm(const half* A, const half* B, float* C)
{
shared half As[BLOCK_M][MMA_K];
shared half Bs[MMA_K][BLOCK_N];
int tid = threadIdx.x;
int warp_id = tid/32;
int warp_row = warp_id/2;
int warp_col = warp_id%2;
int warp_m = warp_row*64;
int warp_n = warp_col*64;
wmma::fragment<wmma::accumulator, MMA_M,MMA_N,MMA_K, float> c[4][4];
for(int i = 0; i < 4; i++)
{
for(int j = 0; j < 4; j++)
wmma::fill_fragment(c[i][j], 0.0f);
}
wmma::fragment<wmma::matrix_a, MMA_M,MMA_N,MMA_K, half, wmma::row_major> a;
wmma::fragment<wmma::matrix_b, MMA_M,MMA_N,MMA_K, half, wmma::row_major> b;
for(int k = 0; k < K; k += MMA_K)
{
// 加载A
for(int idx = tid; idx < BLOCK_M*MMA_K; idx += blockDim.x)
{
int row = idx/MMA_K;
int col = idx%MMA_K;
As[row][col] = A[row*K+(k+col)];
}
// 加载B
for(int idx = tid; idx < BLOCK_N*MMA_K; idx += blockDim.x)
{
int row = idx/BLOCK_N;
int col = idx%BLOCK_N;
Bs[row][col] = B[(k+row)*N + col];
}
__syncthreads();
for(int mma_row = 0; mma_row < 4; mma_row++)
{
int a_row = warp_m + mma_row * MMA_M;
const half* A_ptr = &As[a_row][0];
wmma::load_matrix_sync(a, A_ptr, MMA_K);
// A fragment可以被同一行4个MMA复用
for(int mma_col = 0; mma_col < 4; mma_col++)
{
int b_col = warp_n + mma_col * MMA_N;
const half* B_ptr = &Bs[0][b_col];
wmma::load_matrix_sync(b, B_ptr, BLOCK_N);
wmma::mma_sync(c[mma_row][mma_col], a, b, c[mma_row][mma_col]);
}
}
__syncthreads();
}
for(int mma_row = 0; mma_row < 4; mma_row++)
{
for(int mma_col = 0; mma_col < 4; mma_col++)
{
int c_row = blockIdx.y * BLOCK_M + warp_m+mma_row*MMA_M;
int c_col = blockIdx.x * BLOCK_N + warp_n+mma_col*MMA_N;
float* C_ptr = C + c_row*N + c_col;
wmma::store_matrix_sync(C_ptr, c[mma_row][mma_col], N, wmma::mem_row_major);
}
}
}
int main()
{
const int size_A = M*K;
const int size_B = K*N;
const int size_C = M*N;
size_t bytes_A = size_A*sizeof(half);
size_t bytes_B = size_B*sizeof(half);
size_t bytes_C = size_C*sizeof(float);
std::vector<half> A(size_A);
std::vector<half> B(size_B);
std::vector<float> C(size_C, 0.0f);
for(int i = 0; i < size_A; i++)
A[i] = __float2half(1.0f);
for(int i = 0; i < size_B; i++)
B[i] = __float2half(1.0f);
half* d_A = nullptr, *d_B = nullptr;
float* d_C = nullptr;
cudaMalloc(&d_A, bytes_A);
cudaMalloc(&d_B, bytes_B);
cudaMalloc(&d_C, bytes_C);
cudaMemcpy(d_A, A.data(), bytes_A, cudaMemcpyHostToDevice);
cudaMemcpy(d_B, B.data(), bytes_B, cudaMemcpyHostToDevice);
dim3 block(128);
dim3 grid(N/BLOCK_M, M/BLOCK_M);
mma_gemm<<<grid, block>>>(d_A, d_B, d_C);
cudaMemcpy(C.data(), d_C, bytes_C, cudaMemcpyDeviceToHost);
std::cout
<< "C[0][0] = "
<< C[0]
<< std::endl;
std::cout
<< "C[0][1] = "
<< C[1]
<< std::endl;
std::cout
<< "C[64][64] = "
<< C[64 * N + 64]
<< std::endl;
std::cout
<< "C[127][127] = "
<< C[127 * N + 127]
<< std::endl;
std::cout
<< "C[512][512] = "
<< C[512 * N + 512]
<< std::endl;
std::cout
<< "C[1023][1023] = "
<< C[1023 * N + 1023]
<< std::endl;
cudaFree(d_A); cudaFree(d_B); cudaFree(d_C);
return 0;
}
七、MMA
1.MMA Operand Layout
操作数布局,一个MMA指令执行时,A、B、C这些操作数的数据,按照什么规则分布在Warp的32个线程的各自寄存器里,就是MMA Operand Layout。
2.MMA
前面说的WMMA其实就是MMA的封装版,它将MMA封装起来,只暴露出load、sync、store几个接口让我们更加方便地进行矩阵乘法。WMMA不需要我们关系内部的数据布局,但是我们想要更加清楚的判断性能瓶颈,就需要更加底层,mma就是更接近底层,通过mma我们可以更加清楚的了解到数据的流动,更能找出真正的性能瓶颈,并进一步优化。
3.PTX内联
①PTX:Parallel Thread Execution,是NVIDIA GPU的一种中间汇编语言,它不是真正的硬件指令,但更接近底层,不同架构的GPU都可以先编译到PTX,再由驱动编译成对应的SASS。
②PTX内联:我们平时写的C/C++代码编译器会帮我们生成PTX。但是有时候想要直接控制底层某些指令,需要在C/C++代码中直接编写嵌入式指令,这就是PTX内联。
③基本语法
cpp
asm volatile(
"指令"
:输出操作数列表
:输入操作数列表
);
例如:使用mma进行计算,d=a*b+c
cpp
asm volatile(
"mma.sync.aligned.m16n8k16.row.col.f32.f16.f16.f32\n "
"{%0}, " "{%1}," "{%2}, ", "{%3}; "
: "=f"(d)
: "r"(a), "r"(b), "r"(c)
);
④约束
告诉编译器,这个变量应该放在哪一类寄存器里
r:32位整数
l:64位整数
f:32位浮点数
d:64位整数
h:16位整数
m:内存操作数
约束前面的 '=' 表示只写, '+' 表示读写
4.ldmatrix
①概念
ldmatrix指令是PTX级别的指令,它是Warp级别的数据加载指令,它会将数据从shared memory中加载到32个线程中的寄存器中,也就是32个线程都会调用ldmatrix,把数据从shared memory中搬运到寄存器中。
②功能
ldmatrix让一个warp中的32个线程高效地从shared memory搬运矩阵数据到各自的寄存器中,并按照Tensor Core的MMA所需要的寄存器布局进行组织。
如果没有ldmatrix,我们需要自己设计哪个lane加载哪几个数据、一个线程加载几个数据,如何保证地址合并、如何把数据放进正确的寄存器、最终寄存器布局还要符合mma.sync的要求,这个过程非常繁琐且容易出错,所以ldmatrix把这个过程硬件化了,它会按照NVIDIA规定好的lane->地址->寄存器映射把数据搬运到寄存器中。
③指令介绍
cpp
ldmatrix.sync.aligned.m8n8.x4.shared.b16
指令功能解释:一个warp的32个线程协同从Shared Memory中加载4个8×8的half矩阵,并把数据分配到各线程的寄存器中,供后面的mma.sync使用。
逐个拆分解释:ldmatrix:从Shared Memory加载矩阵到寄存器
sync:warp内线程同步参与这次操作
aligned:要求warp中线程以一致方式执行
m8n8:每个矩阵tile是8×8
x4:一次加载4个8×8矩阵
shared:数据源是Shared Memory
b16:每个元素是16bit,也就是half类型
5.mma.sync
矩阵乘加指令,需要ldmatrix按照mma.sync需要的布局将数据放入warp内各寄存器中,并进行计算。
cpp
mma.sync.aligned.m16n8k16.row.col.f32.f16.f16.f32
指令功能解释:一个warp的32个线程协同执行一个16×16 × 16×8的矩阵乘加;A使用row-major的FP16,B使用col-major的FP16,乘法结果以FP32累加,并产生FP32的16×8输出。
逐个拆分解释:aligned:warp内的线程需要以一致、符合要求的方式参与该指令
m16n8k16:矩阵形状。A(16×16) B(16×8) C(16×8)
row:A矩阵按row-major解释
col:B矩阵按col-major解释
f32.f16.f16.f32:分别是D、A、B、C的矩阵数据类型。
补充:下图PTX官方文档列出的不同版本的PTX所支持的mma shape对于.row.col是硬件MMA指令定义的特定的操作数布局方式,PTX只支持这种方式不支持.row.row/.col.col/.col.row。
6.数据到寄存器的映射
MMA规定它需要什么样的寄存器Fragment;ldmatrix按照这个Fragment的要求,把Shared Memory中的数据加载并分配到各个线程的寄存器中。
①矩阵A的布局
矩阵A的元素类型是half,占2字节,线程内每个寄存器占4字节,所以每个寄存器可以保存两个half元素,矩阵A的形状为16×16共256个元素,而warp内共有32个线程,所以每个线程将持有矩阵A的8个元素,占用4个寄存器,布局如下:

一个16×16的A矩阵被分为4个8×8的子矩阵,A矩阵中子矩阵的排布式列主序的,子矩阵内部式行主徐的,如下图,红色背景的数据为线程0的寄存器数据:0 1、128 129、8 9、136 137

②矩阵B的布局
矩阵B的形状为16×8,共128个元素,warp有32个线程,所以每个线程持有4个元素,占用2个寄存器,布局如下:

一个16×8的矩阵被分为2个8×8的子矩阵,B矩阵中子矩阵的排布是列主序的,子矩阵内部也是列主序的,如下图,红色北京的数据为线程0的寄存器数据:0 1、8 9

③矩阵C和D的布局
矩阵C和D的形状为16×8,共128个元素,warp内32个线程,所以每个线程持有C和D的4个元素,占用2个寄存器,不同线程持有的矩阵元素布局如下:

16×8的C矩阵被分为2个8×8的子矩阵,C和D矩阵中子矩阵的排布是列主序的,子矩阵内部是行主序的
④分析
现在我们将mma的指令计算的各个矩阵数据分布划分好了,我们要提前想办法将每个线程的寄存器中的元素按上边的布局填充好,所以我们需要使用ldmatrix来对数据进行填充。
上述讨论的是m16n8k16的半精度矩阵乘法,所以对矩阵A应使用m8n8.x4,矩阵B应使用m8n8.x2,type均为b16。
ldmatrix是一个warp级别的数据加载指令,并且是按行进行加载的,比如m8n8.x1就是一个8×8的矩阵,它的加载是被分为8行进行加载的,着8个行不一定连续,也就是说这个8×8的矩阵可能位于一个16×16的大矩阵中,这样就产生了8个共享内存空间的地址对应每行的首地址,着8个地址需要由线程0~7分别获取。

如下表,是8×8矩阵不同获取次数,每个小矩阵中首地址与获取地址的线程对应关系表
|-----|-----------|------------|-------------|-------------|
| num | Thread0-7 | Thread8-15 | Thread16-23 | Thread24-31 |
| x1 | Addr0-7 | - | - | - |
| x2 | Addr0-7 | Addr8-15 | - | - |
| x4 | Addr0-7 | Addr8-15 | Addr16-23 | Addr24-31 |
注:加载地址的线程和ldmatrix的线程无关,比如线程8加载16×16的矩阵的第9行的起始地址,但是它加载寄存器的时候和这个没关系。
⑤加载A矩阵
cpp
Unsigned int addr = static_cast<unsigned int>(
__cvta_generic_to_shared(
s_a+(lane_id%16)*16+(lane_id/16)*8
)
);
Asm volatile(
"ldmatrix.sync.aligned.x4.m8n8.shared.b16 {%0, %1, %2, %3}, [%4];\n"
: "=r"(a0), "=r"(a1), "=r"(a2)", "=r"(a3)"
: "l"(addr)
);
如下图:蓝色方块是每一行的首地址,由不同的线程获取

⑥加载B矩阵
B矩阵的形状为16×8,mma要求矩阵B是列主序的,所以需要考虑矩阵B的原始内存排列情况,就是在Shared Memory中的内存布局。
Ⅰ 列主序
如下图,数据在Shared Memory中的数据是按列写入的

cpp
Unsigned int addr = static_cast<unsigned int>(
__cvta_generic_to_shared(
s_b+(lane_id%8)*16+((lane_id/8)%2)*8
)
);
asm volatile(
"ldmatrix.sync.aligned.x2.m8n8.shared.b16 {%0, %1}, [%2];\n"
: "=r"(b0), "=r"(b1)
: "l"(addr)
);
如下图,蓝色方块是每一列的首地址,由不同的线程获取
注:16~31号线程会执行指令并获取地址,只是获取的地址不会被使用而已。

Ⅱ 行主序
如下图,数据在Shared Memory中是按照行写入的

由于mma要求的布局是按列读取的,所以在将行主序的共享内存中的数据拿到寄存器中时,需要使用转置参数.trans
cpp
Unsigned int addr = static_cast<unsigned int>(
__cvta_generic_to_shared(
s_b+(lane_id%16)*8
)
);
Asm volatile(
"ldmatrix.sync.aligned.x2.trans.m8n8.shared.b16{%0, %1},[%2];\n"
: "=r"(b0), "=r"(b1)
: "l"(addr)
);
7.Swizzle
Swizzle通过改变Globle Memory->Shared Memory的映射关系从而改变数据在Shared Memory底层bank内的映射。
我们可以使用padding来缓解bank conflict,但是pingding会浪费空间,
①问题
我们平时算矩阵时常常按行、按列访问,如果正常存放,那么按列访问会发生bank conflict,在我们当前场景下,由于我们访问的单位是矩阵,所以它们在共享内存中的布局不一定是连续的,所以正常访问也会产生bank conflict。
由于Swizzle这个概念就是比较抽象,网上的博客也比较,我有些看不懂,所以只能用最笨的方法就是按照流程画一遍的方式来学习Swizzle,我们就以上边分析寄存器映射规则的布局来演示Swizzle的整体流程。
如下图,展示了正常情况下A矩阵中的数据从Global Memory映射到全局内存中的过程,可以见到在访问的过程中会发生很明显的bank冲突。

②引入Swizzle
Ⅰ 预期结果
如下图,我们的处理方式是将在相同bank中的数据错开

Ⅱ使用方式
cpp
for(int i = 0; i < 8; i++)
{
int idx = tid*8+i;
int row = idx/16;
int col = idx%16;
//swizzle
int s_col = col^(((row/4)&1)<<3);
As[row][s_col] = A[row*16+col];
}
解释:row/4(0或1)计算行,&1判断是否需要换,<<3等价于×8,col^0/col^8从而达到交换对应列的目的。
这就是Swizzle的一个简单流程示例,对于不同的布局有不同的Swizzle处理方式,它是很灵活的,这里只是一个简单的示例,其他情况也可以按照这种方式去分析。
8.异步拷贝指令
①cp.async
cpp
cp.async.ca.shared.global[shared_addr],[global_addr],size;
解释:
cp.async:异步复制
ca:cache操作方式
shared:目的地址是shared memory
global:源地址是global memory
size:复制多少字节
功能:从global memory的global_addr异步复制4bytes到shared memory的shared_addr
②重要功能
在传统的GEMM中,我们的流程是Global Memory->Register->Shared Memory--ldmatrix-->Registers--mma.sync-->Tensor Core->Register->Global Memory,我们需要先将需要的数据全部拷贝到Shared Memory再进行计算,而再拷贝的过程中,计算单元会闲着,计算的时候,数据搬运单元没有充分利用,cp.async就是解决这种情况的,允许搬运和计算同时进行。
③分析
使用cp.async并不一维数据马上到Shared Memory了,而是发起了一次异步复制请求,所以GPU需要知道什么时候这些异步复制已经完成,可以安全使用Shared Memory里的数据。于是出现了cp.async.commit_group和cp.async.wait_group num。
④cp.async.commit_group
它本身不负责等待,也不负责搬数据,它的作用可以简单理解为把前面已经发出的、尚未归入某个group的cp.async操作,正式划分成一个group。
⑤cp.async.wait_group
它的作用是等待之前提交的cp.async group,直到未完成的group数量不超过指定值。
例如:wait_group 0 代表等待之前提交的cp.async group都完成;wait_group N表示允许还有N个较新的group未完成,除此之外更老的group必须完成。
⑥使用案例
Ⅰ 实现:一个warp内32个线程使用cp.async进行从Global Memory拿数据到Shared Memory的操作
cpp
#include <cuda_runtime.h>
#include <cuda.h>
#include <cstdio>
global void cp_sync_test(const float* global_data)
{
shared float shared_data[32];
int tid = threadIdx.x;
unsigned int smem_ptr =
static_cast<unsigned int>(
__cvta_generic_to_shared(&shared_data[tid])
);
// 每个线程负责一个float
asm volatile(
"cp.async.ca.shared.global [%0], [%1], 4;\n"
:
: "r"(smem_ptr),
"l"(&global_data[tid])
);
asm volatile(
"cp.async.commit_group;\n"
);
// 前面的cp.async属于一个group
asm volatile(
"cp.async.wait_group 0;\n"
);
__syncthreads();
if(tid == 4)
printf("shared_data[4] = %f\n", shared_data[4]);
}
int main()
{
float data[32];
for(int i = 0; i < 32; i++)
data[i] = (float)i;
float* d_data = nullptr;
cudaMalloc(&d_data, sizeof(float)*32);
cudaMemcpy(d_data, data, sizeof(float)*32, cudaMemcpyHostToDevice);
cp_sync_test<<<1, 32>>>(d_data);
cudaDeviceSynchronize();
cudaFree(d_data);
return 0;
}
Ⅱ使用cp.async计算矩阵乘法
思路:先由一个 Warp 的 32 个线程分别通过 cp.async 将 Global Memory 中的 A(16×16) 和 B(16×8) Tile 协作搬运到 Shared Memory,并通过 commit_group + wait_group + __syncthreads() 保证数据已经就绪;然后使用 ldmatrix.x4 和 ldmatrix.x2.trans 按照 Tensor Core 规定的硬件映射,将 Shared Memory 中的 A、B 数据加载并分发到各线程寄存器中的 MMA fragment;接着初始化 C 的 accumulator,通过 mma.sync.m16n8k16 让整个 Warp 协作执行 D=A×B+C 的 Tensor Core 矩阵乘法,结果分别保存在每个线程自己的 c0~c3 寄存器中;最后各线程把自己持有的部分结果写回 Global Memory,从而完成一次完整的 Global → Shared → Registers → Tensor Core → Registers → Global 数据流。
cpp
__global__ void test(const half* A, const half* B, float* C)
{
// 1.Shared Memory
__shared__ half As[16][16];
__shared__ half Bs[16][8];
int lane = threadIdx.x & 31;
// 2.Global Memory -> Shared Memory
int row_A = lane/2;
int col_A = (lane%2)*8;
unsigned int smem_A = static_cast<unsigned int>(
__cvta_generic_to_shared(&As[row_A][col_A])
);
const half* gmem_A = &A[row_A*16+col_A];
asm volatile(
"cp.async.ca.shared.global [%0], [%1], 16;\n"
:
: "r"(smem_A),
"l"(gmem_A)
);
int row_B = lane/2;
int col_B = (lane%2)*4;
unsigned int smem_B =
static_cast<unsigned int>(
__cvta_generic_to_shared(&Bs[row_B][col_B])
);
const half* gmem_B = &B[row_B*8+col_B];
asm volatile(
"cp.async.ca.shared.global [%0],[%1], 8;\n"
:
: "r"(smem_B),
"l"(gmem_B)
);
// 3.cp.async grop
asm volatile("cp.async.commit_group;\n");
// 4.wait_group
asm volatile("cp.async.wait_group 0;\n");
__syncthreads();
// 5.Shared Memory -> Registers
// A:ldmatrix.x4
unsigned long long A_addr;
if(lane < 8)
{
A_addr = static_cast<unsigned long long>(
__cvta_generic_to_shared(&As[lane][0])
);
}
if(lane < 8)
{
A_addr = static_cast<unsigned long long>(
__cvta_generic_to_shared(&As[lane][0])
);
}
else if(lane < 16)
{
A_addr = static_cast<unsigned long long>(
__cvta_generic_to_shared(&As[lane-8][8])
);
}
else if(lane < 24)
{
A_addr = static_cast<unsigned long long>(
__cvta_generic_to_shared(&As[lane-16+8][0])
);
}
else
{
A_addr = static_cast<unsigned long long>(
__cvta_generic_to_shared(&As[lane-24+8][8])
);
}
unsigned int a0,a1,a2,a3;
asm volatile(
"ldmatrix.sync.aligned.m8n8.x4.shared.b16 "
"{%0,%1,%2,%3}, [%4];\n"
: "=r"(a0), "=r"(a1), "=r"(a2), "=r"(a3)
: "l"(A_addr)
);
// B:ldmatrix.x2
unsigned long long B_addr;
if (lane < 8)
{
B_addr =
static_cast<unsigned long long>(
__cvta_generic_to_shared(&Bs[lane][0])
);
}
else if (lane < 16)
{
B_addr =
static_cast<unsigned long long>(
__cvta_generic_to_shared(&Bs[lane - 8 + 8][0])
);
}
else
{
B_addr =
static_cast<unsigned long long>(
__cvta_generic_to_shared(&Bs[0][0])
);
}
unsigned int b0,b1;
asm volatile(
"ldmatrix.sync.aligned.m8n8.x2.trans.shared.b16 "
"{%0, %1}, [%2];\n"
: "=r"(b0), "=r"(b1)
: "l"(B_addr)
);
// 6.Accumulator
float c0 = 0.0f;
float c1 = 0.0f;
float c2 = 0.0f;
float c3 = 0.0f;
asm volatile(
"mma.sync.aligned.m16n8k16.row.col.f32.f16.f16.f32 "
"{%0,%1,%2,%3}, "
"{%4,%5,%6,%7}, "
"{%8,%9}, "
"{%10,%11,%12,%13};\n"
:"=f"(c0), "=f"(c1), "=f"(c2), "=f"(c3)
: "r"(a0), "r"(a1), "r"(a2), "r"(a3),
"r"(b0), "r"(b1),
"f"(c0), "f"(c1), "f"(c2), "f"(c3)
);
// 7.Registers -> Global Memory
C[lane*4+0] = c0;
C[lane*4+1] = c1;
C[lane*4+2] = c2;
C[lane*4+3] = c3;
}
9.ping-pong line
未引入cp.async的普通方式从global搬运一个tile到shared memory的时候,需要等待这个tile搬完,再进行计算,计算完之后再搬运下一个tile...,计算的时候,内存搬运是没有干活的;cp.async的目标是计算tile0的同时,提前把tile搬进shared memory。
①示例
两个Tile
第一步:搬tile0
第二步:等待tile0
第三步:搬tile1
第四步:计算tile0
第五步:等待tile1
第六步:计算tile1
注意:这里是先计算tile0再等待tile1的,这样就实现了异步
②问题
Tile1放在哪里,tile0还在Shared Memory中,如果Tile1直接覆盖Tile0,Tile0还没有算完,Tensor还在计算,这时候覆盖会导致计算的结果出错
③double buffer
针对上边的问题,解决思路是设置两个buffer,一个保存Tile0的数据,另一个保存Tile1的数据,buffer0进行当前计算,buffer1后台搬运;等tile0计算完了,Tile2的数据就可以放在buffer0里边了,这样循环往复

④代码展示
如下代码实现计算当前buffer的数据时,同时把下一轮数据搬到另一个buffer,计算完之后交换两个buffer,如此循环。
cpp
#include <__clang_cuda_runtime_wrapper.h>
#include <cuda_runtime.h>
#include <cstdio>
#define TILE_SIZE 128
__device__ __forceinline__
void cp_async_16B(void* smem_ptr, const void* gmem_ptr)
{
unsigned int smem_addr =
static_cast<unsigned int>(
__cvta_generic_to_shared(smem_ptr)
);
asm volatile(
"cp.async.ca.shared.global [%0], [%1], 16;\n"
:
: "r"(smem_addr), "l"(gmem_ptr)
);
}
__global__ void double_buffer_kernel(const float* __restrict__ input, float* __restrict__ output, int N)
{
__shared__ float smem[2][TILE_SIZE];
int tid = threadIdx.x;
// 1.先加载第一个Tile到buffer0
if(tid*4 < N)
{
cp_async_16B(
&smem[0][tid*4],
&input[tid*4]
);
}
asm volatile("cp.async.commit_group;\n");
// 等待tTile0搬完
asm volatile("cp.async.wair_group 0;\n");
__syncthreads();
// 2.开始循环
for(int tile = 0; tile < N / TILE_SIZE; tile++)
{
int current = tile & 1;
int next = current ^ 1;
// 3.提前加载下一个Tile
int next_offset = (tid+1) * TILE_SIZE;
if(next_offset+tid*4 < N)
{
cp_async_16B(
&smem[next][tid*4],
&input[next_offset + tid*4]
);
}
asm volatile("cp.async.commit_group;\n");
// 4.使用当前buffer中的数据进行计算
float value = smem[current][tid];
value = value * 2.0f;
// 5.等待下一个Tile搬运完成
asm volatile("cp.async.wait_group 0;\n");
__syncthreads();
// 6.将结果写回
output[tile*TILE_SIZE+tid] = value;
}
}
⑤ping-pong line
Double Buffer是机制,Ping-Pong是这种机制的交替使用。
Buffer0 = ping Buffer1 = pong,它们交替工作

两个buffer交替使用,这个过程叫做ping-pong,它们的整体流程叫做ping-pongline。
