CUDA系统拆解-06-共享内存与Coalescing:访存优化先抓哪几个抓手

本文是「CUDA系统拆解」系列第 06 篇。
系列导读:CUDA系统拆解-00-导读:从编程模型到 AI 推理系统的学习路线
上一篇:CUDA系统拆解-05-内存层级与访存本质:性能瓶颈为什么常卡在数据
下一篇:CUDA系统拆解-07-同步、原子操作与内存一致性:并发正确性怎么保证

1. 这篇解决什么问题

  • 为什么很多 CUDA kernel 明明计算不复杂,却仍然跑不快。
  • 什么是 coalescing,坏的访问模式为什么会明显变慢。
  • shared memory 真正解决的到底是什么问题。
  • 为什么 shared memory 不只是“更快读”,而是“更好组织数据流”。
  • 什么是 bank conflict,为什么 shared memory 自己也会出问题。
  • 为什么转置、tiling、GEMM、attention 这类高性能实现都绕不开这些概念。

2. 先记住的核心结论

  • 很多 kernel 的主要瓶颈不是算术运算,而是 global memory 访问方式不够高效。
  • coalescing 的本质,是让一个 warp 的线程访问尽量落在规则、连续、容易合并的地址上。
  • 差的访问模式通常不是“读错了”,而是让一次内存事务的有效利用率变低了。
  • shared memory 的核心价值有两个:数据复用,以及访存重排。
  • shared memory 不是自动加速器;如果没有复用价值、没有重排价值,反而可能增加搬运和同步成本。
  • bank conflict 说明 shared memory 也有自己的硬件访问约束。
  • 转置和 tiling 是最典型的访存优化案例,因为它们把“差的全局访问”改造成“更好的数据流”。

3. 正文讲解

3.1 为什么 global memory 常常会成为瓶颈

前一篇已经讲过,很多 CUDA 程序首先是 memory-bound
到了这一篇,要把这件事再往前推进一步:

真正的问题通常不是“用了 global memory”,而是“怎么用 global memory”。

global memory 的现实地位很尴尬:

  • 它容量大,离不开
  • 但它远、慢、带宽宝贵

所以高性能 CUDA 的核心不是逃离 global memory,而是:

  • 尽量减少不必要的访问
  • 访问时尽量让硬件更容易高效服务
  • 能复用的数据不要反复从远处重拿

很多 kernel 慢,并不是因为 ALU 没能力,而是因为线程在等数据。

3.2 什么是 coalescing

coalescing 可以先这样理解:

同一个 warp 内线程访问 global memory 时,如果地址分布足够规则、连续、对齐,硬件就更容易把这些访问合并成更少、更高效的内存事务。

这件事的关键不是“看起来连续很美观”,而是:

  • 一次内存请求搬来的数据,有多少真的被当前 warp 用到了
  • 硬件要为这次访问付出多少事务开销

所以 coalescing 的本质是:

提高一个 warp 访问 global memory 时的有效利用率。

3.3 好访问模式和坏访问模式到底差在哪

最典型的好模式是:

  • 相邻线程访问相邻元素
  • 访问方向和数据在内存中的主存储方向一致

例如一个 warp 里:

  • thread 0 访问第 0 个元素
  • thread 1 访问第 1 个元素
  • thread 2 访问第 2 个元素

这通常对硬件很友好。

典型的坏模式则包括:

  • stride 很大的访问
  • 相邻线程访问彼此很远的数据
  • 行主序数据却按列方向稀疏读取
  • 地址乱跳,导致一次事务搬来的很多字节没人用

所以坏模式为什么慢,不是因为“逻辑不正确”,而是因为:

线程访问太散,硬件需要做更多事务,而每次事务的有效载荷又不高。

3.4 为什么转置、列访问、stride 访问总容易出问题

很多二维数据默认是按行连续存的。
这意味着:

  • 按行读,往往更自然
  • 按列读,往往更容易出现稀疏访问

这类问题的共同点是:

  • 算法逻辑本身没问题
  • 但线程和数据布局之间的方向不匹配

于是就会出现一种典型现象:

  • 读可能是好的
  • 写很差

或者反过来:

  • 写比较顺
  • 读却很散

这就是为什么转置会成为 CUDA 教学里的经典案例。它几乎把“算法很简单,但访存模式很差”这件事浓缩到了极致。

3.5 shared memory 为什么存在

如果只看表面,shared memory 只是片上更快的一块内存。
但真正重要的理解是:

shared memory 是 block 内线程共同使用的可控 scratchpad,用来主动组织数据流。

它最核心的两个价值是:

第一,数据复用。
如果一块数据会被 block 内多个线程多次用到,那么先从 global memory 读一次到 shared memory,再在片上反复消费,通常比让每个线程都去远处重读更划算。

第二,访存重排。
很多时候,算法需要的访问顺序,对 global memory 并不友好。这时就可以:

  1. 先以更好的方式把数据读到 shared memory
  2. shared memory 中改变布局或访问方向
  3. 再以更好的方式继续算,或者写回 global memory

这就是为什么 shared memory 不只是“更快读”,而是“更好组织数据流”。

3.6 shared memory 不是什么

这点也很重要。shared memory 不是:

  • 自动提速按钮
  • 默认一定要上的优化
  • 单纯替代 cache 的东西

如果一个 kernel:

  • 没有明显数据复用
  • 不需要访存重排
  • 用了 shared memory 还得多搬一次数据、多做一次同步

那它反而可能更慢。

所以成熟的判断方式不是“能不能用 shared”,而是:

  • 这里有没有复用关系
  • 这里有没有重排收益
  • 这里的同步和共享开销值不值得

3.7 bank conflict 的基本直觉

很多人以为解决了 global memory 问题,事情就结束了。
还没结束,因为 shared memory 自己也有访问结构。

