GPU / CUDA / Memory Architecture
给工程师的基础理解页
System intuition for acceleration

显卡内存结构和 CUDA,到底该怎么理解、怎么用、为什么要用

真正把 CUDA 用好,不是记住几个 API,而是先明白 GPU 的计算核心是怎么组织的显存层级为什么这样设计线程为什么必须成批行动,以及 什么样的问题适合扔给 GPU。这页从计算核心和内存架构往下拆,把“为什么快”讲清楚,再落到“怎么写才不浪费 GPU”。

先别把 GPU 当“更强的 CPU”

CPU 的设计目标是 低延迟、强控制、复杂分支、少量线程也能跑得很好。GPU 的设计目标是 高吞吐、海量并行、让很多相似操作一起干。这两者不是一个东西的大小号版本,而是完全不同的取舍。

CPU 擅长“一个线程很聪明地做事”,GPU 擅长“一大群线程用统一节奏狠狠干活”。

所以你一旦把问题写成:同一种计算,作用在大量元素上,数据访问尽量规则,线程之间少分歧,那么 GPU 就舒服;反过来,如果你的程序到处是分支、指针追逐、随机访问、细粒度同步,GPU 就会显得很笨。

CPU 风格问题:
  if/else 很多
  指针跳来跳去
  每个任务逻辑都不一样
  -> 更适合少量强核低延迟执行

GPU 风格问题:
  对大量元素做相同/相近运算
  数据布局规则
  批量处理
  -> 更适合海量线程高吞吐执行

计算核心:你真正调度的不是“线程”,而是一整套分层机器

CUDA 代码看起来像你在开很多 thread,但硬件真正关心的是:这些 thread 怎么组成 warp,warp 怎么挂到 SM 上,SM 里还有多少寄存器、shared memory、warp scheduler、tensor core 资源可以吃下你的 block。

Unit 1

Thread

你写 kernel 时最小的编程单位。每个 thread 有自己的寄存器和局部执行状态。

Unit 2

Warp

NVIDIA 上通常是 32 个 thread 组成一个 warp。warp 才是调度和发射的关键粒度,warp 内线程倾向于同一步执行同一条指令。

Unit 3

Thread Block

你显式组织线程协作的单位。一个 block 内线程可以共享 shared memory,也可以同步。

Unit 4

SM

Streaming Multiprocessor。GPU 的核心计算单元,一个 SM 上同时驻留多个 warp / block,靠切换 warp 去隐藏访存延迟。

GPU
├─ SM 0
│  ├─ warp schedulers
│  ├─ CUDA cores / Tensor cores
│  ├─ registers
│  └─ shared memory / L1
├─ SM 1
├─ SM 2
└─ ...

Kernel launch
  -> grid
     -> many thread blocks
        -> each block lands on some SM
           -> block split into warps
              -> warps get issued over time

为什么 warp 很关键

因为 GPU 不是逐个 thread 精细调度的,而是大量依赖 warp 级执行。如果一个 warp 里的线程走不同分支,就会发生 warp divergence:硬件只能先执行一部分线程那条路,再执行另一部分,等于浪费并行度。

这就是为什么很多 CUDA 优化建议看上去都在强调“规则性”:规则的控制流、规则的数据布局、规则的访存模式,最终都是为了让 warp 更整齐。

为什么 block 大小不是随便填

一个 block 吃的不只是线程名额,还会吃 寄存器shared memory。你 block 开太大,单个 block 很胖,SM 上能同时驻留的 block 数就会下降;驻留太少,遇到访存等待时,没有别的 warp 顶上去,吞吐就下来了。这就是 occupancy 背后的直觉。

内存架构:GPU 快不快,常常先输赢在这里

GPU 的内存不是一块平的“大显存”。它是层级化的:离计算核心越近,容量越小、速度越快;离得越远,容量越大、延迟越高。CUDA 优化,本质上就是想办法把热点数据尽量留在更近的层里,并让远处的数据访问尽量有序。

层级 位置 特点 你要关心什么
Register 每个 thread 私有 最快、最小 变量太多会溢出到 local memory,吞吐直接受伤
Shared Memory 每个 SM / block 协作区 低延迟、可手工管理 做数据复用、tiling、线程协作的主战场
L1 / texture / cache SM 附近 自动缓存 受访存模式影响很大,不规则访问会很痛
L2 Cache 全芯片共享 容量更大,连接各 SM 与显存 跨 SM 的数据复用通常会在这里看到收益
HBM / GDDR 显存 板上大容量显存 带宽高,但延迟远高于片上存储 大多数 kernel 最后都在跟它打架
Host Memory CPU 内存 容量大,离 GPU 远 PCIe / NVLink 传输成本很高,别来回搬

为什么总说要 coalesced memory access

因为 GPU 喜欢一个 warp 的线程去读 连续、对齐 的地址。这样硬件能把很多线程的访问合并成更少的 memory transaction。要是 32 个线程每个人都去摸一块分散内存,带宽就会被你撕碎。

理想情况:
thread 0 -> addr 0
thread 1 -> addr 4
thread 2 -> addr 8
...
thread 31 -> addr 124
=> 连续访问,容易合并

糟糕情况:
thread 0 -> addr 0
thread 1 -> addr 4096
thread 2 -> addr 17
...
=> 零碎 transaction,多数时间耗在访存

shared memory 为什么常常是性能关键

因为很多计算不是“算不动”,而是“同一份数据被反复从显存读太多次”。把 tile 搬进 shared memory 后,同一个 block 内多个线程可以复用它,从而把全局内存带宽压力摊薄。这就是矩阵乘、卷积、attention kernel 里各种 tile/blocking 技术的核心动机。

