每个开发人员都应掌握的 GPU 计算原理,五分钟搞懂

360影视 动漫周边 2025-09-10 18:00 1

摘要:大多数程序员对 CPU 和顺序编程都有一定了解,因为大家一开始写代码时接触的就是 CPU。但相比之下,很多人对 GPU 的内部工作原理了解并不多,也不清楚它为什么如此特别。

大多数程序员对 CPU 和顺序编程都有一定了解,因为大家一开始写代码时接触的就是 CPU。但相比之下,很多人对 GPU 的内部工作原理了解并不多,也不清楚它为什么如此特别。

在过去十年里,随着深度学习的迅速发展,GPU 因其强大的并行计算能力而变得愈发重要。可以说,GPU 已经从最初的图形加速器,成长为现代计算体系中不可或缺的组成部分。因此,如今每一位软件工程师都需要对 GPU 的基本原理有所了解。

本文主要参考了 Hwu 等人编写的《Programming Massively Parallel Processors》第四版。书中内容基于 英伟达 GPU 的架构展开,因此本文也会以英伟达 GPU 为例,并采用英伟达相关的术语进行说明。不过需要说明的是,GPU 编程的基本概念和核心思想具有普适性,因此同样适用于其他厂商的产品。

理解 GPU 的最佳方式,是先将它与我们更熟悉的 CPU 放在一起对比,这能帮助我们更快地把握 GPU 的设计理念和整体架构。

CPU 和 GPU 最大的区别在于它们的设计目标,CPU 的设计初衷是执行顺序指令,这些年来 CPU 在架构上不断演进,引入各种功能机制,例如,指令流水线、乱序执行、推测执行以及多级缓存等特性,其目的都是为了降低单条指令的执行延迟,从而提升顺序执行的性能。

相比之下,GPU 的设计目标则完全不同,它强调的是大规模并行计算和高吞吐率,因此在指令延迟方面往往不如 CPU 优秀。这种设计思路最初是由电子游戏、图形渲染和数值计算的需求推动的,而在今天,它又在深度学习中发挥了关键作用。无论是图像处理还是矩阵运算,这些应用场景都需要以极快的速度完成海量的线性代数和数值计算任务,因此 GPU 的架构始终围绕提升吞吐率而构建。

举个具体的例子:在执行两个数的加法时,CPU 的延迟更低,所以它会比 GPU 更快完成这类单个运算,如果任务只是顺序执行少量类似操作,CPU 通常会比 GPU 更高效。

然而,当问题规模扩展到数百万甚至数十亿次运算时,情况就完全不同了。此时,GPU 可以凭借其成千上万个并行核心,同时处理大量运算,从而在整体速度上远远超越 CPU。

如果大家对数字更敏感,我们可以用性能指标来直观对比:衡量硬件数值计算能力的常用指标是 FLOPS(每秒浮点运算次数)。以 32 位精度为例,Nvidia 的 Ampere A100 GPU 吞吐率可达 19.5 TFLOPS;而一颗 24 核的 Intel CPU 在相同精度下的吞吐率只有 0.66 TFLOPS(数据来自 2021 年)。

下面这张图展示了 CPU 与 GPU 在架构上的差异。

从图中可以看到,CPU 的设计重点在于降低指令延迟。因此,它会将相当大的一部分芯片面积用于缓存和复杂的控制逻辑,而真正用于执行运算的单元(ALU)数量相对较少。

与此相对,GPU 会在芯片上堆叠大量的运算单元,以最大化计算能力和吞吐率。至于缓存和控制单元这类用于降低延迟的部分,在GPU 中所占的比例非常小,这种取舍使得 GPU 在单个操作上的延迟可能更高,但在大规模并行运算中能展现出远超 CPU 的性能。

你可能会疑惑:GPU 在指令延迟较高的情况下,为什么仍能保持高性能?这主要得益于 GPU 拥有海量线程和强大的计算能力。即便单条指令的执行需要更长时间,GPU 也能通过高效的线程调度来充分利用计算资源。例如,当部分线程在等待某条指令的结果时,GPU 会立即切换去执行其他不需要等待的线程。这样一来,GPU 内的计算单元几乎始终都在满负荷运转,从而实现远高于 CPU 的吞吐率。等到我们讨论 内核在 GPU 上的执行方式 时,这一点会更加直观。

既然我们已经知道 GPU 更强调高吞吐率,那么它的架构究竟是怎样实现这一点的呢?下面我们来具体看看。

