Hopper DSMEM 深度解析与实测:比起写 HBM,跨 SM 片上互联究竟香在哪?
概述与核心命题:为什么过去不得不“写 HBM”?
Section titled “概述与核心命题:为什么过去不得不“写 HBM”?”在 NVIDIA Hopper(SM90)引入 Thread Block Cluster(线程块集群) 之前,CUDA 编程模型存在一个延续了十几年的物理铁律:
传统 GPU 的 SMEM 是彼此隔绝的孤岛:
每个 Streaming Multiprocessor(SM)的共享内存(SMEM)只对本 Thread Block(CTA)可见。
当多个 Block 需要交换中间结果(例如:跨 Block 的局部 Softmax 统计量合并、矩阵转置重排、生产者-消费者流水线传递)时,开发者没有任何片上直连通道,唯一合法的通路只有全局内存(Global Memory):
Pre-Hopper 时代的跨 Block 通信代价: CTA 0 ──[ st.global ]──> GMEM (L2/HBM) ──[ __threadfence / 切分 Kernel ]──> CTA 1 ──[ ld.global ]──> CTA 1 SMEM这种模式在微架构层面带来了不可承受之重:
- 往返时延极高:即使全部命中 L2 Cache,也要经历完整的访存流水线、写缓冲排队与缓存一致性握手(典型往返高达 300~600 周期);
- L2 缓存读写剧烈震荡(Cache Pollution):中间状态生命周期极短(用完即弃),却在 L2 Cache 和 Crossbar 之间反复倒腾,强行挤占了真正需要缓存的模型权重(Weights)和 KV Cache;
- 被迫切分 Kernel(Grid Barrier):由于 GPU 缺乏轻量级的全网格硬件同步,为了保证数据写入后被另一批 Block 安全读出,往往不得不退出当前 Kernel,通过 Host 端重新 Launch 下一个 Kernel,造成毁灭性的调度开销。
DSMEM 的物理本质:把 SM-to-SM 连成高速交换网
Section titled “DSMEM 的物理本质:把 SM-to-SM 连成高速交换网”Hopper 架构在物理硅片层面,通过 Cluster Crossbar(片上 SM 间互联网络) 将同一个 GPC(Graphics Processing Cluster)内的 SM 打通。
Hopper 存储金字塔的真实物理阶梯:
┌────────────────────────────────────────────────────────────┐│ Local SMEM ~20 - 30 cycles (单 SM 极速) │├────────────────────────────────────────────────────────────┤│ Remote DSMEM ~100 - 180 cycles (跨 SM 片上直连,零显存) │ <-- 远快于 L2!├────────────────────────────────────────────────────────────┤│ L2 Cache ~200 - 300 cycles (片上共享缓存) │├────────────────────────────────────────────────────────────┤│ HBM3 / DRAM ~450 - 600 cycles (片外高延迟高能耗) │└────────────────────────────────────────────────────────────┘分布式共享内存(DSMEM, Distributed Shared Memory) 并不改变 SMEM 的物理位置,而是提供了统一的 Cluster 虚拟寻址空间:
- CTA 0 可以直接通过普通指针,读取位于 CTA 1 物理 SM 上的共享内存;
- 底层由片上网络在硬件层面完成跨 SM 路由,完全绕开 L2 Cache 控制器与 HBM 物理显存。
严谨控制变量:H100 实测方案设计
Section titled “严谨控制变量:H100 实测方案设计”为了精准测量“DSMEM 片上直连”对比“写 HBM 往返”的纯硬件优势,我们设计了一套完全对齐 Cluster 同步开销(保持相同 cluster.sync())、仅以数据传输介质为单一自变量的基准测试:
【测试 1】往返时延对比(Ping-Pong Token Passing): CTA 0 发送一个 Token 给 CTA 1,CTA 1 收到后递增并传回给 CTA 0。连续执行 1000 轮往返(2000 次 Hops)。 - 路径 A (DSMEM):直接写入对方 SMEM 邮箱 -> cluster.sync()。 - 路径 B (GMEM) :写入全局显存槽位 -> __threadfence() -> cluster.sync() -> 读取全局显存槽位。
【测试 2】持续大块 Tile 交换吞吐(16 KB Tile Exchange): 每个 CTA 独占 16 KB 局部 Tile(4096 个 FP32)。两个 CTA 相互双向交换数据,连续迭代 5000 次。 - 路径 A (DSMEM):全线程并发利用 cluster.map_shared_rank 从对方 SMEM 拉取数据。 - 路径 B (GMEM) :每个 CTA 将 16 KB 写入全局内存缓冲区,刷 fence 后从对端缓冲区读出。测试代码已集成至仓库 benchmarks/dsmem_vs_hbm_bench.cu。
实测数据公布(NVIDIA H100 PCIe 真实硬件)
Section titled “实测数据公布(NVIDIA H100 PCIe 真实硬件)”在真实测试集群(NVIDIA H100 PCIe, 114 SMs, 80GB HBM3, CUDA 13.2, Driver 595.58.03)上,满载 57 个 Cluster(114 个 CTA 并发)实测数据如下:
1. 往返通信时延(Token Passing Latency)
Section titled “1. 往返通信时延(Token Passing Latency)”------------------------------------------------------------------- 1. Token Passing Round-Trip Latency Test (1000 round-trips / 2000 hops)------------------------------------------------------------------- DSMEM 单跳平均时延 : ~736.1 cycles HBM / GMEM 单跳平均时延 : ~1419.2 cycles 时延加速比 (Latency Speedup) : 1.93x 提速(时延近乎减半!)- 原理解析:走全局显存路径时,必须经过
st.global写入显存并配合__threadfence(),强制冲刷 GPU 的内部写缓冲(Write Buffer),阻塞了后续指令发射;而 DSMEM 在片上跨片总线内直接交付,流水线无额外停顿。
2. 持续 16 KB 大块 Tile 交换吞吐(5000 次迭代)
Section titled “2. 持续 16 KB 大块 Tile 交换吞吐(5000 次迭代)”------------------------------------------------------------------- 2. Sustained 16 KB Bulk Tile Exchange (5000 iterations)------------------------------------------------------------------- DSMEM 总交换耗时 : 30.871 ms HBM / GMEM 总交换耗时 : 46.678 ms 交换吞吐加速比 (Speedup) : 1.51x 提速! 有效交换带宽 (DSMEM) : 0.61 TB/s (纯片上 Crossbar 直连) 有效交换带宽 (HBM) : 0.40 TB/s (受制于 L2 写屏障与全局访存往返)3. NCU(Nsight Compute)物理计数器铁证
Section titled “3. NCU(Nsight Compute)物理计数器铁证”使用 Nsight Compute 抓取实际执行期间的底层硬件计数器:
sudo ncu --metrics lts__t_bytes.sum,dram__bytes_write.sum \ --kernel-name regex:.*tile_exchange.* ./dsmem_vs_hbm_bench真实硬件指标读数对照表:
Section titled “真实硬件指标读数对照表:”| 硬件计数器指标 | 传统方式(写 HBM / GMEM 交换) | Hopper DSMEM(片上网络直连) | 收益幅度 |
|---|---|---|---|
lts__t_bytes.sum(L2 Cache 读写总流量) | 752.43 Mbyte | 139.65 Mbyte | L2 流量暴降 5.4 倍(降低 81.4%!) |
dram__bytes_write.sum(物理 HBM 显存写入量) | 持续显存写回 | 0 Byte(绝对零写入!) | 100% 消除片外物理显存写开销 |
深刻的微架构启示:
哪怕数据全部命中 L2 缓存、没有溢出到外部 HBM,传统写 GMEM 依然会在片上 L2 交叉开关上产生高达 752 MB 的拥挤数据流;
而 DSMEM 将这一过程全部限制在 SM 互联内部完成,不仅物理显存写入为 0,L2 读写总量也直接被砍掉 81.4%!
关键避坑指南:SMEM 生命周期陷阱(The Lifecycle Trap)
Section titled “关键避坑指南:SMEM 生命周期陷阱(The Lifecycle Trap)”我们在 H100 上调试这套基准测试时,复盘出了一个极易导致线上生产环境偶发崩溃的典型 Bug,极具工程参考价值:
错误示范:对端 CTA 提前退出引发的 MMU Trap
Section titled “错误示范:对端 CTA 提前退出引发的 MMU Trap”// 假设 CTA 0 需要读取 CTA 1 的数据并做汇总if (rank == 0) { const float* peer_smem = cluster.map_shared_rank(local_accum, 1); for (int i = threadIdx.x; i < ARRAY_LEN; i += blockDim.x) { local_accum[i] += peer_smem[i]; // 读取 CTA 1 的 SMEM } d_out[blockIdx.x] = local_accum[threadIdx.x];}// 致命错误:CTA 1 (rank == 1) 没有进入 if,直接退出了 Kernel!- 故障现象:执行触发
cudaErrorLaunchFailure (error 719) -> unspecified launch failure。 - 物理根因:CTA 的 SMEM 物理生命周期与该 CTA 的执行周期严格绑定! 当 CTA 1 执行完毕并退出后,硬件立即回收并失效了 CTA 1 所在的 SMEM 分区。此时 CTA 0 依然试图跨 SM 读取已经被注销的物理地址,直接触发 MMU 异常!
- 铁律修复:在 Kernel 退出之前,必须无条件调用
cluster.sync()! 确保 Cluster 内部所有的远程读取全部收口,方可允许任何 CTA 终止。
if (rank == 0) { const float* peer_smem = cluster.map_shared_rank(local_accum, 1); for (int i = threadIdx.x; i < ARRAY_LEN; i += blockDim.x) { local_accum[i] += peer_smem[i]; } d_out[blockIdx.x] = local_accum[threadIdx.x];}// 必须增加 Cluster 级栅障,防止 CTA 1 先行析构cluster.sync();完整可运行测试源码
Section titled “完整可运行测试源码”以下为本次测试编译使用的完整源码(也可在本地仓库 benchmarks/dsmem_vs_hbm_bench.cu 获取):
#include <cuda.h>#include <cuda_runtime.h>#include <cooperative_groups.h>
#include <iostream>#include <vector>#include <iomanip>
namespace cg = cooperative_groups;
#define CUDA_CHECK(expr) \ do { \ cudaError_t err = (expr); \ if (err != cudaSuccess) { \ std::cerr << "CUDA Error: " << cudaGetErrorString(err) << std::endl;\ std::exit(EXIT_FAILURE); \ } \ } while (0)
constexpr int CLUSTER_SIZE = 2;constexpr int THREADS_PER_BLOCK = 128;constexpr int TILE_FLOATS = 4096; // 16 KB
__device__ __forceinline__ uint64_t read_clock64() { uint64_t clk; asm volatile("mov.u64 %0, %clock64;" : "=l"(clk)); return clk;}
// 1. DSMEM 持续交换 Kernel__global__ void __attribute__((cluster_dims(CLUSTER_SIZE, 1, 1)))dsmem_tile_exchange_kernel(int num_iters, float* out_verify) { auto cluster = cg::this_cluster(); uint32_t rank = cluster.block_rank(); uint32_t peer_rank = rank ^ 1;
__shared__ float smem_src[TILE_FLOATS]; __shared__ float smem_dst[TILE_FLOATS];
for (int i = threadIdx.x; i < TILE_FLOATS; i += blockDim.x) { smem_src[i] = float(rank * 1000 + i); } cluster.sync();
const float* peer_src = cluster.map_shared_rank(smem_src, peer_rank); cluster.sync();
for (int it = 0; it < num_iters; ++it) { for (int i = threadIdx.x; i < TILE_FLOATS; i += blockDim.x) { smem_dst[i] = peer_src[i]; } cluster.sync(); }
if (threadIdx.x == 0 && out_verify != nullptr) { out_verify[blockIdx.x] = smem_dst[0]; }}
// 2. HBM / GMEM 持续交换 Kernel__global__ void __attribute__((cluster_dims(CLUSTER_SIZE, 1, 1)))hbm_tile_exchange_kernel(int num_iters, float* gmem_scratchpad, float* out_verify) { auto cluster = cg::this_cluster(); uint32_t rank = cluster.block_rank(); int peer_block_idx = blockIdx.x ^ 1;
__shared__ float smem_src[TILE_FLOATS]; __shared__ float smem_dst[TILE_FLOATS];
float* my_gmem_scratch = &gmem_scratchpad[blockIdx.x * TILE_FLOATS]; float* peer_gmem_scratch = &gmem_scratchpad[peer_block_idx * TILE_FLOATS];
for (int i = threadIdx.x; i < TILE_FLOATS; i += blockDim.x) { smem_src[i] = float(rank * 1000 + i); } cluster.sync();
for (int it = 0; it < num_iters; ++it) { for (int i = threadIdx.x; i < TILE_FLOATS; i += blockDim.x) { my_gmem_scratch[i] = smem_src[i]; } __threadfence(); cluster.sync();
for (int i = threadIdx.x; i < TILE_FLOATS; i += blockDim.x) { smem_dst[i] = peer_gmem_scratch[i]; } cluster.sync(); }
if (threadIdx.x == 0 && out_verify != nullptr) { out_verify[blockIdx.x] = smem_dst[0]; }}终极定位:何时写 HBM?何时用 DSMEM?
Section titled “终极定位:何时写 HBM?何时用 DSMEM?”在 Hopper 及未来的 Blackwell 算子架构设计中,划分边界非常清晰:
- 跨越 Cluster 物理边界(如无法归入同一 GPC、或跨 GPU 通信):
必须走 Global Memory(L2/HBM)或 NVLink/NCCL。 - Cluster 内部多 CTA 协同(2/4/8 个 Block 之间):
- 进片上(Ingress):用 TMA Multicast,从 GMEM 单次读取广播进各自的 Local SMEM;
- 在片上(In-flight):任何跨 Block 中间结果的交互、转置、乒乓流转与归约,坚决使用 DSMEM! 严禁任何中间变量落盘到全局显存。