CUDA 执行模型:grid、block、thread 只是表层,底层是映射和资源约束

CUDA 的编程模型给你一个很漂亮的抽象:你定义一个 kernel,然后指定 <<<grid, block>>>。但真正的关键不是“会 launch”,而是 launch 出来的 grid / block 形状,能不能让 SM 高效吃下去。

Host code (CPU)
  prepare data
  cudaMemcpy H2D
  launch kernel<<>>
  maybe launch more kernels
  cudaMemcpy D2H

Device side (GPU)
  grid
   -> block 0,1,2,3...
      -> each block mapped to some SM
         -> block split into warps
            -> warps execute, stall, switch, complete

延迟隐藏不是“消灭延迟”,而是“别傻等”

显存访问很慢,GPU 的套路不是像 CPU 那样用超复杂乱序执行去硬顶,而是让一个 SM 同时挂很多 warp。某个 warp 等数据时,调度器切到别的 warp 继续跑。只要你有足够并行、资源没被单个 block 吃爆,GPU 就能把不少等待藏起来。

stream 的本质

stream 让你把一串操作放进不同执行队列,去重叠 kernel 执行数据传输其他 kernel。这不是魔法,前提是硬件资源和依赖关系允许重叠。但很多实际系统里,stream 的价值很大,因为它能减少“GPU 明明能干活却在等”的时间。

怎么把 CUDA 用好:核心不是 API 熟练,而是别跟硬件对着干

下面这些原则,几乎涵盖了大多数 CUDA 优化的根:提高并行度、提高数据局部性、减少不必要搬运、减少线程分歧、提升算访比、让 kernel launch 和数据流组织得更顺。

Rule 1

先判断问题是否 GPU 友好

如果任务批量不够大、分支极多、数据极乱,强上 GPU 常常只会更复杂,不会更快。

Rule 2

尽量减少 Host ↔ Device 拷贝

PCIe 很贵。能在 GPU 上做完的链路,就别每一步都搬回 CPU。

Rule 3

优化访存先于盲目堆算力

很多 kernel 慢不是因为 ALU 不够,而是显存没喂顺。先看 memory-bound 还是 compute-bound。

Rule 4

让线程协作服务数据复用

shared memory、tiling、double buffering,本质都在提高数据复用率。

一个很实用的判断框架

  • 这个 kernel 是被 计算 卡住,还是被 访存 卡住?
  • 线程是否足够多,能让 SM 持续有活干?
  • 访存是否连续、对齐、可缓存、可复用?
  • block size 是否把寄存器 / shared memory 吃爆了?
  • 分支是否导致 warp divergence?
  • kernel 是否太碎,launch overhead 占比过高?

为什么很多高性能库都长得很“块状”

因为 block / tile 是 GPU 世界里最自然的工作单位。矩阵乘会按 tile 切,attention 会按 block/page 切,卷积会按 patch/tile 切。块状化的好处是:更好做数据复用、更好匹配 shared memory、更好控制访存模式,也更容易让 tensor core 吃满。

优化思路常见链条:
原始写法
  -> 每个 thread 直接从 global memory 反复读
  -> 带宽炸掉

改进写法
  -> 数据按 tile 切块
  -> 一块先搬进 shared memory
  -> block 内多个线程复用
  -> 减少 global memory traffic
  -> 吞吐上去

为什么大家不只是在“用 GPU”,而是在“用 CUDA”

因为 CUDA 不只是一个语言扩展,它是一整套围绕 NVIDIA GPU 的 编程模型 + 编译工具链 + runtime + 数值库 + profiler + 生态兼容层。工业界真正依赖的,不是某个 kernel 语法本身,而是整条栈都成熟。

Reason

成熟生态

cuBLAS、cuDNN、NCCL、TensorRT、CUTLASS、Nsight,再往上是 PyTorch / TensorFlow。你不是一个人裸写 kernel。

Reason

性能路径足够深

你可以从高层框架一路钻到 kernel、graph、memory、collective communication,性能优化空间连续。

Reason

硬件与软件协同强

NVIDIA 的 SM、tensor core、memory hierarchy、library、compiler 是一起演进的,很多优化能穿透全栈。

Reason

开发者供给足

行业里会 CUDA、会调 PyTorch kernel、会看 Nsight 的人多,团队协作和招聘现实也会把你推向 CUDA。

这也是为什么很多时候“为什么用 CUDA”的答案并不只是“因为快”,而是:因为它是目前最完整、最现实、最可落地的 GPU 计算工业栈

新手最常见的误区:代码跑在 GPU 上,不等于你真的用好了 GPU

误区 表面现象 实际问题
把小任务也硬塞 GPU launch 了 kernel 但没变快 数据搬运和 launch 开销吃掉了收益
只盯 FLOPS 理论算力很高,实测很慢 多数瓶颈在访存,不在算术单元
thread 开很多就以为并行好了 occupancy 看起来还行 warp divergence 或访存模式太差,吞吐照样低
忽略寄存器和 shared memory 占用 block size 调大后反而变慢 SM 驻留能力下降,延迟隐藏变差
CPU / GPU 之间来回倒腾数据 profile 里 memcpy 很多 你的系统大部分时间在搬运,不在计算

一个够用的结论

要理解 CUDA,最重要的不是背诵定义,而是建立这条因果链:GPU 想要高吞吐 → 必须海量并行 → 必须规则执行 → 必须规则访存 → 必须重视数据布局和内存层级 → 所以 CUDA 程序优化本质上是在顺着硬件组织问题

一旦你把这条线打通,后面再学 stream、shared memory、tensor core、kernel fusion、graph capture,就不会是零散技巧,而会自然归位。