GPU 计算架构

GPU 的核心由一组 流式多处理器(SM,Streaming Multiprocessor) 构成。每个 SM 又包含多个流处理器(也称为核心或线程)。以 Nvidia H100 为例,它拥有 132 个 SM,每个 SM 包含 64 个核心,总数高达 8448 个核心。

在每一个 SM 内部都配备有限的片上内存,通常称为共享内存或本地缓存,供所有核心共同使用。同样,SM 内的控制单元资源也由所有核心共享。此外,每个 SM 还配备了硬件级的线程调度器,用于管理线程的执行。

除了通用核心之外,现代 GPU 还集成了多种 专用计算单元,例如 张量核心(Tensor Cores) 和 光线追踪单元(Ray Tracing Units),以便在深度学习、图形渲染等特定场景下实现更高效的加速。

除此之外,现代 GPU 还集成了多种 专用计算单元,比如张量核心(Tensor Cores)、光线追踪单元(Ray Tracing Units),以便在深度学习、图形渲染等特定场景下实现更高效的加速。

理解了 GPU 的计算架构后,一个自然的问题是:这些成千上万的核心如何访问和管理数据?

接下来,我们再来拆解一下 GPU 的内存结构,看看其内部是如何设计的。

GPU 拥有多层次、不同类型的存储器,每一层都有其特定的用途。下图展示的是 GPU 中单个 SM 的内存层次结构。

为了更好地理解它,我们可以从最靠近计算核心的存储层级开始,一层层往外看:

寄存器(Registers):每个 SM 内部都包含大量寄存器。例如,Nvidia A100 和 H100 的每个 SM 都配备了 65,536 个寄存器。这些寄存器会在不同核心之间共享,并根据线程的需要动态分配。在实际执行过程中,分配给某个线程的寄存器是该线程独占的,其他线程无法对其进行读写。常量缓存(Constant Caches):除了寄存器之外,SM 还配备了片上常量缓存,用于存放执行代码中需要的常量数据。与寄存器不同,常量缓存的使用需要程序员在代码中显式声明对象为“常量”,这样 GPU 才会将其存入缓存。共享内存(Shared Memory):每个 SM 内都配备一块共享内存,也称为本地缓冲区。它是一种片上可编程的高速、低延迟 SRAM,主要用于让同一 SM 上、同一线程块(Block)中的线程共享数据。这样,当多个线程需要使用同一份数据时,只需由一个线程从全局内存中加载,其他线程即可直接复用。合理利用共享内存不仅能减少从全局内存重复加载的操作,还能显著提升内核的执行性能。此外,共享内存还常被用作线程块内线程之间的同步机制。L1 缓存(L1 Cache):每个 SM 还配有 L1 缓存,用于缓存从 L2 缓存中频繁访问的数据。L2 缓存(L2 Cache):GPU 还配备了一块由所有 SM 共享的 L2 缓存,用于缓存全局内存中被频繁访问的数据,从而降低访问延迟。需要注意的是,对于 SM 来说,L1 和 L2 缓存都是 透明的,也就是说,SM 并不关心数据究竟来自 L1 还是 L2,它只认为数据是从全局内存中获取的。这一点和 CPU 的 L1/L2/L3 缓存机制类似。全局内存(Global Memory):GPU 还具备片外的全局内存,它是一种容量大、带宽高的 DRAM。例如,Nvidia H100 配备了 80 GB 的高带宽显存(HBM),带宽可达 3000 GB/s。不过,由于全局内存距离 SM 较远,访问延迟相对较高。为了解决这个问题,GPU 借助多层片上缓存以及大量并行计算单元的配合,能够在很大程度上 掩盖这种延迟。

到这里,我们已经了解了 GPU 硬件的关键组成部分,接下来我们更进一步,看看这些组件在代码执行过程中是如何发挥作用的。

要弄清楚 GPU 是如何执行一个内核(Kernel)的,我们首先需要理解什么是内核,以及它的配置方式。从这里开始讲起。

CUDA 是英伟达提供的 GPU 编程接口。在 CUDA 中,我们需要将要在 GPU 上运行的计算写成类似 C/C++ 函数的形式,这个函数就被称为 内核(Kernel)。内核的特点是可以被成千上万的线程同时执行,用来并行处理输入数据。例如,一个最简单的内核就是实现向量加法:它接收两个向量作为输入,逐元素相加后,将结果写入第三个向量。

要在 GPU 上执行内核,就需要启动一定数量的线程。

