CUDA Programming Guide Notes 1
原文地址:https://docs.nvidia.com/cuda/cuda-programming-guide/01-introduction/programming-model.html
CUDA编程模型
1. 异构系统
CUDA 编程模型假设一个异构计算系统(heterogeneous computing system),即包含 GPU 和 CPU 的系统。CPU 及其直接连接的内存分别称为主机(host) 和主机内存(host memory)。GPU 及其直接连接的内存分别称为设备(device) 和设备内存(device memory)。在某些片上系统(SoC, System-on-Chip) 中,这些可能是单个封装的一部分。在更大的系统中,可能有多个 CPU 或 GPU。
CUDA 应用程序在 GPU 上执行部分代码,但应用程序总是从 CPU 开始执行。主机代码(host code),即在 CPU 上运行的代码,可以使用 CUDA API 在主机内存和设备内存之间拷贝数据、启动在 GPU 上执行的代码,以及等待数据拷贝或 GPU 代码完成。CPU 和 GPU 可以同时执行代码,通常通过最大化 CPU 和 GPU 的利用率来获得最佳性能。
应用程序在 GPU 上执行的代码称为设备代码(device code),而为了在 GPU 上执行而调用的函数,由于历史原因,被称为核函数(kernel)。启动核函数运行的行为称为启动核函数(launching the kernel)。核函数启动可以被认为是在 GPU 上并行执行核函数代码的许多线程的启动。GPU 线程的操作方式与 CPU 线程类似,但存在一些对正确性和性能都很重要的差异,这些将在后续章节中介绍(参见第 3.2.2.1.1 节)。
这里是图2
2. 线程块和网格
当一个程序启动kernel时,涉及了许多线程,经常是数百万的线程。这些线程被组织到Blocks里面。这些block自然就被称为线程块。线程块又被组织进Grid(网格)里面。Grid里面所有的线程块有一样的大小和维度。
这里补充一张图片,图3
线程块和网格可以是 1 维、2 维或 3 维的。这些维度可以简化将单个线程映射到工作单元或数据项的过程。
当启动核函数时,需要使用特定的执行配置(execution configuration)来指定网格和线程块的维度。执行配置还可以包括一些可选参数,如 Cluster 大小、Stream 以及 SM 配置设置,这些将在后续章节中介绍。
使用内置变量,每个执行核函数的线程都可以确定:
- 自身在所属线程块中的位置
- 所属线程块在网格中的位置
- 线程块的维度以及启动核函数时所使用的网格的维度
这使得每个线程在所有运行该核函数的线程中拥有唯一的标识。这个标识常用于确定该线程负责处理哪些数据或执行哪些操作。
一个线程块中的所有线程都由单个 SM 执行。这使得线程块内的线程能够高效地相互通信和同步。线程块内的所有线程都可以访问片上共享内存(on-chip Shared Memory),可用于线程块内线程之间的信息交换。
一个网格可能由数百万个线程块组成,而执行该网格的 GPU 可能只有数十个或数百个 SM(流式多处理器)。一个线程块的所有线程都由单个 SM 执行,并且在大多数情况下 [1],在该 SM 上运行直到完成。线程块之间的调度没有保证,因此一个线程块不能依赖其他线程块的结果,因为那些线程块可能要等到当前线程块完成后才能被调度。图 4 展示了一个示例,说明网格中的线程块如何分配到 SM 上。
图4
CUDA 编程模型使得任意大小的网格都能够在任何规模的 GPU 上运行,无论该 GPU 只有一个 SM 还是拥有数千个 SM。为了实现这一点,CUDA 编程模型要求(除少数例外情况外)不同线程块中的线程之间不存在数据依赖关系。也就是说,一个线程不应该依赖于同一网格中不同线程块中某个线程的结果,也不应该与其同步。一个线程块内的所有线程同时在同一个 SM 上运行。网格内的不同线程块会被调度到可用的 SM 上,并且可以按任意顺序执行。简而言之,CUDA 编程模型要求线程块必须能够以任意顺序执行,无论是并行执行还是串行执行。
3. 线程块Clusters(集群、簇)
此外,计算能力为 9.0 及更高版本的 GPU 还支持一个可选的分组层级,称为簇(Clusters)。簇是一组线程块的集合,与线程块和网格类似,簇也可以按 1 维、2 维或 3 维进行布局。图 5 展示了一个组织成簇的线程块网格。指定簇不会改变网格的维度,也不会改变线程块在网格中的索引。
就是一些高版本的 CUDA 支持在一个网格当中,把线程块分了个组
指定簇(Clusters)会将相邻的线程块分组到簇中,并在簇层级提供一些额外的同步和通信机会。具体来说,一个簇中的所有线程块都在单个 GPC(Graphics Processing Cluster,图形处理簇)中执行。图 6 展示了当指定簇时,线程块如何被调度到 GPC 内的 SM 上。由于这些线程块是同时调度的,并且位于单个 GPC 内,因此不同线程块但位于同一个簇中的线程可以使用 Cooperative Groups 提供的软件接口相互通信和同步。簇中的线程可以访问该簇内所有线程块的共享内存,这被称为分布式共享内存(Distributed Shared Memory)。簇的最大尺寸取决于硬件,不同设备之间有所不同。
就是一个簇里面的线程块可以共享资源:共享内存、相互通信、可以同步
这里是图6
4. Warps和SIMT
在线程块内部,线程被组织成每组 32 个线程的单元,称为线程束(Warp)。线程束以单指令多线程(SIMT,Single-Instruction Multiple-Threads) 范式执行核函数代码。在 SIMT 模式下,线程束中的所有线程执行相同的核函数代码,但每个线程可以在代码中遵循不同的分支。也就是说,尽管程序的所有线程执行相同的代码,但线程不需要遵循相同的执行路径。
当线程被线程束执行时,它们会被分配一个线程束通道(Warp Lane)。线程束通道编号从 0 到 31,线程块中的线程按照《硬件多线程》章节详细说明的可预测方式分配到线程束中。
线程束中的所有线程同时执行相同的指令。如果线程束内的一些线程在执行中遵循某个控制流分支,而其他线程不遵循,那么不遵循该分支的线程将被屏蔽(masked off),而遵循该分支的线程继续执行。例如,如果某个条件判断仅对线程束中一半的线程为真,那么线程束的另一半将被屏蔽,而活跃的线程执行这些指令。图 7 展示了这种情况。当线程束中的不同线程遵循不同的代码路径时,这种情况有时被称为线程束分化(Warp Divergence)。由此可见,当线程束内的线程遵循相同的控制流路径时,GPU 的利用率最大化。
这里是图7
在 SIMT 模型中,线程束中的所有线程以锁步(lock step) 方式在核函数中推进。硬件执行可能有所不同。有关这种区别重要性的更多信息,请参见《独立线程执行》章节。不建议利用线程束执行如何实际映射到真实硬件的知识。CUDA 编程模型和 SIMT 指出,线程束中的所有线程一起在代码中推进。只要遵循编程模型,硬件可以以对程序透明的方式优化被屏蔽的通道。如果程序违反此模型,可能会导致未定义行为,这种行为在不同的 GPU 硬件上可能不同。
线程束执行的一个含义是,线程块最好指定线程总数为 32 的倍数。使用任意数量的线程是合法的,但当总数不是 32 的倍数时,线程块的最后一个线程束将在整个执行过程中有一些未使用的通道。这可能会导致该线程束的功能单元利用率和内存访问性能不佳。
5. 异构系统中的DRAM内存
GPU 和 CPU 都有直接连接的 DRAM 芯片。在拥有多个 GPU 的系统中,每个 GPU 都有自己的内存。从设备代码的角度来看,连接到 GPU 的 DRAM 被称为全局内存(global memory),因为它可以被 GPU 中的所有 SM 访问(不能被CPU直接访问)。这个术语并不意味着它在整个系统中的任何地方都可以访问。连接到 CPU 的 DRAM 被称为系统内存(system memory) 或主机内存(host memory)。
与 CPU 类似,GPU 使用虚拟内存寻址。在所有当前支持的系统中,CPU 和 GPU 使用单一的统一虚拟内存空间(GPU和CPU共享同一个虚拟地址空间)。这意味着系统中每个 GPU 的虚拟内存地址范围是唯一的,并且与 CPU 以及系统中其他所有 GPU 的地址范围相区分。对于给定的虚拟内存地址,可以确定该地址是在 GPU 内存中还是在系统内存中,并且在拥有多个 GPU 的系统中,可以确定是哪个 GPU 的内存包含该地址。
CUDA 提供了 API 来分配 GPU 内存、CPU 内存,以及在 CPU 和 GPU 之间、GPU 内部或多 GPU 系统中的 GPU 之间进行数据拷贝。当需要时,可以显式控制数据的位置。下文讨论的统一内存(Unified Memory) 允许由 CUDA 运行时或系统硬件自动处理内存的放置。
| 概念 | 说明 |
|---|---|
| 物理内存独立 | CPU、GPU 0、 GPU 1、各有自己的DRAM |
| Global Memory | GPU内全局(所有SM可访问),非系统全局 |
| 统一虚拟地址内存 | CPU和所有GPU共享同一个虚拟地址空间,地址范围唯一 |
| 显式控制 | 使用cudaMalloc和cudaMemcpy手动管理数据位置 |
| 统一内存 | 使用cudaMallocManaged自动管理数据迁移 |
6. GPU的片上内存
除了全局内存之外,每个GPU上还有一些片上内存(on-chip memory)。每个SM都有自己的寄存器堆(register file)和共享内存(shared memory)。这些内存是SM的一部分,可以被在该SM内执行的线程极快地访问,但它们对在其它SM中运行的线程不可访问。
寄存器堆存储线程局部变量(thread local variables),这些变量通过由编译器分配。共享内存可以被线程块或者簇内的所有线程访问。共享内存可用于线程块或簇内线程之间的数据交换。
这两段话不矛盾。线程块和SM的关系:
- 规则1:一个线程块的所有线程由单个SM执行
- 规则2:一个线程块不会跨多个SM执行
- 规则3:一个SM可以同时执行多个线程块(如果资源足够)
根据上面的规则,共享内存可以被线程块内的线程共享很好理解,因为规则2线程块不会跨SM,所以其中的线程自然都在一个SM中,因此可以共享SM中的共享内存。
但是SM的共享内存可以被簇内的所有线程访问就有点疑惑了。首先,簇中包含多个线程块。其次,簇的所有线程块都在单个GPC中执行,一个GPC里有多个SM,那么此时共享内存被簇中的所有线程访问,不就是跨SM共享内存了么?
原因就是,簇提供了软件接口和硬件支持,可以使不同线程块(可能在不同的SM上)中的线程相互通信和同步。
- 软件接口:CUDA提供了Cooperative Groups API
- 硬件支持:GPC内部有特殊的互连硬件,允许SM之间快速访问彼此的共享内存
SM中的寄存器堆和统一数据缓存具有有限的大小。SM的寄存器堆大小、统一数据缓存大小,以及如何配置统一数据缓存在L1缓存和共享内存之间的平衡,可以在《每个计算能力的内存信息》中找到。寄存器堆、共享内存空间和 L1 缓存在线程块的所有线程之间共享。
要将线程块调度到 SM 上,每个线程所需的寄存器总数乘以线程块中的线程数必须小于或等于 SM 中可用的寄存器数。如果线程块所需的寄存器数超过寄存器堆的大小,则该核函数无法启动,必须减少线程块中的线程数才能使线程块可启动。
共享内存的分配是在线程块级别进行的。也就是说,与按线程分配的寄存器不同,共享内存的分配是整个线程块共用的。
7. 缓存
除了可编程内存之外,GPU 还有 L1 和 L2 缓存。每个 SM 都有一个 L1 缓存,它是统一数据缓存(unified data cache) 的一部分。一个更大的 L2 缓存由 GPU 内的所有 SM 共享。这可以在图 2 的 GPU 框图中看到。每个 SM 还有一个独立的常量缓存(constant cache),用于缓存在核函数的整个生命周期内被声明为常量的全局内存中的值。编译器也可能将核函数参数放入常量内存中。这可以通过允许核函数参数在 SM 中独立于 L1 数据缓存进行缓存来提高核函数性能。
8. 统一内存
当应用程序在 GPU 或 CPU 上显式分配内存时,该内存只能由运行在该设备上的代码访问。也就是说,CPU 内存只能从 CPU 代码访问,GPU 内存只能从在 GPU 上运行的核函数访问[2]。CUDA 提供的用于在 CPU 和 GPU 之间拷贝内存的 API 被用来在正确的时间将数据显式拷贝到正确的内存中。
一个称为统一内存(unified memory) 的 CUDA 特性允许应用程序进行内存分配,这些分配可以从 CPU 或 GPU 访问。CUDA 运行时或底层硬件在需要时启用访问或将数据重定位到正确的位置。即使使用统一内存,最佳性能也是通过将内存迁移保持在最低限度,并尽可能从直接连接到内存所在位置的处理器访问数据来实现的。
系统的硬件特性决定了如何实现内存空间之间的数据访问和交换。《统一内存》章节介绍了不同类别的统一内存系统。《统一内存》章节包含了关于统一内存在所有情况下的使用和行为的更多详细信息。