一、为什么要学 CUDA:CPU 的困境与 GPU 的答案

作为 C++ 工程师,我们习惯了"多线程 + 锁"的并发模型。但当你面对数据密集型计算——向量相加、矩阵乘法、图像滤波、神经网络推理——CPU 的多核会迅速撞到天花板。

举个直观的例子:两个 1000 万长度的数组逐元素相加。

  • CPU 单线程:约 30ms
  • CPU 16 线程(OpenMP):约 3ms
  • GPU(CUDA):约 0.3ms

GPU 能快两个数量级,靠的不是"更强的核心",而是海量的简单核心

1
2
3
4
5
6
7
8
9
10
11
12
13
14
┌─────────────────────────── CPU ───────────────────────────┐
│ Core0 Core1 Core2 ... Core15 │
│ (复杂, 大缓存, 乱序执行, 4~5GHz) │
└─────────────────────────────────────────────────────────────┘

┌─────────────────────────── GPU ───────────────────────────┐
│ SM0 SM1 ... SM30 │
│ ┌────┬────┬────┬────┐ ┌────┬────┬────┬────┐ │
│ │T0 │T1 │T2 │T3 │ │T0 │T1 │T2 │T3 │ │
│ ├────┼────┼────┼────┤ ├────┼────┼────┼────┤ │
│ │T4 │T5 │T6 │T7 │ │T4 │T5 │T6 │T7 │ │
│ └────┴────┴────┴────┘ └────┴────┴────┴────┘ │
│ 每个 SM 可同时驻留上千个线程(1~2GHz, 单指令多线程) │
└─────────────────────────────────────────────────────────────┘

核心思想:CPU 用少量复杂核心做"难而杂"的事,GPU 用大量简单核心做"简单而重复"的事。CUDA(Compute Unified Device Architecture)就是 NVIDIA 提供的编程模型,让 C++ 开发者能用标准 C++ 语法驱动这块并行怪兽。

二、CUDA 编程模型:线程层次

2.1 三个层级:Thread / Block / Grid

CUDA 的线程组织是分层的——理解它是理解一切优化的前提:

1
2
3
Grid(网格:一次内核启动的全部线程)
└── Block(块:一组可以协作的线程)
└── Thread(线程:最小的执行单元)
1
2
3
4
// 内核启动语法:kernel<<<gridDim, blockDim>>>(args);
// gridDim = 块的数量,blockDim = 每块线程数
vectorAdd<<<128, 256>>>(d_a, d_b, d_c, N);
// 启动 128 个块,每块 256 线程 = 总共 32768 个线程

每个线程在内核里通过内置变量知道自己是谁:

1
2
3
4
5
6
7
8
__global__ void vectorAdd(const float* a, const float* b, float* c, int n) {
// 全局线程号 = 块号 * 块内线程数 + 块内线程号
int idx = blockIdx.x * blockDim.x + threadIdx.x;

if (idx < n) { // 边界检查:线程数可能超过 n
c[idx] = a[idx] + b[idx];
}
}

编译运行输出:

1
2
3
$ nvcc -o vectorAdd vectorAdd.cu && ./vectorAdd
计算结果正确:c[0]=2.0, c[9999999]=19999998.0
用时: 0.31 ms

为什么要有 Block 这一层? 两个原因:

  1. 硬件调度单元——GPU 以 Block 为单位分配给 SM(流式多处理器),一个 Block 的所有线程共享同一块 SM
  2. 线程协作——块内线程可以通过共享内存(shared memory)和同步原语(__syncthreads())协作

2.2 内存层次:从全局内存到寄存器

CUDA 的存储体系是性能优化的主战场:

存储类型 作用域 速度 容量 特点
寄存器(Register) 单线程 最快 每线程 ~255 个 编译器自动分配,最珍贵
共享内存(Shared) Block 内 快(~30 周期) 每块 ~48KB 需手动管理,块内共享
全局内存(Global) 所有线程 慢(~400 周期) 数 GB~数十 GB 主存储,PCIe/显存
常量内存(Constant) 所有线程 快(广播) 64KB 只读,全体一致
纹理/表面内存 所有线程 特殊访问模式

最经典的性能陷阱:每个线程都去读全局内存 → 带宽成为瓶颈。解法是合并访问(coalesced access)——相邻线程访问相邻内存地址,让一次内存事务同时服务多个线程:

1
2
3
4
5
6
7
// ❌ 错误示范:线程跳着访问,一次内存事务只服务 1 个线程
int idx = threadIdx.x * 2;
c[idx] = a[idx] + b[idx]; // 线程0读 a[0],线程1读 a[2] —— 不连续

// ✅ 正确示范:相邻线程访问相邻地址
int idx = threadIdx.x;
c[idx] = a[idx] + b[idx]; // 线程0读 a[0],线程1读 a[1] —— 合并访问

性能差距可达 10~30 倍——这是新手写的 CUDA 慢得离谱的最常见原因。

三、深入:共享内存与同步

3.1 为什么需要共享内存

假设矩阵乘法 C = A × B。每个输出元素 C[i][j] 需要读 A 的一行和 B 的一列。如果全部走全局内存,大量重复读取会耗尽带宽。

解法:分块(tiling)——把数据分块载入共享内存,块内所有线程复用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
#define TILE 16

