设想你经营着一间大型图书馆的借阅服务台。32 位读者恰好坐成一桌,每个人同时递来一张索书单,等着你从地下书库取书。你有一辆固定容量的小推车——一次最多装“128 字节”的书。如果这 32 位读者要的书刚好在书架同一段紧密排列,第一本挨着第二本、第二本挨着第三本,你推一趟车就能把 32 本书全部带回。但如果他们的书散落在书库的八个不同角落,你只能推八趟车,每趟的车厢里只有四本书,其余空间白白空着。 这就是 GPU 全局访存的本质问题:GPU 的计算单元(读者们)翻书的速度快得惊人,但从全局内存(书库)搬运数据的速度却远跟不上。而送货效率,完全取决于同一个 warp 的 32 个线程(读者)需要的地址是否连续、是否对齐。把这一件事做好,很多 CUDA 程序的性能可以瞬间提升数倍。今天我们就用这间图书馆,把 Coalesced Access(合并访问)从头到尾讲透。

岗位关联

对 AI 从业者来说,无论手写 CUDA 算子、优化推理引擎,还是分析模型训练瓶颈,访存模式都是绕不开的性能命门。掌握合并访问,等于看懂了 GPU 性能优化的第一张地图。

目录
  1. 可视化演示
  2. 访存带宽瓶颈
  3. 合并访问原理
  4. 硬件执行机制
  5. 非合并访问代价
  6. 优化策略实战
  7. 对齐与整数倍
  8. AI 应用场景
  9. Python 演示
  10. 常见误区
  11. 面试题

可视化演示

动态展示warp线程合并访问、非合并访问、矩阵布局影响与带宽对比四个视角,直观理解GPU内存事务合并机制。

本节配有交互动效(进入视口后自动加载,约 3KB)。

访存带宽瓶颈

图书馆里,读者翻一页书只要一秒钟,但送书员从地下书库取一趟书要三分钟。GPU 的算力就是“翻书”的速度,全局内存(Global Memory)就是那座遥远的三分钟书库。每个线程想要数据,都必须先经历一次漫长的“取书等待”,而等待期间计算单元只能空转。

你可能听说过 GPU 的显存带宽很高,动辄每秒几百 GB,但那是“大量数据连续搬运”时的理想值。当每个线程零散地要 4 个字节时,带宽再高也发挥不出来。GPU 中衡量这个矛盾的关键叫计算访存比(Arithmetic Intensity),也叫算术强度——它描述每拿一块数据能做多少次计算。

算术强度=浮点运算次数全局内存读写的字节数\text{算术强度} = \frac{\text{浮点运算次数}}{\text{全局内存读写的字节数}}

1. 浮点运算次数:程序里实际完成的乘法和加法等计算总量,它构成公式的分子,代表读者翻书的动作量。

浮点运算次数\text{浮点运算次数}

2. 全局内存读写的字节数:内核从显存读入或写出的数据总量,它构成分母,代表送书员搬运的书本量。

全局内存读写的字节数\text{全局内存读写的字节数}

3. 比值与硬件平衡点:当算术强度高于 GPU 的平衡点,程序是计算密集型;低于平衡点,则是访存密集型,受带宽限制。

浮点运算次数全局内存读写的字节数≥平衡点\frac{\text{浮点运算次数}}{\text{全局内存读写的字节数}} \ge \text{平衡点}

当“取书”时间占比太高时,翻书再快也是白搭。所以想让 GPU 满负荷运转,就必须让每一趟取书都“满载而归”——这正是合并访问要解决的第一个问题。

合并访问原理

读者们并不是单独行动的,而是 32 个人坐成一桌,同时举手向管理员要书。在 GPU 中,这 32 个线程组成的团队叫做一个 Warp(线程束),它是线程调度和访存处理的最小单位。管理员不会分别处理 32 张索书单,而是把 32 个地址放在一起,统一决策怎么取。

这时有个决定性的细节:这 32 个地址是否连续。如果线程 0 要地址 100、线程 1 要地址 104、线程 2 要地址 108……就像 32 本书在书架上紧紧挨着,管理员推一趟车就能全部带回来。这种“同一 warp 的 32 个线程访问连续内存地址”的现象,就是 Coalesced Access(合并访问)。

Amax−Amin+s≤LlineA_{\text{max}} - A_{\text{min}} + s \le L_{\text{line}}

1. 线程组地址跨度:找出同 warp 内最大地址与最小地址之差,再加上元素大小 s,得到请求覆盖的总范围。

Amax−Amin+sA_{\text{max}} - A_{\text{min}} + s

2. 缓存线长度:一次内存事务最多搬运的字节数 L_line,常见的硬件值是 128B。

