CS267 · 第 9 课

CUDA 与 GPU 导论

从 GPU 架构的演进出发,理解 CUDA 编程模型的核心抽象——线程、块、网格与内存层次,并掌握写出高效 GPU 代码的关键技术。

授课:Aydin Buluc 视频:YouTube 预计用时:90 分钟

为什么要用 GPU?

GPU(Graphics Processing Unit)最初为图形渲染设计,但因其极高的吞吐能力,逐渐成为科学计算与机器学习的主力加速器。GPU 并非万能,它擅长的是大规模数据并行任务——对大量数据执行相同或相似的操作。

核心权衡:吞吐 vs 延迟

CPU 优化的是延迟(latency):少量复杂任务尽快完成。它有大缓存、复杂控制逻辑、高主频。

GPU 优化的是吞吐(throughput):大量简单任务同时完成。它用大量小核心、简单控制、高内存带宽来换取单位时间内的总工作量。

GPU 硬件的 5 个关键思想

现代 GPU 的设计建立在 5 个核心硬件思想之上,它们共同决定了 CUDA 编程模型:

#思想含义
1简单核心每个核心只做简单算术/访存,去掉乱序执行、分支预测等复杂逻辑
2大量核心上千个核心同时执行,吞吐远超 CPU
3共享指令流(SIMT)一组线程(warp)共享同一 PC,以锁步(lock-step)执行
4分支掩码(masking)warp 内线程走不同分支时,串行化执行两条路径,屏蔽不符合的线程
5延迟隐藏(latency hiding)当一个 warp 等待内存时,调度器切换到另一个就绪的 warp

分支发散(branch divergence)

当 warp 内线程进入不同分支(如 if (tid % 2 == 0)),两个分支都要执行,被屏蔽的线程空转。这会使该 warp 的有效工作减半甚至更低。尽量让 warp 内线程走同一条路径。

GPU 硬件细节:SM 与 Warp

以 NVIDIA A100 为例:

A100 速览

CUDA 编程模型

CUDA 是 NVIDIA 推出的并行计算平台与编程模型,本质上是 C/C++ 的一组扩展。核心抽象是 kernel——在 GPU 上执行的函数,由大量线程并行调用。

从 CPU 向量加法到 GPU kernel 的演化

先看一个朴素的 CPU 实现:

void vecAdd(float *a, float *b, float *c, int n) {
  for (int i = 0; i < n; i++)
    c[i] = a[i] + b[i];
}

GPU 版本把循环去掉,让每个线程处理一个元素:

__global__ void vecAdd(float *a, float *b, float *c, int n) {
  int i = threadIdx.x + blockIdx.x * blockDim.x;
  if (i < n)
    c[i] = a[i] + b[i];
}

调用方式(host 代码):

int n = 1<<20;
int threads = 256;
int blocks = (n + threads - 1) / threads;
vecAdd<<<blocks, threads>>>(d_a, d_b, d_c, n);

执行配置语法 <<<blocks, threads>>>

这个特殊语法告诉 CUDA runtime:启动 blocks 个 block,每个 block 有 threads 个线程。所有线程执行同一个 kernel 函数,但通过自己的索引区分工作。

线程层次:Thread、Block、Grid

全局线程 ID 的计算:i = threadIdx.x + blockIdx.x * blockDim.x。

为什么用二维/三维?

虽然一维索引足以覆盖任意规模,但二维/三维的 block 和 grid 让图像、矩阵等结构化数据的映射更直观,也更利于 shared memory 的协作模式。

GPU 内存层次

内存位置延迟生命周期可见性
寄存器(register)SM 上~1 cycle线程仅本线程
共享内存(shared memory)SM 上~5 cyclesblock同 block 线程
L1 / L2 cacheSM / 芯片~10–300 cycles—自动管理
全局内存(global memory)HBM 显存~400–800 cycles应用所有线程 + host

在 CUDA 中:

__shared__ float s[256];   // 块内共享内存
__device__ float g[1024];  // 全局内存(设备端声明)
__constant__ float c[64];  // 常量内存(只读,有 cache)

Stencil 计算与 Halo Exchange

Stencil 是科学计算中常见的模式,每个输出元素是输入中一个局部邻域的函数。例如一维 3-point stencil:y[i] = c0*x[i-1] + c1*x[i] + c2*x[i+1]。

在 GPU 上实现时,一个 block 负责一段输出,但需要左右相邻的"halo"元素。通常有两种策略:

__global__ void stencil_1d(float *in, float *out, int n) {
  extern __shared__ float s[];
  int gid = threadIdx.x + blockIdx.x * blockDim.x;
  int lid = threadIdx.x + 1;  // 留出 halo 位置

  // 加载内部元素
  s[lid] = (gid < n) ? in[gid] : 0.0f;
  // 加载左 halo
  if (threadIdx.x == 0)
    s[0] = (blockIdx.x > 0) ? in[gid - 1] : 0.0f;
  // 加载右 halo
  if (threadIdx.x == blockDim.x - 1)
    s[lid + 1] = (gid + 1 < n) ? in[gid + 1] : 0.0f;

  __syncthreads();  // 确保所有数据就绪

  if (gid < n)
    out[gid] = c0 * s[lid - 1] + c1 * s[lid] + c2 * s[lid + 1];
}

__syncthreads() 的陷阱

它同步的是同一个 block内的所有线程。如果 block 内部分线程走了不同分支并调用了 __syncthreads(),会导致死锁。跨 block 的同步只能通过 kernel 结束或 cooperative groups 实现。

同步与通信

GPU 上的同步层次:

访存合并(Memory Coalescing)

GPU 的内存访问以段(segment)为单位——通常 32 字节或 128 字节。如果 warp 中 32 个线程访问连续且对齐的地址,这些访问可以被合并为一次段事务。这就是coalesced access。

合并 vs 非合并

合并:a[i],i = tid 连续 → 1 次 128B 事务服务 32 个 float。

非合并:a[tid * 16] 跨步大 → 每个线程触发一次事务,带宽利用率极低。

规则要点:

CUDA 对 C 的扩展速览

扩展用途
__global__host 调用、device 执行的 kernel 函数
__device__device 内部调用的函数
__host__host 函数(默认)
__shared__声明 block 内共享内存
__constant__声明常量内存
__syncthreads()block 内同步
<<< >>>kernel 启动配置

此外,CUDA 还提供了:


本课要点

吞吐 vs 延迟

GPU 用大量简单核心换取高吞吐,适合数据并行任务。

SIMT 与 Warp

32 个线程以锁步执行;分支发散会串行化两条路径,降低效率。

线程/块/网格

三级并行抽象,让同一 kernel 映射到成百上千个核心。

内存层次

寄存器最快最小,全局内存最慢最大;shared memory 是程序员可控的"用户管理 cache"。

Coalescing

warp 内连续对齐的访存合并为少量段事务,是 GPU 性能的关键。


知识检查

问题 1:GPU 与 CPU 的核心设计目标差异是什么?

问题 2:一个 warp 中的 32 个线程执行 if (tid % 2 == 0) A; else B;,会发生什么?

问题 3:关于 __syncthreads(),下列哪项是正确的?

问题 4:下列哪种访问模式最可能实现 coalesced memory access?

问题 5:为什么 GPU 能用大量简单核心实现高吞吐?