那么,这些执行内核的线程是如何组织的呢?在 CUDA 中,所有的线程按照 网格(Grid) 的形式被统一管理。一个网格由若干个 线程块(Thread Block,简称 Block) 组成,而每个线程块又包含一个或多个线程。

线程块和线程的数量取决于数据规模以及我们希望实现的并行度。例如,在前面的向量加法案例中,如果向量的维度是 256,我们就可以配置一个包含 256 个线程的线程块,让每个线程处理向量中的一个元素。而对于规模更大的问题,GPU 上可能没有足够的线程数,这时就需要让每个线程负责处理多个数据点。

理解了线程的组织方式后,我们再来看内核在代码层面是如何实现的。通常,编写一个 CUDA 程序会涉及 两部分代码:

主机端代码(Host Code):运行在 CPU 上,主要负责数据加载、在 GPU 上分配内存,并按照设定好的线程网格启动内核。设备端代码(Device Code):运行在 GPU 上,也就是内核本身的实现,它定义了每个线程需要完成的计算任务。

以向量加法为例,下图展示的就是对应的主机端代码,负责数据的准备和内核的启动。

下面展示的是设备端代码,也就是实际定义内核函数的部分,用于描述每个线程具体要完成的计算任务。

由于本文的重点并不是讲解 CUDA 编程,因此这里不会对代码做更深入的说明。接下来,我们将关注 GPU 上内核执行的具体过程。

在内核被调度执行之前,需要先把它所需的数据从主机(CPU)的内存复制到设备(GPU)的全局内存中。当然,在最新的 GPU 硬件中,也可以通过统一虚拟内存(Unified Virtual Memory)直接从主机内存中读取数据,从而减少显式拷贝的开销。

当数据准备就绪后,GPU 会将线程块分配到各个流式多处理器(SM)上执行。一个线程块中的所有线程由同一个 SM 统一调度和管理,为了实现这一点,GPU 必须在开始执行之前为这些线程在 SM 上预留所需的寄存器、共享内存等硬件资源。值得注意的是,一个 SM 在大多数情况下可以同时容纳并执行多个线程块,以提升硬件利用率。

由于 SM 的数量有限,而大型内核往往包含成百上千个线程块,因此并不是所有的线程块都会立即被分配执行,对此,GPU 会维护一个等待队列:当某些线程块还没有空闲的 SM 可用时,它们会先进入队列;一旦某个 SM 完成当前任务,就会立刻从队列中取出新的线程块来执行,从而保持整个 GPU 的高并发度和资源利用率。

我们已经知道,一个线程块中的所有线程会被分配到同一个 SM 上。但在此之后,GPU 并不会让其中的所有线程“一股脑”同时执行。相反,线程还会再被进行一次划分:每 32 个线程会被划分为一个线程组,称为 Warp。每个 Warp 会被分配到一组核心(处理块)上执行。

在执行时,SM 会将同一条指令同时下发给一个 Warp 中的所有线程。这些线程虽然执行的是同一条指令,但各自处理的数据不同。例如,在向量加法中,一个 Warp 中的所有线程可能都在执行“加法”操作,只是作用在向量的不同元素上。

这种执行方式被称为 单指令多线程(SIMT)。它与 CPU 中常见的 单指令多数据(SIMD) 指令有些相似。

前面我们提到,线程会被划分成 Warp 并分配到 SM 上执行。但这里还有一些细节值得展开。

首先,即便在同一个 SM 内部,虽然有多个处理单元能够并行调度 Warp,但在任意时刻,真正执行指令的 warp 数量其实并不多,这是因为 SM 内部的执行单元数量是有限的。

其次,有些指令执行时间较长,会让 warp 处于等待结果的状态。遇到这种情况时,SM 会让这个 warp 进入休眠,并切换去执行另一个无需等待的 warp。通过这种方式,GPU 能够最大化利用可用的计算资源,从而实现高吞吐率。

值得注意的是,SM 在切换 warp 时几乎没有额外开销,这是因为每个 warp 中的每个线程都有自己独立的寄存器组,切换时无需保存和恢复上下文。

这与 CPU 上的进程上下文切换形成了鲜明对比,在 CPU 中,如果某个进程因耗时操作而进入等待状态,CPU 会在同一个核心上调度另一个进程执行。但这种切换代价很高,因为 CPU 需要先将寄存器内容保存到主存,再恢复另一个进程的运行状态。

