引言:从熟悉到陌生——为什么工程师需要懂GPU?
如果你是一名软件工程师,特别是刚接触AI或高性能计算领域的,你大概率对CPU和顺序编程模式了如指掌。毕竟,从我们敲下第一行代码开始,就与CPU紧密相连。然而,对于那块在过去的十年里,因深度学习而变得举足轻重、被誉为“AI算力核心”的GPU,它的内部究竟是如何工作的?它为何能在某些任务上比CPU快上百倍千倍?这些问题可能对许多工程师来说还蒙着一层神秘的面纱。
过去,GPU主要用于图形渲染,是游戏玩家的“专属”。但现在,从AI训练、科学计算到大数据分析,GPU的应用无处不在。理解GPU的基本工作原理,已经不再是少数图形学专家的专属技能,而是每一位志在并行计算领域的软件工程师,尤其是AI工程师的“必修课”。
今天,我们就来掀开GPU的神秘面纱,深入了解它的设计哲学、内部结构和独特的代码执行方式。
一、设计理念的本质差异:CPU vs. GPU
要理解GPU,最好的方式是先将它与我们熟悉的CPU进行对比。CPU和GPU最核心的区别在于它们最初的设计目标不同:
-
CPU:追求低延迟的顺序执行 CPU的设计重点在于尽快完成单个任务,也就是最小化指令的执行时延(Latency)。虽然现代CPU通过超线程、多核等技术也能实现并行,但大量设计工作长期以来都聚焦于提高单条指令的执行速度。为了实现这一点,CPU引入了许多复杂的功能,例如:
- 指令流水线 (Pipeline): 将指令执行分解成多个步骤,重叠执行。
- 乱序执行 (Out-of-Order Execution): 打乱指令顺序,提前执行不依赖前一条指令结果的指令。
- 预测执行 (Speculative Execution): 猜测分支结果,提前执行可能的指令。
- 多级缓存 (Multi-level Cache): 大容量、多层级的缓存系统,减少访问主内存的耗时。 这些功能都致力于让CPU能够尽可能快地、一条接一条地执行指令序列。CPU就像一位能力全面的“项目经理”,负责复杂任务的协调、决策和快速响应。
-
GPU:追求高吞吐量的大规模并行 与CPU截然不同,GPU从诞生之初就是为同时处理大量简单任务而设计的。它的设计目标是最大化吞吐量(Throughput),即在单位时间内完成尽可能多的计算任务,而不是最小化单条指令的时延。这种设计方向受其核心应用场景驱动:
- 图形处理: 需要同时计算屏幕上数百万个像素点的颜色、光照等。
- 数值计算: 需要对大量数据进行相同的数学运算(特别是线性代数)。
- 深度学习: 本质上就是超大规模的矩阵乘法和数值计算。 所有这些应用都需要以极高的速度重复执行相同的计算。因此,GPU的设计牺牲了单条指令的低延迟,换取了大规模的并行计算能力。GPU更像是一个拥有成千上万“计算工人”的“大型工厂”,虽然每个工人完成单个任务可能不比项目经理快,但它们可以同时处理海量任务。
用一个简单的例子来说明:将两个数字相加。 CPU可以非常快地完成一次加法。如果需要按顺序进行十次加法,CPU的总耗时会很低。 但如果需要进行数百万甚至数十亿次独立的加法运算(比如向量或矩阵相加),尽管单次加法时延较高,GPU凭借其海量的计算单元可以同时执行成千上万次加法,因此在总耗时上将远远超过CPU。
数据可以直观地说明这一点:衡量硬件数值计算性能的指标是每秒浮点运算次数(FLOPS)。NVIDIA Ampere A100 GPU 在 32 位浮点精度下能达到 19.5 TFLOPS 的吞吐量,而同期(2021年)一款典型的 Intel 24 核 CPU 在相同精度下仅约 0.66 TFLOPS。随着时间推移,GPU在吞吐量上的优势还在不断扩大。
如果我们观察CPU和GPU的芯片架构图,会发现CPU将大量芯片面积用于缓存和复杂的控制单元,以降低指令时延和优化顺序执行。而GPU则将绝大部分面积用于密集的算术逻辑单元(ALU,负责执行计算),辅以少量缓存和控制单元,以最大化计算能力和吞吐量。
二、GPU的并行基石:流式多处理器(SMs)
那么,GPU是如何通过其架构实现如此高的吞吐量的呢?这就需要了解GPU的核心计算单元——流式多处理器(Streaming Multiprocessor,简称SM)。
一个GPU由多个SM组成,每个SM内部又包含大量的计算核心(Core),这些核心也常被称为流式处理器(Streaming Processor)。例如,NVIDIA 最新的 H100 GPU 就拥有 132 个 SM,每个 SM 内部有 64 个计算核心,整个芯片总计核心数量高达 8448 个!
每个SM都是一个相对独立的计算单元,拥有:
- 大量计算核心: 负责实际的数值计算。
- 片上内存: 通常称为共享内存(Shared Memory)或临时存储器(Scratchpad Memory),供该SM内的所有核心共享使用。这是一种非常快速的内存,对于SM内的线程间数据共享至关重要。
- 控制单元和硬件线程调度器: 负责管理和调度在该SM上运行的线程。
- 专用功能单元: 除了基本的计算核心,现代SM还会集成一些专门的硬件加速单元,例如用于矩阵运算的张量核心(Tensor Core)(对深度学习至关重要)或用于图形学的光线追踪单元(Ray Tracing Unit),以满足特定工作负载的高效计算需求。
理解SM是理解GPU并行架构的关键。每个SM都能同时执行多个线程束(我们稍后会详细介绍),从而在SM层面实现并行。而整个GPU拥有多个SM,则在更高的层面实现了大规模并行。
三、层层加速:GPU的内存体系
GPU的高性能不仅依赖于海量计算核心,也依赖于其精心设计的内存层次结构。与CPU类似,GPU拥有多层不同类型、不同速度和容量的内存,以满足不同计算阶段的数据访问需求。
从距离计算核心最近、速度最快到最远、速度相对较慢的内存层次,主要包括:
-
寄存器(Registers):
- 位置: 位于每个SM内部,紧邻计算核心。
- 速度/容量: 速度最快,容量相对较小,每个SM拥有一个较大的寄存器文件总量(例如 NVIDIA A100/H100 的每个 SM 有 65536 个寄存器),这些寄存器在SM内的所有线程之间动态分配。
- 用途: 每个线程都有自己私有的寄存器,用于存储线程执行过程中的局部变量和中间结果。其他线程无法直接访问某个线程的私有寄存器。
-
常量缓存(Constant Cache):
- 位置: 片上(On-chip),每个SM有自己的常量缓存。
- 用途: 缓存 SM 上执行的代码中使用的常量数据。程序员通常需要显式地将数据声明为常量,以便 GPU 可以将其缓存到常量缓存中,提高访问速度。
-
共享内存(Shared Memory / Scratchpad Memory):
- 位置: 片上(On-chip),每个SM拥有一块。
- 速度/容量: 速度非常快,延迟低,容量比寄存器大但比缓存小。这是一种可编程的 SRAM 内存。
- 用途: 供同一个线程块(Thread Block)内的所有线程共享使用。 这是 GPU 并行编程中非常重要的一种内存。线程块内的线程可以将全局内存中的数据加载到共享内存中,然后该块内的所有线程都可以快速访问和共享这些数据,避免重复从较慢的全局内存中加载。它也常用于线程块内的线程同步。
-
L1 Cache:
- 位置: 片上(On-chip),每个SM拥有一块 L1 缓存。
- 用途: 缓存从 L2 缓存中频繁访问的数据。对于 SM 来说,L1 和 L2 缓存通常是透明的,SM 访问全局内存时,数据可能首先经过 L1 和 L2。
-
L2 Cache:
- 位置: 片上(On-chip),但由 所有 SM 共享。
- 用途: 缓存全局内存中被所有 SM 频繁访问的数据,用于降低全局内存访问的时延。
-
全局内存(Global Memory):
- 位置: 片外(Off-chip),是 GPU 主板上的 DRAM 内存。
- 速度/容量: 容量最大,但由于距离 SM 较远,访问时延最高。现代高端 GPU 通常使用高带宽内存(HBM),提供极高的数据带宽(例如 NVIDIA H100 有 80GB HBM,带宽高达 3TB/s)。
- 用途: 存储程序数据、输入输出数据、中间结果等。在 GPU 上执行计算前,通常需要将数据从 CPU(主机)内存复制到 GPU 的全局内存。
虽然全局内存时延高,但 GPU 的其他层级内存(特别是 Shared Memory 和 L1/L2 Cache)以及海量的计算单元和高效的调度机制,能够有效地“隐藏”这种时延,确保计算单元不会长时间空闲等待数据。
四、GPU如何执行代码:Kernel, Grid, Block, Thread 和 Warp
理解了GPU的硬件架构和内存,接下来我们看看编写的代码如何在这些硬件上跑起来。
大多数 GPU 通用计算编程都使用 NVIDIA 提供的 CUDA 编程接口(或者类似的开放标准如 OpenCL/SYCL)。在 CUDA 中:
-
Kernel(核函数): 你要让GPU执行的并行计算任务,通常写成一个函数,称为 Kernel。Kernel 接收输入参数(通常是需要并行处理的向量或矩阵),并在 GPU 上由成千上万个线程并行执行。例如,一个向量加法的 Kernel 就是对输入向量的每个元素执行相加操作,并将结果写入输出向量。
-
执行结构:Grid, Block, Thread 为了在 GPU 上执行 Kernel,你需要启动大量的线程。这些线程不是杂乱无章的,而是组织成一个层次结构:
- 网格(Grid): 所有执行同一个 Kernel 的线程的集合。一个 Kernel 对应一个 Grid。
- 线程块(Thread Block): Grid 由一个或多个线程块组成。线程块内的线程可以互相协作(例如通过 Shared Memory)和同步。同一个线程块中的所有线程会被调度到同一个 SM 上执行。
- 线程(Thread): 线程块由一个或多个线程组成。每个线程执行 Kernel 函数的副本,但处理数据中的不同部分(例如在向量加法中处理向量的不同索引)。
你可以根据数据规模和所需的并行度来配置 Grid 和 Block 的维度和大小。例如,要对一个 256 维的向量进行加法,你可以配置一个包含 256 个线程的线程块,让每个线程处理一个元素。如果向量更大,你可能需要更多的线程块,或者让每个线程处理多个元素。
编写 GPU 代码通常分为两部分:
- 主机代码(Host Code): 运行在 CPU 上。负责数据加载、为 GPU 分配内存、将数据从 CPU 内存复制到 GPU 全局内存(或使用统一虚拟内存),以及配置 Grid 和 Block 的大小,并最终启动(launch)Kernel。
- 设备代码(Device Code): 运行在 GPU 上。这部分就是 Kernel 函数本身。
五、并行执行的秘密:Block分配、Warp与延迟隐藏
当你在主机代码中启动一个 Kernel 后,GPU 是如何分配和执行这些线程的呢?
-
数据传输: 首先,Kernel 需要处理的数据必须从 CPU(主机)内存传输到 GPU 的全局内存(设备内存)。(注:较新的硬件和 CUDA 版本支持统一虚拟内存,可以在一定程度上简化这一步骤,允许 GPU 直接访问主机内存)。
-
线程块分配到SM: 数据准备好后,GPU 会将线程块分配给 SM 进行处理。GPU 内部有一个硬件调度器,负责将待执行的线程块分配给空闲的 SM。同一个线程块内的所有线程保证被调度到同一个 SM 上执行,这样它们才能利用共享内存进行协作和同步。由于 SM 数量有限,如果 Kernel 包含大量线程块,它们会排队等待分配。当一个线程块执行完毕,SM 释放资源,调度器就会从队列中选择下一个线程块进行执行。
-
线程分组到 Warp: 被分配到同一个 SM 上的线程块,其内部的线程还会被进一步划分为更小的执行单元——Warp。一个 Warp 通常包含 32 个线程(这是 NVIDIA GPU 的特性)。SM 会以 Warp 为单位进行指令的调度和执行。
-
SIMT执行模型: SM 通过获取一条指令,然后将这条指令同时发送给 Warp 中的所有 32 个线程执行。所有的线程都执行同一条指令,但是操作的是不同的数据。这种执行模型被称为 单指令多线程(Single-Instruction, Multiple-Thread, SIMT)。在向量加法的例子中,一个 Warp 中的 32 个线程可能都在执行“加法”指令,但每个线程操作的是向量中不同的索引位置上的元素。这与 CPU 的 SIMD (Single-Instruction, Multiple-Data) 指令有点类似,但 SIMT 是在线程层面,更具灵活性。
值得注意的是,较新的 GPU 架构(如 Volta 及之后)引入了独立线程调度(Independent Thread Scheduling),在 Warp 内部允许线程之间有更多的独立性和并发性,不再强制所有线程严格同步执行同一条指令,这能更好地利用执行资源,但也增加了同步的复杂性。
- 延迟隐藏(Latency Hiding): 这是 GPU 高吞吐量的核心秘密之一。我们知道,访问全局内存、执行某些复杂指令都可能引入较高的延迟,导致线程或 Warp 需要等待。此时,SM 不会空等。得益于每个 SM 上存在的大量线程和 Warp,以及每个线程都拥有自己的寄存器状态(切换代价低),SM 的硬件调度器会立即切换到执行另一个已经就绪、不需要等待的 Warp。通过快速地在不同 Warp 之间切换,SM 能够始终保持其计算单元处于忙碌状态,用大量的计算任务填充掉指令的等待时间,从而最大化整体吞吐量。
相比之下,CPU 的上下文切换代价较高,因为它需要将一个进程(或线程)的寄存器状态保存到内存并恢复另一个进程的状态。GPU 的 Warp 切换发生在硬件层面,且每个线程的寄存器是独立的,因此效率极高。
六、性能的考验:资源分配与SM占用率(Occupancy)
虽然 GPU 拥有强大的并行能力,但要充分发挥其性能,需要开发者对资源管理有深入的理解。一个关键的指标就是 SM 占用率(Occupancy)。
占用率衡量了 SM 被有效利用的程度,具体是指分配给一个 SM 的活跃 Warp 数量与该 SM 能够支持的最大 Warp 数量之间的比值。理想情况下,我们希望达到 100% 的占用率,这意味着 SM 拥有足够的活跃 Warp 来隐藏指令延迟,确保计算单元始终满负荷运转,从而实现最大吞吐量。
然而,在实践中实现 100% 占用率并非易事,因为 SM 的资源是有限的,并且需要在多个线程块和线程之间进行动态分配。SM 拥有固定数量的资源池,主要包括:
- 总寄存器数量
- 共享内存容量
- 同时支持的线程块数量上限
- 同时支持的总线程数量上限(通常等于最大 Warp 数 × Warp 大小,如 64 Warp × 32 = 2048 线程在 H100 上)
当一个线程块被分配到 SM 上时,它会申请并占用这些资源。资源的分配是动态的:
- 每个线程需要一定数量的寄存器。
- 每个线程块需要一定量的共享内存。
- 每个线程块占用一个线程块槽位。
- 线程总数不能超过 SM 的上限。
资源分配是动态的,意味着资源会根据每个线程块或线程的实际需求来划分。例如,NVIDIA H100 的一个 SM 最多可以支持 32 个线程块、64 个 Warp(2048 个线程)。
资源的限制直接影响占用率。举例来说明:
- 假设你的 Kernel 使用了 32 个线程的线程块,总共需要 2048 个线程来处理数据。这需要 2048 / 32 = 64 个线程块。但如果一个 SM 最多只能处理 32 个线程块,那么即使该 SM 有 2048 个线程槽,它一次最多也只能运行 32 个线程块,总计 32 * 32 = 1024 个线程(或 32 个 Warp),此时占用率就只有 32 / 64 = 50%。
- 假设你的 Kernel 每个线程需要 64 个寄存器。一个 SM 总共有 65536 个寄存器。如果一个 SM 最多支持 2048 个线程,那么平均每个线程最多只能分配 65536 / 2048 = 32 个寄存器。如果你的 Kernel 需要 64 个寄存器,那么一个 SM 最多只能运行 65536 / 64 = 1024 个线程(或 1024 / 32 = 32 个 Warp),占用率同样只有 32 / 64 = 50%。
占用率不足意味着 SM 没有足够的活跃 Warp 来有效地隐藏指令延迟,或者无法满负荷地利用计算资源,从而无法达到硬件的最佳性能。
编写高效的 GPU Kernel 是一项充满挑战的工作,开发者需要在最大化占用率(保证有足够的 Warp 来隐藏延迟)和优化单线程/单 Warp 性能(如通过使用更多寄存器减少访存,但这可能降低占用率)之间找到平衡。合理地配置线程块大小、管理共享内存的使用以及优化寄存器使用都至关重要。
结论:跨越鸿沟,掌握并行计算的钥匙
回顾一下我们今天探讨的关键要点:
- CPU与GPU设计理念截然不同: CPU追求低延迟的顺序执行,GPU追求高吞吐的大规模并行。
- GPU的核心计算单元是SM: 每个SM包含多个核心、共享内存、控制单元和硬件调度器。
- GPU内存体系多层级: 从快速私有的寄存器,到SM内共享的Shared Memory,再到全局共享的L1/L2 Cache,最终是容量大但慢的Global Memory (HBM/DRAM)。
- GPU代码执行基于层次结构: Kernel组织成Grid,Grid包含Block,Block包含Thread。同一个Block的线程运行在同一个SM上。
- Warp是调度的基本单位: SM以32个线程组成的Warp为单位执行SIMT指令,并通过在不同Warp间快速切换来隐藏延迟。
- 资源分配影响性能: SM资源(寄存器、Shared Memory等)在线程间动态分配,直接影响SM的占用率。高占用率是实现高吞吐量的关键。
- 优化Kernel需要权衡: 开发者需要精细管理资源,平衡各项指标,以达到最佳的SM占用率和性能。
理解这些基本概念,是你从CPU的顺序思维跨越到GPU并行世界的敲门砖。虽然编写高效的GPU Kernel 是一项复杂的任务,涉及对硬件细节的深刻理解和精妙的代码优化,但基础原理是相通的。
希望这篇文章能帮助你初步构建起对GPU架构和执行模型的认知框架。未来,随着你深入学习CUDA等并行编程模型,这些概念会越来越清晰。