__global__ void matMulTiled(const float* A, const float* B, float* C, int n) {
__shared__ float As[TILE][TILE]; // 共享内存分块
__shared__ float Bs[TILE][TILE];

int row = blockIdx.y * TILE + threadIdx.y;
int col = blockIdx.x * TILE + threadIdx.x;
float sum = 0.0f;

for (int k = 0; k < n; k += TILE) {
// 协作载入:块内线程一起把 A、B 的分块搬进共享内存
As[threadIdx.y][threadIdx.x] = A[row * n + (k + threadIdx.x)];
Bs[threadIdx.y][threadIdx.x] = B[(k + threadIdx.y) * n + col];
__syncthreads(); // 同步:确保整块载入完成

for (int i = 0; i < TILE; i++) {
sum += As[threadIdx.y][i] * Bs[i][threadIdx.x];
}
__syncthreads(); // 同步:防止下一轮覆盖正在用的数据
}
C[row * n + col] = sum;
}

运行输出(对比):

1
2
朴素全局内存版本:     415.2 ms
共享内存分块版本: 68.9 ms (6 倍加速)

__syncthreads() 是块内线程的"汇合点"——所有线程必须到达后才能继续。这是块内协作的基础,但也要注意:

  • 它只同步块内线程,不同块之间天然独立(无需同步)
  • 使用不当(比如在分支里调用)会导致死锁

3.2 性能对比:三种优化阶段

优化阶段 手段 效果
第 1 阶段 合并访问(coalescing) 10~30 倍
第 2 阶段 共享内存分块 + 同步 再 3~10 倍
第 3 阶段 循环展开、向量化、寄存器优化 再 1.5~3 倍

四、实战:CUDA 流与异步执行

4.1 为什么需要流(Stream)

GPU 计算和内存拷贝(host↔device)是可以重叠的。默认的同步执行会浪费大量等待时间:

1
2
3
4
5
6
7
8
9
同步执行(总耗时 = 拷贝 + 计算 + 拷贝):
拷贝 H→D ──► 计算 ──► 拷贝 D→H
30ms 60ms 30ms = 120ms

异步执行(流水线重叠):
拷贝 H→D #1 ──► 计算 #1 ──► 拷贝 D→H #1
拷贝 H→D #2 ──► 计算 #2 ──► 拷贝 D→H #2
...
≈ 70ms(隐藏了大部分拷贝时间)
1
2
3
4
5
6
7
8
9
10
11
12
13
cudaStream_t stream1, stream2;
cudaStreamCreate(&stream1);
cudaStreamCreate(&stream2);

// 把独立的内核放进不同流,让它们并行执行
kernelA<<<grid, block, 0, stream1>>>(d_a, N); // 流 1
kernelB<<<grid, block, 0, stream2>>>(d_b, N); // 流 2

// 异步内存拷贝也可以放进流,与计算重叠
cudaMemcpyAsync(d_in, h_in, size, cudaMemcpyHostToDevice, stream1);

cudaStreamSynchronize(stream1); // 等待流 1 完成
cudaStreamDestroy(stream1);

运行输出:

1
2
单流串行:         124.7 ms
双流并行: 72.3 ms (吞吐量 +72%)

4.2 常见坑:异步不等于自动

  • cudaMemcpy 是同步的,cudaMemcpyAsync 才是异步的——别用错
  • 异步操作里访问的内存必须是页锁定内存(pinned memory),普通 malloc 的 host 内存不行:
1
2
3
float* h_a;
cudaMallocHost(&h_a, size); // 页锁定内存,支持异步拷贝
// 用完 cudaFreeHost(h_a);

五、性能优化清单与总结

5.1 一张图理解 CUDA 优化主线

1
2
3
4
5
6
7
8
9
10
线程层次 ──► 决定了"并行度够不够"


内存访问 ──► 决定了"数据搬运快不快"(合并访问、共享内存)


同步与调度 ──► 决定了"有没有空闲等死"(流、占用率)


用 nvprof / Nsight Compute 验证,回到第一步

5.2 黄金检查清单

# 检查项 原因
1 线程数 ≥ 数据量的 10 倍? 并行度不足,GPU 闲置
2 访问全局内存是否合并? 非合并访问是最大杀手
3 有数据复用吗?用共享内存了吗? 复用是分块优化的前提
4 有分支发散吗?(warp 内) 同一 warp 走不同分支会串行化
5 内存拷贝和计算重叠了吗? 流水线是吞吐量关键
6 用 Nsight Compute 看瓶颈了吗? 别猜,测

5.3 与 CPU 并行编程的对比

维度 CPU 多线程(OpenMP/std::thread) GPU(CUDA)
核心 少量复杂核(2~64) 大量简单核(数千~上万)
适用 任务并行、I/O、逻辑复杂 数据并行、计算密集、重复
同步 锁、原子、条件变量 __syncthreads、原子操作(块内轻量)
内存 共享同一内存,缓存一致性 显式分层(全局/共享/寄存器)
调试 成熟工具链 Nsight、cuda-gdb(难度更高)
开销 线程切换 ~微秒级 内核启动 ~10 微秒级(小块计算不划算)

最后一课:CUDA 不是万能的。数据量太小(< 1 万元素)时,内核启动开销就吃掉所有收益;数据搬移次数多于计算次数时,瓶颈在 PCIe 带宽。先问"该不该上 GPU",再问"怎么写"——这是老手和新手最本质的区别。

六、总结

CUDA 编程的核心可以浓缩成三条主线:

  1. 线程层次(Grid/Block/Thread)——决定并行度,是理解一切的骨架
  2. 内存层次(寄存器/共享/全局)——决定数据搬运效率,是性能优化的主战场
  3. 异步流水线(Stream/事件)——决定硬件利用率,是吞吐量的放大器

再浓缩成一句话:

让每个线程做最简单的事,让数据尽量少搬家,让所有硬件时刻在忙——CUDA 性能优化的本质,就是这三件事。

工程师视角的一句话总结:CUDA 不是"把 C++ 搬到 GPU",而是一次思维模式的转换——从"一个线程做复杂的事"变成"一万个线程做简单的事"。想通了这一点,剩下的都是套路。