1. 从“串行”到“并行”:为什么我们需要CUDA?
如果你写过代码,尤其是处理过一些计算量大的任务,比如图像处理、科学模拟或者机器学习训练,那你一定对“程序跑得太慢”这件事深有体会。在单核CPU上,你的程序就像一条单车道,所有车辆(数据)都得一辆接一辆地排队通过。当数据量爆炸式增长时,这条单车道就成了最大的瓶颈。
这时候,你可能会想到多线程。没错,多线程确实能让CPU的多个核心同时工作,把单车道拓宽成几条车道。但CPU的设计初衷是“通用计算”,它要处理复杂的逻辑判断、分支预测、中断响应,它的核心数量有限(通常几个到几十个),每个核心都非常“聪明”但“昂贵”。对于海量数据中那些简单、重复、但数量极其庞大的计算任务(比如对一张1000万像素的图片,每个像素都做同样的滤镜计算),用CPU的多线程来处理,就像是让一群博士去流水线上拧螺丝,效率不高,成本还大。
于是,GPU(图形处理器)登场了。GPU最初是为图形渲染设计的,它的任务极其规律:对屏幕上成千上万个像素点(顶点、片元)执行几乎相同的着色器程序。这种“单指令,多数据”(SIMD)的计算模式,催生了GPU高度并行的架构。它拥有成百上千个更简单、更专注的计算核心,虽然每个核心的“智商”不如CPU,但“人多力量大”,在并行处理海量同质化数据时,能爆发出惊人的吞吐量。
CUDA(Compute Unified Device Architecture),就是NVIDIA公司为它的GPU打造的一套通用并行计算平台和编程模型。它让开发者能够使用熟悉的C/C++等语言,直接编写在GPU上运行的程序,从而将GPU的强大并行计算能力从图形领域解放出来,应用到科学计算、深度学习、金融分析等更广阔的领域。
简单来说,CUDA让你能指挥GPU这支“千军万马”的部队,去完成那些适合大规模并行处理的计算任务。而要指挥好这支军队,你首先得了解它的“军制”和“术语”。这篇文章,我们就来彻底厘清CUDA编程中最核心的那些名词概念,这是你踏入GPU并行计算世界的第一步,也是避免后续“踩坑”的关键。
2. 硬件架构视角:GPU是如何组织它的计算资源的?
理解CUDA编程,必须从理解GPU的硬件架构开始。你不能把GPU当成一个黑盒子,只知道它“快”,而要明白它为什么快,以及它的能力边界在哪里。
2.1 流式多处理器(SM):GPU的“计算兵团”
你可以把GPU想象成一个庞大的计算军团。这个军团的基本作战单位不是单个士兵,而是一个个“连队”,在NVIDIA的术语里,这个“连队”就叫流式多处理器。
SM是GPU上真正执行指令和计算的核心部件。一个GPU芯片由多个SM组成。例如,NVIDIA Tesla V100有80个SM,而消费级的RTX 4090则有128个SM。每个SM内部,又包含了:
- CUDA核心:这是执行整数和单精度浮点运算的基本单元。你可以粗略地理解为“一个计算士兵”。一个SM里集成了几十到上百个CUDA核心。
- 张量核心:从Volta架构开始引入,专门用于执行矩阵乘加运算,在深度学习训练和推理中速度极快。
- 寄存器文件:SM内部的高速存储,供正在SM上执行的线程快速存取私有变量。速度极快,但容量有限(每个线程能分到的寄存器数量是重要限制)。
- 共享内存:一块被同一个SM内所有线程共享的、可编程的高速缓存。它比全局内存快得多,是优化性能的关键。
- 调度器/分发单元:负责将线程块(后面会讲)调度到SM上执行,并管理线程束。
关键理解:你的CUDA程序(内核)是被分配到各个SM上执行的。一个SM可以同时处理多个线程块,但一个线程块只能在一个SM上执行,不能被拆分到多个SM。SM的数量直接决定了GPU的并行处理能力上限。
2.2 内存层次结构:数据的“高速公路与乡间小道”
在GPU上,数据存放在不同速度和容量的存储器中,形成了一个层次结构。理解这个层次,是进行有效内存访问优化的基础。
- 全局内存:GPU的“主内存”,也就是我们常说的显存。容量最大(几GB到几十GB),但延迟最高,带宽也相对较慢(虽然比CPU内存带宽高很多)。所有SM都可以访问全局内存。在主机(CPU)代码中通过
cudaMalloc分配的就是这块内存。 - 常量内存:位于显存中,但有特殊的缓存机制。适合存储所有线程都需要读取、且在核函数执行期间不会改变的数据(如常数、查找表)。访问常量内存如果缓存命中,速度极快。
- 纹理内存:同样是具有缓存的只读内存,最初为图形纹理设计,优化了具有空间局部性的访问模式(比如图像中相邻像素的读取)。在某些访问模式下比全局内存高效。
- 共享内存:位于SM内部,速度堪比寄存器。由同一个线程块内的所有线程共享。这是手动性能优化的主战场。你可以把需要频繁读写、在线程间需要通信的中间数据放在共享内存中,从而避免昂贵的全局内存访问。
- 寄存器:位于SM内部,速度最快。每个线程都有自己私有的寄存器。编译器会尽可能将自动变量(如循环索引、临时变量)分配到寄存器。寄存器资源是稀缺的,如果一个线程使用了太多寄存器,会导致SM上能同时驻留的线程数量减少,可能影响并行度。
- 本地内存:实际上位于全局内存中。当线程的私有数据(如大的局部数组、寄存器溢出的变量)无法完全放入寄存器时,编译器会将其放入本地内存。访问速度很慢,应尽量避免。
一个生动的类比:把SM比作一个工厂车间(计算单元),寄存器就是每个工人手边的工作台(极快,但空间小)。共享内存是这个车间里的公共工具墙(很快,车间内共享)。全局内存则是工厂外的大型中央仓库(容量大,但来回取货慢)。高效的程序要尽量让工人在工作台(寄存器)完成操作,频繁使用的工具放在工具墙(共享内存),只有原材料和最终成品才去仓库(全局内存)存取。
2.3 线程束:SM执行的基本单元
这是CUDA模型中最精妙也最容易让人困惑的概念之一。GPU的SM并不是以单个线程为单位进行调度和执行的。
线程束是SM执行指令的基本单位。目前,一个线程束包含32个连续的线程。这32个线程被“捆绑”在一起,以锁步的方式执行同一条指令。也就是说,在任何一个时钟周期,线程束中的所有32个线程都在执行相同的指令,只是操作的数据可能不同。
这源于GPU的SIMD(单指令多数据)架构。这种设计极大地简化了控制逻辑和调度开销。但这也带来了一个核心约束:分支发散。
分支发散:如果线程束中的线程在执行时遇到了条件判断(如if-else),并且这32个线程的走向不一致(一部分走if,一部分走else),那么线程束就必须串行化执行所有分支路径。先执行走if的线程(走else的线程等待),再执行走else的线程。这会导致性能严重下降。
注意:编写CUDA内核时,要尽量避免线程束内的分支发散。例如,尽量让相邻的线程(它们的
threadIdx连续)执行相同的控制流。可以通过重构算法或使用类似__shfl_sync的束内原语来减少发散。
3. 编程模型视角:如何用代码组织你的并行任务?
硬件架构决定了GPU的能力,而CUDA编程模型则提供了我们组织计算任务的方法。这是你编写.cu文件时直接打交道的抽象层。
3.1 网格、线程块与线程:三级并行层次
这是CUDA编程模型的骨架。它以一种层次化的方式组织并行线程,完美映射到GPU的硬件层次。
- 线程:最小的执行单元。每个线程都独立运行内核函数的一份副本,并通过内置的
threadIdx、blockIdx等变量来区分自己,从而处理不同的数据。 - 线程块:一组线程的集合。一个线程块内的线程:
- 会被调度到同一个SM上执行。
- 可以通过共享内存进行高效通信与协作。
- 可以通过
__syncthreads()函数进行同步,确保块内所有线程都执行到某个点后再继续。 - 线程块的大小(每个块包含多少线程)在启动内核时由开发者指定(如
<<<numBlocks, threadsPerBlock>>>中的第二个参数)。通常,线程块的大小是线程束大小(32)的整数倍,例如128、256、512。
- 网格:所有线程块的集合。一个内核启动就对应一个网格。网格中的线程块可以被调度到任意可用的SM上执行,且执行顺序是不确定的、并行的。
它们的关系与硬件映射:
- 一个网格被启动后,其包含的所有线程块被分发到GPU的各个SM上等待执行。
- 一个SM可以同时执行多个线程块(具体数量受限于SM的资源,如寄存器、共享内存总量)。
- 一个线程块一旦被分配到一个SM上,就会一直驻留直到执行完毕。
- 在SM上,线程块被进一步划分为线程束来调度执行。
如何确定网格和线程块的尺寸?这是一个经验与性能分析相结合的过程。一个常见的启发式方法是:
- 线程块大小:通常设为256或512。太小(如64)可能无法充分利用SM;太大(如1024)可能因为寄存器限制而减少SM上同时驻留的块数。
- 网格大小:根据总数据量
N和线程块大小blockSize计算:gridSize = (N + blockSize - 1) / blockSize(向上取整)。确保有足够多的线程块(至少是SM数量的几倍)来隐藏内存访问延迟,并让所有SM都保持忙碌。
3.2 内核函数:在GPU上执行的代码
内核函数是CUDA编程的核心,它定义了每个线程要执行的操作。它用__global__关键字声明,由主机(CPU)调用,在设备(GPU)上执行。
// 一个简单的向量加法内核 __global__ void vectorAdd(const float* A, const float* B, float* C, int numElements) { // 计算当前线程的全局索引 int i = blockDim.x * blockIdx.x + threadIdx.x; // 确保索引不越界 if (i < numElements) { C[i] = A[i] + B[i]; // 每个线程负责一个加法 } }内核启动语法:kernelName<<<gridDim, blockDim, sharedMemSize, stream>>>(arguments...);
gridDim:网格的维度,可以是dim3类型,指定了线程块在x, y, z三个方向上的数量。blockDim:线程块的维度,同样是dim3类型,指定了每个线程块中线程在x, y, z三个方向上的数量。sharedMemSize:可选,动态分配的共享内存大小(字节)。stream:可选,关联的CUDA流。
3.3 主机与设备:CPU与GPU的协同
CUDA编程是异构编程,涉及两个处理器:
- 主机:指CPU及其内存(主机内存)。
- 设备:指GPU及其显存(设备内存)。
它们有各自独立的内存空间。因此,数据必须在主机和设备之间进行传输,这是CUDA程序中的一个主要开销来源。基本流程如下:
- 在主机上分配并初始化数据。
- 使用
cudaMalloc在设备上分配内存。 - 使用
cudaMemcpy将数据从主机复制到设备。 - 启动内核函数在设备上处理数据。
- 使用
cudaMemcpy将结果从设备复制回主机。 - 使用
cudaFree释放设备内存。
关键API与概念:
cudaMalloc/cudaFree:设备内存的分配与释放。cudaMemcpy:内存复制。方向由cudaMemcpyHostToDevice、cudaMemcpyDeviceToHost等参数指定。- 固定内存:使用
cudaMallocHost分配的主机内存,该内存页被锁定,不可被操作系统交换出去。GPU可以通过DMA直接访问固定内存,从而在主机到设备的数据传输中获得更高的带宽。对于频繁传输的数据,应使用固定内存。 - 统一内存:从CUDA 6.0开始引入,通过
cudaMallocManaged分配。系统自动管理数据在主机和设备间的迁移,简化了编程模型,但开发者需要对访问模式有一定理解以获得最佳性能。
4. 执行与调度模型:GPU如何幕后管理成千上万的线程?
理解了静态的编程模型,我们还需要了解动态的执行过程。GPU如何调度成千上万个线程,以实现极高的吞吐量?
4.1 隐藏延迟:GPU高性能的秘诀
GPU计算核心的执行速度非常快,但访问全局内存的延迟非常高(需要几百个时钟周期)。如果线程在发出内存加载请求后只是空等,那么大部分时间计算核心都会处于闲置状态,性能会极其低下。
GPU解决这个问题的方法是大量并行线程的快速切换,以隐藏内存访问延迟。当一个线程束因为等待内存数据而停滞时,SM的调度器会立刻切换到另一个就绪的线程束去执行。由于SM上驻留着成百上千个线程(来自多个线程块),调度器可以确保几乎在任何时刻,都有线程束可以执行计算指令,从而让计算核心始终保持忙碌,将内存延迟“隐藏”在计算之下。
这就要求你的内核启动必须有足够的并行度。即,同时启动的线程总数(网格大小 × 线程块大小)要远远大于GPU的物理核心数,通常是几万甚至几十万上百万,才能充分隐藏延迟。
4.2 占用率:一个重要的性能指标
占用率是指每个SM上活跃的线程束数量,与SM支持的最大线程束数量之比。高占用率意味着SM上有更多的线程束可以参与调度,有助于更好地隐藏延迟。
然而,高占用率并不总是等于高性能。影响占用率的主要因素有:
- 线程块大小:线程块越大,每个块提供的线程束越多,但SM上能同时驻留的块可能越少(受资源限制)。
- 寄存器使用量:每个线程使用的寄存器数量。SM上的寄存器总量是固定的。如果每个线程使用很多寄存器,那么SM上能同时驻留的线程总数就会减少,从而降低占用率。
- 共享内存使用量:每个线程块使用的共享内存大小。同样,SM的共享内存总量固定,使用过多会限制同时驻留的线程块数量。
有时,为了使用更多的寄存器或共享内存来优化单个线程的性能或减少全局内存访问,可以接受较低的占用率。这是一个需要权衡和通过性能分析工具来评估的过程。NVIDIA提供的CUDA Occupancy Calculator可以帮助你分析这些约束。
4.3 同步:在正确的时间点协调线程
并行计算中,线程间的协调至关重要。
__syncthreads():这是一个线程块级别的屏障同步。调用该函数后,线程块内的所有线程都必须执行到此位置,然后才会继续执行后面的指令。非常重要:必须确保线程块内所有线程都能到达这个同步点,否则会导致死锁。例如,不能在只有部分线程满足的if条件内调用__syncthreads()。- 原子操作:当多个线程需要读写同一个全局内存或共享内存地址时,为了避免竞争条件,需要使用原子操作,如
atomicAdd、atomicExch等。原子操作保证该操作是“不可分割”的。但原子操作是串行的,会严重影响性能,应尽量避免或减少使用。 - 网格级别的同步:在内核函数内部,没有直接提供网格级别的同步原语。因为线程块执行顺序不确定,且可能在任何时候结束。如果需要全局同步,通常需要将计算拆分为多个内核启动,利用CUDA流和事件进行更复杂的控制。
5. 内存访问模式:为什么对齐与合并访问如此重要?
即使你启动了足够多的线程,如果内存访问模式很糟糕,性能也会一落千丈。GPU的全局内存带宽虽然高,但要高效利用它,必须满足其特定的访问模式。
5.1 内存事务与合并访问
GPU的全局内存控制器是以内存事务为单位来服务内存请求的。一次事务可以读取32字节、64字节或128字节对齐的连续内存数据。理想情况下,一个线程束(32个线程)的所有内存请求,应该合并成少数几个甚至一个内存事务。
合并访问:当一个线程束中的所有线程访问全局内存中一片连续的、对齐的数据块时,它们的访问请求可以被“合并”成一个或几个内存事务,从而最大化内存带宽利用率。
未合并访问:如果线程束中的线程访问的内存地址是分散的、不连续的,那么每个线程的请求都可能需要单独的内存事务,导致有效带宽急剧下降。
5.2 实践中的访问模式优化
- 确保对齐访问:分配设备内存时,尽量使用
cudaMalloc,它保证至少256字节对齐。对于自定义数据结构,可以使用__align__关键字或alignas来确保对齐。 - 设计线程索引映射数据索引:这是最关键的一点。要让相邻的线程(
threadIdx.x连续)访问相邻的内存地址。- 优化前(跨步访问,差):
int tid = blockDim.x * blockIdx.x + threadIdx.x; int index = tid * stride; // 如果stride很大,相邻线程访问的地址相隔很远 - 优化后(连续访问,好):
对于多维数组,要特别注意行主序/列主序,确保内层循环对应的线程索引是连续的。int tid = blockDim.x * blockIdx.x + threadIdx.x; int index = tid; // 相邻线程访问连续的index
- 优化前(跨步访问,差):
- 利用共享内存作为中转:当数据访问模式无法做到全局内存完美合并时,一个经典的优化模式是:
- 让线程块中的线程以合并访问的方式,将数据从全局内存加载到共享内存。
- 在共享内存中进行需要随机访问或线程间共享数据的计算。
- 最后,再将结果以合并访问的方式写回全局内存。 共享内存的访问延迟远低于全局内存,且对访问模式不敏感,这完美地解决了问题。
5.3 一个简单的性能对比思考
假设你要对一个矩阵的每一行元素求和。矩阵在内存中是按行存储的。
- 方案A:每个线程负责一行。那么线程束中的32个线程,每个线程访问的是不同行的首元素(地址间隔为“一行的大小”),这是最糟糕的未合并访问。
- 方案B:每个线程负责一列。那么线程束中的32个线程,访问的是同一行的前32个连续元素。这实现了完美的合并访问。当然,这需要在线程块内进行归约求和,但内存访问效率的提升是巨大的。
理解并应用这些概念,是写出高性能CUDA程序的关键。它不仅仅是“让程序跑起来”,而是“让程序飞起来”的必经之路。在后续的实际编码中,你会反复用到这些概念来分析和优化你的内核。