CPU 内的并行编程
CPU 的功耗 $ P=C\cdot V^{2}\cdot f $ 导致纵使有更大的电路,热功耗限制了性能上限,从而有一堵“功耗墙”限制了 CPU 的性能,为此需要考虑如何在降低 $ V $ 和 $ f $ 的同时用面积换性能
有两个思路:
让一条指令能处理更多的数据:SIMD (Single Instruction, Multiple Data)
- “一条指令” 浪费的能量大致是定数
- 处理的数据越多,浪费越少
用更多更简单的处理器:多处理器系统、异构多处理器
- 同等面积,处理器越简单,数量越多
- 异构计算:最经典的例子是大小核架构(如 Apple M1)
SIMD
SIMD 的核心思想是在硬件层面实现数据级并行,它通过引入专门的硬件单元来达成这个目标:
宽位寄存器 (Wide Registers):CPU 内部增加了比通用寄存器宽很多的专用寄存器
- Intel 的 SSE 指令集引入了 128 位的 XMM 寄存器,而最新的 AVX-512 拥有 512 位的 ZMM 寄存器
- 这些宽位寄存器可以一次性装入多个数据元素,比如一个 128 位的寄存器可以同时容纳 4 个 32 位的浮点数,或者 16 个 8 位的整数
- 这些被打包在一起的数据被称为 Vector
向量处理单元 (Vector ALU):CPU 内部也配备了能够对整个向量进行并行计算的 ALU
- 当执行一条 SIMD 指令时,这个特殊的 ALU 会在同一个时钟周期内,同时对寄存器中的 4 对浮点数分别执行操作
例子:假设我们要计算两个数组 A
和 B
的和,存入数组 C
,每个数组都有 4 个元素。
Traditional:
load A[0]
load B[0]
add
->store C[0]
load A[1]
load B[1]
add
->store C[1]
- … 重复 4 次,需要执行 4 轮独立的“load-compute-store”指令
SIMD:
LOAD
:将数组A
的 4 个元素一次性加载到一个 128 位的 XMM 寄存器中LOAD
:将数组B
的 4 个元素加载到另一个 XMM 寄存器中VADDPS
:CPU 的向量处理单元对这两个寄存器中的 4 对元素 同时 进行加法运算STORE
:将计算结果寄存器中的 4 个和值一次性存回内存中的数组C
通过这种方式,原来需要循环多次的计算被压缩成了几条高效的向量指令,极大地提升了吞吐率,尤其是在图像处理、视频编解码、科学计算和人工智能等需要大量重复性计算的领域,效果非常显著
GPU 和 GPGPU
第二种克服“功耗墙”的思路——使用更多、更简单的处理器——直接催生了 GPU (Graphics Processing Unit) 的发展,它最初为图形渲染这种高度并行的任务而设计,其架构被证明非常有效,最终演变成了一个通用计算的强大引擎
从 PPU 到 GPU
对专用图形硬件的需求在早期游戏机中就很明显:CPU 在渲染方面效率极低,计算屏幕上每个像素的颜色,每秒重复 60 次,这种“大规模并行”任务会完全压垮为串行任务设计的 CPU
早期方案 - PPU:早期系统将图形任务交给 PPU (Picture Processing Unit) 处理,这是一种领域专用硬件,它操作的是高级图形对象,如 tiles (8x8 像素块) 和 sprites (可移动的前景角色),而非单个像素,CPU 的工作被简化为告诉 PPU 该画 哪个 图块以及画在 哪里
固定功能 Pipeline:随着 3D 图形的出现,简单的 PPU 模型演变为更复杂但仍然固化的 “固定功能管线” (Fixed-Function Pipeline),它有一系列硬件实现的固定阶段,虽然功能强大,但除了调整预设参数外,几乎没有创造性空间
随后开发者为了自定义视觉效果,把图形管线中的关键部分被替换为可编程单元,发展出了可编程的特性
这种可编程性赋予了开发者前所未有的控制力,也标志着现代 GPU 的诞生
GPGPU
人们很快意识到,一个为像素并行执行数百万个简单程序的芯片,用途远不止于图形
- 早期的 hack :最初的 GPGPU (General-Purpose computing on GPU) 是开发者将一个科学问题(如矩阵乘法)伪装成一个图形任务,例如将矩阵作为“纹理”加载,然后编写一个“片元着色器”来进行乘法计算,最后将结果“颜色”写出
- 演变为计算平台:这种强大但繁琐的方法证明了其可行性,硬件厂商(比如 Nvidia)随即推出了专用的编程框架(CUDA)和开放标准(OpenCL),这些平台彻底剥离了图形接口,将 GPU 强大的并行计算能力直接暴露给开发者
AI 时代的并行编程
随着 GPGPU 平台的成熟,可编程的 Shader 模型也演变成了更通用的线程模型,最终在 AI 时代大放异彩
SIMT:单指令,多线程
CUDA 编程模型的核心是 SIMT (Single Instruction, Multiple Threads),这是对 SIMD 思想的扩展
GPU 程序中每个像素执行一次的“着色器”,在 CUDA 被看作是一个 Kernel 函数,会被成千上万甚至数百万个 线程 并行执行
SIMT 的魔法在于,GPU 会将线程分组(通常 32 个线程为一个 Warp),一个 Warp 内的所有线程共享同一个程序计数器 (PC),在硬件层面,它们在同一时刻执行完全相同的指令
这实际上创造了一种类似巨型 SIMD 的效果,每个线程虽然有自己独立的寄存器和数据(通过 threadIdx
等内置变量区分),但它们的执行流被捆绑在一起,这使得控制单元的设计可以极其简化,从而在芯片上集成海量的计算核心
Challenges
SIMT 架构在带来巨大并行优势的同时,也给带来了挑战:
内存合并 (Memory Coalescing):当一个 Warp 中的多个线程连续访问内存时,GPU 硬件能将这 32 个独立的访问请求合并成一笔或几笔大的内存事务,这是 CUDA 性能优化的关键
分支发散 (Branch Divergence):SIMT 最大的难点在于处理分支,如果一个 Warp 内的线程在
if-else
语句上做出不同选择,由于它们共享同一个 PC,硬件必须串行地执行if
路径和else
路径,并在执行每个路径时,将另一部分线程暂时屏蔽,这会使 Warp 的执行速度取决于内部执行最慢的线程共享内存 (Shared Memory):虽然 CUDA 线程可以访问共享内存,但如何避免访问冲突 (Bank Conflict),如何组织数据以最大化并行加载,都会增加 CUDA 程序的编写难度
尽管编写高效的 CUDA 程序充满挑战,但其回报是巨大的,尤其对于那些计算密集、模式固定的任务,例如深度学习中的矩阵乘法和卷积运算,GPU 能提供比 CPU 高出数个数量级的性能和能效比