尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

显存省了GPU反而更闲?LLM微调吞吐调优实战指南

显存省了GPU反而更闲?LLM微调吞吐调优实战指南 如果你在显存优化这条路上已经走到“梯度检查点、混合精度、ZeRO全都开了显存终于从 OOM 降到 14G”然后盯着nvidia-smi里不到 60% 的 GPU 利用率发愁——那我们是掉进同一个坑里了。这一篇 LLM Training Lab 第 10 期我本来只想记录一次 7B 模型微调的吞吐调优实验结果发现结论比预想的更有意思显存省下来了GPU 反而更闲了。这本质上是两笔账显存是“空间账本”GPU 利用率是“时间流水账”。绝大多数显存优化手段都不是免费拿到显存而是用一种资源换另一种资源。如果换进来的东西重计算、通信、PCIe 搬运在时间线上不能和计算重叠你的 GPU 就会空转。这篇文章我会先拆解每种常见省显存方案到底向 GPU 收了什么费再讲怎么用工具把“吃不满”拆成可诊断的层次然后逐个聊 CPU 数据管线、多卡通信、算子级瓶颈最后给出一份可以照抄的调参清单。适合正在做单卡或多卡 LLM 微调、而且既想压显存又想保住吞吐的朋友。1. 科学记账显存优化方案到底在向 GPU 收取什么费用很多人把显存优化当成“免费午餐”其实每一条路背后都有明码标价的额外开销。把这些账算清楚了才能解释为什么显存省下来之后 GPU 反而更容易闲。1.1 梯度检查点显存少一半计算多三成梯度检查点activation checkpointing的原理很粗暴前向传播时不再保存每一层中间激活值只保留输入反向传播需要某一层的激活值时再把输入拿出来重跑一次前向重新算一遍。这个方案的显存收益立竿见影。我在 4090 上用一个 2.8B 模型做实验开 checkpoint 之后显存占用从 18G 掉到 11G直觉上应该“轻松多了”。但训练吞吐从 1.4k tokens/s 降到了 1.1k tokens/s。原因有两层一次 step 里增加了约 1/3 的计算量。前向传播本身多算一遍虽然这一遍不保存激活、也不做 dropout但矩阵乘法确确实实多执行了一次。重算的 kernel 粒度比正常前向更碎。反向传播过程中每个被重新算出的激活都要回到计算流里等待后续算子GPU 的调度开销被放大了。所以梯度检查点适合的场景是显存已经卡死到 OOM 边缘且 batch size 不得不压到很小。如果 batch size 已经够大、GPU 计算还算饱和开 checkpoint 会明显拖慢吞吐。1.2 ZeRO 分片把显存压力换成通信压力DeepSpeed 的 ZeRO 分三挡ZeRO-1 只分片优化器状态ZeRO-2 分片优化器状态加梯度ZeRO-3 连参数都分片。分得越多显存占用越低但每一档都额外购入了一笔通信账单。以 7B 模型、bf16 精度为例参数总量 14GB。如果用 ZeRO-2每训练一步梯度需要做 reduce-scatter参数要重新做 all-gatherZeRO-3 更进一步每次前向传播中逐层 all-gather 参数反向时再逐层 reduce-scatter 梯度。这些操作都是实打实的数据搬运。在 8 卡环境下每张卡每步至少要收发十几 GB 的数据。如果卡间连接是 NVLink比如 A100/H100 的 600GB/s 双向带宽14GB 的 all-gather 纯传输也要 23ms 左右如果走 PCIe 4.0 x16约 32GB/s 理论带宽一次 all-gather 就是 400ms 量级。假设你的 step 计算本来只有 300ms启用 ZeRO 后每步时间直接翻倍GPU 有超过一半时间在等通信。这就是典型的“显存省下来了GPU 吃不满”。1.3 CPU 卸载PCIe 带宽直接变成时间线瓶颈有些同学在显存极小的机器上比如 6G/8G 的笔记本卡会把优化器状态甚至参数卸载到 CPU 内存训练时再用回 GPU。这个方案能救命但你一定要知道代价是什么。7B 模型的 bf16 参数是 14GB。每训练一步参数需要从 CPU 搬到 GPU计算完梯度后梯度又要从 GPU 搬回 CPU。按 PCIe 3.0 x16 实际 12GB/s、PCIe 4.0 x16 实际 25GB/s 来算一次完整搬运来回至少 28GB。哪怕按 PCIe 4.0 计算光搬运就要 1 秒以上。如果 GPU 算一个 step 只需要 300ms最终一个 step 的耗时就是 300ms 计算 1000ms 搬运。GPU 利用率是 300/1300也就是 23%。你还敢说“显存省下来了GPU 应该吃得满”吗CPU 卸载只适合“跑得动比跑不动好”的场景追求吞吐的话尽量别碰。1.4 混合精度与量化字节减半但 Tensor Core 才是重点混合精度FP16/BF16把参数从 FP32 变成 16 位显存减半同时利用 Tensor Core 使 FP16 算力往往翻倍。听起来全是优点但有一个前提你的算子真的跑在 Tensor Core 上。FP16 计算在大多数硬件上都会自动走 Tensor Core但 INT8/INT4 量化就不一样了。模型量化后显存确实大幅下降可每个 linear 层在计算前通常需要反量化dequantize回高精度再进矩阵乘这个反量化本身是额外的计算和访存。对于小 batch 场景新增的 kernel 数量可能比矩阵乘本身还多GPU 甚至更忙但有效 token 吞吐反而下降。四种常见省显存方案的代价汇总如下优化手段省下的是新增的额外成本适合场景梯度检查点激活值显存约 33% 重计算量显存 OOM 附近ZeRO-1/2/3参数/梯度/优化器显存大量 all-gather/reduce-scatter 通信多卡环境CPU/磁盘卸载全部显存压力PCIe/磁盘搬运延迟跑得动最重要FP16/BF16一半字节宽度需要 Tensor Core 生效几乎所有训练INT8/INT4 量化一半以上字节反量化计算推理或极低显存看到这里你应该明白省显存不是一个孤立动作每一次“省”都会压缩 GPU 的计算节奏。接下来要做的是搞清楚你的训练里GPU 到底是在“真忙”还是“假闲”。2. 测量先行把 GPU 吃不满拆成三个可诊断的层次在动手改任何配置前先用工具把瓶颈层次定位清楚。最常见的错误是一上来就调 ZeRO stage 或 batch size结果看数据才发现瓶颈根本不在 GPU 侧。2.1 为什么 nvidia-smi 看到的利用率不是你想要的利用率很多人对“GPU 利用率”有一个误解nvidia-smi里显示的utilization.gpu并不是 SM 实际计算占用率。它的定义是采样时间窗口内是否有 kernel 在 GPU 上活跃的百分比。反直觉的例子一个 kernel 只用了 8% 的 SM 资源但它持续运行了整整一秒nvidia-smi就会显示 100% 利用率。反过来如果你的训练循环里每个 step 之间有 200ms 的空闲等待即使计算那段时间 GPU 忙到冒烟采样结果也可能只有 70%。所以不要只盯nvidia-smi的利用率数字。要判断 GPU 吃没吃满必须看更细粒度的指标SM 占用率、显存带宽占比、时间线上的空闲段长度。2.2 一组可以直接照抄的诊断命令我最常用的工具链是三件套nvidia-smi dmon看 GPU 整体实时状态Nsight Systems 看时间线Nsight Compute 看 kernel 内部瓶颈。# 每 1 秒刷新一次显示进程、利用率、电压、内存、温度、错误 nvidia-smi dmon -s pucvmet -d 1 # 按进程维度看每张卡的利用率与显存占用 nvidia-smi pmon -s pcum -o T # 按结构化输出看 GPU 利用率、显存、功耗、温度 nvidia-smi --query-gpuindex,utilization.gpu,memory.used,power.draw,temperature.gpu --formatcsv -l 1dmon输出里的sm列可以理解为 SM 流处理器占用率mem列是显存带宽利用率。这两个数字结合起来基本能判断一个 kernel 是计算密集还是带宽密集。想要看到整个训练循环的时间线用 Nsight Systemsnsys profile --tracecuda,nvtx,cudnn,cublas -o profile_output python train.py打开profile_output.nsys-rep后重点找 GPU 时间线上的“大段空隙”。如果空隙长度超过几个正常 kernel 的耗时说明 GPU 在等什么东西。等的是数据加载、通信还是 CPU 调度时间线上一目了然。如果某个 kernel 本身运行时间长再用 Nsight Compute 深入ncu --set full --kernel-name regex -o kernel_metrics python train.py2.3 根据 SM、DRAM、等待时间三张表判断瓶颈类型我习惯用一个三分法来判断SM 利用率显存带宽利用率时间线空闲段结论高低少计算密集考虑算子融合、Tensor Core低高少带宽密集考虑小 dtype、调整数据布局低低明显等待/调度瓶颈查 CPU 数据加载、NCCL 通信高高少已饱和优化空间有限“SM 和显存都低”这种情况最容易让人困惑也是本文标题对应的主场景。表象是显存省下来了内核似乎没怎么干活实际是 GPU 在流水线里被迫“排队等料”。接下来几节我会按瓶颈类型逐一排查。3. CPU 数据管线最容易被忽略的 GPU 空转供应商先说一个我踩得最深、也最不想承认的坑忙了半天 GPU 配置最后发现瓶颈出在 CPU 侧的 DataLoader。3.1 先做一个简单实验证明数据加载在拖后腿怎么判断数据加载是不是瓶颈最直接的办法是做一个对照实验写一个伪训练循环batch 从 DataLoader 里取但 GPU 计算部分用一个假的线性层代替真实模型。如果“只取数据不计算”的耗时已经接近“取数据完整训练”的耗时瓶颈就锁定在 CPU 数据管线。更简单的办法是看 Nsight Systems 时间线里的两个数据GPU 实际在跑 kernel 的总时间和 GPU 空闲的时间。如果空闲时间占总训练时间的 30% 以上且空闲段集中在 step 开始处那八成是在等 batch。实际工作中我见过一个 7B 模型微调脚本一个 step 里 GPU 计算只要 500ms但 DataLoader 每步要花 800ms 去读文件、做 tokenize、动态 padding。结果 GPU 利用率死活上不去——不是模型问题是“料没送到”。3.2 离线 tokenize 和缓存把预处理赶出训练循环很多人的训练脚本长这样每 step 从原始文本里读 sample然后当场调用 tokenizer。这非常致命因为 tokenizer 在 CPU 上是出了名的慢尤其是词表很大、序列很长、包含自定义分词规则的时候。正确做法是训练前花几分钟离线把所有样本 tokenize 并缓存到磁盘def tokenize_and_cache(dataset, tokenizer, save_dircache): for i, sample in enumerate(dataset): tokens tokenizer( sample[text], truncationTrue, max_length2048 ) torch.save({ input_ids: torch.tensor(tokens[input_ids], dtypetorch.long), attention_mask: torch.tensor(tokens[attention_mask], dtypetorch.long) }, f{save_dir}/sample_{i}.pt)训练时直接加载缓存class CachedDataset(Dataset): def __init__(self, cache_dir): self.files sorted(Path(cache_dir).glob(*.pt)) def __getitem__(self, idx): return torch.load(self.files[idx], map_locationcpu)如果数据量大到磁盘缓存吃紧可以用内存映射格式如.npy或 safetensors 的分段保存避免每个样本都触发一次小文件 I/O。实测下来仅这一步就把 7B 模型微调的 GPU 利用率提高了 10 到 15 个百分点。3.3 DataLoader 参数不是玄学是按硬件带宽算出来的DataLoader 的num_workers、pin_memory、prefetch_factor三个参数是 GPU 空转的重灾区。DataLoader( dataset, batch_sizebatch_size, shuffleTrue, num_workers4, pin_memoryTrue, persistent_workersTrue, prefetch_factor4, )pin_memoryTrue让数据落在锁页内存里后续 CPU 到 GPU 的拷贝H2D可以用异步 DMA而不是普通可分页内存的同步拷贝。persistent_workersTrue避免每个 epoch 重新 fork worker省掉大量进程启动开销。num_workers不是越大越好。worker 太多会导致 CPU 核之间抢内存带宽反而不如适度并行。我的经验是从 CPU 核数的一半开始逐步往上试。还有一个容易被忽略的问题如果 worker 数量超过了 CPU 可用核数CPU 会被数据加载线程彻底占满反而影响 GPU kernel launch 前 CPU 端的计算调度进一步恶化“CPU 跟不上 GPU”的局面。4. 多卡训练里的隐形天花板通信拓扑与 NCCL 行为单卡场景的显存省下来后瓶颈多半落在 CPU 数据管线多卡场景则更复杂通信拓扑和 NCCL 配置通常会直接决定你能吃多少算力。4.1 通信量算一笔账ZeRO 的 all-gather / reduce-scatter前面提过7B bf16 模型参数 14GB。8 卡 ZeRO-2 下每个 step 的数据搬运量大致等于模型参数的若干倍量级。即便卡间是 NVLink一次 all-gather 也要 23ms 以上如果走 PCIe 4.0400ms 是跑不掉的。问题在于很多框架的通信和计算是串行等待的先 all-gather 参数再算前向反向算完梯度再 reduce-scatter。每一步都卡在同步点上GPU 只能干瞪眼。先用nvidia-smi topo -m看一下卡间拓扑nvidia-smi topo -m输出矩阵里的NV#表示 NVLink 直连PIX表示经过同一个 PCIe 交换机PHB表示经过 PCIe 根桥SYS表示跨 CPU/跨节点延迟和带宽依次递减。如果卡与卡之间全是PHB或SYS多卡训练的通信瓶颈几乎是必然的。要实测通信带宽用 NVIDIA 的 nccl-tests 测 all_reducegit clone https://github.com/NVIDIA/nccl-tests.git cd nccl-tests make ./build/all_reduce_perf -b 128M -e 16G -f 2 -g 8重点看busbw那一列。如果总线带宽只有理论值的 30% 以下你的 NCCL 配置大概率有问题。4.2 先看拓扑再调 NCCL别凭感觉常见的 NCCL 环境变量要根据拓扑来调export NCCL_DEBUGINFO export NCCL_P2P_DISABLE1 export NCCL_P2P_LEVELPHB export NCCL_IB_DISABLE1 # 没有 Infiniband 的机器上避免无意义尝试如果卡间有 NVLinkNCCL_P2P_DISABLE不要开否则会强制走 PCIe白白损失一大截带宽。如果卡间连 PCIe 直连都不通NCCL_P2P_DISABLE1反而是为了避免反复握手失败带来的超时等待。我调过一个 4 卡 PCIe 环境默认 NCCL 配置下 all_reduce 带宽只有 11GB/s。打开NCCL_DEBUGINFO后发现NCCL 在尝试 P2P 时大量失败回退加上NCCL_P2P_DISABLE1之后带宽反而稳定到了 20GB/s 以上。这就是“凭感觉调参数”和“按拓扑调参数”的区别。4.3 尽量让通信与计算重叠而不是串行等待想让通信不占计算时间核心思路是 overlap也就是把通信操作和矩阵乘放到不同的 CUDA stream 上让它们在 GPU 上并行执行。DeepSpeed 里直接用配置打开{ zero_optimization: { stage: 2, overlap_comm: true, reduce_scatter: true } }另一个实用手段是梯度累积gradient accumulation。它的直观作用是“攒几个 step 再更新一次参数”同时也能降低梯度同步频率。例如 batch size 固定 16、8 卡环境下梯度累积 4 步可以让优化器更新和梯度通信从每步一次变成每 4 步一次通信量不变但通信频率降低了GPU 等待的断点也就更少。5. 算到算子级别kernel launch、Tensor Core 与 batch size如果 CPU 数据管线和通信都排查过了GPU 还是吃不饱就要往下看到算子级别。5.1 小 batch 下CPU 可能成为 GPU 的调度器瓶颈GPU kernel 不是“一键执行”的。CPU 要调用 CUDA API 把 kernel 提交给 GPU每个 kernel launch 大约需要 3 到 10 微秒。LLM 训练一个 step 动辄几百上千个 kernel如果每个 kernel 本身在 GPU 上只跑几十微秒那么 CPU 的启动开销就会变成主导。这就是为什么显存省下来以后如果你没有同步把 batch size 调大GPU 会更闲显存变多但你没有利用它去增大单 step 的计算量反而每个 step 里 kernel 数量不变CPU 调度开销占比更高。解决方向有两个。第一用torch.compile把碎算子融合成大 kernel并减少中途 Python 层的干预model torch.compile(model, modereduce-overhead)第二用 CUDA Graph 把整张计算图捕获一次之后直接回放绕开逐 kernel 的 CPU 启动开销g torch.cuda.CUDAGraph() s torch.cuda.Stream() with torch.cuda.stream(s): for _ in range(3): # warmup先把内存池稳定下来 y model(x) g.capture_begin() y model(x) g.capture_end() # 训练循环里直接回放不再走 Python 每层调用 g.replay()注意 CUDA Graph 捕获期间不能做新的内存分配和 CPU 同步所以要先 warmup 几次否则会报捕获失败。实测在 batch size 12 左右的 7B 微调上CUDA Graph 能把 GPU 利用率提高 10% 左右。5.2 Tensor Core 不是默认亮着的很多同学以为开了 FP16 就等于用了 Tensor Core。实际上 Tensor Core 对矩阵维度有对齐要求典型情况下矩阵的 M、N、K 维度最好是 8 或 16 的倍数。如果你的自定义模型 hidden size 不是这种对齐值或者某些 PyTorch 算子回退到了普通 CUDA kernelTensor Core 根本不会参与计算。一个常见检查方法是看 GPU 功耗和dmon里的sm占用率。如果sm高但功耗不高、且 FP16 训练没有比 FP32 快多少多半是算子没吃到 Tensor Core。标准 LLM比如 LLaMA 系列的 hidden size 4096、8192通常不存在这个问题但如果是自己改的结构务必检查各 linear 层的矩阵形状。顺带提一下如果你在 A100/H100 上用 FP32 跑 transformer可以打开 TF32 让 FP32 矩阵乘自动走 Tensor Core 加速torch.backends.cuda.matmul.allow_tf32 True torch.backends.cudnn.allow_tf32 True5.3 显存省下来之后正确姿势是把算力空档填上而不是只省着显存优化做得越好模型占的显存越小你能开的数据量就越大。很多人忘了这一步显存省下来不是终点用多出来的显存把 batch size 或 sequence length 顶上去才是提升 GPU 利用率的关键。从硬件视角看GPU 的浮点算力要通过足够大的数据量才能“喂饱”。算术强度arithmetic intensity越大GPU 越不容易因为等待数据而闲下来。具体操作上优先增大 batch size让每个 linear 层吃进更大的矩阵乘。如果显存允许增加 sequence length 而不是盲目堆 batch但在长序列下要配合 FlashAttention否则注意力部分显存会二次方增长。用梯度累积控制有效 batch size不要让单卡 batch 大到显存溢出。这里有个平衡点batch size 也不是越大越好。当矩阵乘尺寸达到一定程度算力已经饱和再增大 batch 对吞吐没有线性提升反而可能让延迟变大。这是为什么好多人说“试了 batch 增大但吞吐没涨多少”——很正常不是显存问题是算力已经吃满了。6. 踩坑后整理的显存紧张场景调参清单最后把我在这个 Lab 里反复验证过的配置组合整理成清单按硬件场景区分可以直接当模板用。6.1 按硬件场景的推荐组合硬件场景推荐配置组合单卡 24G4090 等BF16 混合精度 梯度检查点 适度 batch不要开 CPU offload单卡 8G笔记本 GPU 等4bit QLoRA paged optimizers能跑起来为主别追求吞吐多卡 PCIe 互联ZeRO-2 overlap_comm 梯度累积关闭 P2P 尝试多卡 NVLink 互联ZeRO-2 或 ZeRO-3梯度累积后视通信量决定 stage显存极低但有 NVLinkZeRO-3 activation offload优先省显存如果你是跑 7B 往上的模型微调LoRA/QLoRA 几乎是必须的。全参微调在单卡 24G 上也能跑但 batch 会小到算力完全喂不饱整体效率远不如“LoRA 大 batch”。6.2 训练循环里的隐性吞吐杀手除了显存优化手段本身训练代码里还有几个我每次都会检查的点不要在循环里频繁调用.item()做指标输出。.item()会强制 CPU 与 GPU 同步破坏整个异步流水线。正确做法是每 N 步才收集一次 loss 等标量。保存 checkpoint 也会打断 GPU 流水线。如果是torch.save在主线程里执行保存几 GB 的权重期间 GPU 大概率在等。建议用独立线程异步保存或者用 safetensors 格式减少序列化开销。验证集评估不要插得太频繁。eval 同样占显存而且通常不会和训练流水线共享每次插入都是 GPU 空转的断点。动态 sequence length 会触发频繁 kernel 编译。如果每个 sample 长度差异很大GPU 时间线会变得很碎。先 padding 到固定长度或用 bucket grouping 聚合相近长度能减少大量空白段。训练崩溃后的进程残留会占显存。启动新训练前用nvidia-smi或fuser -v /dev/nvidia*查一下残留进程避免因显存碎片导致新进程被迫压低 batch。我自己建过一个“显存优化副作用清单”每改一次显存配置就记下吞吐变化、GPU 利用率变化、通信时间占比。改到第 5 轮的时候发现最大收益根本不是省显存而是把 dispatch 出去的 batch 数据量恢复了。这个思路是值得复制的——每一个优化项都要拿吞吐和利用率来回测验证否则很容易陷入“显存降了但速度更慢”的自我感动。一次“省显存”实验后的个人体会这个 Lab 做下来我最大的感受是显存和吞吐之间不是正比关系而是空间账本与时间流水之间的关系。显存是你可以同时摆多少东西在桌上吞吐是单位时间内能加工多少东西。桌上东西少的时候除非你把加工节奏提上去否则机器照样空闲。我之后的调优顺序彻底变了先测时间线定位 SM/DRAM/等待哪个环节吃紧再决定要不要梯度检查点、ZeRO 或者卸载最后才考虑把省下来的显存转化成更大的 batch 或更长的序列。实践下来这套顺序比“无脑堆优化手段”高效得多。
返回列表