第2章 CUDA编程模型详解
2.1 高度多线程协处理器:GPU并行计算的核心
谈到CUDA编程,其核心思想就是将GPU视为一个能够并行执行海量线程的高性能计算设备。GPU作为主CPU的协处理器——更精确地说,是主机(host)的辅助处理器:主机上运行的应用中,那些数据并行性强、计算密集度高的部分,会被“卸载”到GPU上执行,从而大幅提升整体性能。

具体来说,那些需要多次运行但每次处理不同数据且彼此独立的代码段,可以封装成一个函数,让它在GPU设备上以成百上千个独立线程的形式并行执行。实现方法是:将这样的函数编译成设备指令集,然后将这个程序——也就是内核(kernel)——下载到设备上运行。这种CUDA并行计算模型极大简化了GPU编程的复杂度。
主机和设备各自拥有独立的DRAM,分别称为主机内存和设备内存。两者之间的数据拷贝可以通过优化的API调用来完成,这些调用会利用设备的高性能直接内存访问(DMA)引擎,确保数据传输速度高效可靠。
2.2 线程分批与协作机制
2.2.1 高度多线程协处理器(续)
线程分批策略
执行内核的线程批次,会被组织成线程块的网格。这一关键概念在2.2.1和2.2.2中有详细说明,图2-1给出了直观的图示。通过合理分批,CUDA程序员可以高效利用GPU的并行资源。
线程块详解
线程块是一组能够协同工作的线程,它们通过一块快速的共享内存来高效共享数据,并通过同步执行来协调内存访问。换句话说,用户可以在内核中指定同步点,线程块里的所有线程都必须到达那个点才能继续执行,在此之前都会被挂起。这种同步机制保证了数据的一致性。
每个线程都有一个线程ID,即线程在块内的唯一编号。为了让基于线程ID的寻址更加灵活,应用程序还可以把块声明为任意大小的二维或三维数组,然后使用2个或3个分量索引来标识每个线程:
- 对于大小为 \((D_x, D_y)\) 的二维块,索引为 \((x, y)\) 的线程的线程ID是 \(x + y D_x\)
- 对于大小为 \((D_x, D_y, D_z)\) 的三维块,索引为 \((x, y, z)\) 的线程的线程ID是 \(x + y D_x + z D_x D_y\)
2.2.2 线程块网格组织
每个线程块能包含的线程数量存在上限。不过,多个大小和维度相同的线程块可以组合成一个块网格,这样一来,单个内核调用能够启动的总线程数就可以大幅增加。代价是什么呢?不同线程块之间的线程无法互相通信或同步,线程间的协作能力受到了限制。但恰恰是这种分层模型,让内核可以在不同并行能力的设备上高效运行而无需重新编译:如果设备并行能力较弱,网格的所有块可以顺序执行;如果并行能力强,就可以全部并行执行;通常情况下是一种混合模式。
每个线程块用块ID来标识,即块在网格中的索引编号。同样,应用程序也可以把网格声明为任意大小的二维数组,用两个分量索引来标识每个块:
- 对于大小为 \((D_x, D_y)\) 的二维网格,索引为 \((x, y)\) 的块的块ID是 \(x + y D_x\)。
主机可以连续发起多个内核调用。每个内核会以一批线程的形式——这些线程被组织成线程块的网格——来执行。这种CUDA编程模型为大规模并行计算提供了灵活高效的支撑。
2.3 CUDA内存模型详解
在设备上执行的线程,只能通过下面这些内存空间来访问设备的DRAM和片内存储单元(如图2-2所示):
- 读写每线程寄存器
- 读写每线程本地内存
- 读写每块共享内存
- 读写每网格全局内存
- 只读每网格常量内存
- 只读每网格纹理内存
其中,全局内存、常量内存和纹理内存空间可以被主机读写,并且会在同一应用程序的不同内核启动之间持久存在。这三种内存空间针对不同的使用场景做了优化,例如纹理内存还支持特定数据格式的寻址模式和数据过滤功能。合理选择内存空间是优化CUDA程序性能的关键。
线程正是通过这些不同范围的内存空间,来访问设备的DRAM和片内内存的。理解了这套层次清晰的内存模型,才能写出高效且可扩展的CUDA并行程序。