Lline=128BL_{\text{line}} = 128\text{B}

3. 条件判断:如果覆盖范围不超过 128B,说明 32 个请求挤在一条“传送带”上,一次事务即可满足全部。

Amax−Amin+s≤128BA_{\text{max}} - A_{\text{min}} + s \le 128\text{B}

所以,要给“读者”安排座位,就得让“书”按座位顺序排在书架上。数组的内存布局,直接决定管理员推一趟车能带回多少有用的书。

硬件执行机制

管理员的小推车容量是固定的,不能只装一本书就跑一趟——GPU 的内存系统也是如此。全局内存以固定大小的“块”为单位搬运,这个块叫缓存线(Cache Line),典型大小为 128 字节。即便程序只想要其中的 4 个字节,硬件也会把所在的整个块搬回来。

更细致的划分是 32 字节的扇区(Sector)。一条 128B 缓存线被切成 4 个扇区,实际传输时按扇区粒度组织。如果一个 warp 的请求恰好落在半个扇区上,另一半的字节也会被无差别搬回——这都是带宽开销。理解这一点后,你就能算出一笔账:一个 warp 读 32 个 float(每个 4 字节),理想情况下正好凑满 128 字节。

32×4B=128B=Lline32 \times 4\text{B} = 128\text{B} = L_{\text{line}}

1. 线程数乘以元素大小:一个 warp 有 32 个线程,每个线程读一个 4 字节的 float。

32×4B32 \times 4\text{B}

2. 与缓存线长度比较:计算结果恰好等于一条标准缓存线 128B,说明装载量与小车容量完全匹配。

32×4B=128B32 \times 4\text{B} = 128\text{B}

3. 满载事务数:在这种完美匹配下,内存系统只需 1 次事务就能完成整个 warp 的请求。

Ntrans=1N_{\text{trans}} = 1

小车一趟能装多少书,决定了“满载”的定义。设计任何 GPU 数据结构时,心里都要装着 128 字节这把尺子——它是硬件世界的“车厢容量”。

非合并访问代价

如果读者要的书散落在书库的八个不同角落,管理员就得推八趟车,每趟车厢里只装四本书,其余空间全部空置。在 GPU 里,这种场景叫非合并访问(Uncoalesced Access),是最常见的性能杀手。

举个例子:32 个线程以 stride=32B 的间隔读 float,也就是线程 i 读地址 i×32 处的 4 个字节。线程 0 读 0,线程 1 读 32,线程 2 读 64……直到线程 31 读 992。从地址 0 到 992,跨越了整整 8 条 128B 缓存线,而每条线上只用到 16 字节。硬件被迫做 8 次事务,搬回 1024 字节,真正用到的只有 128 字节。

Ntrans=⌈(Nthread−1)×stride+sLline⌉N_{\text{trans}} = \left\lceil \frac{(N_{\text{thread}} - 1) \times \text{stride} + s}{L_{\text{line}}} \right\rceil

1. 首末线程覆盖宽度:首个线程与最后一个线程之间的地址距离,再加上一个元素大小 s,得到完整访问范围。

(Nthread−1)×stride+s(N_{\text{thread}} - 1) \times \text{stride} + s

2. 除以缓存线长度:用覆盖宽度除以 128B,看它横跨了几条缓存线。

(Nthread−1)×stride+sLline\frac{(N_{\text{thread}} - 1) \times \text{stride} + s}{L_{\text{line}}}

3. 向上取整:只要碰到某条线的边界,哪怕只越界 1 字节,也要多算一次事务。

⌈(Nthread−1)×stride+sLline⌉\left\lceil \frac{(N_{\text{thread}} - 1) \times \text{stride} + s}{L_{\text{line}}} \right\rceil

4. 代入 stride=32B 的实例:31×32+4=996,除以 128 向上取整得到 8 次事务,带宽利用率仅 12.5%。

⌈31×32+4128⌉=8\left\lceil \frac{31 \times 32 + 4}{128} \right\rceil = 8

带宽浪费就像推车空驶——运输的趟数是最硬的成本,千万别让小车运空气。8 倍事务意味着访存时间可能膨胀 8 倍,原本几分钟跑完的内核,可能变成几十分钟。

优化策略实战

解决问题的思路,是重新组织“借书单”和“书架排布”,让同一位管理员的每一趟车都满载。在 CUDA 中,最常见的优化是矩阵布局调整:二维数组默认按行主序存储,如果让相邻线程访问同一行的相邻列,地址就是连续的;但若让它们访问同一列的相邻行,地址就隔着一整行的距离,立刻变成非合并访问。

