本文目标

读完本文,你将能够:

  • 判断并修正破坏总线事务打包(Memory Coalescing)的糟糕寻址步长
  • 利用 Nsight Compute 量化证明 L2 Cache 对所谓 AoS 阵型伪劣势的遮蔽效应
  • 精确计算对齐 Padding 量以摧毁 Shared Memory 中的严重 Bank 冲撞
  • 接入 sm_80 硬件级 cp.async 管线彻底掩掉 Global 搬入 Shared 时的 LSU 等待泡

配套仓库:psmarter/CUDA-Practice
本系列文章与仓库中的源码、实验结果、目录结构相互对应;建议阅读文章时同步对照仓库中的实现。

对应代码路径

硬件环境:NVIDIA RTX 4090 (Ada Lovelace, sm_89)
128 SMs | FP32 82.6 TFLOPS | HBM 1008 GB/s | L2 72 MB | Roofline 拐点 81.9 FLOP/Byte

源文件 Kernel 名称 核心技术 测试规模
10_Memory_Optimization/01_coalesced_access/coalesced_access.cu coalesced_access
strided_access
aos_access
soa_access
Global 合并访存 Stride=1 vs 2、AoS vs SoA 布局 N=16.7M
(单数组 64 MB)
10_Memory_Optimization/02_bank_conflict/bank_conflict.cu no_bank_conflict
with_bank_conflict
padded_no_conflict
analyze_bank_patterns
Shared 行/列访问、32-way 冲突、Padding+1 消除 4096×4096
(64 MB)
10_Memory_Optimization/03_async_copy/async_copy.cu sync_copy_kernel
async_copy_kernel
pipeline_kernel
同步拷贝、cg::memcpy_asynccuda::pipeline 多阶流水 N=67M
(256 MB)

Kernel 名称与源码中 __global__ 函数签名完全一致。

本篇在系列中的位置:承接 01 基础概念与分块 的合并访存与 Tiling、06 线程束原语与寄存器通信 的寄存器通信(避免 Shared 的 Bank 冲突),本篇系统拆解三层访存瓶颈:Global 合并(Coalescing)、Shared Bank Conflict 与 Padding、Global↔Shared 的 Async Copy。后续 08 多流、图执行与扩展开发 用 Multi-Stream 掩盖 H2D/D2H、11 推理优化、融合与键值缓存 在推理链路中复用本篇的合并与 Bank 优化;13 性能分析、屋顶线与占用率 用 Nsight 量化 Bank 冲突与带宽。

Baseline

问题陈述:在 GPU 端,“Memory Bound”不仅仅代表主存带宽打满,由于内存分层的断崖式延迟特性,它可细分崩坏为外围显存物理跨距(Uncoalesced)、片上缓存阵列列撞车(Bank Conflict)和单纯的同步拷贝浪费时间片(Stall)。

Baseline 类别 测试场景 指标 数据来源
合并访问 (Stride=1) N=16.7M (64 MB) Kernel 时间 0.15 ms [实测] Results/10_Memory_Optimization.md
无 Bank 冲突 4096×4096 (64 MB) Kernel 时间 0.15 ms [实测] Results/10_Memory_Optimization.md
同步拷贝 (Sync Copy) N=67M (256 MB) Kernel 时间 0.60 ms [实测] Results/10_Memory_Optimization.md

瓶颈分析

为何 GPU 看似吞吐巨大,一旦内存访问凌乱其实力就会极速跌落十分之一?

  1. 未按 32 字节扇区齐平拉取(Global Memory Uncoalesced)
    • L2 至全局存取的底层是 32 字节的事务包 (Sector)。当 Warp 中 32 个工作线程去拿完全随机的地址时,硬件的最优解瞬间沦丧,它不得不为这群散兵发起高达 32 次的独立捞取信令。即便只有 stride=2,也将有一半抽上来的缓存直接作废,硬生生削去一半带宽下限。
  2. 高速缓存网段冲关单行道(Shared Bank Conflict)
    • 极速的 Shared Memory 其实是被横向斩为 32 管名为 Bank 的流水口(单管 32-bit 列宽)组装起的阵列。每当同一个 Warp 内部有超过数名线程非常不走运地正好卡向具有相同偏移余数 (addr) % 32 的通道提取不同数据,底座将无情锁死将其拉平成串行处理。
  3. 计算核被长载波空停(Pipeline Bubbles)
    • 诸如 GEMM 或者迭代核心通常会有“拉取外层数据 \rightarrow 同步落入 \rightarrow 计算”的三段死循环。在未启动流水线前,读取进行时整个强大昂贵的浮点矩阵列被强行停机发呆(Data Dependency Stall)。

