2、内存管理基础:虚拟内存、物理内存、页表、TLB、内存碎片等核心概念
大模型推理与训练过程中的显存(GPU Memory)或内存(CPU RAM)管理,本质上是对操作系统与硬件内存管理机制的深度应用。无论是使用 PyTorch 的 caching allocator,还是手动管理 KV Cache 的显存池,其底层都绕不开虚拟内存、物理内存、页表、TLB 以及内存碎片这些核心概念。理解这些基础机制,是后续进行内存池设计、显存碎片优化和调试的前提。本章将系统梳理这些概念,并重点剖析它们在大模型部署场景下的具体表现与影响。
2.1 虚拟内存与物理内存:大模型地址空间的基石
核心原理:虚拟内存是操作系统为每个进程提供的独立、连续、逻辑上的地址空间抽象。对于大模型推理服务而言,进程看到的地址空间(如 PyTorch 中 tensor.data_ptr() 返回的地址)是虚拟地址,而非真实的硬件地址。物理内存(GPU 上的 HBM 或 CPU 的 DRAM)则是实际存储数据的硬件资源。操作系统通过页表将虚拟地址动态映射到物理地址。这种隔离机制带来的关键优势是:进程间内存隔离(一个模型的崩溃不会直接拖垮另一个模型)和超量分配(虚拟内存可以大于物理内存,按需换入换出)。
大模型部署中的表现:当加载一个 70B 参数的模型时,其权重文件约 140GB(FP16)。在 GPU 上,CUDA 驱动会为进程创建虚拟地址空间,并建立页表映射。但实际物理显存可能只有 80GB,此时若开启 Unified Memory 或使用 CPU offload,虚拟内存机制允许程序“假装”拥有全部 140GB 地址空间,实际数据在 GPU 显存和 CPU 内存之间按页迁移。理解这一点,有助于解释为何有时 `nvidia-smi` 显示的显存占用远小于模型文件大小——因为部分页尚未被访问,尚未映射到物理显存。
2.2 页表与 TLB:地址翻译的性能瓶颈
核心原理:页表是虚拟地址到物理地址的映射表。现代 64 位系统使用 4 级页表(如 x86-64 的 PGD/PUD/PMD/PTE),每次访问内存都需要 4 次额外的内存读取来完成地址翻译。为了加速这一过程,CPU/GPU 内部设计了 TLB(Translation Lookaside Buffer),即页表项的缓存。TLB 命中时,地址翻译几乎零开销;TLB 未命中时,需要遍历多级页表,产生数十纳秒的延迟。
大模型场景的挑战:大模型推理时的内存访问模式具有稀疏性和大跨度特点。例如,KV Cache 的访问通常集中在最近生成的 token 对应的内存区域,但 Attention 计算需要随机访问所有历史 token 的 KV 块。如果页表映射的粒度是 4KB,那么一个 8MB 的 KV Cache 块就需要 2048 个页表项,极易导致 TLB 未命中。为此,CUDA 和最新操作系统引入了大页(Huge Pages)机制,如 2MB 或 1GB 页。使用大页可以将页表项数量减少 512 倍,显著提升 TLB 命中率。
| 页大小 | 页表项数量 (8MB 区域) | TLB 覆盖范围 | 适用场景 |
|---|---|---|---|
| 4KB | 2048 | 约 512KB (典型 TLB 64项) | 通用场景,碎片小 |
| 2MB | 4 | 约 128MB | KV Cache、权重加载 |
| 1GB | 1 | 约 64GB | 超大模型权重驻留 |
操作流程:在 PyTorch 中启用大页支持,通常需要设置环境变量 `PYTORCH_CUDA_ALLOC_CONF=expandable_segments:True`,该配置允许 CUDA 分配器使用更大的虚拟内存段(类似大页效果)。对于 CPU 场景,可以通过 `libhugetlbfs` 或 `mmap` 的 `MAP_HUGETLB` 标志来分配大页内存。
2.3 内存碎片:显存池管理的头号敌人
核心原理:内存碎片分为外部碎片和内部碎片。外部碎片指空闲内存块不连续,导致无法满足大块连续内存请求;内部碎片指分配器返回的内存块大于请求大小,造成浪费。在大模型推理中,外部碎片尤为严重。由于 Attention 计算的 KV Cache 大小随序列长度动态变化,频繁的申请和释放不同大小的内存块,会在显存池中留下大量“空洞”。
案例拆解:假设一个推理服务同时处理 4 个请求,显存池初始有 40GB 连续空闲。请求 A 申请 10GB,请求 B 申请 8GB,请求 C 申请 12GB,请求 D 申请 10GB。此时显存池被完全占用。当请求 B 完成并释放 8GB 后,请求 E 需要 9GB 的 KV Cache。由于 8GB 空洞小于 9GB,分配失败,即使总空闲内存仍有 8GB。这就是典型的外部碎片问题。
# 模拟显存碎片化场景
import torch
# 模拟分配 4 个块
blocks = []
for size in [10, 8, 12, 10]:
blocks.append(torch.empty(size * 1024**3, dtype=torch.float16, device='cuda'))
# 释放中间块
del blocks[1]
torch.cuda.empty_cache()
# 尝试分配 9GB 块 - 可能失败或触发碎片整理
try:
new_block = torch.empty(9 * 1024**3, dtype=torch.float16, device='cuda')
print("分配成功")
except torch.cuda.OutOfMemoryError:
print("分配失败:外部碎片导致")
解决方案与复盘:业界主流方案是内存池分块管理(如 vLLM 的 PagedAttention)。其核心思想是将 KV Cache 划分为固定大小的块(如 16KB/块),通过页表(块表)将不连续的物理块映射为连续的虚拟地址。这样,即使物理块分散,逻辑上依然连续,彻底消除了外部碎片。内部碎片则通过调整块大小来控制——块越小,碎片越少,但页表项越多,TLB 压力越大。
torch.cuda.memory_snapshot() 分析显存池状态,识别碎片化程度。
2.4 常见误区与总结复盘
误区一:认为显存占用等于模型参数大小。实际上,推理时的显存占用还包括激活值、梯度(训练时)、优化器状态(训练时)以及 CUDA 上下文(约 500MB-1GB)。误区二:盲目使用 `torch.cuda.empty_cache()`。该操作会释放缓存块,但代价是后续分配需要重新向驱动申请,增加延迟。在推理服务中,应避免频繁调用。误区三:忽略 TLB 性能。在 CPU 上做模型推理时,如果内存访问模式导致 TLB 频繁未命中,性能可能下降 20%-30%。
总结复盘:内存管理基础决定了上层内存池设计的边界。虚拟内存提供了灵活性,页表和 TLB 决定了访问效率,碎片化则直接影响显存利用率。在实际调试中,建议按以下顺序排查:先看物理显存是否充足(`nvidia-smi`),再看进程内部分配情况(`torch.cuda.memory_summary()`),最后分析碎片化程度(`memory_snapshot` 可视化)。只有将这三层机制理解透彻,才能设计出高效、稳定的大模型部署内存池。