土法炼钢兴趣小组的算法知识备份

【GPU 算子工程】访存优化:合并访问、bank conflict 与对齐

文章导航

分类入口
gpuarchitecture
标签入口
#cuda#memory-coalescing#bank-conflict#shared-memory#alignment#bandwidth

目录

访存优化:合并访问、bank conflict 与对齐

第 03 篇 说大多数算子卡在访存。但”卡在访存”还分两种:你能不能拿满该有的带宽,取决于访问模式。同样读同样多的数据,合并访问能跑满带宽,跨步访问可能浪费一大半。这一篇讲两个最重要的访问模式问题:global memory 的合并访问,和 shared memory 的 bank conflict。

一、合并访问:warp 的访存是按事务发起的

GPU 访问 global memory 不是一个线程一个字节地读,而是以 warp 为单位、按内存事务(transaction)发起。一个 warp 的 32 个线程同时发出访存请求,硬件把这些请求合并成尽可能少的、对齐的内存事务(通常以 32 字节或 128 字节为粒度)。

用一个 gather kernel 实测:线程 tin[t * stride],stride 越大、访问越分散。RTX 3060 Ti 上 \(N=2^{26}\)、256 线程/block,统计有效带宽(只算真正需要的字节):

访问跨步 有效带宽
1(完全合并) 412 GB/s
2 278 GB/s
4 167 GB/s
8 93 GB/s
16 92 GB/s
32 90 GB/s

stride=1 时拿到 412 GB/s,接近该卡 448 GB/s 理论上限;stride=32 时有效带宽只剩 90 GB/s,掉到约 1/4.6。原因不是 DRAM 变慢了,而是每条事务取回的 128 字节里只有 4 字节有用,其余被浪费——总线在搬运你不需要的数据。

实用规则:让相邻线程访问相邻地址

// 好:相邻线程访问相邻地址,合并
int i = blockIdx.x * blockDim.x + threadIdx.x;
float v = in[i];

// 差:相邻线程跨大步,事务浪费严重
float v = in[i * 32];

处理二维数据时,这意味着让 threadIdx.x 对应行内连续维度(最内层、stride 为 1 的维度)。矩阵转置、某些 reduction 布局之所以慢,根因常常是访问跨了大步。解决办法通常是先用 shared memory 把数据”换布局”——但这又引出 shared memory 自己的访问陷阱。

二、bank conflict:shared memory 的 32 个并行通道

shared memory 为了高带宽,被分成 32 个 bank(存储体),每个 bank 每周期能服务一个 32 位字。32 这个数字和 warp 大小对齐:理想情况下一个 warp 的 32 个线程各访问一个不同的 bank,32 个请求一个周期全部完成。

bank conflict 发生在一个 warp 内多个线程访问同一个 bank 的不同地址时。硬件只能串行化这些访问:如果 32 个线程全落在同一个 bank(但不同地址),就要 32 个周期才能服务完,这叫 32 路冲突。

bank 的编号规则:地址(按 4 字节字计)模 32。所以 s[threadIdx.x](stride 1)让 32 个线程落在 32 个不同 bank,无冲突;s[threadIdx.x * 32] 让所有线程落在同一个 bank,32 路冲突。

实测对照:让 block 内线程反复读 s[idx],stride 控制冲突程度。这里只取无冲突(stride 1)和满冲突(stride 32)两端做对照,不展开中间 stride。RTX 3060 Ti,reps=100000:

shared 访问跨步 耗时 说明
1 19.2 ms 无冲突
32 215.0 ms 32 路冲突

32 路冲突慢约 11 倍。没有到理论 32 倍,是因为只有 shared 读这一步被串行化,循环里其他指令开销和延迟隐藏摊薄了差距;但一个数量级的代价足以说明问题。

padding 消除冲突

最常见的修复手法是加一列 padding。二维 shared 数组 s[N][32] 在按列访问时(s[i][threadIdx.x] 固定列变行)会产生冲突,因为同一列的元素地址间隔 32,全落同一 bank。把它声明成 s[N][33],每行多一个无用元素,列访问的地址间隔变成 33(与 32 互质),32 个线程就散落到 32 个不同 bank:

__shared__ float tile[32][33];   // +1 padding 列,消除按列访问的 bank conflict

代价是多用一点 shared memory。矩阵转置、tiled GEMM 里这是标准做法,GEMM 篇 会再见到。

三、对齐与向量化访存

除了合并和 bank,两个补充手段能进一步压榨带宽:

// 向量化:每线程一次搬 4 个 float
const float4* in4 = reinterpret_cast<const float4*>(in);
float4 v = in4[i];
out4[i] = v;

向量化对访存密集 kernel(拷贝、element-wise、量化打包)常有可观收益,但要小心对齐和尾部不整除的处理。

四、把两件事串起来:转置为什么需要 shared

矩阵转置是访存模式问题的经典缩影:直接转置时,读是连续的(合并),写却是跨大步的(不合并),有效带宽被写端拖垮。标准解法是分块:

  1. 一个 block 合并地把一个子块从 global 读进 shared;
  2. __syncthreads()
  3. 从 shared 里按转置后的顺序读、再合并地写回 global。

shared 充当”换布局的中转站”,把两端的 global 访问都变成合并的。而 shared 里的转置读如果不加 padding,又会触发 bank conflict——于是 tile[32][33] 的 padding 正好补上。这个例子把本篇两个主题接到了一起,也是 kernel fusion 篇 里布局变换的基础。

五、小结与下一步

访问模式之外,另一个影响带宽利用的因素是有没有足够多的并发 warp 来隐藏延迟。下一篇讲 occupancy 与延迟隐藏,以及为什么 occupancy 高不一定快。

同主题继续阅读

把当前热点继续串成多页阅读,而不是停在单篇消费。


By .