上次聊了聊CUDA程序的性能优化思路,发现写起来真是停不下来……因为换了工作,后续就拖了挺久。现代人真的挺忙的,长文章意义有限,我懒得写,大家也懒得看。想了想,先把一些系统的东西讲讲清楚,然后讲些琐碎的感想,每次就讲一两个要点,理论少点,随性些,不拘于格式,多举点具体例子,也许效果更好些。不过这一次,好吧,长文预警……
顺便还是强调一下,这些知识还是有些门槛的,对刚入门的初学者可能不太友好。但只要你有些基础知识,看完后如果有不清楚的地方,可以自己找找资料深入研究,也许可以增加一些可能并不怎么有用的知识。我也会尽量多给出一些参考材料。有些地方我可能也会有一些想当然的地方,也希望大家能批评指正,共同学习。
今天开始,准备花几篇文章聊聊CUDA的总体流程设计。这是第一部分,主要讲CUDA程序的等级结构。
我这里说的总体流程设计,指的是怎么把一个具体任务转化为Kernel,Kernel每个thread和block之间应该怎么组合。如果一个kernel做不成,那多个Kernel怎么分工。这里的流程也包括Kernel之间,Kernel和其他API(比如cudaMemcpy
),或者是和其他CPU任务之间,怎么配合等等。想对CUDA程序做一个很好的规划,就要先深入了解CUDA程序的各级组成单元。只有了解每层单元具体是怎么工作的,清楚它们相互同步、通信的机制,才能更好的让它们协作配合。所以这篇先讲CUDA程序的等级结构。
Kernel 上层的等级结构
首先简单阐述一下CUDA kernel以上的等级结构。每个CUDA程序都必须是一个CPU进程,其中可能包括一个到多个CPU线程,CPU线程间的共享关系与普通多线程程序是一样的。每个CUDA程序都包含(或隐含)一个到多个CUDA context(driver api里的CUcontext
)。CUDA context可以看成是类似CPU进程的GPU进程,大部分的runtime api,包括memcpy,启动kernel,bind texture等等,都会运行在这个context上。每个context都有自己的资源空间,相互隔离,互不影响,所以有一个出错也不会影响到其他context。每个CUDA程序至少会有一个context,不显式初始化的话程序会自动分配一个,这也是runtime api大部分时候的用法。也可以申请多个,但每线程当前只能用一个。CUDA会维护一个context的栈,可以通过类似push/pop的方式切换当前context。每个context会包含一些模块(CUmodule
,有的是以cubin文件形式保存),类似dll。模块内会有一些kernel function(CUfunction
),也会有一些模块内资源的描述(比如constant memory,texture reference等),load模块时这些资源就会得到分配。值得一提的是,kernel function不仅可以访问本模块内的资源,其实整个context的内容都是可见的。更具体的描述可以参考CUDA C++ Programming Guide中driver api的部分。具体的driver api的内容也可以看CUDA Driver API.
这里有几个微妙的地方:
- 一是多线程的CPU程序能共享context吗?答案是可以。其实本身同一进程的资源就是相互可见的,所以多线程可以共享context。每个context执行时有一个或多个stream,同一个stream内的api是序列化的,前一个返回后才能运行后一个。所以其实多线程并不适合同时操作同一个stream,但不同stream其实是可以同时操作的。一些更大型的应用也可以让每个线程维护自己的context,这样相互干扰更少,万一一个出错也不会导致整个进程崩溃。当然,CPU同时执行api是一回事,到了GPU设备端肯定还是有一个queue来维护顺序执行关系(比如申请资源这种肯定要排队,不可能完全并行)。
- 二是多GPU的情况怎么办?其实每个卡(或者更精确的说是每个GPU芯片,对应driver api里的
CUdevice
)都有相互独立的context。资源也是相互独立的。CUDA里有managed memory,可以让它看起来是每个device都可访问(也包括host),但其实只是把数据搬运自动化后隐藏起来而已,本质上每个卡的资源还是分开的。CUDA程序暂时还不能做到自动把多块卡当成一块来用,一个kernel也不能自动分在两个GPU上运行,有NVlink也不行。所以当前情况下多GPU还是需要用户自己来手动分割任务。
Kernel的等级结构
一个CUDA Kernel大概可以分为这么几层(从底层到顶层):thread,warp,block,grid。下面就分别讲讲这几层的特征和运行模式。
1. Thread
Thread是CUDA程序的底层任务单元,与CPU的thread具有比较高的相似性(其实独立性上更像进程一些)。每个Thread都有一些私有资源:
- General Perpose Register: 通用寄存器,简称GPR,有时也直接叫Register(寄存器)。GPR通常按个算,一个是32bit,在CUDA的SASS汇编里一般写成
R0
,R123
这种格式。每个线程使用的具体GPR数目是编译器根据需要进行配置的,每个kernel的所有线程都保有相同数目的GPR。最近几代架构单线程最大可用数目是255,因为指令集里GPR编码有8bit(2^8=256),而且R255=RZ