第二个经典优化是 AoS 转 SoA。假设你有一个存储 3D 坐标的结构体数组 struct Vec3 { float x, y, z; },数组中每个元素连续存放 x、y、z。如果 32 个线程分别读取 32 个元素的 x 字段,它们之间的间隔是 12 字节——没对齐也没连续。改进办法是把布局改成三个独立数组 float xs[N]、float ys[N]、float zs[N],读 x 字段的线程就连续访问了。

pos(r,c)=r×W+c\text{pos}(r, c) = r \times W + c

1. 行偏移:第 r 行起始于 r×W 的位置,因为前面已经存了 r 个完整行。

r×Wr \times W

2. 列偏移:在本行内部,第 c 个元素要再往右移动 c 个位置。

+c+ c

3. 行主序连续的含义:相邻列 c 和 c+1 的地址相差 1 个元素,这正是合并访问需要的连续模式。

pos(r,c)=r×W+c\text{pos}(r, c) = r \times W + c

书要按“读者座位”重新上架,这是合并访问优化的灵魂——布局先行,代码在后。同样一套计算逻辑,仅仅改变数据存储方式,访存效率就能从 12.5% 变成 100%。

对齐与整数倍

书架上的隔板位置是固定的,如果管理员第一本书没有放在隔板起点,第二本书就会横跨两个隔板,取书时反而要多翻一个格子。GPU 内存同理:基地址的对齐决定了你的连续请求会不会跨过 128B 边界。

即使 32 个地址完全连续,如果基地址错开了 64B,那么这 128B 的数据会横跨两条缓存线,硬件仍然要做 2 次事务。解决办法有两个:一是让分配器返回按 128B 对齐的内存(如 CUDA 的 cudaMalloc 通常保证 256B 对齐);二是让二维数组的每一条“行”长度恰好是缓存线大小的整数倍,这样下一行又从对齐位置开始。

W′=⌈W×selemLline⌉×LlineselemW' = \left\lceil \frac{W \times s_{\text{elem}}}{L_{\text{line}}} \right\rceil \times \frac{L_{\text{line}}}{s_{\text{elem}}}

1. 行原始字节数:矩阵一行有 W 个元素,每个元素占 s_elem 字节,因此一行占 W×s_elem 字节。

W×selemW \times s_{\text{elem}}

2. 向上取整到缓存线倍数:除以 128B 后向上取整,得到这一行实际占用的缓存线条数。

⌈W×selemLline⌉\left\lceil \frac{W \times s_{\text{elem}}}{L_{\text{line}}} \right\rceil

3. 换算回元素个数:把缓存线条数乘回 L_line / s_elem,得到 padding 后的新行宽,让每行起点对齐到 128B 边界。

W′=⌈W×selemLline⌉×LlineselemW' = \left\lceil \frac{W \times s_{\text{elem}}}{L_{\text{line}}} \right\rceil \times \frac{L_{\text{line}}}{s_{\text{elem}}}

对齐是“让每趟推车都从隔板起点出发”的纪律。配合布局调整,合并访问才能万无一失:连续性负责满载,对齐性负责不踩线。

AI 应用场景

深度学习张量连续化

阶段:数据预处理与 CUDA 算子实现(训练 / 推理)

解决的问题:PyTorch 中 transpose、view 等操作会产生非连续张量,索引访问时相邻线程可能跳到相距很远的地址,访存效率急剧下降。

具体案例:Transformer 的 QKV 投影常常对 [batch, seq, head, dim] 做 reshape 和 transpose。如果后续调自定义 CUDA Kernel 前忘记调用 .contiguous(),张量底层就是“跳着存”的,相当于读者要的书散落书库各处。调用 .contiguous() 的本质就是一次“重新上架”,把数据复制成连续布局。

矩阵乘法分块优化

阶段:高性能计算内核实现(科学计算、深度学习矩阵乘)

解决的问题:朴素矩阵乘法中,B 矩阵按列访问时,相邻线程读取的地址跨越整行,完全违背合并访问原则。

具体案例:经典的 SGEMM 优化会把 B 矩阵分块读入共享内存,或者先做一次转置,再用连续地址加载。同样的算法,仅通过改变访存顺序和布局,性能可以从几十 GFLOPS 提升到数千 GFLOPS——这就是图书馆里“重新安排借书单”的威力。

图像卷积的通道布局选择

阶段:模型部署与图像预处理

解决的问题:NCHW 与 NHWC 两种张量布局下,warp 内相邻线程访问的通道地址连续性截然不同。

