CUDA执行模型

1.设备管理和查看:

cudaError_t cudaGetDeviceProperties(cudaDeviceProp * prop,int device)


用户可以通过这个函数来查看自己GPU设备的相关信息,Device表示要查看的GPU索引,得到的Prop是一个包含GPU信息的结构体。

2.GPU架构:GPU架构是围绕一个流式多处理器(SM)的可扩展阵列搭建的

SM的关键核心:

  • CUDA核心

  • 共享内存/一级缓存

  • 寄存器文件

  • 加载/存储单元

  • 特殊功能单元

  • 线程束调试器

一个GPU通常搭载由多个SM,一个SM通常能支持数百个线程并行执行。

3.当核函数启动一个网格时,对应的线程块会被调度到可用的SM上,一旦分配好SM,线程块中的线程只能够在分配好的SM上运行。

4.CUDA采用单指令多线程(SIMT)架构来管理和执行线程,其中32个线程被划分为一组,称为一个线程束,线程束中的线程执行同一条指令。每个SM都将分配一个线程束给它的线程块。

*5.SIMT允许线程束中的线程独立执行,也就是说线程束中的线程可能有不同的行为。

SIMT包含有SIMD(单指令多数据)不同的地方:

  • 每个线程都有自己的指令地址计数器

  • 每个线程都有自己的寄存器状态

  • 每个线程都可以由一个独立的执行路径

6.一个线程块只能在一个SM上工作,一个SM在同一时间内可以支持多个线程块同事工作,线程块会一直保留在该SM中知道执行完成。

*7.SM中,共享内存和寄存器是非常重要的资源,他们被分配在了SM的常驻线程块中。

8.线程块里的线程不一定都会在物理层内执行,因此线程块内的线程的速度不一定是相同的。

9.在并行线程中,共享内存可能会引起竞争,CUDA中有提供实现线程块内线程之间的同步的方法,但是并没有提供线程块与线程块之间的同步方法。

10.当某一线程束处于闲置状态(比如说正在等待读取数据),此时SM可以调用同一
SM常驻线程块内的其他线程束进行执行,且切换方便不产生任何开销。因为线程块已经被分配到了指定的SM上并进行保存。

11.SM中的共享内存和寄存器是稀缺资源,他们严重限制了SM中活跃的线程束的数量。

12.不同的GPU架构:

Fermi架构:

  1. Fermi架构

Fermi架构中包含由512CUDA核心,每个CUDA核心包含有整数逻辑运算器(ALU)和浮点运算器(FPU),每个SM中分配由32CUDA核心,这些CUDA核心共分配给了16SMFermi架构包含有6384位的GDDR5 DRAM接口,支持高达6GB的全局机载内存,主机接口通过PCIE总线将CPUGPU联系在一起,GigaThread引擎是个全局调度器,负责调度线程块到SM线程束调试器上。Fermi架构内部有一个768KB二级缓存,这个缓存支持16SM共享。特殊功能单元(SFU)执行固有命令,如正弦,余弦,平方根和插值,每个SFU在一个时钟周期的每个线程上执行一个固有指令,每一个SM上有16个加载器/存储单元,允许在一个时钟周期内对线程束的一半计算源地址和目的地址。Fermi架构,计算性能2.x,可以在一个SM上同时运行48个线程束,也就是运行1536个线程。同时Fermi架构还有一个64KB的片内可配置存储器,它在共享内存与一级缓存之间分配,CUDA提供了一个运行时API,可以根据不同的存储配置调整共享内存与一级缓存之间的数量。Fermi架构允许多达十六个内核同时在设备上运行。


  1. Kepler架构

Kepler K20X为例,它包含了15SM664位内存控制器,相对于Fermi,Kepler架构强化了以下内容:

  • 强化了SM

  • 动态并行

  • Hyper-Q技术

强化的SM:每个Kepler架构的SM上包含了192个单精度CUDA核心,64个双 精度双精度单元和32个特殊功能单元(SFU)以及32个加载/存储单元 (LD/ST) 每个SM同时包含有4个线程束调度器和8个指令调度器,以保证可以在每个SM 上同时发送和执行4个线程束,KeplerSM可以实现同时并行运算64个线程束, 运算能力为3.5,也就是说每个SM可以运行2048个线程。相较于Fermi架构, Kepler K20X每瓦的性能提升了三倍之多,并且能够提供超过1TFLOPS的峰 双精度计算能力,

动态并行:Kepler架构允许GPU动态启动新的网格,有了这个特点,任一内核都 能够在内核内启动其他的内核,并且正确管理核间依赖关系来正确执行附加工作, GPU可以启动嵌套内核。

