CUDA 编程完全理解系列(第一篇):GPU 的设计哲学与硬件架构基础
前言
当你打开 Autoware CenterPoint 的源码时,会看到大量 CUDA kernel 和核函数调用。很多人的反应是"代码太复杂,看不懂"。但其实,CUDA 的复杂性不在代码层面,而在思维模式------它要求你用"数据并行"的方式重新思考问题。
这个系列将从为什么 GPU 要这样设计开始讲起。当你理解了 GPU 设计的根本动机,之后所有的 kernel、block、thread、warp 等概念都会变得理所当然。
第一部分:CPU vs GPU,从性能矛盾看设计哲学
1.1 基本事实:计算速度与内存速度的巨大鸿沟
拿 NVIDIA RTX 3090 和现代 CPU 做对比:
CPU(Intel/AMD)的设计指标:
- 核心数:8-16 个
- 时钟频率:3-4 GHz
- 单核性能:优化延迟
- 缓存策略:大缓存(L1/L2/L3)来隐藏内存延迟
GPU(RTX 3090)的设计指标:
- CUDA Core 数:10,496 个
- 时钟频率:1.7 GHz
- 总吞吐量:35.6 TFLOPS(浮点运算)
- 显存带宽:936 GB/s
乍一看,GPU 的时钟频率反而更低,但可用的计算单元(core)数量是 CPU 的几百倍。这背后隐藏着一个矛盾的事实:
CPU: 1 个核 × 3 GHz × 高效能 = 优化单个任务的延迟
GPU: 10000 个核 × 1.7 GHz × 简单计算 = 优化大量任务的吞吐
根本矛盾:计算速度 vs 内存速度
一个简单的浮点乘法在 GPU 上耗时:约 0.6 纳秒(1 个时钟周期)
一次从显存读取数据的延迟:约 240 纳秒(400 个时钟周期)
延迟是计算的 400 倍!
这意味着,如果一个 GPU 核每次计算都要等内存,99.75% 的时间会在等待。GPU 的 10,000 个核大部分时间都会闲置,浪费计算能力。
1.2 CPU 的思路:优化单线程延迟
CPU 设计者的策略是:"让单个任务跑得尽可能快"。
为了隐藏 400 周期的内存延迟,CPU 采用:
├─ 大缓存(L1/L2/L3)
│ └─ 希望数据已经在缓存里,不用等内存
├─ 分支预测
│ └─ 提前猜测下一条指令,减少等待
├─ 乱序执行
│ └─ 如果指令 A 等内存,先执行指令 B
└─ 超线程
└─ 在等待时切换到另一个线程
这些技术很复杂,但核心目标只有一个:"让这一个线程跑快一点"。
1.3 GPU 的思路:用并发战胜延迟
GPU 设计者的策略截然相反:"让硬件永远不闲着"。
与其花费大量晶体管做复杂的缓存和分支预测,不如直接把这些晶体管用来制造更多的计算核心 ,让大量线程并发运行。
GPU 的解决方案:
├─ 大量线程(几万个)
│ └─ 线程 A 等内存时,立即切换到线程 B、C、D...
├─ 零开销上下文切换
│ └─ 不像 CPU 那样需要保存/恢复寄存器状态
└─ 简单的控制逻辑
└─ 没有分支预测、乱序执行等复杂机制
核心创新:当任何一个线程因为内存访问而停滞(需要 400 个周期),GPU 调度器立即从其他 10,000 个等待的线程中选一个继续执行。只要有足够多的线程,硬件从来不会真正"闲置"。
1.4 从"让单个线程快"到"让硬件永不停"
这是 GPU 与 CPU 最根本的设计哲学差异:
┌──────────────────────────────────────────────┐
│ CPU: 复杂控制 × 少量核心 │
│ 目标:单线程延迟最小化 │
│ 策略:花晶体管在分支预测/缓存 │
├──────────────────────────────────────────────┤
│ GPU: 简单控制 × 海量核心 │
│ 目标:整体吞吐最大化 │
│ 策略:花晶体管在计算单元 │
└──────────────────────────────────────────────┘
这就是为什么 GPU 在点云处理中这么快:
- 100 万个点需要逐个处理(数据并行天堂)
- 单个点的计算很简单(不需要复杂缓存)
- 大量点并发处理可以隐藏内存延迟(100 万个线程中总有人在计算)
第二部分:GPU 硬件架构与编程概念的映射
理解了设计哲学后,我们看 GPU 的真实硬件,就会发现编程概念与硬件完全对应。
2.1 GPU 的硬件层次(以 RTX 3090 为例)
GPU 芯片(24 GB 显存)
├─ SM (Streaming Multiprocessor) × 82 个
│ ├─ CUDA Core × 128 个(执行单元)
│ ├─ Warp Scheduler × 4 个(调度器)
│ ├─ Register File: 65,536 个 32 位寄存器
│ ├─ Shared Memory: 100 KB
│ ├─ L1 Cache: 128 KB
│ └─ Warp 槽位: 64 个(可同时驻留 64 个 Warp)
├─ L2 Cache: 5.3 MB(全 GPU 共享)
└─ Global Memory (GDDR6X): 24 GB(显存)
这个硬件架构是分布式的,不像 CPU 是单一的整体。每个 SM 都像一个"小 CPU",有自己的缓存、寄存器、调度器。
2.2 CUDA 编程概念与硬件的对应关系
当你写:
cuda
kernel<<<num_blocks, threads_per_block>>>(args);
实际发生的映射是:
编程概念 硬件实体 数量关系
────────────────────────────────────────────────
kernel → GPU 芯片 1 对 1
Grid → 所有 SM 的集合 1 对 1
Block → 驻留在 SM 上的单元 多个 Block → 1 个 SM
Thread → 执行单元的工作项 32 个 Thread → 1 个 Warp
Warp → 调度的最小单位 硬件概念(32 线程)
关键认识 :Block 不是 1 对 1 映射到 SM,而是多个 Block 可以共享一个 SM 的资源。GPU 调度器会动态分配 Block 到 SM,当 SM 完成一个 Block 后,立即加载下一个 Block。
2.3 为什么是 Warp(32 线程)?
GPU 为什么选择 32 作为硬件调度单位?这是工程折中的结果:
太小(如 8 线程):
- 单条指令无法充分利用 SM 的执行单元
- 调度开销相对较大
太大(如 64 线程):
- 寄存器和共享内存压力增加
- 分支分歧时浪费严重
32 线程的优势:
- 256 个线程 / Block = 8 个 Warp → 刚好是调度的高效粒度
- 足够的并行度来隐藏延迟
- 内存访问合并效率最优(128 字节缓存行 = 32 个 float)
第三部分:Warp 调度器如何战胜内存延迟
这是 GPU 整个设计最精妙的地方:Warp 调度器的零开销上下文切换。
3.1 Warp 的四个状态
┌─────────────────────────────────────────┐
│ 就绪 (Ready) │
│ 数据已就绪,可以执行 │
│ 调度器最优先选择这些 Warp │
├─────────────────────────────────────────┤
│ 等待内存 (Waiting for Memory) │
│ 发起了全局内存请求 │
│ 等待数据返回(~400 周期) │
├─────────────────────────────────────────┤
│ 等待同步 (Waiting for Sync) │
│ 到达 __syncthreads() │
│ 等待 Block 内其他 Warp 到达 │
├─────────────────────────────────────────┤
│ 已完成 (Completed) │
│ 所有线程执行完毕 │
│ 等待释放 Block 资源 │
└─────────────────────────────────────────┘
3.2 每周期的调度过程
每个 SM 有 4 个 Warp 调度器,每个时钟周期独立工作:
时钟周期 T:
调度器 0: 扫描 64 个 Warp,选择 1 个就绪的 → 发射指令
调度器 1: 扫描 64 个 Warp,选择 1 个就绪的 → 发射指令
调度器 2: 扫描 64 个 Warp,选择 1 个就绪的 → 发射指令
调度器 3: 扫描 64 个 Warp,选择 1 个就绪的 → 发射指令
结果: 每周期最多 4 个 Warp 同时发射指令到 128 个 CUDA Core
3.3 如何隐藏 400 周期的内存延迟
场景 1:只有 8 个 Warp(不好的情况)
时钟周期 1-8: 8 个 Warp 各发射 1 个全局内存读指令
时钟周期 9-400: 所有 Warp 等待,SM 完全闲置 ❌
场景 2:64 个 Warp(好的情况)
时钟周期 1: Warp 0-3 发起内存请求
时钟周期 2: Warp 4-7 发起内存请求
...
时钟周期 16: 所有 64 个 Warp 发起请求
时钟周期 401: Warp 0 数据到达 → 从"等待内存"变为"就绪" → 继续执行计算
时钟周期 402: Warp 1 数据到达 → 继续执行计算
...
关键: 当 Warp 0 的数据到达时,已经有 60 多个其他 Warp 在排队执行
SM 永不闲置 ✅
这就是为什么 GPU 需要大量线程 的原因:不是为了让单个线程快,而是为了形成一个流水线,永远有线程在计算。
第四部分:Orin 上的特殊考量
在 Autoware 部署到 AGX Orin 时,这个"内存速度"的矛盾会被进一步放大。
4.1 x86 系统上的数据传输成本
在台式机(RTX 3090)上:
CPU 内存 ←→ GPU 显存:必须通过 PCIe 总线
总线带宽:16 GB/s(PCIe 4.0)
延迟:要拷贝 160 MB 的 100 万个点 = 10 ms
这对 50 ms/frame 的系统来说是灾难级的
4.2 ARM 系统上的统一内存架构
AGX Orin 采用的是ARM 异构架构 ,CPU 和 GPU 共享同一套内存系统:
x86 系统: ARM 系统(Orin):
CPU GPU 统一内存空间
└─ RAM ─ PCIe ─ VRAM └─ NVME/DRAM
独立 低速 高速互连
影响 :在 Orin 上,GPU 可以直接访问 CPU 内存 ,而不需要显式的 cudaMemcpy 拷贝。这就是"零拷贝"的来源。
4.3 性能含义
x86 上的点云多帧融合:
数据准备 (1 ms) → H→D 拷贝 (10 ms) → GPU 计算 (5 ms)
→ D→H 拷贝 (10 ms) → 转换格式 (1 ms)
总计:~27 ms,占帧预算的 50%
Orin 上的点云多帧融合:
数据准备 (1 ms) → GPU 直接访问 (0 ms,零拷贝)
→ GPU 计算 (5 ms) → 结果已在内存中 (0 ms)
总计:~6 ms,占帧预算的 12%
5 倍的性能差异就是来自于这个架构差异。
总结:为什么这么设计?
如果你能记住以下两点,就理解了 CUDA 的本质:
-
GPU 为了战胜内存延迟,放弃了单线程优化,转而用大量并发线程形成流水线
- CPU: 1 个核,缓存深度 3-4 层,花晶体管做缓存和分支预测
- GPU: 10000 个核,深度 0.5 层,花晶体管做计算单元
-
Warp(32 线程)是硬件真正关心的调度单位,不是 Thread
- Thread 是软件概念(方便编程者)
- Warp 是硬件概念(真正的调度粒度)
- 1 个 Warp = 1 个硬件调度周期
-
ARM 统一内存架构(Orin)消除了 PCIe 拷贝开销
- 零拷贝不是没有拷贝,而是硬件自动搬数据
- GPU 和 CPU 看同一套内存,减少延迟
下一篇将深入讨论Warp 调度器的工作细节 和Block 的生命周期,这些是理解源码的直接基础。
快速检查清单
- 理解了"计算速度 400 倍快于内存速度"这个根本矛盾
- 能解释 CPU 和 GPU 为什么有完全不同的设计哲学
- 知道为什么需要大量线程来隐藏延迟
- 理解 Warp(32 线程)为什么是硬件选择的单位
- 明白 Orin 上零拷贝为什么这么快