吞吐 vs 延迟
GPU 用大量简单核心换取高吞吐,适合数据并行任务。
CS267 · 第 9 课
从 GPU 架构的演进出发,理解 CUDA 编程模型的核心抽象——线程、块、网格与内存层次,并掌握写出高效 GPU 代码的关键技术。
GPU(Graphics Processing Unit)最初为图形渲染设计,但因其极高的吞吐能力,逐渐成为科学计算与机器学习的主力加速器。GPU 并非万能,它擅长的是大规模数据并行任务——对大量数据执行相同或相似的操作。
CPU 优化的是延迟(latency):少量复杂任务尽快完成。它有大缓存、复杂控制逻辑、高主频。
GPU 优化的是吞吐(throughput):大量简单任务同时完成。它用大量小核心、简单控制、高内存带宽来换取单位时间内的总工作量。
现代 GPU 的设计建立在 5 个核心硬件思想之上,它们共同决定了 CUDA 编程模型:
| # | 思想 | 含义 |
|---|---|---|
| 1 | 简单核心 | 每个核心只做简单算术/访存,去掉乱序执行、分支预测等复杂逻辑 |
| 2 | 大量核心 | 上千个核心同时执行,吞吐远超 CPU |
| 3 | 共享指令流(SIMT) | 一组线程(warp)共享同一 PC,以锁步(lock-step)执行 |
| 4 | 分支掩码(masking) | warp 内线程走不同分支时,串行化执行两条路径,屏蔽不符合的线程 |
| 5 | 延迟隐藏(latency hiding) | 当一个 warp 等待内存时,调度器切换到另一个就绪的 warp |
当 warp 内线程进入不同分支(如 if (tid % 2 == 0)),两个分支都要执行,被屏蔽的线程空转。这会使该 warp 的有效工作减半甚至更低。尽量让 warp 内线程走同一条路径。
以 NVIDIA A100 为例:
CUDA 是 NVIDIA 推出的并行计算平台与编程模型,本质上是 C/C++ 的一组扩展。核心抽象是 kernel——在 GPU 上执行的函数,由大量线程并行调用。
先看一个朴素的 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 函数,但通过自己的索引区分工作。
全局线程 ID 的计算:i = threadIdx.x + blockIdx.x * blockDim.x。
虽然一维索引足以覆盖任意规模,但二维/三维的 block 和 grid 让图像、矩阵等结构化数据的映射更直观,也更利于 shared memory 的协作模式。
| 内存 | 位置 | 延迟 | 生命周期 | 可见性 |
|---|---|---|---|---|
| 寄存器(register) | SM 上 | ~1 cycle | 线程 | 仅本线程 |
| 共享内存(shared memory) | SM 上 | ~5 cycles | block | 同 block 线程 |
| L1 / L2 cache | SM / 芯片 | ~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 是科学计算中常见的模式,每个输出元素是输入中一个局部邻域的函数。例如一维 3-point stencil:y[i] = c0*x[i-1] + c1*x[i] + c2*x[i+1]。
在 GPU 上实现时,一个 block 负责一段输出,但需要左右相邻的"halo"元素。通常有两种策略:
__syncthreads() 同步后再计算。__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 上的同步层次:
__syncthreads()、__threadfence_block()。cooperative groups 的 grid 级同步。cudaMemcpy 隐式同步;cudaDeviceSynchronize() 显式同步。GPU 的内存访问以段(segment)为单位——通常 32 字节或 128 字节。如果 warp 中 32 个线程访问连续且对齐的地址,这些访问可以被合并为一次段事务。这就是coalesced access。
合并:a[i],i = tid 连续 → 1 次 128B 事务服务 32 个 float。
非合并:a[tid * 16] 跨步大 → 每个线程触发一次事务,带宽利用率极低。
规则要点:
| 扩展 | 用途 |
|---|---|
__global__ | host 调用、device 执行的 kernel 函数 |
__device__ | device 内部调用的函数 |
__host__ | host 函数(默认) |
__shared__ | 声明 block 内共享内存 |
__constant__ | 声明常量内存 |
__syncthreads() | block 内同步 |
<<< >>> | kernel 启动配置 |
此外,CUDA 还提供了:
cudaMallocManaged 让 host 和 device 共享同一指针,由驱动自动迁移页面。GPU 用大量简单核心换取高吞吐,适合数据并行任务。
32 个线程以锁步执行;分支发散会串行化两条路径,降低效率。
三级并行抽象,让同一 kernel 映射到成百上千个核心。
寄存器最快最小,全局内存最慢最大;shared memory 是程序员可控的"用户管理 cache"。
warp 内连续对齐的访存合并为少量段事务,是 GPU 性能的关键。
问题 1:GPU 与 CPU 的核心设计目标差异是什么?
问题 2:一个 warp 中的 32 个线程执行 if (tid % 2 == 0) A; else B;,会发生什么?
问题 3:关于 __syncthreads(),下列哪项是正确的?
问题 4:下列哪种访问模式最可能实现 coalesced memory access?
问题 5:为什么 GPU 能用大量简单核心实现高吞吐?