Hyper-Q技术:Fermi架构通过一个单一的硬件工作队列来从CPUGPU传送任 务,会出现某一任务阻塞该任务后的其他任务,Kepler通过Hyper-Q技术在CPU GPU之间增加了32个硬件工作队列,保证了在GPU上有更多的并发执行。

13.线程束是SM的基本构成单位,当一个线程块的网格被启动时,线程块就会被调度到SM上,线程块内的线程会被根据线程组号进行划分成32个线程组成一个线程束并进行处理。在一个线程束中,所有的线程按照单指令多线程的方式执行,所有线程都执行相同的指令。

14.从硬件角度观察,线程则是被组织成一维,但是对于线程块而言可能存在一维二维三维的线程块,对于一个一维的线程块,线程索引由threadIdx.x决定,若是二维的线程块,则索引为

idx=threadIdx.y * blockDim.x + threadIdx.x

对于一个三维的线程块,线程索引为

idx=threadIdx.z * blockDim.y*blockDim.x + threadIdx.y * blockDim.x +threadIdx.x

15.假设说由66个线程,分配在一个二维线程块中,其中,线程块X轴中分布有33个线程,Y轴中分布有两个线程,在将该线程块分配到单个SM上时,线程块中的线程32为一线程束,那么这时候硬件为该线程块分配有3个线程束,共计占用硬件线程96个,其中第三个线程束的30个线程束处于闲置状态。

16.线程束分化:在CPU中,可以使用if(cond){

}else{

}来进行处理相对较为复杂的逻辑结构,但在GPU状态下,线程束中的线程所执行的指令是相同的,但若是对于一些线程中cond条件成立,在另一些线程中cond又无法成立,那么对于一个线程束中的线程势必一部分线程执行if下语句,剩余部分执行else下的指令,这样便可称之为线程束分化,这与一个线程束中的线程是执行同一条指令的理论相悖,在这种情况下,GPU将会先执行一部分执行if(cond){}下的线程,而禁用else{}下的线程,这种情况下活跃的线程将会减少,大大降低了并行性,分支越多,并行性越差。因此,在同一线程束应尽可能的保证所有线程的执行路径都是相同的,一种提高分支率的方法是对线程束进行分配,通过不同线程束的执行路径的不同从而决定不同的操作,源代码如下(注意的是,此时Kernel_1Kernel_2两者核函数对应的操作是不同的,但是这也给优化提供了一个方向,将分支数力度调整为分支束的大小的倍数,从而降低性能损耗):

#include<cuda_runtime.h>

#include<stdio.h>

#include<sys/time.h>


doublewhat_time_is_it_now(){

structtimeval time_t;

gettimeofday(&time_t,NULL);

return ((double)time_t.tv_sec + (double)time_t.tv_usec*1.e-6);

}



__global__voidkernel_1(void){

int idx = blockIdx.x * threadIdx.y + threadIdx.x;

float kernel = 0.0f;

if(idx % 2){

kernel = 100.0f;

}else{

kernel = 200.0f;

}

float kernel_sum = kernel + 200.0f;

//printf("threadIdx.x:%d,result is : %lf\n",idx,kernel_sum);

}


__global__voidkernel_2(){

int idx = blockIdx.x*threadIdx.y + threadIdx.x;

float kernel = 0.0f;

if((idx/warpSize)%2){

kernel = 100.0f;

}else{

kernel = 200.0f;

}

float kernel_sum = kernel + 200.0f;

// printf("warpSize:%d\n",warpSize);

}



voidCHECK(cudaError_t status){

if(status != cudaSuccess)

{

printf("cudaError:%s\n",cudaGetErrorString(status));

exit(1);

}

}


intmain(){

cudaSetDevice(0);

dim3 block(32,32);

dim3 grid(1,1);

double time_t = what_time_is_it_now();

kernel_1<<<grid,block>>>();

CHECK(cudaGetLastError());

cudaDeviceSynchronize();

time_t = what_time_is_it_now() - time_t;

printf("cost_time: %lf\n",time_t);

time_t = what_time_is_it_now();

kernel_1<<<grid,block>>>();

CHECK(cudaGetLastError());

cudaDeviceSynchronize();

time_t = what_time_is_it_now()-time_t ;

printf("second_cost : %lf\n",time_t);

time_t = what_time_is_it_now();

kernel_2<<<grid,block>>>();

CHECK(cudaGetLastError());

cudaDeviceSynchronize();

time_t = what_time_is_it_now()-time_t ;

printf("second_cost : %lf\n",time_t);

cudaDeviceReset();

return 0;

}

17.nvprof提供了相关的可供查询分支数量和利用率的命令行:

