第 5 章 · 内存层级

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

学习目标

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(T4 典型):

Pattern时间有效带宽vs peak (~300 GB/s)
coalesced (stride=1)0.85 ms~158 GB/s~50%
strided 21.42 ms~94 GB/s~30%
strided 83.91 ms~34 GB/s~11%
strided 3214.0 ms~9 GB/s~3%
影响有多大? stride=32 时实际带宽掉到 1/16。这就是为什么 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 实测三种差距。结论:

5.6 自检

Q1: 我看到 Nsight 报告 "Memory Throughput 90%",但只跑出理论带宽的 30%,怎么回事?

Nsight 报的 % 是相对你当前 kernel 内存子系统的利用率,不是相对硬件 peak。Roofline 视图才能看出离 peak 多远。常见原因:访问 strided(事务多)、L2 命中差(重复访问相隔太远)。

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

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

Q3: 为什么 stride=2 还能跑 ~94 GB/s,没掉一半?

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

Q4: 寄存器太多会怎样?

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

Q5: HBM 和 GDDR 啥区别?

都是显存。HBM 用 3D 堆叠 + 宽位 (1024-8192 bit),带宽超高 (1-3 TB/s),数据中心卡 (A100/H100/MI300) 用。GDDR (GDDR6/6X/7) 位宽 256-384 bit,带宽 500-1000 GB/s,消费级 (RTX 30/40/50) 用。架构差距决定了为什么 H100 推理吞吐远高于 RTX 4090。

5.7 练习

  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);

典型加速:在 GEMM mainloop 里配 double-buffer,能让 HBM 加载和 compute 完全 overlap,整体 +20-40%。

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
192 GB (B200)"] H["Host RAM
~50000 cycles via PCIe / NVLink-C2C"] R --> T --> S --> L2 --> G --> H style T fill:#f3f1e8,stroke:#a86420

TMEM 的特殊性:

普通 CUDA 开发者:通过 CUTLASS 3.5+ 自动使用,不需要直接写。

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

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

算法思路显存节省精度损失
KV cache 量化 (KIVI, KV-quant)fp16 → int4 / int24-8×< 0.5 PPL
Token 选择性丢弃 (H2O, SnapKV, StreamingLLM)只留"重要"或"最近" token 的 KV2-10×0.5-2 PPL
层间共享 (CLA, You Only Cache Once)多层共享同一个 KV2-4×需重训
Multi-head Latent Attn (MLA) (DeepSeek-V2/V3)Q/K/V 投影到低维潜空间~7× (DeepSeek 实测)无损 / 改进
分页 + 前缀共享 (PagedAttention + RadixAttention)多请求共享公共 prompt 的 KV2-5×(依赖请求)无损

实战组合:vLLM 默认用 PagedAttention + 可选 fp8/int4 KV 量化;SGLang 加上 RadixAttention;DeepSeek-V3 / V2 用 MLA + paged + fp8。三者叠加把 Llama-7B 长 context 推理的 KV 显存从 GB 级降到 100 MB 级。

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

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

5.9.4 NVLink-C2C 与 unified memory 复活

过去 unified memory 在生产中很少用(page fault 慢)。Grace Hopper (GH200) 和 Grace Blackwell (GB200) 用 NVLink-C2C 900 GB/s 把 CPU 和 GPU 内存接成一体,让 unified memory 性能逼近本地显存:

// 在 GH200 上, managed memory 不再是性能反模式
float* w; cudaMallocManaged(&w, 600 * (1u << 30));   // 600 GB 模型权重
// w 落在 Grace LPDDR5X (480 GB), 由 NVLink-C2C 让 GPU 访问
// 比起 fp4 量化, 这种"显存外溢"对精度敏感模型更友好

典型用例:DeepSeek-V3 671B 在 GH200 上推理,权重一部分驻留 GPU HBM,一部分常驻 Grace LPDDR5X,按需走 C2C。

5.9.5 显存优化的 2026 工业组合

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

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

效果: 单卡 (80GB HBM) 服务 70B fp4 模型, 同时 batch=64 并发

5.9.6 CUDA 12.6+ 内存子系统改进

5.10 常见坑