访存合并 coalescing:warp 连续地址如何被合并成少数几次内存事务
更新时间:2026-09-25。前置阅读:GPU 架构:SIMT 与 warp 调度、GPU 内存层级与带宽;配套实战:矩阵乘法分块、共享内存 bank conflict。
本文要回答的问题
- 一个 warp 的 32 个线程同时执行 load,硬件为什么不是发起 32 次独立访存?
- 「128B 段」「内存事务」是什么?连续、错步、随机三种访问分别要发几次事务?
- 为什么同样是遍历矩阵,按行走能打满带宽、按列走带宽利用率最低只剩 1/32?
- AoS 与 SoA 的数据布局差异,为什么能让同一段计算代码快几倍?
- 现代 GPU 已经放宽了合并规则,coalescing 为什么仍然是头号性能杠杆?
一、一次 warp load 在硬件里发生了什么
在 SIMT 模型下,warp 是调度和发射的基本单位,一个 warp 包含 32 个线程,它们锁步执行同一条指令。当这条指令是访存(例如 float v = a[tid];)时,32 个线程各算出自己的有效地址,同时提交给 SM 内的 load/store 单元。接下来的处理与 CPU 上 32 个核心各发一次 load 完全不同:硬件不会把 32 个请求原样送进内存系统,而是先把这批地址收集起来,按它们落在显存的哪个段进行归并。
经典的教学模型是 128B 段(segment):显存地址空间被切成一个个 128 字节、按 128B 边界对齐的段。硬件检查 32 个地址覆盖了哪些段,每碰到一个段就发起一次内存事务(memory transaction),把整段 128B 全部取回,然后从取回的数据里抽出各线程真正需要的字节。这个「先检查地址连续性,再按段归并发射」的动作,就是访存合并(memory coalescing)。
硬件这样设计,根源在 DRAM 本身的脾气。显存颗粒在内部按行打开行列缓冲区,命中已打开行时可以连续吐出一段突发数据,效率极高;而每切一个分散地址,就要经历预充电、行激活的完整流程,代价是突发传输的很多倍。GPU 面对的是几千个线程的海量流,索性在控制器入口规定「按整段搬运、按连续突发发射」,把地址是否连续的判断从运行时的调度问题,变成 SM 入口一次廉价的归并电路。这套机制对线程访问宽度也成立:读 1 字节、2 字节、4 字节、8 字节的指令各有归并规则,32 个线程访问宽度一致时归并效率最高;同一 warp 内混用不同宽度虽合法,却往往无法填满整段。
这里要区分两件事:延迟和带宽利用率。一次事务无论段里有多少字节是线程需要的,发起开销和等待时间大致相同;非合并访存的灾难不在单次变慢,而在于同样 128B 的有效数据,硬件可能搬回 2 倍、8 倍甚至 32 倍的字节,多余的字节全部丢掉,DRAM 到 SM 的数据通路被浪费了。另一个容易被忽略的细节是,归并是按 warp、按单条访存指令进行的:同一个 kernel 里两条相邻的 load 语句即使访问的全局地址恰好连续,硬件也不会跨指令替你合并,顺序应安排在同一条语句的 32 个线程之间。
二、四种访问模式的账:事务数与带宽利用率
以最常见的场景算账:一个 warp 的 32 个线程各读一个 4 字节 float,有效数据恒为 32×4B = 128B。差别只在这 128B 在地址空间里如何分布,从而落在几个段里。
| 访问模式 | 地址示例 | 128B 事务数 | 实际搬回字节 | 带宽利用率 |
|---|---|---|---|---|
| 连续且对齐 | base + 4*tid | 1 | 128B | 100% |
| 连续但跨段错位 | base + 64 + 4*tid | 2 | 256B | 50% |
| 步长 8B(隔一个取一个) | base + 8*tid | 2 | 256B | 50% |
| 步长 32B | base + 32*tid | 8 | 1024B | 12.5% |
| 完全分散(最坏情况) | 每个线程落在不同段 | 32 | 4096B | 3.1%(1/32) |
逐行解释。连续对齐时,32 个地址正好填满一个 128B 段,一次事务、零浪费,这是所有访存内核应当追求的目标。把起始地址错动 64 字节(半个段)后,同样 128B 的连续数据横跨两个段,硬件只好把两个段都取回来,利用率立刻腰斩——仅仅因为起始地址没对齐,性能就减半。
步长(stride)访问的规律可以直接写出来:32 个线程地址覆盖的范围约为 步长×31 + 4 字节,事务数等于这段范围覆盖的段数,上限是 32;带宽利用率就是 128 ÷(128×事务数)。步长 8B 时每个段里只有一半数据有用,利用率 50%;步长 32B 时每 4 个线程占一个新段,8 个段、利用率 12.5%。
最坏情况是完全分散:32 个地址各自落进不同的段,硬件发起整整 32 次事务、搬回 4KB,其中只有 128B 被使用,有效带宽利用率 1/32,即 3.1%。一块标称 2 TB/s 的 GPU,在这种访问模式下内核实际拿到的有效带宽只有约 62 GB/s——计算单元再快也无济于事。
需要说明现代硬件的粒度:近年的 GPU 以 32B sector 为最小取数粒度,一个 128B 段由 4 个 sector 组成,段内没有线程访问的 sector 不再强制取回。对 4B 访问而言,利用率地板从 1/32 抬升到约 1/8(每线程独占一个 sector 时,32×32B 中取 128B);但 L1 启用时的填充路径仍按更粗粒度工作,且步长越大、访问越随机,新旧模型的结果越趋同。结论很朴素:对齐和连续的满分仍然是 100%,错步和随机的惩罚仍然是数倍到数十倍。
还可以补一个字节级的直觉。段不是软件可见的边界,线程自己毫无感知,它只看到「我这一个 float 取到了」;真正数过段的是归并电路。因此优化时不要去猜编译器会不会帮忙重排——编译器不会把一个按列读的循环自动改成按行读,也不会替你调换结构体字段顺序。地址公式掌握在程序员手里,这正是 coalescing 优化确定性极高、几乎没有玄学色彩的原因:算出步长、数出段数,利用率是多少就是多少。
三、矩阵:行主序与遍历方向
C、C++ 中的二维数组按**行主序(row-major)**存储:M[i][j] 与 M[i][j+1] 在内存中相邻,而 M[i][j] 与 M[i+1][j] 相隔一整行。GPU kernel 的典型写法是让一个线程负责一个元素:tid 映射列、block 映射行。于是「线程沿哪个方向走」直接决定了合并与否。
沿行遍历时,同一个 warp 的 32 个相邻线程访问 M[i][j], M[i][j+1], ...,地址是连续的 float 流,每个 warp 一次事务,带宽打满。沿列遍历时,32 个线程访问 M[i][j], M[i+1][j], ...,相邻地址间隔为 列宽×4B。设列宽 256,则步长是 1024B,32 个线程必然分散在 32 个不同的段里——利用率 3.1%,与理论最坏值完全吻合。这也是为什么初学 CUDA 写矩阵转置,即使算法逻辑一行不错、占用率也正常,朴素版本也往往只有合并版本 1/5 到 1/10 的性能,瓶颈完全出在地址通路上。
矩阵乘法把两类访问同时摆上台面:内维循环中,A 的一行访问是合并的,而 B 的一列访问是错步的。工业界的标准解法是先把 B 的对应小块转置后读入共享内存,或直接做分块(tiling):让全局内存的所有读写都沿连续方向,跨行列的复用交给片上存储完成。具体的分块尺寸与数据流,见 矩阵乘法分块。
值得专门一提的是矩阵转置这个「入门第一道坎」。朴素转置里,读输入矩阵是合并的,写输出矩阵却是按列错步的——store 指令同样要经过归并,非合并写浪费的带宽与非合并读完全对等。标准做法是在线程块内借共享内存中转:全局侧按行合并读入小块,在共享内存里转置,再按行合并写出去。共享内存中的读写方向相反,恰好引出 bank conflict 问题,需要靠对角索引或 padding 一行化解。可以看到,同一个矩阵操作在全局内存和共享内存上有两套不同的「连续」规则,两层都处理干净才能拿到满分。
四、AoS 与 SoA:重构数据布局
第二类高频反模式藏在结构体里。粒子系统、物理仿真常这样定义数据:
// AoS:结构体数组
struct Particle { float x, y, z; }; // 每个粒子 12B
Particle p[N];当 kernel 只想更新所有粒子的 x 坐标时,线程 tid 访问 p[tid].x,相邻线程的地址间隔是 12B:每个 128B 段里只装得下约 10 个粒子的 x,32 个线程覆盖约 3 个段,利用率只有约 1/3,即 33%。若结构体还带颜色、生命周期等字段,每个实例撑到 32B,步长就是 32B,利用率跌到 12.5%——搬回来的 y、z 和其他字段全部被丢弃。
对应的改造是 SoA(structure of arrays,数组结构体):把同一字段集中存放,px[N]、py[N]、pz[N] 各成一个连续数组。取 x 就是一条完美的 float 流,warp 一次事务、利用率 100%;取 y、z 亦然。代价是访问单个粒子的全部字段时要查三个数组,但 GPU 的热点循环几乎总是「对全体粒子做同一件事」,SoA 的收益碾压这点不便。反过来,如果某个 kernel 确实每次都把一个粒子的所有字段一起用掉,AoS 并不吃亏——布局要跟着访问模式走,而不是跟着教条走。
工程上更常见的折中是按访问频率冷热拆分,而不是非此即彼。一个几十字段的结构体里,内层循环真正读取的往往只有三四项;把热点字段抽成一个紧凑的 SoA 集合,其余低频字段保留原布局,既拿到合并带宽,又避免把所有代码改成多数组索引。这一思路与 CPU 上的 cache line 优化同源:别让不参与计算的数据占据数据通路。另外,把同类型字段相邻声明、把布尔标志压缩成位掩码,也能实质缩短步长;反过来,为了对齐而插入的无名 padding 字段,在 AoS 热点路径上等于主动给步长加罚,应当能省则省。
五、对齐、padding 与向量化载入
合并的一半问题是连续,另一半是对齐。cudaMalloc 返回的地址本身已按 256B 对齐,但在大数组内部做偏移、使用自建内存池或处理二维矩阵的每一行起点时,未对齐的代价就会冒头:一行宽 100 个 float(400B),第二行起点相对段边界错动 16B,于是每一行都横跨两个段,整段内核的上限被钉死在 50%。
处理不等宽行的标准工具是 pitch padding:分配时把每行的实际宽度向上补齐到对齐边界(如 cudaMallocPitch),索引时用 pitch 而不是行宽计算地址。补进去的空洞字节不产生有效数据,但保证每一行的访问都只落在整数个段内——用少量空间换回合合并格。嵌入式与自管显存场景中,这个补齐动作要自己在分配器里完成。
结构体层面还有两个互相拉扯的手段。#pragma pack 压缩对齐能缩小小字段间的空洞、缩短步长,对利用率有利;但压缩后字段可能落在非对齐地址,标量访问由硬件兜底,而 float4 这类向量化载入要求严格对齐,反而会出错。实践中更常用的优化是反向操作:让一个线程一次载入 16B(float4)而不是 4B。32 个线程各取 16B,有效数据 512B,恰好 4 个段、4 次事务,利用率仍是 100%,而指令数减少到原来的 1/4,地址生成和事务发起开销同步下降。SoA 布局配合 float4 向量化,是带宽密集型 kernel 的常规组合拳。
写路径同样有讲究。store 的归并规则与 load 对称:32 个线程写连续地址时,控制器可以把零散写攒成整段突发;错步写则形成 read-modify-write 式的部分写,先取回整段、合并字节、再写回,代价甚至高于错步读。对「初始化大数组」这类场景,让 warp 写出满段而不是每线程写一个标志字节,差距会非常明显。代码层面排查对齐不需要特殊工具:把起始地址对 128 取模、把步长代入本节公式手工算一遍,通常几行纸笔就能定位问题;确保持久化的数据结构(文件映射、跨核共享缓冲、网络收包缓冲区)也遵守同样的对齐约定,否则 CPU 侧写好的数据到了 GPU kernel 里仍然是错位的。
六、现代架构放宽了什么,又有什么没变
最早的 CUDA 设备规则极其严苛:必须 32 个线程按线程号顺序、对齐、同宽度地访问同一个段,少一个条件就整 warp 退化为 32 次串行事务。计算能力 1.2 起引入放宽合并:段内任意排列的地址都能归并;之后的架构加入 L1/L2 缓存、32B sector、更大的 L2 与 HBM,偶发的错步访问可以被缓存吸收。Volta 之后的独立线程调度改变了 warp 内的分支语义,但访存指令仍以 warp 为单位归并,coalescing 规则原样保留。关于各层带宽的量级,见 GPU 内存层级与带宽 和 HBM 接口物理设计。
放宽不等于可以不看。规则只放宽了「段内排列」和「取数粒度」,没有改变「连续地址共享事务、分散地址各付一次」的根本结构;矩阵按列、AoS 跨步这类系统性的非合并模式,在任何一代硬件上都不会被缓存救回来。缓存能帮忙的场景是「局部性偶然成立」:相邻 warp 随后会访问同一批段,或一次错步读入的数据在 L1 里被复用;而每个 warp 稳定地只取段里 1/8 的字节、且取完再不回头时,缓存只放大了搬运量,命中率永远是零。只读数据还可以走只读缓存(read-only/data cache)路径,其归并约束与普通全局路径一致,并不会放宽连续性要求。
还要避免一种连锁误判:warp divergence 与 coalescing 会叠加。分支使部分线程在本次执行中不活跃,归并只考虑活跃线程的地址,段里剩余位置没有线程领取,利用率天然下降;而「先按某种条件排序再处理」有时既减 divergence 又改善局部性,是少见的一石二鸟。反过来,用条件判断跳过元素(如只处理活跃粒子),往往让原本连续的数组变得稀疏——这类优化要连同访问模式一起重新评估,不能只数分支次数。
优化是否生效要用指标验证。Nsight Compute 的 gld_efficiency(global load efficiency,全局载入效率)定义为理想字节数与实际请求字节数之比,满分 100%,含义就是本文表格里的带宽利用率。调内核时先看这个数字:接近 100% 说明合并已经做好,瓶颈另寻;明显偏低就回到地址公式检查步长与对齐。实际工程中,把列遍历改成行遍历、把 AoS 重排为 SoA、把错位起点补齐,单点改动带来 2 倍到 10 倍加速是常态。在 occupancy、指令调度这些杠杆之前,访存合并永远是第一个要排查的项目。
这一篇在知识体系里的位置
- 上游:GPU 架构:SIMT 与 warp 调度 讲清 warp 是什么、32 这个数字从哪里来。
- 横向:GPU 内存层级与带宽 给出寄存器、共享内存、L2、HBM 的带宽量级;HBM 接口物理设计 解释高带宽在物理层如何实现。
- 实战:矩阵乘法分块 用共享内存化解矩阵乘中的错步访问;共享内存 bank conflict 是数据搬进片上之后的下一道关。
总结
warp 的 32 个线程访存时,硬件按 128B 段归并地址:连续对齐只需一次事务、带宽利用率 100%,跨段、错步与随机访问按段数付出 2 到 32 次事务的代价,最坏只剩 1/32;让遍历沿行主序连续方向、把 AoS 重排为 SoA、保证对齐并用 pitch padding 补齐行宽,是 GPU 性能优化中投入最小、收益最大的第一杠杆。