CUDA系统拆解-05-内存层级与访存本质:性能瓶颈为什么常卡在数据

本文是「CUDA系统拆解」系列第 05 篇。
系列导读:CUDA系统拆解-00-导读:从编程模型到 AI 推理系统的学习路线
上一篇:CUDA系统拆解-04-warp、SIMT 与 SM:真实执行不是“线程各跑各的”
下一篇:CUDA系统拆解-06-共享内存与Coalescing:访存优化先抓哪几个抓手

1. 这篇解决什么问题

  • 为什么很多 CUDA 程序明明计算不复杂,却还是很慢。
  • 为什么 CUDA 必须设计成分层内存,而不是“所有内存都一样”。
  • registerlocal memoryshared memoryglobal memory 各自解决什么问题。
  • 什么叫 register spill,为什么 local memory 往往不是你想象中的“近”。
  • 为什么理解内存层级,是后面分析 coalescing、shared memory、fusion、GEMM、attention 的前提。

2. 先记住的核心结论

  • 很多 CUDA kernel 首先是 memory-bound,也就是先受内存访问和数据搬运限制,而不是先受算术能力限制。
  • CUDA 内存层级的核心矛盾是:越靠近计算单元,通常越快,但容量越小、成本越高。
  • register 最快,但线程私有、数量有限;shared memory 适合 block 内协作和数据复用;global memory 容量大,但常常是主要瓶颈。
  • local memory 的“local”说的是可见性,不代表物理上更近;它经常意味着寄存器不够或线程私有对象太大。
  • register spill 的本质是本来想放在寄存器里的数据,被迫落到更慢的内存路径。
  • 真正该建立的不是“哪种内存快”的静态记忆,而是“数据应该怎样在这些层级之间流动”的思维。

3. 正文讲解

3.1 为什么访存常常比计算更关键

很多人一想到 GPU,会先想到:

  • 核很多
  • 并行强
  • FLOPS 高

这些都对,但不够。
对 CUDA 来说,更关键的现实是:

算术运算本身未必贵,昂贵的往往是把数据拿过来、等它回来、再把结果写回去。

所以很多 kernel 的真实性能瓶颈并不是“乘加太慢”,而是:

  • global memory 读得太多
  • 访问模式不好
  • 本该复用的数据被反复读取
  • 中间结果过早写回,再被重新读出

这就是为什么很多 CUDA 程序首先是 memory-bound,而不是 compute-bound
前者可以先理解成“受数据搬运和带宽限制”,后者则是“受计算单元本身限制”。

3.2 为什么一定要有分层内存

如果理想一点,我们当然希望:

  • 所有线程都访问同一块大内存
  • 它像寄存器一样快
  • 容量还无限大

现实做不到,因为这里有天然冲突:

  • 速度
  • 容量
  • 带宽
  • 功耗
  • 距离计算单元的远近

通常规律是:

  • 越靠近计算单元,延迟越低、带宽越高
  • 但容量越小、成本越高

所以 CUDA 的内存层级,本质上是在不同层之间做取舍。
它不是为了让你背术语,而是为了给你一个更清晰的数据流组织方式。

3.3 先把 CUDA 内存层级总图建立起来

初学阶段你最该抓住的是四层:

  • register
  • local memory
  • shared memory
  • global memory

可以先这样理解:

register

  • 每个线程私有
  • 最快
  • 最稀缺

local memory

  • 也是线程私有可见
  • 但通常不是片上快存储
  • 常常意味着寄存器放不下了

shared memory

  • 一个 block 内线程共享
  • 片上
  • 适合协作和数据复用

global memory

  • 所有线程都能访问
  • 容量大
  • 延迟高,带宽宝贵

除此之外还有 constant memorytexture memory 这类更特殊的路径,但对你当前这条学习线来说,优先级不如前四者高。

3.4 register:最快,但最稀缺

register 可以理解成线程手边的临时工作区。

它最适合放:

  • 当前线程频繁使用的小变量
  • 中间结果
  • 累加值

为什么它这么重要?因为它体现了一个很核心的优化原则:

能不去更远的内存拿数据,就不要去。

如果数据已经在寄存器里:

  • 不需要额外同步
  • 不需要走更慢的内存路径
  • 单线程局部计算会更顺畅

但寄存器有两个硬约束:

  • 它只能线程私有,不能共享
  • 数量有限,会影响一个 SM 上能同时挂多少线程和多少 warp

所以寄存器不是越多越好。
每线程寄存器占用一旦太高,就会:

  • 压低并发
  • 影响 occupancy
  • 甚至触发 register spill

3.5 local memoryregister spill 到底是什么

这是最容易被名字误导的地方。

local memorylocal,说的是:

  • 数据对线程私有可见

它不代表:

  • 物理上就在这个线程附近
  • 访问很快

很多时候,local memory 出现的原因是:

  • 寄存器不够
  • 线程私有数组太大
  • 编译器无法把某些对象稳定留在寄存器里

这时就会发生 register spill
也就是本来想待在寄存器里的数据,被挤到了更慢的路径上。

所以你要牢牢记住一句话:

local memory 是“可见性局部”,不是“性能局部”。

理解这一点很重要,因为后面很多 kernel 变慢,不是因为算法逻辑变复杂了,而是因为寄存器压力太大,变量开始 spill。

