第 5 章 · 内存层级

⏱️ 60 分钟🎯 跑出 coalescing 差距📂 code/ch05_memory/🔥 关键瓶颈章

学习目标

前置知识

已完成 Ch04,理解 warp、occupancy,以及 host/device 指针和显式数据搬运。

核心概念

5.1 内存金字塔

graph TD
    R["Registers
~1 cycle, thread 私有
256 KB / SM"] S["Shared Memory / L1
~30 cycles, block 内共享
192 KB / SM (A100)"] L2["L2 Cache
~200 cycles, 全 GPU 共享
40 MB (A100)"] G["Global / HBM
~500 cycles
40-80 GB"] H["Host RAM
~50000 cycles via PCIe"] R --> S --> L2 --> G --> H style R fill:#f3f1e8,stroke:#2f5d3a style S fill:#f3f1e8,stroke:#8b1538 style G fill:#f3f1e8,stroke:#a86420

一句话总结:"越靠上越快越小"。性能优化 = 让数据尽量待在上层

层级延迟容量/SM关键字谁能写
Register1 周期256 KB普通局部变量编译器分配
Shared / L1~30 周期192 KB__shared__block 内
Constant~30 周期 (cache hit)64 KB 全局__constant__host
L2~200 周期40 MB (whole GPU)自动
Global~500 周期40-80 GBcudaMalloc所有 thread
Local同 global寄存器溢出去的thread 私有

关键代码

5.2 合并访问 (Memory Coalescing)

GPU 一次最少读 128B(= 32 个 float = 一个 warp 的份)。如果 warp 内 32 个 lane 恰好访问相邻 32 个 float,硬件合并为一次内存事务。如果它们访问跳跃的位置,就需要 32 次独立事务。

合并 vs 跳访

// ✅ COALESCED — warp 内 lane k 访问 in[base + k]
out[i] = in[i];

// ❌ STRIDED  — warp 内 lane k 访问 in[base + k*STRIDE]
out[i*STRIDE] = in[i*STRIDE];

// ❌ TRANSPOSED — 经典 row-major 数据按列访问就是 strided
out[col*M + row] = in[row*N + col];

coalesce_vs_strided.cu,在你的目标 GPU 上填这张表:

Pattern时间有效带宽vs measured peak
coalesced (stride=1)TODO(on GPU)TODO(on GPU)TODO(on GPU)
strided 2TODO(on GPU)TODO(on GPU)TODO(on GPU)
strided 8TODO(on GPU)TODO(on GPU)TODO(on GPU)
strided 32TODO(on GPU)TODO(on GPU)TODO(on GPU)
影响有多大? stride 越大,warp 内请求会被拆成更多 memory transaction,实际带宽通常显著下降。 这就是为什么 row-major 矩阵按列访问容易让 kernel 变成访存瓶颈。LLM 里 KV cache 布局选择直接影响这个。

5.3 Shared Memory: 片上 SRAM

__shared__ 关键字让你在 SM 的片上 SRAM 上开一块给整个 block 共享的数组。读写延迟接近寄存器,是把 global memory 数据驻留在芯片上重复使用的关键。

__global__ void block_sum_shared(const float* x, float* partial, int n) {
    __shared__ float sdata[256];          // 静态 shared,编译期定大小

    int tid = threadIdx.x;
    int gid = blockIdx.x * blockDim.x + threadIdx.x;

    sdata[tid] = (gid < n) ? x[gid] : 0;   // 1) 拉数据
    __syncthreads();                       // 2) 等齐

    for (int s = blockDim.x / 2; s > 0; s >>= 1) {  // 3) tree-reduce
        if (tid < s) sdata[tid] += sdata[tid + s];
        __syncthreads();
    }
    if (tid == 0) partial[blockIdx.x] = sdata[0];
}

5.4 Constant Memory

__constant__ 是 64 KB 大小的全局只读区,配有专门的 constant cache。最适合的访问模式:warp 内 32 lane 同时访问同一地址——硬件一次广播给 32 lane,0 额外开销。

__constant__ float c_coeff[16];   // 文件作用域声明

// host:
cudaMemcpyToSymbol(c_coeff, h_coeff, sizeof h_coeff);

// kernel: 所有 lane 读 c_coeff[k] → 广播
for (int k = 0; k < 16; ++k) acc += c_coeff[k] * x;

但如果 warp 内不同 lane 读不同的 constant 地址 → 序列化,反而比 global 慢。所以适用场景窄:小型 LUT、模型超参数、kernel 内不变的因子。

5.5 三种分配方式对比

分配 API位置典型用法陷阱
cudaMalloc设备显存常驻数据:权重、KV cache必须显式 memcpy
cudaMallocHost主机 pinned异步 DMA 源/目的占用宝贵的物理内存
cudaMallocManaged统一虚拟地址原型、教学page fault 慢;不好控制位置
cudaHostAlloc(...Mapped)主机 zero-copy不存在kernel 每次访问走 PCIe,奇慢

mem_modes.cu 实测三种差距。结论:

运行结果

运行本章四个示例,先验证输出,再记录不同 stride、内存模式和 shared-memory 路径的实际带宽。

性能数据

⚠️ 需 GPU 验证:不同 GPU 与传输规模不可直接横比;仓库统一结果表暂记为 TODO(on GPU)

自检清单

Q1: 为什么 Nsight 的 Memory Throughput 百分比和我按字节/时间算出的带宽比例不同?

先核对 metric 定义、单位、峰值口径与 cache traffic,再用 Roofline、requested/actual bytes 和 L2 hit 分析;不要把两个不同分母的百分比直接比较。

Q2: __shared__ float arr[256]__shared__ float arr[] 区别?

第一个是静态 (编译期定大小,48 KB 上限)。第二个是动态 (运行期 launch 时定,可到 192 KB 上限但要 cudaFuncSetAttribute 解锁)。

Q3: 为什么 stride=2 不一定刚好掉一半?

L2 / texture cache 命中会改变结果:你只用了一半数据但事务仍带回 128B,下次可能正好用到。stride 大到访问超出 cache line 后才会断崖式下降。

Q4: 寄存器太多会怎样?

编译器先 spill 到 local memory(其实是 global memory 私有分区,奇慢)。Nsight 看 "Stack Frame Spill" 行。控制方法:用 -maxrregcount=N 强制上限,或者重构 kernel 减少活跃变量。

Q5: HBM 和 GDDR 啥区别?

两者都是显存。HBM 采用堆叠与宽接口,GDDR 采用板级颗粒与较窄接口;容量、带宽和功耗需按具体 SKU 的官方规格比较,不能只凭产品代际推断推理吞吐。

练习题

  1. 01_coalesce_starter.cu:调换 row/col indexing 让 copy 变 coalesced。
  2. shared_demo.cu 改成把 256 个 float 求最大值而不是和。
  3. constant_demo.cu 里 c_coeff 改成 1024(超 4 KB),看是否还有加速?为啥?(提示:constant cache 只有 8 KB / SM。)
  4. 给自己 GPU 测实际 vs 理论带宽比,记下结果——后面所有 kernel 的 "好不好" 都拿这个比。

5.8 工业实战:内存池、cp.async、显存碎片、NCCL

5.8.1 cudaMallocAsync — 解决 LLM 服务的显存碎片

问题:LLM 推理服务里,每个请求要分配几十 MB 的 KV cache,请求完后释放。频繁 cudaMalloc/cudaFree 在 hot path 上消耗几百微秒,且显存逐渐碎片化——明明 free 显存还有 20 GB 却分不出一块连续 5 GB。

CUDA 11.2+ 提供异步内存池(基于 stream-ordered memory allocator):

// 创建 / 配置 memory pool
cudaMemPool_t pool;
cudaDeviceGetDefaultMemPool(&pool, 0);

// 设置 release threshold: 池中保留多少字节不归还 OS
size_t threshold = size_t(20) << 30;   // 20 GB
cudaMemPoolSetAttribute(pool, cudaMemPoolAttrReleaseThreshold, &threshold);

// 异步 malloc / free, 跟 stream 绑定
void* ptr;
cudaMallocAsync(&ptr, bytes, stream);     // 几乎 0 开销 (复用池)
// ... use ptr on stream ...
cudaFreeAsync(ptr, stream);                // 也几乎 0 开销

vLLM / TensorRT-LLM 都用这个。配合 PyTorch 用 torch.cuda.set_per_process_memory_fractionPYTORCH_CUDA_ALLOC_CONF

# PyTorch 推荐配置(避免 OOM + 碎片):
export PYTORCH_CUDA_ALLOC_CONF=expandable_segments:True,max_split_size_mb:512

5.8.2 cp.async — Ampere+ 的异步 shared 加载

Ch6 已经提过,这里看 PTX 级细节。普通 shared 加载:

// global -> register -> shared (两次访存事务)
LDG.E.SYS R0, [R2]      ; 从 global 读到 register
STS [R5], R0            ; 从 register 写到 shared

Ampere 的 cp.async

// global -> shared 一步到位, 不经过 register, 异步
LDGSTS.E.BYPASS.LTC256B [R5], [R2]
// 之后 kernel 可继续做别的, 真要用 shared 时:
LDGSTS.WAIT

C++ 侧两种 API:

// 1) High-level: cuda::pipeline
#include <cuda/pipeline>
auto pipeline = cuda::make_pipeline();
pipeline.producer_acquire();
cuda::memcpy_async(&smem[0], &gmem[0], cuda::aligned_size_t<16>(64), pipeline);
pipeline.producer_commit();
pipeline.consumer_wait();
// ... use smem ...
pipeline.consumer_release();

// 2) Low-level: __pipeline_memcpy_async
__pipeline_memcpy_async(&smem[i], &gmem[i], sizeof(float4));
__pipeline_commit();
__pipeline_wait_prior(0);

收益要用 Nsight 验证:在 GEMM mainloop 里配 double-buffer 后,重点看 HBM 加载是否与 compute 重叠、Long Scoreboard 是否下降、Tensor Core 是否更连续地工作。不同 tile、寄存器压力和硬件代际下,端到端提升差异很大。

5.8.3 cudaMemPrefetchAsync — Unified Memory 的正确姿势

5.5 提到 unified memory(cudaMallocManaged)在生产中很少用,因为 page fault 慢。但如果非用不可(例如显存不够,要 host-managed swap),cudaMemPrefetchAsync 是关键:

float* m; cudaMallocManaged(&m, bytes);
// 提前把数据从 host 拉到 device, 避免 kernel 内 page fault
cudaMemPrefetchAsync(m, bytes, /*device=*/0, stream);
my_kernel<<<..., stream>>>(m);
// 用完拉回 host (eg. checkpoint)
cudaMemPrefetchAsync(m, bytes, cudaCpuDeviceId, stream);

NVIDIA Grace Hopper Superchip (GH200) 的 NVLink-C2C 让 unified memory 性能逼近显存,CPU+GPU 共享 480 GB——是为超大模型设计的,开始改变 unified 在生产中的地位。

5.8.4 跨 GPU:NCCL 集合通信

多 GPU 训练 / 张量并行推理必用 NCCL (NVIDIA Collective Communications Library):

#include <nccl.h>
ncclComm_t comm;
ncclCommInitAll(&comm, n_gpus, devs);

// All-Reduce: 每张卡有 local result, 跨卡求和后所有卡都有 sum
ncclAllReduce(send_buf, recv_buf, count, ncclFloat, ncclSum, comm, stream);

// All-Gather: 每张卡有 1/N 数据, gather 完每张卡都有完整数据
ncclAllGather(send_buf, recv_buf, count, ncclFloat, comm, stream);

关键 pattern: 把 NCCL 调用放在独立 stream 上,跟计算 stream 并行:

cudaStream_t compute_s, comm_s;
cudaStreamCreate(&compute_s); cudaStreamCreate(&comm_s);

// 前向第 N 层算完, 异步把梯度 reduce, 同时算第 N+1 层
backward_layer_N<<<..., compute_s>>>(grad);
ncclAllReduce(grad, grad, ..., comm_s);    // overlap!
backward_layer_N1<<<..., compute_s>>>(...);

NCCL 拓扑感知(自动用 NVLink、忽略 PCIe 慢路径)。但你必须在同一个 process初始化所有 GPU 或用 NCCL_P2P_LEVEL 调优。详细见 NCCL 文档

5.8.5 显存占用 debug 工具

# 看哪些进程占用 GPU 显存
nvidia-smi --query-compute-apps=pid,process_name,used_memory --format=csv

# 进入 Python 看 PyTorch 的分配
python -c "import torch; torch.cuda.memory._dump_snapshot('mem.pickle')"
# 然后用 https://pytorch.org/memory_viz 在线可视化

# 在 PyTorch 训练里加打印
print(torch.cuda.memory_summary())
print(f"allocated: {torch.cuda.memory_allocated()/1e9:.2f} GB")
print(f"reserved : {torch.cuda.memory_reserved()/1e9:.2f} GB")

典型显存泄漏症状:训练 N 步后 OOM,重启就好。9 成原因是 hold 住了 computation graph(debug 时 forget .detach()),或者把 tensor 加进 list 不释放。

5.8.6 LLM 推理的显存分布

7B 模型 fp16 推理,典型显存占用:

组件占用说明
权重14 GB7B × 2 字节
KV cache (batch=1, T=2K)~1 GBn_layer × 2 × n_head × T × head_dim × 2
KV cache (batch=32, T=2K)~32 GB线性放大
activation~1 GB每层中间 tensor
CUDA runtime + cuBLAS~1.5 GB固定开销
NCCL buffer (TP)~1 GB多卡推理才有

结论:A100-80G 跑 7B 服务约能容 32-64 并发;70B fp16 + KV 直接超过 80G,必须 TP 切到多卡或者 W4A16 量化。

5.9 研究前沿(2025-2026):Blackwell TMEM 与 KV cache 革命

5.9.1 Tensor Memory (TMEM) — 内存层级新成员

Blackwell 把所有 SM 加了第六层内存。新的金字塔:

graph TD
    R["Registers
~1 cycle, thread 私有
256 KB / SM"] T["Tensor Memory (TMEM)
~5 cycles, SM 内 MMA 专用
256 KB / SM, sm_100+ 新增"] S["Shared Memory / L1
~30 cycles, block / cluster
228 KB / SM"] L2["L2 Cache
~200 cycles
50-80 MB"] G["Global / HBM
~500 cycles
B200 180 GB; GB200/GPU 186 GB; B300 288 GB"] H["Host RAM
~50000 cycles via PCIe / NVLink-C2C"] R --> T --> S --> L2 --> G --> H style T fill:#f3f1e8,stroke:#a86420

TMEM 的特殊性:

普通 CUDA 开发者:通过 CUTLASS 4.6.1 / CuTe DSL(2026-07 快照) 自动使用,不需要直接写。

5.9.2 KV cache 压缩前沿(2024-2026)

LLM 推理的最大显存怪兽是 KV cache。2024-2026 出现了五种主流压缩思路:

算法思路节省来源验证项
KV cache 量化 (KIVI, KV-quant)fp16 → int4 / int2每 token 字节下降PPL / 业务指标 + TODO(on GPU)
Token 选择性丢弃 (H2O, SnapKV, StreamingLLM)只留"重要"或"最近" token 的 KV保留 token 数下降长上下文准确率 + TODO(on GPU)
层间共享 (CLA, You Only Cache Once)多层共享同一个 KV跨层 KV 冗余下降通常需要训练侧配合
Multi-head Latent Attn (MLA) (DeepSeek-V2/V3)Q/K/V 投影到低维潜空间模型结构性压缩由模型架构决定,推理侧不可随意改
分页 + 前缀共享 (PagedAttention + RadixAttention)多请求共享公共 prompt 的 KV内部碎片和重复前缀下降trace 命中率 + KV pool 压力

实战组合:vLLM 默认用 PagedAttention + 可选 fp8/int4 KV 量化;SGLang 加上 RadixAttention;DeepSeek-V3 / V2 用 MLA + paged + fp8。多种技术叠加后,KV 显存从“按最大长度预留”转为“按真实 token 与共享前缀付费”,实际节省幅度必须按模型结构、上下文长度和请求分布测算。

5.9.3 PagedAttention 与显存碎片化的"解药"

vLLM 论文(SOSP 2023)后两年的演进:

5.9.4 NVLink-C2C 与 unified memory 复活

过去 unified memory 的 page migration 往往不适合延迟敏感路径。Grace Hopper 与 Grace Blackwell 的 NVLink-C2C 改变了 CPU/GPU 共享内存的访问边界,但是否适合仍需按驻留位置、访问模式和目标系统实测:

// On a coherent Grace Hopper system, test managed placement explicitly.
float* w; cudaMallocManaged(&w, 600 * (1u << 30));   // 600 GB 模型权重
// w 落在 Grace LPDDR5X (480 GB), 由 NVLink-C2C 让 GPU 访问
// 比起 fp4 量化, 这种"显存外溢"对精度敏感模型更友好

典型用例是超大模型的混合内存推理:热权重和当前层激活尽量驻留 GPU HBM,冷权重、长尾 KV 或可重取数据放在 Grace 内存侧,按需通过 C2C 访问。是否划算取决于访问频率和重算成本。

5.9.5 显存优化的 2026 工业组合

一个完整 LLM 推理服务的显存优化技术栈:

1. fp8 / fp4 量化权重     → 权重显存减半到 1/4
2. fp8 KV cache           → KV 显存减半
3. PagedAttention         → 降低按 T_max 预分配造成的内部碎片
4. RadixAttention 前缀共享  → 多请求公共部分一份 KV
5. MLA 或 GQA             → KV head 维度压缩 (模型架构层面)
6. Stream-K + Stream-P    → batch 间显存峰值降低
7. cudaMallocAsync 内存池  → 减少碎片
8. NVLink-C2C 外溢 (GH200) → 大模型放得下

效果: 能否单卡服务 70B 级模型取决于量化格式、KV 长度、batch、并发调度和权重常驻策略

5.9.6 CUDA 12.6+ 内存子系统改进

常见坑

5.11 CUDA 官方手册精讲(CUDA Programming Guide 13.2(核验:2026-07-20))

L1/Shared Carveout、DSMEM、Hints API、Pinned Memory、内存池

本节定位:把 NVIDIA 官方 CUDA Programming Guide 13.2(核验:2026-07-20) 当前版中和本章直接相关的硬核细节抽出来——概念、API、踩坑点、版本兼容性—— 让你不必通读官方手册也能掌握本章主题的"标准答案"。引用按命名章节回查,避免把旧版编号当作稳定接口。

跨代 GPU 内存层级数字一览

选 kernel 优化方向之前先看你的硬件落在哪一格:

层级V100 (sm_70)A100 (sm_80)H100 (sm_90)B200 (sm_100)
Register file / SM256 KB256 KB256 KB256 KB
Shared + L1 / SM128 KB192 KB228 KB228 KB
Shared per block 上限96 KB164 KB228 KB228 KB
Tensor Memory / SM256 KB
L2 cache (全卡)6 MB40 MB50 MB~80 MB(est.)
HBM 容量16 / 32 GB40 / 80 GB80 GB (H100) / 141 GB (H200)180 GB(B200;GB200 每 GPU 为 186 GB,B300 为 288 GB)
HBM 带宽0.9 TB/s1.6-2.0 TB/s3.4 TB/s8 TB/s
NVLink 带宽300 GB/s600 GB/s900 GB/s1800 GB/s
PCIe 带宽 (双向)32 GB/s (Gen3 x16)64 GB/s (Gen4 x16)128 GB/s (Gen5 x16)128 GB/s (Gen5 x16)

典型延迟(周期数,跨代基本不变):

层级延迟(cycles)说明
Register1back-to-back FMA 即可消化
Tensor Memory (Blackwell)~5仅 MMA accumulator 用
Shared / L1 hit~30warp 切换轻松隐藏
L2 hit~200需要 ~7 warp 同时在等
HBM / L2 miss400-700需 ~16 warp 才能隐藏
PCIe (host)~50 000kernel 内访问永远禁忌
NVLink-C2C (GH200)~1 000per cache line, hardware coherent
L2 容量决定 LLM 推理什么? KV cache 的工作集如果能塞进 L2,QK^T 重复访问 K 就是 L2 hit (~200 cyc);否则每次 HBM (~500 cyc)。H100 的 50 MB L2 能装下 batch=1, n_layer=80, head_dim=128 的部分 KV,但 batch=32 直接 spill 回 HBM——这就是为什么 long context 推理 H100 比 V100 快远超 HBM 带宽比。

L1 / Shared 切片配置 — 解锁 48 KB 以上 shared

从 Volta 起,L1 和 shared memory 共用同一块物理 SRAM("unified data cache")。CUDA 让你 per-kernel 调整两者比例:

架构Unified cache / SM可选 shared 上限默认 shared
Volta sm_70128 KB0 / 8 / 16 / 32 / 64 / 96 KB96 KB
Turing sm_7596 KB0 / 32 / 64 KB64 KB
Ampere sm_80192 KB0 / 8 / 16 / 32 / 64 / 100 / 132 / 164 KB164 KB
Hopper sm_90228 KB0 / 8 / 16 / 32 / 64 / 100 / 132 / 164 / 196 / 228 KB228 KB

API 调用模式

__global__ void my_big_shared_kernel(float* x) {
        extern __shared__ float buf[];   // 动态 shared, 大小在 launch 时给
        // ...
    }

    int main() {
        // 1) 申请超过默认的 shared 大小 (例: A100 上想用 96 KB)
        int maxbytes = 96 * 1024;
        cudaFuncSetAttribute(my_big_shared_kernel,
                             cudaFuncAttributeMaxDynamicSharedMemorySize,
                             maxbytes);

        // 2) 偏好 shared 多一点 / L1 多一点 (driver 把它当 hint)
        cudaFuncSetAttribute(my_big_shared_kernel,
                             cudaFuncAttributePreferredSharedMemoryCarveout,
                             cudaSharedmemCarveoutMaxShared);   // 或一个 0-100 整数百分比

        // 3) launch 时第三个参数 = 动态 shared 字节数
        my_big_shared_kernel<<<grid, block, maxbytes>>>(x);
    }
    

三个 carveout 常量:

陷阱 1:超过 48 KB 必须用动态 shared。静态 __shared__ float buf[16384](= 64 KB)会编译失败。改写成 extern __shared__ float buf[]; + launch 时第三个参数。

陷阱 2:driver 可能改你设的值。如果你设 carveout = MaxShared 但 kernel 实际 shared 用量很小,driver 会反过来给 L1 更多空间——所以 carveout 只是 hint,不是硬性约束。要硬性约束用废弃的 cudaFuncSetCacheConfig(不推荐,会引发 kernel 切换时序列化)。

FlashAttention 在 A100 上的典型配置:用 MaxDynamicSharedMemorySize = 100 KB + CarveoutMaxShared,把整个 KQ block + V tile 都放 shared,几乎不依赖 L1。Hopper 上进一步用 228 KB 全 shared 跑更大的 head_dim。

Constant cache 内部行为与 texture 简介

Constant cache 真正的"广播"

Constant memory 总容量 64 KB(全 GPU 共享),每 SM 上有 ~8 KB 的 constant cache。它的硬件特性:

warp 内 32 lane 的访问模式事务数延迟
全 lane 读同一个地址1~ register (硬件自动广播)
16 lane 读 addr A, 16 lane 读 addr B2~30 cycle (两次串行)
32 lane 读 32 个不同地址32序列化, > global memory!

所以 constant memory 只在"全 warp 读相同标量"这一个场景下有优势。RoPE 系数、attention scale 因子、batchnorm 的 affine 系数都是好用例;如果 LUT 大到要按 lane 取不同元素,立刻退化到比 global 慢。

Texture / Surface(仅简介)

texture memory 是为图形渲染设计的,对 CUDA 程序员意味着:

LLM 推理几乎用不到 texture——transformer 全是 1D 向量和 2D 矩阵的"按规则读",spatial locality 帮不上。但你看 stable diffusion 的 VAE decoder、ControlNet 的 image warping 仍然有 cudaTextureObject_t,知道存在即可。

// 用 cuda runtime 创建 texture object (现代 API, CUDA 5.0+)
    cudaResourceDesc resDesc = {};
    resDesc.resType = cudaResourceTypeLinear;
    resDesc.res.linear.devPtr = d_data;
    resDesc.res.linear.desc = cudaCreateChannelDesc<float>();
    resDesc.res.linear.sizeInBytes = N * sizeof(float);

    cudaTextureDesc texDesc = {};
    texDesc.readMode = cudaReadModeElementType;

    cudaTextureObject_t tex;
    cudaCreateTextureObject(&tex, &resDesc, &texDesc, nullptr);

    // kernel 内: tex1Dfetch<float>(tex, i);
    

Distributed Shared Memory (DSMEM) — Hopper 新增内存空间

5.5.1.1 Thread Block Cluster 概念

Hopper (sm_90) 在 grid → block → warp → thread 之间插了一层:cluster。一个 cluster = 2~16 个 thread block,它们保证被同时调度到同一个 GPC(GPU Processing Cluster,含若干物理相邻的 SM)上。同一 cluster 内的所有 block 可以:

  1. 互相访问对方的 shared memory(distributed shared memory, DSMEM)
  2. 共享同一个硬件 barrier(cluster-scope barrier)
  3. 通过 TMA multicast 一次性把 global memory 数据写进 cluster 内多个 block 的 shared
    graph TB
        subgraph GPC["GPC (GPU Processing Cluster, 含 8-16 个相邻 SM)"]
            subgraph C["Thread Block Cluster (sm_90+)"]
                B0["Block 0\nshared mem"]
                B1["Block 1\nshared mem"]
                B2["Block 2\nshared mem"]
                B3["Block 3\nshared mem"]
                B0 <--> B1
                B0 <--> B2
                B0 <--> B3
            end
        end
        style C fill:#f3f1e8,stroke:#8b1538
    

5.5.1.2 启动一个带 cluster 的 kernel

// kernel 声明: 用 __cluster_dims__ 编译期固定 cluster 形状
    __global__ void __cluster_dims__(2, 2, 1) cluster_kernel(/*...*/) {
        namespace cg = cooperative_groups;
        auto cluster = cg::this_cluster();
        auto block   = cg::this_thread_block();

        extern __shared__ float smem[];      // 本 block 的 shared
        smem[threadIdx.x] = some_value;
        cluster.sync();                       // 等 cluster 内所有 block 都填好

        // 拿到 cluster 内 block 1 的 shared mem 指针
        float* peer_smem = cluster.map_shared_rank(smem, /*peer_block_rank=*/1);
        float v = peer_smem[threadIdx.x];     // 直接读对面 block 的 shared!
    }

    // host: launch 时也要带 cluster 配置 (runtime API)
    cudaLaunchConfig_t config = {0};
    config.gridDim = dim3(8, 1, 1);
    config.blockDim = dim3(128, 1, 1);
    config.dynamicSmemBytes = 16384;

    cudaLaunchAttribute attrs[1];
    attrs[0].id = cudaLaunchAttributeClusterDimension;
    attrs[0].val.clusterDim = {2, 2, 1};      // 4-block cluster
    config.attrs = attrs;
    config.numAttrs = 1;

    cudaLaunchKernelEx(&config, cluster_kernel, /*args*/);
    

5.5.1.3 用什么场景?

5.5.1.4 查询当前 kernel 可支持的最大 cluster 大小

// 不同 SM 占用情况下能装下的最大 cluster
    int max_cluster_size;
    cudaOccupancyMaxPotentialClusterSize(
        &max_cluster_size, cluster_kernel, &config);

    // 同时能并发跑多少个该尺寸的 cluster
    int max_active_clusters;
    cudaOccupancyMaxActiveClusters(
        &max_active_clusters, cluster_kernel, &config);
    
⚠️ 限制:cluster 是 sm_90+ 独有;< sm_90 上 cluster.size() == 1,cluster API 自动退化为单 block。Portable cluster size 上限是 8,更大需要 cudaFuncAttributeNonPortableClusterSizeAllowed 显式打开(牺牲跨架构二进制兼容)。

cudaMemAdvise — Unified memory 性能调优三件套

5.8.3 的 prefetch 只是"把数据搬过去"。真正控制 unified memory 行为的是 cudaMemAdvise 提供的三种持久 hints,配合 prefetch 一起用:

Advise 常量含义典型场景
cudaMemAdviseSetReadMostly这块内存几乎只读,少写权重数据、embedding 表
cudaMemAdviseSetPreferredLocation偏好驻留在指定设备大模型权重钉在 GPU
cudaMemAdviseSetAccessedBy该设备会频繁访问 → 预先建立映射,避免 page fault多 GPU 读同一份数据

5.8.7.1 三种 hints 的实际效果

// 场景: 共享只读权重, CPU 偶尔更新, GPU 0/1 都要高速读
    float* w;
    size_t bytes = 4ULL * (1u << 30);   // 4 GB
    cudaMallocManaged(&w, bytes);
    init_on_host(w, bytes);

    // (1) 标记 "ReadMostly" → driver 会在每个 device 上各放一份只读副本
    cudaMemLocation gpu0 = {.type = cudaMemLocationTypeDevice, .id = 0};
    cudaMemAdvise(w, bytes, cudaMemAdviseSetReadMostly, gpu0);

    // (2) preferred location = GPU 0
    cudaMemAdvise(w, bytes, cudaMemAdviseSetPreferredLocation, gpu0);

    // (3) 告诉系统 GPU 1 也会读 → 预建映射
    cudaMemLocation gpu1 = {.type = cudaMemLocationTypeDevice, .id = 1};
    cudaMemAdvise(w, bytes, cudaMemAdviseSetAccessedBy, gpu1);

    // (4) 真正物理 prefetch 到 GPU 0
    cudaMemPrefetchAsync(w, bytes, gpu0, /*flags=*/0, stream);

    // 现在两张 GPU 都能高速读 w, 无 page fault
    my_kernel<<<..., stream>>>(w);
    

5.8.7.2 重要陷阱:避免 host 频繁写 GPU-resident 内存

PDF §4.1.1.2.8 强调一个反直觉:

如果 unified memory 物理上在 GPU 上,CPU 写它时会触发整个 cache line 先从 GPU 读到 CPU cache,CPU 写完再传回 GPU。频繁小量写效率极低。

正确做法:把要 host 频繁写的小变量 preferred location 设为 host,让 GPU 跨 NVLink-C2C 去读:

int* control_flag;       // 主机准备频繁更新的小标志
    cudaMallocManaged(&control_flag, sizeof(int));

    cudaMemLocation host = {.type = cudaMemLocationTypeHost};
    cudaMemAdvise(control_flag, sizeof(int),
                  cudaMemAdviseSetPreferredLocation, host);
    cudaMemAdvise(control_flag, sizeof(int),
                  cudaMemAdviseSetAccessedBy, host);

    for (int i = 0; i < N; ++i) {
        *control_flag = i;                                  // 直接写 CPU 内存, 快
        poll_kernel<<<...>>>(control_flag);                 // GPU 跨 C2C 读
        cudaDeviceSynchronize();
    }
    

5.8.7.3 查询当前内存范围的属性

// 查这块内存现在 preferred location 是谁
    cudaMemLocationType locType;
    cudaMemRangeGetAttribute(&locType, sizeof(locType),
                              cudaMemRangeAttributePreferredLocationType,
                              w, bytes);

    // 查上一次 prefetch 到的位置
    int last_loc;
    cudaMemRangeGetAttribute(&last_loc, sizeof(last_loc),
                              cudaMemRangeAttributeLastPrefetchLocationId,
                              w, bytes);
    
性能 hint 的 hint:PDF 反复强调"hint 只在能提升性能时使用——错用会更慢"。在 GH200 / GB200 上 hints 收益最大(NVLink-C2C 让 host-device 之间真正高速);在没有硬件一致性的旧平台上,hints 主要降低 page fault 频率,但抵不过 hint 自身的开销时反而拖累。先 measure,再 hint。

Pinned / mapped memory 完整 API

5.5 节给了三种分配的速查表。这里展开 host 端 page-locked memory 的细节——这是 H2D / D2H 异步 overlap、CPU-GPU 双工管道的基石。

5.8.8.1 三种创建 pinned memory 的 API

API分配新内存?典型用途
cudaMallocHost(&p, n)简单:分配 + 锁页一步到位
cudaHostAlloc(&p, n, flags)带 flags 的强化版(mapped / portable / write-combined)
cudaHostRegister(p, n, flags)已分配的内存(malloc / mmap 出来的)锁页

5.8.8.2 cudaHostAlloc 的 flags

void* p;

    // (1) 默认: 仅 host 可访问, 适合作 H2D async copy 的源
    cudaHostAlloc(&p, n, cudaHostAllocDefault);

    // (2) portable: 多 GPU context 都能看到 (默认只 alloc 时 current context 能看)
    cudaHostAlloc(&p, n, cudaHostAllocPortable);

    // (3) mapped: GPU 可以直接通过 PCIe 访问(zero-copy)
    //    适合: 单次小数据传输, 或 GPU 偶尔 poll host 标志
    //    Avoid on a bandwidth-critical path; accesses traverse the host link.
    cudaHostAlloc(&p, n, cudaHostAllocMapped);
    void* d_p;
    cudaHostGetDevicePointer(&d_p, p, 0);  // 拿到对应的设备端指针

    // (4) write-combined: 给 CPU 写、GPU 读的 buffer
    //    CPU 写性能高 (不走 cache, 直接 burst)
    //    但 CPU 读会奇慢 (cache miss + 走总线)
    cudaHostAlloc(&p, n, cudaHostAllocWriteCombined);

    // 组合: 异步源 + 多 device 可见 + write-combined
    cudaHostAlloc(&p, n,
        cudaHostAllocPortable | cudaHostAllocWriteCombined);
    

5.8.8.3 为什么 pinned 才能异步?

普通 malloc 出来的内存可能被 OS 换出到磁盘 (swap)。CUDA 做 H2D 的时候必须保证物理地址稳定,所以:

throughput 差别请在目标机器上测,尤其要记录 PCIe/NVLink 拓扑、NUMA 绑定、传输大小和是否与 kernel overlap:

方式带宽vs measured peak
cudaMemcpy + malloc 内存TODO(on GPU)TODO(on GPU)
cudaMemcpy + pinnedTODO(on GPU)TODO(on GPU)
cudaMemcpyAsync + pinned + 计算 overlapTODO(on GPU)TODO(on GPU)

5.8.8.4 pinned memory 的代价

慎用 cudaMallocHost 当成"更快的 malloc"
  • 占用真实物理内存(无法被 OS 换出),1 GB pinned = 1 GB 永久 commit
  • 分配 / 释放比普通 malloc 昂贵得多(要修改内核 page table)
  • 过多 pinned 会拖垮整个系统:OS 找不到可换页帧
生产做法:启动时一次性 cudaHostAlloc 一大块,自己做内部 sub-allocation

5.8.8.5 LLM 推理的典型 pinned 用法

// vLLM / TRT-LLM 风格: 启动时一次性分配 pinned staging buffer
    size_t staging_size = 256 * 1024 * 1024;   // 256 MB
    void* h_staging;
    cudaHostAlloc(&h_staging, staging_size, cudaHostAllocDefault);

    // 请求来时, 用户 prompt 先拷到 staging, 再异步推到 GPU
    memcpy(h_staging, user_prompt_tokens, n * sizeof(int));
    cudaMemcpyAsync(d_input, h_staging, n * sizeof(int),
                    cudaMemcpyHostToDevice, stream);
    prefill_kernel<<<..., stream>>>(d_input);

    // 同时另一个 stream 把上一轮的 output token 拷回 host
    cudaMemcpyAsync(h_staging_out, d_output, m * sizeof(int),
                    cudaMemcpyDeviceToHost, stream_out);
    

stream-ordered memory pool 内部机制

5.8.1 给了最朴素的 cudaMallocAsync。其底层是 memory pool,是真正决定显存碎片率的对象。深入掌握三件事:

  1. 每个 device 有一个 default memory pool,可以自己 create 显式 pool
  2. Pool 有 release threshold:低于此阈值的空闲内存不归还 OS,下次 malloc 直接复用
  3. Pool 有 reuse policy:决定 stream A free 的内存 stream B 能否立即用

5.8.9.1 设置 release threshold 减少 OS 调用

cudaMemPool_t pool;
    cudaDeviceGetDefaultMemPool(&pool, /*device=*/0);

    // 池中保留 20 GB 不归还 OS——下次 malloc 几乎 0 开销
    uint64_t threshold = 20ULL * (1u << 30);
    cudaMemPoolSetAttribute(pool,
        cudaMemPoolAttrReleaseThreshold, &threshold);

    // 实际用 cudaMallocAsync 时自动走 pool
    void* ptr;
    cudaMallocAsync(&ptr, bytes, stream);    // 复用池, 不调 cuMemMap
    cudaFreeAsync(ptr, stream);               // 还给池
    

5.8.9.2 三种 reuse policy

Policy含义风险
cudaMemPoolReuseFollowEventDependencies仅当目标 stream 已经显式 wait 过 free stream 的 event 才重用最安全, 但有时拒绝可重用的释放
cudaMemPoolReuseAllowOpportunistic如果释放已实际完成(query 过),就重用引入run-to-run 不确定性(timing 决定)
cudaMemPoolReuseAllowInternalDependenciesdriver 自动插同步等待释放完成再重用可能序列化本来独立的 stream
int enable = 1;
    cudaMemPoolSetAttribute(pool,
        cudaMemPoolReuseAllowOpportunistic, &enable);
    

5.8.9.3 跨进程共享 pool(IPC)

vLLM 多进程架构里,每个 worker 一个 process,但共享同一显存池可省 N-1 份 reserve:

// 进程 A: 创建 IPC-enabled pool
    cudaMemPoolProps props = {};
    props.allocType = cudaMemAllocationTypePinned;
    props.location.type = cudaMemLocationTypeDevice;
    props.location.id = 0;
    props.handleTypes = cudaMemHandleTypePosixFileDescriptor;

    cudaMemPool_t pool;
    cudaMemPoolCreate(&pool, &props);

    int fd;
    cudaMemPoolExportToShareableHandle(&fd, pool,
        cudaMemHandleTypePosixFileDescriptor, 0);
    // 通过 UNIX domain socket 把 fd 发给进程 B...

    // 进程 B: 导入
    cudaMemPool_t imp_pool;
    cudaMemPoolImportFromShareableHandle(&imp_pool, (void*)&fd,
        cudaMemHandleTypePosixFileDescriptor, 0);
    

5.8.9.4 监控 pool 占用

uint64_t reserved, used, reserved_high, used_high;
    cudaMemPoolGetAttribute(pool, cudaMemPoolAttrReservedMemCurrent, &reserved);
    cudaMemPoolGetAttribute(pool, cudaMemPoolAttrUsedMemCurrent,     &used);
    cudaMemPoolGetAttribute(pool, cudaMemPoolAttrReservedMemHigh,    &reserved_high);
    cudaMemPoolGetAttribute(pool, cudaMemPoolAttrUsedMemHigh,        &used_high);

    printf("pool reserved=%.1f GB, in-use=%.1f GB, peak=%.1f GB\n",
           reserved / 1e9, used / 1e9, reserved_high / 1e9);

    // reset 高水位计
    uint64_t zero = 0;
    cudaMemPoolSetAttribute(pool, cudaMemPoolAttrReservedMemHigh, &zero);
    
生产配置建议(vLLM / TRT-LLM 验证过):
  • 显式 cudaMemPoolAttrReleaseThreshold = 显存上限 - 1 GB(永不还 OS)
  • cudaMemPoolReuseAllowOpportunistic(避免 run-to-run timing 抖动影响 latency p99)
  • 启动时大块 cudaMallocAsync 一次到位填满 pool,避免推理高峰时 driver 临时找页

下一章导览

第 6 章把 shared memory 组织成 tile,用数据复用把朴素矩阵乘推向高吞吐实现。