具体案例:TensorRT 在 GPU 上偏好 NHWC 布局,因为相邻线程可以连续读取同一行相邻像素的同通道值;而 NCHW 布局会导致通道维度跳变。把预处理阶段的数据布局从 NCHW 转为 NHWC,相当于把书按“读者需要的顺序”重新排列,一次内存事务就能取回 32 份有效数据。

Python 演示

python26 行
import math def simulate_warp_access(addr_start, stride, num_threads=32, line_size=128, bytes_per_thread=4):    """模拟一个 warp 的 32 个线程访问全局内存,统计缓存线事务数与带宽利用率。"""    lines = set()    for i in range(num_threads):        addr = addr_start + i * stride        line = addr // line_size        lines.add(line)    num_trans = len(lines)    useful_bytes = num_threads * bytes_per_thread    moved_bytes = num_trans * line_size    efficiency = useful_bytes / moved_bytes    return num_trans, efficiency  def main():    print("=== 模拟 GPU Warp 访存模式 ===")    print(f"{'stride':>6} {'trans':>5} {'efficiency':>10}")    for stride in [4, 8, 16, 32, 64, 128]:        trans, eff = simulate_warp_access(0, stride)        print(f"{stride:>5}B {trans:>5} {eff:>10.1%}")  if __name__ == "__main__":    main()
目的:用 Python 模拟一个 warp 的 32 个线程在不同步长下访问全局内存,清晰展示缓存线事务数和带宽利用率的变化,帮助理解合并访问与非合并访问的量化差异。
  1. lines = set()用集合去重,多个线程落在同一条缓存线上时,只计一次内存事务。
  2. line = addr // line_size将地址整除 128,得到这条地址所属的缓存线编号。
  3. moved_bytes = num_trans * line_size硬件实际搬运的总字节数是事务数乘以 128B,不管有没有用到。
  4. efficiency = useful_bytes / moved_bytes有效带宽利用率:真正读到的数据除以被搬运的总数据。
  5. for stride in [4, 8, 16, 32, 64, 128]从小到大遍历步长,直观看出 stride=4(连续 float)时效率 100%,stride 越大浪费越严重。

常见误区

误区我看 CUDA 官方说广播效率也很高,那 32 个线程读同一个数组元素应该也算 coalesced access 吧?

正解这不是合并访问,而是广播(Broadcast)。合并访问强调的是 32 个不同地址连续排列,让一次事务载入一整段有用数据;广播是 32 个线程要同一份数据,硬件只需读一次再复制给所有人。两者虽然都高效,但底层机制完全不同,不能混为一谈。

为何常见:初学者看到两种模式的带宽都很高,就误以为它们是一回事,忽略了“连续”这两个字才是合并访问的灵魂。

误区我把数组设计成连续的了,为什么性能测试显示还是有两倍的事务数?不是说连续就合并吗?

正解连续性只是必要条件,基地址的对齐同样关键。如果起始地址不是 128B 的整数倍,一段连续的 128B 数据会横跨两条缓存线,硬件仍然要做两次事务。好比管理员取书时,第一本书没放在隔板起点,第二排书就横跨了两个隔板。

为何常见:分析访存模式时只盯着“相邻线程间隔”,忽略了数组首地址的边界位置,而后者同样决定事务数量。

误区合并访问讲的是线程读数据的场景吧?写数据不过是把结果放回去,应该不会有性能问题。

正解写操作同样以事务为单位搬运,同样要遵循合并规则。比如矩阵转置时按列写入,相邻线程写的目标地址跨行跳跃,写事务会被打散成多次,带宽浪费和读非合并一样严重。GPU 的写回(Writeback)也会按扇区和缓存线组织,散乱写反而可能触发更多读改写开销。

为何常见:多数教程用“读数组”举例,容易让人以为只有读有合并的概念;实际上对称的写路径也共享同一套内存事务机制。

误区既然行要对齐到 128B,那我在每行末尾多加两个 int(8 字节)不就行了吗?

正解对齐必须满足“行宽是 128B 的整数倍”,加多少取决于当前行宽离 128 的哪个倍数最近。如果原始行宽是 130B,加 2B 变 132B 依然不对齐,需要补齐到 256B 才安全。正确做法是先把行宽换算成字节数,向上取整到 128B 的倍数,再换回元素个数。

为何常见:人们习惯用“补一点”的直觉解决问题,但硬件边界是固定刻度,只有补到刻度线才算真正对齐。

面试题

Q1. GPU 中一个 warp(线程束)固定包含多少个线程?

单选题 · easy

  • A.16 个线程
  • B.32 个线程← 正确答案
  • C.64 个线程
  • D.128 个线程