3.6 shared memory:为什么它是 CUDA 最有代表性的设计之一

如果说寄存器是线程自己的口袋,那 shared memory 更像是 block 共用的一张工作台。

它的关键特性是:

  • block 内线程共享
  • 片上,延迟低、带宽高
  • 需要程序员显式管理

它最核心的价值有两个。

第一,做数据复用。
如果一块数据会被一个 block 内很多线程反复用到,那么:

  • 每个线程都去 global memory 读一遍,会很浪费
  • 先搬到 shared memory,再复用,通常更划算

第二,重组访问模式。
有时原始数据布局对 global memory 不友好,这时常见做法是:

  1. 先用较好的方式从 global memory 读进来
  2. 放到 shared memory
  3. 在 block 内按更合适的方式重排和消费

所以 shared memory 的本质不是“它更快,所以总要用”,而是:

当存在明显复用或访存重组需求时,它特别有价值。

3.7 为什么 global memory 常常是瓶颈

global memory 是大部分真实数据的主战场:

  • 大张量在这里
  • 权重在这里
  • activation 在这里
  • KV cache 也在这里

你不可能绕开它。
真正的问题从来不是“不要用 global memory”,而是:

必须用时,能不能少用一点、用得更整齐一点、让每次访问更值一点。

这也是为什么很多推理算子虽然数学不复杂,却依然很慢。
例如:

  • LayerNorm
  • RMSNorm
  • softmax
  • 一些 elementwise 算子

它们常常不是算不动,而是读写太多,典型地属于 memory-bound

3.8 速度、容量、距离之间到底怎么取舍

把内存层级真正串起来看,核心就是三件事:

  • 快的,通常小
  • 大的,通常远
  • 远的,通常贵

所以一个成熟的 CUDA 视角不是“哪种内存最快”,而是:

  • 哪些数据必须长期放在 global memory
  • 哪些数据值得临时搬到 shared memory
  • 哪些中间值应该尽量留在 register
  • 哪些对象大到可能导致 local memory 或 spill

一旦你开始这样想,内存层级就不再是静态定义,而变成了动态的数据流设计问题。

3.9 用“数据怎么流动”来理解 kernel

这篇最重要的思维切换是:

不只看线程在算什么,还要看数据在哪一层、接下来会往哪一层走。

很多高性能 kernel 背后的典型路径都是:

global memory -> shared memory -> register -> global memory

这条路径背后的含义是:

  • 大块原始数据先在 global memory
  • 当前 block 需要的一小块搬进 shared memory
  • 每个线程再把自己最频繁用的值放进 register
  • 算完后结果再写回 global memory

这几乎就是后面 GEMM、卷积、attention、fused kernel 的基本骨架。

3.10 为什么理解内存层级是后续优化的前提

从这一篇开始,你后面很多知识点都会自动串起来:

  • 为什么 coalescing 重要:因为 global memory 访问方式直接决定带宽利用率
  • 为什么 bank conflict 会拖慢 shared memory:因为片上共享存储也有自己的硬件约束
  • 为什么 fusion 常能提速:因为减少了中间结果写回 global memory 再读回的次数
  • 为什么 occupancy 不能脱离寄存器和 shared memory 看:因为这些资源会限制一个 SM 上能挂多少活跃 warp

所以“内存层级”不是一个独立章节,而是后面几乎所有优化章节的地基。

4. 和 AI 推理的关系

这篇和 AI 推理的关系非常直接,因为很多推理优化,本质上都在减少慢速数据流量。

例如:

  • fusion:减少中间结果写回 global memory 的次数
  • tiled GEMM:把可复用数据先搬到 shared memory
  • FlashAttention:尽量避免把过大的中间结果落回慢速内存
  • decode 阶段的很多 kernel:常常更受访存和带宽限制,而不是受乘加能力限制

所以你后面看推理系统时,真正该问的是:

  • 数据主要卡在哪一层
  • 哪些数据可以复用
  • 哪些搬运是不必要的
  • 现在这个 kernel 更像 memory-bound 还是 compute-bound

5. 常见误区

  • GPU 算力很强,所以性能问题主要来自计算。不是,很多 kernel 首先卡在数据搬运。
  • local memory 听起来很近,所以应该很快。不是,它常常意味着寄存器放不下了。
  • 寄存器越多越好。不是,寄存器太多会压并发,甚至 spill。
  • shared memory 一定值得手动用。不是,只有在复用和重组访问明显值得时才划算。
  • global memory 很慢,所以应该完全避免。不是,大数据离不开它,关键是减少不必要访问并优化访问方式。
  • 内存层级就是背几个名词。不是,真正要学的是数据怎样在不同层级之间流动。

6. 复习自测

  • 为什么很多 CUDA 程序首先受访存限制,而不是先受计算限制?
  • CUDA 为什么必须设计成分层内存?
  • registerlocal memoryshared memoryglobal memory 各自解决什么问题?
  • 为什么 local memory 不一定快?
  • 什么是 register spill,它为什么会拖慢程序?
  • shared memory 最核心的两个价值是什么?
  • 为什么 global memory 常常是推理算子的主要瓶颈?
  • 为什么说真正重要的不是“哪种内存快”,而是“数据应该怎样流动”?

系列导航