优化思路

优化 1:保证 Warp 提取绝对连续紧凑 (Coalescing Pattern)

解决的瓶颈:浪费显存扇区读取动作带宽。
核心思想:无论多复杂的坐标网格运算,最底层负责映射回 Memory Array 时,必须保证在 threadIdx.xthreadIdx.x + 1 之间,他们索取的数据内存物理字节偏移量恰好绝对并接相连不留缝隙(或者全部指向同一格引发 Broadcast),确保汇编底层的一笔扇区指令一次性带出最多有效元。
预期收益:相比于 stride=2 强行折损降速的版本,连续对齐提取跑出 925.31 GB/s 的压榨级宽带满水表现 [实测]。

优化 2:矩阵二维铺设的边界 Padding 陷阱

解决的瓶颈:同一列抽取引发的 32-Way Serialized Stall。
核心思想:尤其在 GEMM 这类取块缓存(Shared Memory Tile)动作里,对于大小设定常常是 [32][32][32][32] 正态方块。由于列访问时所有的跨距恰好均为 32 从而一击撞在死巷孔。解决手段极其简单巧妙,人为给这块 Shared 布置多塞一列废物位:Smem[32][33]。所有的错位使得同一行的内存列偏移错开了一道,把所有的撞击在数学同余圈上完美错落抹平。
预期收益:消灭极度夸张的延迟,将性能由劣化版的拉回到了与绝对无冲状态极其接近贴合的 0.16 ms 满血点 [实测]。

优化 3:启用 cp.async 在硬件端做时空平移

解决的瓶颈:拉内存与计算时必须非此即彼。
核心思想:彻底摈弃手写用本地寄存器充当过客双缓冲的陋习。自 Ampere (SM 8.0) 架构后,CUDA 打开了硬件级别的 DMA 跨接令。它允许我们在内核中使用底层 API 并置数道 Stage。由控制器在底端默默搬数据进共享墙,并在此同时释放原管程允许 ALU(算术器)径直继续在旧池里疯狂啃算上个周期的遗留数。
预期收益:此种真正的全隐藏使得该流程中 256 MB 级大型运转不仅在有效带宽逼平了传统做法,更是将计算死角填补缝合。[实测注:本测试用例由于整体吞吐极快被带宽墙反卡导致无算术重核下的微涨幅 0.95x 效应;但在重计算矩阵类下将迎来质变。]

关键代码解释

规避 Bank Conflict 大杀器:Padding Offset

1
2
3
4
5
6
7
8
9
10
11
12
13
// 来源:10_Memory_Optimization/02_bank_conflict/bank_conflict.cu
// 场景:需要在 Shared Mem 中放入一整块巨大的 Tile 用于后续高频倒取
#define TILE_DIM 32

// 【灾祸版】:极其方正,导致若有 threadIdx.x=0 取 [0][0], threadIdx.x=1 取 [1][0]
// 此时 (0*32 + 0) % 32 == 0 ; (1*32 + 0) % 32 == 0 完形全撞同车道
__shared__ float bad_tile[TILE_DIM][TILE_DIM];

// 【救赎版】:仅仅加了1 (PADDING=1),空间占比几可不察
__shared__ float good_tile[TILE_DIM][TILE_DIM + 1];

// 此时访问:
float val = good_tile[threadIdx.x][col];

要点解读

  • 将二维方块打入连续一维的展平函数是 Row * Stride + Col。对于 good_tile,此时 Stride = 33。那么原本同一列中行距为 1 的两个元素:Rn+1×33+C(Rn×33+C)1(mod32)R_{n+1} \times 33 + C - (R_n \times 33 + C) \equiv 1 \pmod{32}。这意味着每一个往下的成员其通道门牌号自动平移了一格错在不同 Bank 上了!这是代价最小的最硬质解决方针。

现代化 cp.async 管线搭建

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// 来源:10_Memory_Optimization/03_async_copy/async_copy.cu (cuda::pipeline + cuda::memcpy_async)
#include <cuda/pipeline.h>

__global__ void pipeline_kernel(...) {
// 搭建阶段深度为 3 的流水软管管道
__shared__ float stage_buffers[3][BLOCK_SIZE];
auto pipeline = cuda::make_pipeline();

// 先导热车拉灌阶段 0 和阶段 1
... pipeline.producer_acquire();
cuda::memcpy_async(stage_buffers[0], global_ptr, ... , pipeline);

// 主环线发进
for (int i = 2; i < num_tiles; ++i) {
... // 放进下一班发车货令
cuda::memcpy_async(stage_buffers[i%3], global_ptr, ... , pipeline);
pipeline.producer_commit();

// 【关键】:阻塞点后移!只截停并等待极其老旧早应搬完的最末序列(i-2)完成
pipeline.consumer_wait();

... // 对 stage_buffers[(i-2)%3] 大量发起数学处理轰炸 ...

pipeline.consumer_release(); // 释空这个老站牌留作下一轮接新货
}
}