答案:B。NVIDIA GPU 的 warp 固定为 32 个线程,这是线程调度和访存合并的基本单位。16、64、128 都不是标准 warp 大小,虽然它们可能是 block 的某个维度。

Q2. 以下哪种访问模式最可能产生合并访问?

单选题 · mid

  • A.相邻线程间隔 32 字节
  • B.相邻线程间隔 4 字节← 正确答案
  • C.线程用随机数定地址
  • D.所有线程读同一地址

答案:B。相邻线程间隔 4 字节意味着访问连续 float 数组,是典型合并访问。间隔 32 字节会跨越多条缓存线,随机地址必然非合并,而所有线程读同一地址属于广播而非合并。

Q3. 一个 warp 连续读取 32 个 float(每个 4B),基地址 128B 对齐,需要多少次 128B 内存事务?

单选题 · mid

  • A.需要 4 次事务
  • B.需要 2 次事务
  • C.需要 1 次事务← 正确答案
  • D.需要 8 次事务

答案:C。32 个线程 × 4 字节 = 128 字节,恰好填满一条缓存线,且基地址已对齐到 128B 边界,因此只需 1 次事务就能完成全部请求,效率 100%。

Q4. 哪些因素会使本应合并的访问变成非合并?

多选题 · mid

  • A.线程号与数组下标映射错位← 正确答案
  • B.访问对象按结构体打包存放← 正确答案
  • C.数组基地址不对齐 128B← 正确答案
  • D.数据放在只读常量缓存中

答案:ABC。线程与下标的错位会打乱地址顺序;结构体打包会让同字段间隔变大;基地址不对齐会让连续数据跨线。只读常量缓存是独立的缓存路径,本身不破坏合并访问。

Q5. 32 个线程以 stride=32B 各读 4B 数据,128B 缓存线,事务数与带宽利用率是多少?

单选题 · hard

  • A.8 次事务,利用率 12.5%← 正确答案
  • B.4 次事务,利用率 25%
  • C.2 次事务,利用率 50%
  • D.1 次事务,利用率 100%

答案:A。首末线程跨越 31×32+4≈996B,覆盖 8 条缓存线,因此需要 8 次事务。实际有用的数据是 32×4=128B,搬运总量是 8×128=1024B,利用率 12.5%。

Q6. 把结构体数组(AoS)转换为数组结构体(SoA)的主要作用是什么?

单选题 · easy

  • A.让同对象字段减少内存
  • B.让同类型字段连续存储← 正确答案
  • C.让线程数量明显增多
  • D.让随机访问变顺序写

答案:B。SoA 将同一字段抽出来单独连续存放,这样 32 个线程访问同一字段时地址连续,形成合并访问。AoS 中字段互相交错,间隔为整个结构体大小,必然破坏连续性。

Q7. 矩阵按行主序存储,第 r 行第 c 列元素的内存位置表达式是什么?

单选题 · mid

  • A.位置 = r × W + c← 正确答案
  • B.位置 = r + c × W
  • C.位置 = r × c + W
  • D.位置 =(r + c)× W

答案:A。行主序中每一行有 W 个元素,第 r 行的起点在 r×W,再向右偏移 c 列,即 r×W+c。这个公式表明同一行元素在内存中连续,按行访问天然满足合并条件。

Q8. 下列哪些做法能提升全局内存的有效带宽?

多选题 · hard

  • A.让相邻线程读相邻 float← 正确答案
  • B.矩阵转置后按行访问← 正确答案
  • C.用随机步长打乱访问序
  • D.给行尾 padding 对齐 128B← 正确答案

答案:ABD。相邻线程读相邻 float 是合并访问;矩阵转置后按行读数据连续;padding 让每行从对齐地址开始。随机步长会让请求分散到更多缓存线,反而降低有效带宽。

Q9. 非合并访问导致性能下降的本质原因是什么?

单选题 · easy

  • A.计算指令执行变慢
  • B.搬运大量未使用字节← 正确答案
  • C.线程数被自动削减
  • D.缓存被强制写穿

答案:B。非合并访问时,硬件按整条缓存线搬运数据,但每趟只用到其中一小部分,大量字节被白白搬回。这本质是带宽浪费,而不是计算变慢或线程减少。

Q10. 关于合并访问的下列说法,哪些是正确的?

多选题 · mid

  • A.写操作同样需要合并← 正确答案
  • B.合并只与地址连续性有关
  • C.基地址对齐影响事务数← 正确答案
  • D.32 个线程必须访问同地址

答案:AC。写操作同样按缓存线事务搬运,所以写也要合并;基地址不对齐会让连续数据跨线,增加事务数。合并的关键是“连续且不同地址”,同地址是广播,所以 B 和 D 都不对。