可以先把 bank conflict 理解成:

一个 warp 内多个线程访问 shared memory 时,如果它们不幸挤到了同一条内部通路上,原本能并行完成的访问就会部分串行化。

这就是 bank conflict

最直观的比喻是:

  • shared memory 像有很多服务窗口
  • 如果线程分散去不同窗口,整体吞吐很好
  • 如果很多线程同时挤同一个窗口,就要排队

所以 shared memory 虽然快,但也不是“无脑快”。
它同样要求访问模式对硬件友好。

3.8 为什么二维 tile 经常会引出 bank conflict

二维 tile 是最容易暴露这个问题的地方。

原因很简单:

  • 按一个方向访问时,地址排布可能很好
  • 换一个方向访问时,步长突然变得很整齐
  • 这种“整齐”有时恰好会让很多线程撞到相同 bank 上

这也是为什么在矩阵转置里,经常会看到一个非常经典的修复方式:

  • 原本是 32 x 32
  • 改成 32 x 33

这个 +1 的本质不是魔法,而是:

改变步长,打散原来不理想的 bank 映射。

所以你真正该记住的不是“答案是加 1”,而是:

  • 数据布局的 stride 会影响 bank 映射
  • bank 映射又会影响 warp 访问 shared memory 的吞吐

3.9 为什么转置是最典型的访存优化案例

转置之所以经典,是因为它同时暴露了两类问题:

  • global memory 的 coalescing
  • shared memory 的 bank conflict

一个常见的优化思路是:

  1. 先按对 global memory 友好的方向,把一个 tile 读进 shared memory
  2. shared memory 中完成局部重排
  3. 再按对 global memory 友好的方向写回

如果只做到这里,你解决的是全局访存问题。
如果你还处理了 padding,避免了 shared memory 内部冲突,那这条数据流才算真正顺了。

所以转置不是一个孤立技巧,而是一个完整范式:

用片上 staging,把坏的全局访问模式改造成更好的全局访问模式。

3.10 tiling 的本质到底是什么

以后你会频繁听到:

  • tile
  • blocking
  • tiled GEMM
  • block tiling

这些词本质都在说同一件事:

不直接对整块大数据做生硬操作,而是切出一小块一小块,在更近的地方反复使用。

为什么这样做?

  • 大数据放在 global memory,远且慢
  • 把当前 block 需要的一块搬到 shared memory
  • 再让线程把最热点的数据留在 register

于是数据流就变成了:

global memory -> shared memory -> register -> 计算 -> global memory

这条链几乎就是 CUDA 高性能 kernel 的标准骨架。

3.11 为什么说 shared memory 的价值是“复用 + 重排”

把前面内容收束一下,shared memory 的核心作用其实就是两种:

复用

  • 一次从 global memory 读进来
  • 多个线程、多轮计算反复使用

重排

  • global memory 上不好读/写
  • 先按更友好的方式搬到 shared memory
  • 再在片上改变布局和访问方向

这两件事共同指向一个目标:

减少慢速路径上的无效流量,让每次远距离访问都更值。

3.12 为什么这些问题会直接决定后续算子性能

到了这里,你应该能把很多后续话题接起来了:

  • GEMM 为什么依赖 tiling:因为它有强数据复用
  • transpose 为什么总讲 shared memory:因为它天然需要访存重排
  • fusion 为什么常能提速:因为减少了中间结果写回 global memory 再读回的次数
  • FlashAttention 为什么强调 IO-aware:因为它本质上是在重写数据流,减少慢速内存往返

所以这一篇不是“访存技巧合集”,而是在建立一种更通用的判断框架:

  • 访问顺不顺
  • 数据复不复用
  • 重排值不值
  • 慢速路径上的流量是不是太多

4. 和 AI 推理的关系

这篇和 AI 推理的关系非常直接,因为推理里的大量算子,本质上都在和数据流打交道。

几个最典型的例子:

  • GEMM:核心是 tile 化和复用,把数据尽可能留在更近的层级
  • attention:既要考虑访存模式,又要考虑中间结果是否必须落回 global memory
  • LayerNorm、RMSNorm、softmax:常常更接近 memory-bound,非常依赖访存组织
  • layout transform、transpose、KV cache 访问:本质上都在问访问方向和局部性是否友好

所以做推理优化时,真正关键的问题往往不是“这个公式怎么写”,而是:

  • 一个 warp 在读什么
  • 这些读写是否 coalesced
  • block 内是否值得 staging 到 shared memory
  • 中间结果能不能不落回更慢的层级

5. 常见误区

  • coalescing 就是“连续访问”四个字。不是,核心是 warp 级内存事务的有效利用率。
  • shared memory 只是更快的缓存。不是,它更重要的作用是复用和重排数据流。
  • 只要用了 shared memory 就会更快。不是,没有复用或重排收益时,反而可能更慢。
  • 解决了 global memory 访问就够了。不是,shared memory 自己也可能有 bank conflict
  • 转置只是一个小例子。不是,它几乎把 coalescing、重排、padding、bank conflict 都串起来了。
  • 访存优化只对 CUDA 教学样例有用。不是,GEMM、attention、FlashAttention、KV cache 都在用同一套思路。

6. 复习自测

  • 为什么 global memory 会成为很多 kernel 的主要瓶颈?
  • coalescing 的本质是什么,坏访问模式为什么慢?
  • shared memory 的核心价值为什么是“复用 + 重排”?
  • 为什么说 shared memory 不只是“更快读”,而是“更好组织数据流”?
  • bank conflict 的基本直觉是什么?
  • 为什么转置是理解访存优化的经典案例?
  • tiling 到底在解决什么问题?
  • 如果你在看一个 GEMM 或 attention kernel,你会先从哪些访存问题开始分析?

系列导航