要点解读

  • 这个库级别的封装底层调用到了直抵汇编的专有 DMA cp.async 指式。它的精妙在于你调下函数那一刻代码丝毫未在此停留分毫;只有当你在极长周转步下踩实那脚 pipeline.consumer_wait(),只要传输已经在那几千次算术周期内被后台搞定完结了,这就是真正的零阻塞满速穿透。

结果与边界

性能对比

测试条件:双 RTX 4090 (sm_89sm\_89), nvcc -O3
数据来源Results/10_Memory_Optimization.md 原始实机日志,均以 100 次求均值

1. 极端带宽绞杀局:合并失效下沿探测(64 MB体量)

内存拉取模式 实机运行期跨 有效吸吞带宽 较基线倍比 数据性质
完全连缀读取 (Stride=1) 0.15 ms 925.31 GB/s 1.00x 基期 [实测]
断裂点状单步 (Stride=2) 0.16 ms 427.34 GB/s 耗时约 1.08×,带宽约折半 [实测]

极其残酷:在仅发生一步偏移拉接失效,由于被物理抓头强行补足垃圾位块数据,有效运力轰塌至原先 46%。

2. 不可原谅的结构编排虚谎:AoS vs SoA 物理界翻转

组织结构形式 实机执行总间 榨取有效总宽 缓存穿梭效能比 数据性质
SoA (连续聚合群拉) 0.59 ms 912.82 GB/s 1.00x 基数 [实测]
AoS (跳步结构抽取) 0.58 ms 922.31 GB/s 全等位持平 [实测]

本实验中 AoS kernel 同时读写 x/y/z/w 四字段,访问模式仍是合并的,故 AoS 与 SoA 带宽接近(922 vs 913 GB/s)。若只读单字段的 AoS 会呈非合并,未在本测试中单独跑。数据量 64 MB 落在 L2(72 MB)内,也会抬高有效带宽;更大体量抛向 HBM 时合并与否的差距会更明显。

边界条件与局限

  • 多阶段 Pipeline 的算率强倒置雷区:引入异步管架(Async Copy)是有极为昂贵的寄存器维持和指令维护大税的!在该算本极速轻任务内,流水段居然相比起传统硬取法在跑频段出现了 0.95x 下阻退缩。这就是典型的 T_compute << T_copy 被完全封死在存取口的绝境(Compute 压根没耗时不够填传输的深沟)。它只有在大型 GEMMs 内挂置方可见暴发。

常见误区

  1. 误区:一旦报出了 Bank Conflict 用 Padding 无论多大垫就完事。
    实际:如果在 16x16 甚至奇数阶阵格中根本不涉猎 32 进制撞面循环节时加填充不但是作死还会额外废掉极为可怜的仅限单块的内存口。只在确信产生基于 N%32N\%32 倍乘周波折返碰撞上方可用大。
  2. 误区:看见代码有乱步飞跃只要加 L1 Cache / Texture Cache 锁住就能高枕。
    实际:即便你锁死在小粒面缓存阵中,Warp 内无法凑出完整一线的提取操作仍然会令处理调度分发器发起复数次 requests。总线被切斩为几十分之一的极其破碎的事物(Transactions)极速拉高了整个发接排程管线的负重直到被其直接冲挂。必须去源码重排算法逻辑才是真正清障。

系列导航

前置阅读

文章 与本篇的衔接
01 基础概念与分块 先建立 Grid/Block/Warp、合并访存与 Shared Tiling 的直观,再在本篇深入 Coalescing 与 Bank 的定量分析
06 线程束原语与寄存器通信 Warp Shuffle 用寄存器通信规避 Shared 的 Bank 冲突与 __syncthreads 开销;本篇从 Shared 侧讲清冲突成因与 Padding 解法

推荐后续

文章 与本篇的衔接
08 多流、图执行与扩展开发 用 Multi-Stream 掩盖 H2D/D2H,与本篇的「单 Kernel 内合并 + Async Copy」形成系统级与内核级两层访存优化
11 推理优化、融合与键值缓存 推理链路中合并访存、Bank 优化与 Async Copy 的落地场景
13 性能分析、屋顶线与占用率 用 Nsight Compute 量化 Bank 冲突、带宽与 Roofline 拐点,验证本篇优化效果

顺序导航