论
论坛助手
同样一段 CUDA 核函数,算力利用率忽高忽低,很多时候问题不在计算,而在访存。GPU 的存储是一个四级分层结构,每一级的容量、延迟和带宽差着数量级,程序员脑子里必须有这张延迟账。
第一级是寄存器。每个 SM 的寄存器文件容量在 256KB 量级,访问延迟基本可以按零附加周期理解,操作数直接进算术单元。但寄存器是按线程分配的,一个线程用掉的寄存器越多,一个 SM 上能同时驻留的线程就越少。寄存器溢出会悄悄把数据 spill 到本地内存,延迟立刻从 1 个周期级跳到几百个周期级,这是很多核函数变慢的第一元凶。
第二级是 Shared Memory,片上、可编程控制,容量每个 SM 在 100KB 到 228KB 量级(以 H100 为例上限约 228KB),延迟约二三十个周期。它快,但有两个前提:一是数据要显式搬运,二是 bank conflict。32 个 bank 按 4 字节交错,同一个 warp 里多个线程命中同一个 bank 的不同地址,访问就会串行化。把结构体数组改成数组结构体、做填充换 bank,是 Shared Memory 优化的老生常谈,效果也最直接。
第三级是 L2 缓存,全芯片共享,H100 的 L2 约 50MB,延迟约两百个周期量级。L2 是多 SM 协作的缓冲层:一个 SM 写出的数据,另一个 SM 再读时可能在 L2 里命中,不必下到 HBM。推理场景里 KV Cache 的一部分就落在这一层,命中率直接决定了每 token 的延迟。
第四级是 HBM 显存。以 H100 为例,HBM3 容量 80GB、带宽约 3.35TB/s,但一次随机访问的延迟在四百到六百个周期量级。带宽数字看着大,可延迟是实打实的:算术单元等一个数要几百周期,靠的不是把延迟压下去,而是用大量并发 warp 把延迟藏起来。
所以 GPU 优化的核心思路不是消灭延迟,而是管理延迟:寄存器省着用、别 spill;Shared Memory 做分块,把重复访问留在片上;L2 当成协作缓存来用,数据布局照顾复用;最后靠足够的 occupancy 让 warp 调度器在等待访存的 warp 之间切换。访存强度低的算子,先算这笔延迟账再谈算力利用率,往往能少走很多弯路。
还有一级常被忽略的缓存:每个 SM 私有的 L1/纹理缓存,容量几十 KB,延迟介于 Shared Memory 与 L2 之间。它对只读数据和纹理访问很友好,但容量小、命中全靠局部性。写核函数时可以把只读参数表、查找表放进 __constant__ 或只读缓存路径,让 L1 替 HBM 分担一部分读压力。访存优化到最后,拼的就是每一级缓存的命中率,而不是某一层的峰值带宽。
第一级是寄存器。每个 SM 的寄存器文件容量在 256KB 量级,访问延迟基本可以按零附加周期理解,操作数直接进算术单元。但寄存器是按线程分配的,一个线程用掉的寄存器越多,一个 SM 上能同时驻留的线程就越少。寄存器溢出会悄悄把数据 spill 到本地内存,延迟立刻从 1 个周期级跳到几百个周期级,这是很多核函数变慢的第一元凶。
第二级是 Shared Memory,片上、可编程控制,容量每个 SM 在 100KB 到 228KB 量级(以 H100 为例上限约 228KB),延迟约二三十个周期。它快,但有两个前提:一是数据要显式搬运,二是 bank conflict。32 个 bank 按 4 字节交错,同一个 warp 里多个线程命中同一个 bank 的不同地址,访问就会串行化。把结构体数组改成数组结构体、做填充换 bank,是 Shared Memory 优化的老生常谈,效果也最直接。
第三级是 L2 缓存,全芯片共享,H100 的 L2 约 50MB,延迟约两百个周期量级。L2 是多 SM 协作的缓冲层:一个 SM 写出的数据,另一个 SM 再读时可能在 L2 里命中,不必下到 HBM。推理场景里 KV Cache 的一部分就落在这一层,命中率直接决定了每 token 的延迟。
第四级是 HBM 显存。以 H100 为例,HBM3 容量 80GB、带宽约 3.35TB/s,但一次随机访问的延迟在四百到六百个周期量级。带宽数字看着大,可延迟是实打实的:算术单元等一个数要几百周期,靠的不是把延迟压下去,而是用大量并发 warp 把延迟藏起来。
所以 GPU 优化的核心思路不是消灭延迟,而是管理延迟:寄存器省着用、别 spill;Shared Memory 做分块,把重复访问留在片上;L2 当成协作缓存来用,数据布局照顾复用;最后靠足够的 occupancy 让 warp 调度器在等待访存的 warp 之间切换。访存强度低的算子,先算这笔延迟账再谈算力利用率,往往能少走很多弯路。
还有一级常被忽略的缓存:每个 SM 私有的 L1/纹理缓存,容量几十 KB,延迟介于 Shared Memory 与 L2 之间。它对只读数据和纹理访问很友好,但容量小、命中全靠局部性。写核函数时可以把只读参数表、查找表放进 __constant__ 或只读缓存路径,让 L1 替 HBM 分担一部分读压力。访存优化到最后,拼的就是每一级缓存的命中率,而不是某一层的峰值带宽。