查询分化支的数量和总的分支数

nvprof --events branch,divergent_branch ./simpleDivergence(应用名称)

查询分支效率

nvprof --metrics branch_efficiency ./simpleDivergence

18.nvcc可以对代码进行优化,通过分支预测优化内核,命令行为:

nvcc -O3 -arch sm_61 simpleDivergence.cu -o simpleDivergence

实际上该优化方式是在执行时依旧全部执行,但是对应的将条件设置为1,不符合条件的设置为0,同时对所有线程执行,尽管分支数得到了优化,但是并没有对线程的时间进行优化。

也可以使用

nvcc -G -g -arch sm_61 simpleDivergence.cu -o simpleDivergence

意思是进行分支预测和代码优化。

19.线程束的本地执行由以下资源构成:

  • 程序计数器

  • 寄存器

  • 共享内存

每个核函数启动的线程的占用的寄存器越多,那么对应的一个SM中可以同事启动的线程束的数量就越少,同时,对于一个线程块而言,若是一个线程块占用的共享内存越少,那么SM中就可以有更多的线程块运行。(48KB)

如果SM中没有足够的寄存器和共享内存来启动一个线程块,那么这个核函数将无法启动。

20.当计算资源(寄存器和共享内存)分配给线程块时,对应的线程块就被称为活跃的块,对应的线程束就被称之为活跃的线程束。活跃的线程束包含:

  • 选定的线程束

  • 阻塞的线程束

  • 符合条件的线程束

SM上的线程束调度器在一个周期内选择一个线程束进行执行,对应的称为选定的线程束,当一个线程束被选定但还未被执行,那么这个线程束就被称之为符合条件的线程束,当一个线程束被选定但还没做好执行的准备,那么这个线程束就被称之为阻塞的线程束。线程束执行的条件如下:

  • 32CUDA核心可用于执行

  • 当前指令下的所有参数都已准备就绪

20.当一个线程束被阻塞时,线程束调度器会选择一组符合条件的线程束执行。因为线程束的整个生存周期都在芯片内部,因此线程束上下文切换是非常快的。

21.计算资源限制了线程束的数量

*22.延迟隐藏:SM依赖于线程级并行,以最大化功能单元的利用率,因此,利用率与常驻线程束的数量密切相关,在指令发出和指令完成之间的时钟周期被定义为指令延迟,当每个时钟周期内每个线程束调度器都由可执行的线程束,那么就可以达到计算资源的完全利用,通过在其他线程束中发布其他指令,可以隐藏每个指令的延迟。

23.考虑到指令延迟,指令被分为两种基本类型:

  • 算数指令

  • 内存指令

两者的延迟大概为:

  • 算数指令延迟10~20个周期

  • 全局内存访问指令延迟400~800个周期

如何估算隐藏延迟所需的活跃的线程束的数量:

所需线程束的数量 = 延迟 * 吞吐量

24.吞吐量和带宽:

两者都被用来度量性能的速度指标,带宽是指理论峰值,吞吐量是指已经达到的值。

带宽通常是用来描述单位时间内最大可能的数据传输量,而吞吐量是用来描述单位时间内任何形式的信息或操作的执行速度,如,每个周期完成多少条指令。

25.占用率:

理想情况下,我们想要由足够多的线程束来占用设备的核心,占用率是每个SM中的活跃线程束占SM最大线程束的比值

占用率 = 单个SM活跃的线程束数量/单个SM最大线程束数量

可以通过CUDA samples下的deviceQuery来获得SM最大线程束数量:

Maximum number of threads per multiprocessor

26.极端的操纵线程块会限制资源的利用:

  • 小线程块:每个块中的线程数量太少,会在所有资源被利用完之前每个SM到达线程束数量限制的最大值

  • 大线程块:没个线程块线程数量太多,会导致每个SM中每个线程可用的资源太少。

27.网格和线程块大小的准则:

  • 保持每个块中线程数量是线程束大小(32)的倍数

  • 避免块太小:每个块中至少要由128或者256个线程

  • 根据内核资源的需求适当调整块的大小

  • 块的数量要远远大于SM的数量

  • 通过实验得到最佳执行配置和资源使用情况

28.CUDA中,同步可以分为两个级别执行:

  • 系统级:等待主机和设备完成所有的工作

  • 块级:在设备执行过称重等待一个块中的所有线程到达同一个点

29.对于主机来说,由于CUDA API调用和内核启动不是同步的,因此可以调用cudaDeviceSynchronize()函数来阻塞主机的程序直到所有内核操作完成后再继续进行下一步:

cudaError_t cudaDeviceSynchronize()