我们通常用一个指标 “占用率(occupancy)” 来衡量 GPU 资源的利用情况,它表示分配给某个 SM 的 warp 数量 与该 SM 理论所能支持的最大 warp 数量 的比例。

占用率越高,GPU 的计算单元就越不容易闲置,吞吐量就越大,因此我们通常希望它能接近 100%,但在实际应用中,由于各种限制,这往往无法实现,这是因为 SM 的执行资源(包括寄存器、共享内存、线程块槽位以及线程槽位)是有限的,这些资源会根据线程的需求和 GPU 的限制被动态分配。

以 Nvidia H100 为例:每个 SM 最多支持 32 个线程块、64 个 Warp(即 2048 个线程),而且单个线程块最多 1024 个线程。如果我们启动的网格中配置的线程块大小正好是 1024 个线程,那么一个 SM 最多只能同时容纳 2 个线程块(2048 ÷ 1024 = 2),即便它的线程块槽位还有余量,也无法继续调度新的线程块。

动态划分 vs 固定划分:动态划分能够让 GPU 的计算资源得到更高效的利用。相比之下,如果采用固定划分的方式,每个线程块都被强制分配固定数量的资源,那么往往会出现资源使用不均:有的线程块资源富余但无法利用,有的线程块则资源不足。这不仅造成浪费,还会降低整体吞吐量。

下面我们通过一个例子来看看资源分配是如何影响 SM 的占用率的。

假设我们要启动 2048 个线程,每个线程块配置 32 个线程,那么就需要 64 个线程块。但一个 SM 最多只能同时容纳 32 个线程块。结果就是:尽管理论上它能并行执行 2048 个线程,但实际上只能运行 1024 个线程,占用率只有 50%。

同样,每个 SM 拥有 65536 个寄存器。如果要同时执行 2048 个线程,那么平均下来每个线程最多只能使用 32 个寄存器(65536 ÷ 2048 = 32)。但如果某个内核需要每个线程使用 64 个寄存器,那么 SM 最多就只能运行 1024 个线程,占用率依然只有 50%。

占用率不足的问题在于,它可能无法提供足够的延迟容忍度,也无法达到硬件所能支持的最高计算吞吐量。

高效地编写 GPU 内核是一项复杂的工作。我们需要合理分配资源:既要保持较高的占用率,以便充分利用硬件;又要尽量降低延迟。比如,增加寄存器数量可能会让单个线程的执行更快,但同时会降低占用率,因此需要在代码编写时进行权衡和取舍。

总结

要一下子弄懂这么多新概念和术语确实不容易。下面我们来快速回顾一下本文的重点内容,以帮助大家消化和理解。

GPU 由多个流式多处理器(SM)组成,每个 SM 内部包含若干处理核心。GPU 外部有全局内存,通常是 HBM 或 DRAM,由于它们距离 SM 较远,访问延迟也相对较高。GPU 还配备有片外的 L2 缓存和片上的 L1 缓存,它们的工作方式与 CPU 中的 L1/L2 缓存类似。每个 SM 还带有一块小容量、可配置的共享内存,供线程块内部的线程共享使用。通常情况下,线程块中的线程会把一部分数据加载到共享内存中,然后重复使用,而不是每次都从全局内存中加载。此外,每个 SM 还拥有大量寄存器,按需分配给线程使用。例如,Nvidia H100 的每个 SM 配备了 65,536 个寄存器。在 GPU 上执行一个内核时,首先需要启动一个由大量线程组成的网格(grid)。一个网格包含多个线程块(thread block),而每个线程块又由若干线程构成。GPU 会根据资源的可用情况,把一个或多个线程块分配到某个 SM 上执行。需要注意的是,一个线程块内的所有线程必须被分配到同一个 SM 上运行,这样可以更好地利用数据局部性,并支持线程之间的同步操作。在具体执行时,分配到 SM 的线程会进一步被划分为大小为 32 的分组,这样的分组称为 warp。warp 内的所有线程会在同一时间执行相同的指令,但作用于不同的数据部分(SIMT 模型)。不过,在较新的 GPU 架构中,也引入了独立线程调度机制,使得 warp 内的线程在一定条件下能够独立执行。GPU 会根据每个线程的需求以及 SM 的资源限制,在不同线程之间进行动态资源划分。因此,程序员在编写 GPU 内核时需要仔细优化代码,以尽可能提高 SM 的占用率(occupancy),从而充分发挥硬件的计算潜力。

来源:心平氣和

相关推荐