CUDA系统拆解-06-共享内存与Coalescing:访存优化先抓哪几个抓手
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 并不友好。这时就可以:
- 先以更好的方式把数据读到
shared memory - 在
shared memory中改变布局或访问方向 - 再以更好的方式继续算,或者写回
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的 coalescingshared memory的 bank conflict
一个常见的优化思路是:
- 先按对
global memory友好的方向,把一个 tile 读进shared memory - 在
shared memory中完成局部重排 - 再按对
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,你会先从哪些访存问题开始分析?


