模型训练和推理时,很多人第一次看到nvidia-smi的输出都会愣一下:显存占用 22GB,但系统内存也吃掉了 40 多个 GB,明明模型文件才十几个 GB,这些数字到底是怎么算出来的?更让人困惑的是,同一份代码在同事的机器上跑得好好的,换到自己的环境就报CUDA out of memory,可任务管理器里内存还剩一大半。这类问题几乎每个算法工程师都遇到过,根源就在于对 CPU 内存和 GPU 显存的分工、容量计算、以及数据在两者之间的流转过程缺乏一个完整的认知框架。
这篇文章面向刚入行或正在从传统软件开发转向模型训练的工程师,也会覆盖一些已经能跑通训练、但遇到 OOM 只会调小 batch size 的读者。我会从存储层级的基本概念讲起,把模型参数、梯度、优化器状态、激活值、临时缓冲区这些占用项逐一拆开算清楚,再解释数据是怎么从磁盘到内存再到显存的,最后给出几套实测有效的省显存和省内存方案。读完之后,你应该能对着自己的模型和硬件配置,大致估算出需要多大的显存和内存,而不是靠反复试错。
1. 先把存储层级理清楚:从磁盘到寄存器的完整链路
1.1 为什么不能把模型直接放在显存里跑
刚接触深度学习的人常有一个直觉:既然 GPU 算得快,那把模型全部塞进显存不就行了?这个想法在模型只有几百 MB 的时候勉强成立,但一旦参数量上到十亿级别,就会撞上物理限制。显存(VRAM)是 GPU 板载的专用内存,通过高带宽总线与 GPU 核心相连,带宽通常在几百 GB/s 到数 TB/s 之间,但容量有限,消费级显卡常见 8GB、12GB、16GB、24GB,专业卡能到 48GB 甚至 80GB。而系统内存(RAM)容量通常是 16GB 起步,服务器上 128GB、256GB 很常见,但 CPU 和内存之间的带宽只有几十 GB/s,比显存带宽低一个数量级。
这就形成了一个经典的层级结构:磁盘容量最大但最慢,内存居中,显存最快但最小,寄存器最快但只有几十 KB。模型训练的本质,就是让数据在这个层级之间高效流动,同时把计算密集的部分放在最快的层级上执行。GPU 负责矩阵乘法、卷积这类并行度极高的运算,CPU 负责数据加载、预处理、调度、以及那些不适合并行的逻辑。两者各司其职,谁也替代不了谁。
理解这一点之后,很多现象就说得通了。比如为什么数据加载器(DataLoader)的num_workers设置会影响训练速度——因为数据预处理是在 CPU 上做的,worker 太少会导致 GPU 等数据,worker 太多又会争抢内存和 CPU 资源。再比如为什么混合精度训练能省显存——因为 FP16 只占 FP32 一半的字节数,同样的显存能放下更多数据。
1.2 显存里到底住了哪些东西
要估算显存需求,得先知道显存被谁占了。一个典型的训练任务,显存占用可以拆成以下几块:
- 模型参数:这是最直观的部分。参数量乘以每个参数的字节数就是参数占用的显存。FP32 每个参数 4 字节,FP16 是 2 字节,INT8 是 1 字节。一个 7B 参数的模型,FP32 下光参数就要 28GB,FP16 下 14GB。
- 梯度:反向传播需要存储每个参数的梯度,通常和参数同精度同大小。FP32 训练时梯度也占 4 字节每参数。
- 优化器状态:这是最容易被低估的部分。以 Adam 为例,它需要为每个参数维护一阶矩估计和二阶矩估计,各占一份参数大小的空间,再加上可能的 FP32 主权重副本。所以 Adam 的优化器状态通常是参数量的 2 到 3 倍。
- 激活值:前向传播过程中每一层的输出都需要保存下来供反向传播使用。这部分和 batch size、序列长度、网络深度强相关,往往是 OOM 的元凶。
- 临时缓冲区:cuDNN、cuBLAS 等库在执行算子时会申请工作空间,大小取决于算子类型和配置。
- CUDA 上下文:CUDA runtime 本身会占用几百 MB 显存,这部分是固定开销。
把这些加起来,一个 7B 模型用 Adam 做 FP32 全量微调,显存需求轻松超过 100GB,这就是为什么单卡很难直接微调大模型,必须借助各种并行和优化技术。
1.3 内存的角色:不只是"中转站"
很多人把内存当成单纯的缓存,觉得数据从磁盘读进来,马上就会被送到显存,内存大不大无所谓。这个理解是片面的。内存在实际训练中承担了好几个关键职责:
第一,它是数据预处理的场所。图像解码、文本分词、数据增强这些操作都在 CPU 上完成,处理好的张量先放在内存里,再由 DataLoader 搬运到显存。如果内存不够,数据加载就会变慢甚至失败。
第二,它是模型加载的必经之路。从磁盘读取的模型权重文件先加载到内存,再逐层拷贝到显存。加载一个几十 GB 的模型时,内存峰值占用可能比显存还高。
第三,它承载了那些不需要上 GPU 的状态。比如训练日志、指标统计、检查点保存时的序列化缓冲区,都在内存里。
第四,当显存不够时,一些框架支持把部分参数或优化器状态卸载(offload)到内存,用时间换空间。DeepSpeed 的 ZeRO-Offload、PyTorch 的 CPU offload 都是这个思路。
所以内存和显存不是简单的上下游关系,而是协同工作的两个层级。配置机器时,内存容量和显存容量要匹配,不能一个特别大一个特别小。
2. 参数量、精度与显存占用的换算逻辑
2.1 从参数量到字节数的计算
估算显存的第一步是算清楚模型有多少参数。对于 Transformer 类模型,参数量可以按以下方式粗算:
- 嵌入层:词表大小 × 隐藏维度
- 每层注意力:4 × 隐藏维度²(Q、K、V、O 四个投影矩阵)
- 每层前馈网络:通常 8 × 隐藏维度²(两个线性层,中间维度是隐藏维度的 4 倍)
- 层归一化等:相对较小,可忽略
以一个隐藏维度 4096、层数 32、词表 32000 的模型为例,嵌入层约 1.3 亿参数,每层约 2 亿参数,32 层约 67 亿参数,总计约 68 亿参数,也就是常说的 7B 级别。
算完参数量,乘以精度对应的字节数就得到基础占用。但要注意,训练时的总占用远不止参数本身。下面这张表列出了不同训练方式下,每参数大致需要的显存字节数:
| 训练方式 | 参数 | 梯度 | 优化器状态 | 每参数合计 |
|---|---|---|---|---|
| FP32 全量微调 + SGD | 4 | 4 | 0 | 8 |
| FP32 全量微调 + Adam | 4 | 4 | 8 | 16 |
| FP16 混合精度 + Adam | 2 | 2 | 12 | 16 |
| FP16 + Adam + 主权重 FP32 | 2 | 2 | 12 | 16 |
可以看到,Adam 优化器让显存需求翻倍甚至更多。这也是为什么很多大模型训练会采用 Adafactor、Lion 等更省内存的优化器,或者用 8-bit Adam 这类量化优化器状态的方法。
2.2 混合精度为什么能省显存又不掉精度
混合精度训练的核心思路是:前向和反向传播用 FP16(或 BF16)计算,因为 GPU 对半精度的计算吞吐更高,同时激活值和梯度只占一半空间;但参数的主副本用 FP32 保存,避免更新时精度损失累积。
具体来说,PyTorch 的 AMP(自动混合精度)会维护两份权重:一份 FP16 用于计算,一份 FP32 用于更新。优化器状态也是 FP32 的。所以显存占用并没有简单减半,而是参数和激活省了一半,优化器状态没省。实测下来,混合精度通常能省 30% 到 40% 的显存,同时训练速度提升 1.5 到 2 倍。
BF16 相比 FP16 的优势在于动态范围更大,不容易出现梯度下溢,训练更稳定,但需要硬件支持(Ampere 架构及以后的 GPU)。如果你的卡支持 BF16,优先用它。
2.3 激活值:最容易被忽视的显存大户
激活值占用和 batch size、序列长度、隐藏维度、层数都成正比。以 Transformer 为例,每层需要保存的激活包括注意力矩阵、前馈网络的中间输出等。粗略估算,激活值占用约为:
batch_size × seq_len × hidden_dim × num_layers × 系数
这个系数取决于具体实现,通常在 10 到 20 之间。当序列长度从 512 增加到 2048 时,激活值占用会翻四倍,这就是长文本训练特别吃显存的原因。
梯度检查点(gradient checkpointing)是应对激活值爆炸的常用手段。它的思路是不保存所有中间激活,只保存少数几个检查点,反向传播时重新计算缺失的激活。这样显存占用从 O(n) 降到 O(√n),代价是增加约 30% 的计算量。对于显存紧张但算力有余的场景,这是非常划算的交换。
3. 数据搬运的完整路径与常见瓶颈
3.1 从磁盘到显存的四段旅程
一个训练样本从磁盘到参与计算,要经过四段搬运:
- 磁盘到内存:数据加载器读取文件,解码成张量。这一步受磁盘 IO 速度限制,机械硬盘是瓶颈,SSD 会好很多。
- 内存内的预处理:数据增强、归一化、分词等操作在 CPU 上完成。这一步受 CPU 核心数和内存带宽限制。
- 内存到显存:通过 PCIe 总线拷贝,PCIe 4.0 x16 的带宽约 32GB/s,PCIe 5.0 翻倍。这一步是很多训练管道的瓶颈。
- 显存内的计算:GPU 核心执行算子,数据在显存和寄存器、共享内存之间流动。
这四段中,任何一段成为瓶颈,GPU 都会处于等待状态,利用率上不去。用nvidia-smi看 GPU 利用率,如果长期低于 80%,就要检查是不是数据加载或传输拖了后腿。
3.2 固定内存与异步拷贝
PyTorch 的 DataLoader 有一个pin_memory参数,开启后会把数据放在固定内存(pinned memory)里。固定内存不会被操作系统换出到磁盘,GPU 可以直接通过 DMA 访问,拷贝速度比普通内存快很多,而且支持异步传输。
配合non_blocking=True,可以在 GPU 计算当前 batch 的同时,把下一个 batch 的数据拷贝到显存,实现计算和传输的重叠。这个技巧在数据加载较慢时效果明显,实测能提升 10% 到 30% 的吞吐。
但要注意,固定内存是有限资源,开太多 worker 且都开 pin_memory 可能导致内存耗尽。一般建议 pin_memory 配合 4 到 8 个 worker 使用。
3.3 显存碎片:为什么释放了还是 OOM
有时候你会遇到这种情况:明明用del删除了大张量,torch.cuda.empty_cache()也调用了,但再申请显存还是失败。这通常是显存碎片导致的。
PyTorch 的 CUDA 缓存分配器会缓存已释放的显存块,以便后续复用。但如果申请的大小和缓存块不匹配,就会出现"总量够但连续空间不够"的情况。解决办法有:
- 设置环境变量
PYTORCH_CUDA_ALLOC_CONF=expandable_segments:True,让分配器使用可扩展段,减少碎片。 - 尽量避免频繁创建和销毁不同大小的张量。
- 在训练循环中保持 batch size 稳定,不要忽大忽小。
这个坑我在实际项目中踩过好几次,尤其是做变长序列训练时,不同 batch 的序列长度差异大,显存占用波动剧烈,很容易碎片化。后来统一 padding 到固定长度,问题就缓解了。
4. 显存不够时的排查顺序与实战方案
4.1 先定位是谁在吃显存
遇到 OOM,不要急着改代码,先搞清楚显存被谁占了。推荐按以下顺序排查:
- 用
nvidia-smi看整体占用,确认是训练进程占的,还是其他进程残留。 - 在代码里用
torch.cuda.memory_summary()打印详细分配情况,能看到已分配、已缓存、峰值等数据。 - 用
torch.cuda.memory_allocated()和torch.cuda.max_memory_allocated()在训练循环中打点,找出显存增长发生在哪一步。 - 如果是加载模型时就 OOM,检查是不是加载了 FP32 权重后又转 FP16,导致峰值翻倍。
我见过最常见的情况是:模型本身不大,但数据加载时把整个数据集读进了内存,然后又试图全部搬到显存,结果直接爆掉。这种问题看memory_summary一眼就能发现。
4.2 梯度累积:用小 batch 模拟大 batch
当显存装不下想要的 batch size 时,梯度累积是最简单的方案。思路是:用小的 micro-batch 跑多次前向和反向,累积梯度,等累积够了再更新一次参数。这样等效于大 batch,但显存占用只和小 batch 相关。
accumulation_steps = 4 optimizer.zero_grad() for i, (inputs, labels) in enumerate(dataloader): outputs = model(inputs) loss = criterion(outputs, labels) / accumulation_steps loss.backward() if (i + 1) % accumulation_steps == 0: optimizer.step() optimizer.zero_grad()注意 loss 要除以累积步数,否则梯度会放大。另外,如果模型里有 BatchNorm,梯度累积会改变统计量的计算方式,需要特别处理。
4.3 梯度检查点与激活重计算
前面提到过梯度检查点,这里给出具体用法。PyTorch 提供了torch.utils.checkpoint:
from torch.utils.checkpoint import checkpoint def forward(self, x): x = checkpoint(self.layer1, x) x = checkpoint(self.layer2, x) return x对于 Hugging Face 的 Transformer 模型,通常只需要设置model.gradient_checkpointing_enable()即可。实测在 7B 模型上,开启后显存从 40GB 降到 25GB 左右,训练速度下降约 25%。这个交换在显存紧张时非常值得。
4.4 优化器状态分片与 CPU 卸载
如果单卡显存实在不够,可以考虑把优化器状态分片到多卡,或者卸载到内存。DeepSpeed 的 ZeRO 系列是这方面的成熟方案:
- ZeRO-1:分片优化器状态
- ZeRO-2:分片优化器状态和梯度
- ZeRO-3:分片优化器状态、梯度和参数
配合 ZeRO-Offload,可以把优化器状态和梯度放到内存,GPU 只保留计算所需的部分。这样单卡也能微调大模型,代价是通信开销增加,训练变慢。
选择哪个级别,取决于你的瓶颈在哪。如果显存只差一点,ZeRO-1 就够;如果差很多,上 ZeRO-3 加 offload。但要注意,分片级别越高,通信越频繁,对卡间带宽要求越高。
4.5 量化:用精度换空间
量化是另一条路。把模型权重从 FP16 量化到 INT8 或 INT4,显存占用直接减半或降到四分之一。QLoRA 就是典型代表:把基础模型量化到 4-bit,只训练低秩适配器(LoRA),这样 7B 模型在 6GB 显存上就能微调。
量化的代价是精度损失,但对于微调任务,LoRA 本身只更新少量参数,基础模型的量化误差影响有限。实测 QLoRA 在多数任务上能达到全量微调 95% 以上的效果。
不过量化对硬件有要求,INT4 需要特定的算子支持,老卡可能跑不了。选型时要确认自己的 GPU 架构是否支持。
5. 内存侧的优化:别让 CPU 拖了后腿
5.1 内存占用的构成与监控
训练进程的内存占用主要包括:Python 解释器和库本身的开销(几百 MB 到几 GB)、数据集缓存、数据加载 worker 的副本、模型加载时的临时缓冲、以及框架的运行时状态。
监控内存用psutil或系统的top、htop都行。重点看 RSS(常驻内存)和峰值。如果内存持续增长不释放,可能是数据加载器里有引用泄漏,或者缓存没设上限。
PyTorch 的 DataLoader 如果num_workers > 0,每个 worker 都会复制一份数据集对象。如果数据集本身很大(比如加载了全部图片到内存),worker 数量一多,内存就爆了。解决办法是用惰性加载,只在__getitem__里读取当前样本,而不是在__init__里加载全部。
5.2 数据加载的常见内存陷阱
几个我实际踩过的坑:
- 在
__init__里加载全部数据:这是最常见的错误。正确做法是只存文件路径,在__getitem__里按需读取。 - worker 数量设置过大:每个 worker 都有独立的内存空间,8 个 worker 就是 8 份开销。一般设为 CPU 核心数的一半到相等即可。
- 缓存没有上限:有些实现会用字典缓存已处理的数据,如果不设上限,内存会一直涨。可以用
functools.lru_cache限制缓存大小。 - 大 batch 的预处理结果堆积:如果预处理比 GPU 计算快很多,数据会在内存里排队。可以通过限制预取数量(
prefetch_factor)来控制。
5.3 内存与显存的协同配置建议
配置机器时,内存和显存的配比有个经验值:内存容量建议是显存的 2 到 4 倍。比如单卡 24GB 显存,配 64GB 到 128GB 内存比较合理。如果要做 CPU offload,内存还要更大,可能需要显存的 6 到 8 倍。
另外,内存频率和通道数也影响数据加载速度。双通道比单通道带宽翻倍,对数据密集型任务有明显提升。如果主板支持四通道,尽量插满。
6. 几个真实场景的配置复盘
6.1 单卡 12GB 微调 7B 模型
这是我帮朋友配过的一套方案,硬件是 RTX 3060 12GB,目标是在小数据集上微调 7B 模型。直接全量微调肯定不行,最终方案是 QLoRA:
- 基础模型 4-bit 量化加载,占用约 4GB
- LoRA 适配器参数量很小,可忽略
- 优化器只更新 LoRA 参数,状态占用几百 MB
- batch size 设为 1,梯度累积 16 步
- 开启梯度检查点
实测显存峰值约 9GB,训练速度约每秒 2 到 3 个样本。虽然慢,但能跑通,对于实验性质的任务足够了。
6.2 双卡 24GB 全量微调 13B 模型
另一套配置是两张 RTX 3090 24GB,微调 13B 模型。用 DeepSpeed ZeRO-2 加混合精度:
- 参数 FP16 约 26GB,分片到两张卡各 13GB
- 梯度同样分片
- 优化器状态用 8-bit Adam,进一步压缩
- batch size 每卡 2,梯度累积 8 步
显存峰值每卡约 20GB,留有一定余量。训练吞吐比单卡快接近两倍,通信开销在可接受范围内。
6.3 推理场景的显存估算
推理比训练省显存,因为没有梯度和优化器状态。但要注意 KV Cache 的占用,尤其是长文本生成时。KV Cache 大小约为:
2 × batch_size × seq_len × hidden_dim × num_layers × 精度字节数
一个 7B 模型,序列长度 2048,batch size 1,FP16 下 KV Cache 约 1GB 多。如果 batch size 开到 16,就是 16GB 以上,很容易爆。所以推理时 batch size 和序列长度要权衡。
7. 给不同阶段工程师的实用建议
7.1 新手最容易犯的三个错误
第一,不看硬件就抄配置。别人的 batch size 是 64,你直接抄过来,结果 OOM。一定要根据自己的显存调整。
第二,忽略 CUDA 上下文开销。以为 8GB 显存能放 7.5GB 的模型,实际上 CUDA 上下文和框架本身就要占 1GB 左右,根本放不下。
第三,不监控就调参。改一个参数跑一次,失败了再改,效率极低。应该先用小规模数据跑通,打印显存和内存占用,建立基线,再有针对性地优化。
7.2 进阶工程师的优化清单
如果你已经能跑通训练,想进一步提升效率,可以按这个清单逐项检查:
- 是否开启了混合精度
- 是否使用了固定内存和异步拷贝
- DataLoader 的 worker 数和预取是否合理
- 是否有不必要的张量保留在计算图中
- 优化器是否可以用更省内存的替代品
- 是否可以用梯度检查点换取更大 batch
- 多卡场景下通信是否成为瓶颈
每一项优化前,先测量当前状态,优化后再测量,用数据说话,不要凭感觉。
7.3 硬件选型的取舍
最后聊聊选型。显存容量比算力更重要,因为算力不够可以等,显存不够直接跑不了。同价位下,优先选显存大的卡。内存方面,容量优先于频率,但双通道是底线。存储用 NVMe SSD,数据加载速度对训练效率影响很大。
如果预算有限,可以考虑租用云 GPU 做实验,本地配一台中等配置做开发调试。这样既能跑大模型,又不用一次性投入太多。
显存和内存的管理,说到底是对资源约束的理解和权衡。没有万能的配置,只有适合当前任务和硬件的方案。多动手测量,多记录数据,慢慢就会形成自己的判断。我在实际项目中最深的体会是:与其花时间调各种参数,不如先把显存和内存的账算清楚,知道每一 GB 花在哪里,优化起来才有方向。