30.对于不同的GPUCUDA代码可以根据GPU硬件的不同,将线程块分布在不同的SM上,举例CUDA核心中存在有6个线程块,对于一个具有2SM的设备,则一个SM上分配有3个线程块,当该CUDA核函数启动在一个具有6SM的设备上时,则一个SM上分配有1个线程块,对应的速度也会得到增加,这就是可扩展性。

31.可以通过

nvprof --metrics acheived_occupancy ./应用程序名称

来获得CUDA程序的占用率

通过

nvprof --metrics gld_throughput ./应用程序名称

来获得CUDA程序的内存读取速度

可以通过

nvprof --metrics gld_efficiency ./应用程序名称

来获得CUDA程序的内存读取效率

32.GPU并行运算中,如何对一串数组进行加法求和操作:

  • 将输入向量划分到更小的数据块中

  • 用一个线程计算一个数据块的部分和

  • 对每个数据块的部分和再求和得到最终结果

通过迭代的方式实现,首先将n个数据划分成n/2个数据块,每个数据块中包含一对数据的计算,当得到结果后,对得到的结果进行就地保存,然后重新迭代划分成一组新的数据块,依次,直到N=1结束,那么最终的和就被计算出来了。

在进行数据块配对的时候,配对的方式有两种:

  • 相邻配对:元素与他们直接相邻的元素配对

  • 交错配对:根据给定的跨度配对元素

相邻配对需要对数组进行n-1次求和运算。进行log2n

33.动态并行:CUDA的动态并行允许在核函数中创建和同步新的核函数,有了动态并行,可以在运行时决定需要在GPU上创建多少个网格和块,可以动态利用GPU硬件调度器和加载平衡器。动态并行同时可以减少数据和控制在CPUGPU间传送所使用的时间。

34.在动态并行中,内核执行分为两种类型:父母和孩子,父线程,父线程块和父网格启动一个新的网格,子网格必须在父网格,父线程和父线程块执行完成前执行完成。只有所有的子网格执行完成后,父网格才会执行完成。

35.主机线程配置启动父网格线程配置,父网格线程配置启动子网格线程,要保证父网格与子网格之间的显示同步,因此可以在父网格设置栅栏同步。这样便可以使父网格与子网格之间显示同步。

36.设备上创建的网格启动,在线程块间是可以见到的,这意味着,子线程可能与该父线程启动的或者相同线程块中的其他线程的子线程同步,在线程块中,只有当所有线程创建的网格结束后,线程块的执行才会结束,因此,如果块中所有线程在所有子网格完成之前退出,那么子网格上的隐式同步就会被触发。

37.父网格和子网格拥有共同拥有相同的全局内存和常量内存存储,但他们拥有着不同的局部内存和共享内存,父网格和子网格可以进行并发全局内存存储,有两个时刻,父网格和子网格所见到的内存完全相同,即子网格启动和子网格完成时。当父线程优于子线程时,要保证所有父线程中的内存在子网格中可见,当子线程结束并且与父线程完成同步操作的时候必须保证子线程中的所有内存操作父线程中可见。

38.共享内存和局部内存对于线程块或者线程来说是私有的,对于父母和孩子来说不是可见的或者一致的,局部内存对于线程来说是私有存储,并且对该线程外部是不可见的,当启动一个子网格时,向局部内存传递一个指针作为参数是无效的。

39.HelloWorld 嵌套代码:

#include<stdio.h>

#include<iostream>

usingnamespace std;

#include<cuda_runtime.h>



__global__voidnestedHelloWorld(intconst iSize,int iDepth){

int tid = threadIdx.x;

printf("HelloWorld from block : %d,thread: %d\n",blockIdx.x,threadIdx.x);

if(iSize == 1) return ;

int nthreads = iSize >> 1;

if(tid == 0 && iDepth >= 0)

{

nestedHelloWorld<<<1,nthreads>>>(nthreads,++iDepth);

printf("============>Circle is %d\n",iDepth);

}

}


intmain(){

cudaSetDevice(0);

nestedHelloWorld<<<1,8>>>(8,0);

cudaDeviceReset();

}

编译时默认含有sm_21Fermi架构不支持动态并行,因此需要手动输入-rdc=true(可重定位设备代码)来进行编译。

40.动态并行的限制条件:

动态并行只有在计算能力为3.5或者更高的设备上才能被支持,动态并行的最大嵌套深度限制为24,但是实际上,在每一个新的级别中,大多数内核同事还受限于设备运行时系统需要的内存数量,因为为了对每个嵌套层中的父网格和子网格之间进行同步管理,设备通常还要留有额外的内存空间。

猜你喜欢

转载自www.cnblogs.com/Jetson-xie/p/10421843.html