首页 / 资讯中心 / 文章详情

显存优化后GPU利用率反而下降?从Warp调度到PCIe的排查指南

显存优化后GPU利用率反而下降?从Warp调度到PCIe的排查指南 ★ FEATURED ARTICLE
这是 LLM Training Lab 系列的第 10 期。前几篇我们一边做显存治理一边记录数据把 7B 参数模型从 18GB 压到 8GB 以内混合精度、激活重计算、优化器 offload 全都用上数字确实好看。但真正让我紧张的是从显存优化后的第二天开始nvidia-smi里的 Utilization 一直趴在 50% 上下偶发时间甚至低于 40%。显存省下来了GPU 计算资源却吃不满训练时长不降反升总感觉到手的算力又悄悄溜走了。这个现象在低显存跑大模型、LoRA 微调、单卡训练的场景里尤其常见很多人会误判成“显存优化把模型搞坏了”或者直接怪驱动和容器。我把背后的原因链完整拆了一遍涉及 CUDA 的 warp 调度、内存带宽、kernel 启动、PCIe 搬运、数据加载和通信同步这些平时不容易看见的环节。下面这篇文章既是我自己的排查复盘也算一份可以直接照着用的 GPU 利用率诊断清单。1. 显存优化到底动了哪部分内存又花了什么代价想搞清楚为什么显存降了 GPU 反而吃不满第一步不是盯着 Profiler 看而是先想明白显存优化到底优化了哪块内存省下显存的过程付出了什么额外成本。1.1 模型训练时显存里装的到底是什么大模型训练时的显存占用几乎可以画成四个桶模型参数、梯度、优化器状态一阶动量、二阶动量、前向计算过程中的中间激活值。假设一个 7B 模型用 AdamW 优化器训练即使只算 fp16/bf16 的参数本身模型权重大约是 14GBAdamW 额外要存 fp32 的 master weight、momentum、variance这部分轻轻松松超过 30GB。所以裸奔跑 7B 全参训练一块消费级 24GB 显卡根本放不下。显存和模型参数之间的关系说白了是“每个参数在训练时至少要有一个或者多个副本”。你省显存本质上就是减少这些副本塞进显存的数量。混合精度省在优化器状态梯度检查点省在激活值offload 省在参数/梯度/优化器状态里任选其一。这个逻辑本身没问题但它省的都是“存储空间”不是“计算量”。GPU 要真正算起来依然需要把权重、中间张量准备好搬到 SM 附近的寄存器和共享内存里。空间省下来了数据搬运路径反而可能变长这才是后面利用率上不去的根源。1.2 显存优化方案背后都有隐性开销显存优化是一种典型的“用别处资源换显存”的交易。拿梯度检查点来说它不存中间激活而是在反向传播时重算一遍前向结果这等于把原本一次前向的工作变成两次依赖重算的那部分层计算量直接翻倍拿 CPU offload 来说把参数从显卡显存搬到主机内存一个 step 里光参数同步就可能要走好几轮 PCIePCIe 的带宽比显存带宽低一到两个数量级搬运耗时会被直接打进 step 时间。更隐蔽的是这些优化往往会让 kernel 之间的依赖链变长GPU 在等数据或者等上一个 kernel 完成时SM 是空的利用率就掉下来了。所以我一开始得到“显存省下来但 GPU 利用率下降”这个结果时情绪从困惑变成了释然——这不是某个工具坏了而是优化交易本身换来的副作用。关键问题不是“要不要做显存优化”而是“省完显存之后计算路径上是否出现了新的、更窄的瓶颈”。带着这个思路去排查后面的一切就顺了。2. 为什么显存降了GPU 利用率反而下来SM、Warp 和内存带宽很多做训练的人对显存占用很敏感但对计算单元怎么跑起来反而不那么熟。这里我把 GPU 执行一个算子背后的完整链路讲清楚你会明白“显存数据少”和“SM 吃得饱”是两件完全独立的事。2.1 一个 kernel 在 GPU 上的完整执行流程从 PyTorch 的角度看一个算子就是一次cudaLaunchKernel。但从 GPU 内部看过程是这样的CPU 端把 kernel 参数和任务提交到命令队列GPU 前端把任务解析出来后会把这个 kernel 拆成若干个线程块Thread Block每个线程块又被进一步划分成若干 warp一个 warp 固定 32 个线程这是硬件调度器真正的最小执行单元。这里有个概念特别容易被搞混cooperative thread arrayCTA和 warp 的关系。CTA 就是你在 CUDA 里写的线程块它会在执行时被分配到某个 SM 上SM 再把块内线程切成多个 warp以 warp 为单位发射指令、执行访存和同步。所以 CTA 决定了一个 SM 上有多少块可以并行驻留warp 决定的是每个时刻到底有多少条指令流在流水线上跑。GPU 利用率高不是显存里数据多而是每个 cycle 的 warp 调度器都能从准备好的 warp 里发射指令。实际训练里一个 GEMM kernel 的 block 数量和 block 大小是由 cuBLAS/cuDNN 根据矩阵形状自动选的。如果显存优化把 batch size 或 sequence length 压得太小生成的 kernel 可能只有几十个 block而一块 4090 有 128 个 SM几十个 block 扔上去每个 SM 平均分不到一个块最后只有一小部分 SM 参与计算另一些 SM 直接空闲。这就是“GPU 没坏但很多计算单元在闲着”的典型场景。2.2 算力、带宽与计算强度为什么很多层天然吃不满GPU 的标称算力FLOPs和显存带宽是两个独立资源。我们把“单位字节数据上做了多少次浮点运算”定义为算术强度理想情况下的峰值算术强度就是算力 / 带宽。比如某张显卡 FP16 Tensor Core 算力约 330 TFLOPS显存带宽约 1.0 TB/s那么想要同时吃满算力和带宽每个算子至少要有约 330 FLOP/Byte 的计算强度。LLM 训练里的大量算子比如 LayerNorm、残差连接、RoPE、mask、softmax要么只读一遍激活值做少量运算要么把权重和激活都搬一遍实际算术强度常常只有几十 FLOP/Byte。至于 Attention 里的 QKV 投影 GEMM如果 batch 或 sequence 偏小GEMM 的小矩阵维度会让计算强度进一步下降。换句话说这些算子早就被内存带宽卡住了SM 里的算力根本等不到足够的数据进来。就算你把显存从 18GB 省到 8GB这些 memory-bound 算子的行为不会因此变好该等数据还是等数据只是等待的时间更长。顺带回答一个经常被问到的问题LLM 的 token 在注意力机制里拆成 Q、K、V 三份向量简单说就是“key 代表我是谁、query 代表我在找什么、value 代表我能提供什么”这种拆分直接决定了 attention 的访存模式。所以在做 GPU 利用率分析时Attention 部分的 memory traffic 经常是重点怀疑对象光看它算得快不快没用要看到底搬了多少字节。2.3 显存占用与计算吞吐不是一一对应很多时候我们会被线性思维带偏显存变小了模型总该跑得更快吧实际上计算吞吐只取决于“有多少 SM 在工作”和“每个周期处理多少指令”显存占用影响的是“能放多大的 batch、多大的模型”它决定的是计算任务规模的上限而非当前时刻 SM 的忙碌程度。显存省下来后如果 batch 没跟上、数据加载变慢、通信变多上层任务的并发度反而降低SM 自然就开始偷懒了。3. 显存省下来之后我实测遇到的 7 个“GPU 喂不饱”瓶颈下面这些问题是这期实验中真实踩过并记录下来的不一定每个都同时出现但只要你做了显存优化大概率会撞上其中一两项。3.1 batch 或 sequence 太小SM 占用率不足和波次尾部浪费显存优化后第一件容易被忽略的事就是你会不自觉地调小 batch 来换取显存空间。batch 和 sequence 统一变小kernel 内部生成的 CTA 数量也会变少。假设每块卡的 SM 数是 128一个 GEMM kernel 只生成 40 个 block每个 SM 只有 0.31 个 wave。一般来说kernel 的 block 数量应该是 SM 数量的整数倍或者至少达到 4 倍以上才能保证调度器有足够的 warp 来隐藏访存延迟。这个“wave quantization”问题在 batch 小的时候特别致命尾部那一批不满的 wave 会造成明显的时间空洞。这里还要注意很多框架会把大的 batch 切成 micro-batch叫 gradient accumulation。这个策略对显存很友好但如果你把它配得太碎比如原来 batch 16现在切成 16 个 micro-batch 一个一个算那么每一轮小 GEMM 都会重复启动 kernel启动开销和尾部浪费会被放大很多倍。显存是省下来了有效计算时间却也碎了。3.2 数据加载和 CPU 预处理成了新的硬瓶颈显存优化通常会伴随一个更大的 batch 或更长的序列来保持算力这时显存不炸了但 CPU 端如果没跟上GPU 会在每个 step 之间干等。尤其在你启动DataLoader没有开启足够多的num_workers或者pin_memoryFalse或者 tokenizer 在预处理阶段做了大量重复计算时CPU 会变成第一个无可争议的瓶颈。我做过一次实验显存优化完成之后GPU 利用率下降当时第一时间怀疑是 CUDA kernel 的问题结果用torch.profiler看 entire timeline发现每个 step 里有大约 600ms 的空闲GPU 在等 CPU 产出 batch真正的 kernel 计算时间反而只有 400ms。把数据管线换成预编码后的二进制缓存并开启pin_memoryTrueprefetch_factor后GPU 利用率立刻提升了大概 15 个百分点。3.3 多卡训练时梯度同步和 offload 一起放大了通信等待如果你在用多卡训练显存优化带来的一个隐藏变化是参数 offload 或者梯度检查点会让每个 rank 的计算速度变慢原本大家计算时间大致相等通信同步可以自然对齐现在某些 rank 因为 offload 命中 cache 的情况不同计算时间差距变大NCCL 的 allreduce 就必须等最慢的那个节点这个现象叫 straggler。另一个问题是很多 offload 方案会在反向传播结束后把梯度从 CPU 拷贝回来做优化器更新或者把参数从 CPU 拷贝出去做下一次前向。这些搬运如果和通信操作挤在同一时间段GPU 就会在 PCIe 和 NVLink 之间来回切换通道互相抢占吞吐进一步恶化。结果就是 Step 时间变长GPU utilization 虽然在跑但始终有等待气泡。3.4 Offload 路径上的 PCIe/HBM 带宽消耗非常惊人显存 offload 是省显存最直接的方案但它也是坑最多的地方。典型流程是这样的前向开始前把当前层的参数从 CPU 搬到 GPU算完当前层后把激活值或中间结果存到反向阶段需要的地方反向后再把梯度从 GPU 传到 CPU 交给优化器。看起来只挪了几次但一个 step 里参数、梯度、优化器状态都有可能来回倒腾。以 7B 模型为例bf16 参数单份重量约 14GB。如果某些 offload 方案每个 step 要搬一次参数到 GPU 再搬一次梯度回 CPU那就是至少 28GB 的 PCIe 流量。PCIe Gen4 x16 的峰值带宽大约 32GB/s理想情况下也要接近 1 秒的纯搬运时间。而 GPU 真算起来7B 的一个 forwardbackward 在 4090 上可能只要一两秒。搬运占比如此之高GPU 想满也满不了。更糟糕的是大多 offload 实现不会把搬运和计算重叠好很多时间 GPU 都在等数据从 PCIe 那头过来。对我个人而言能不用 offload 就尽量不用真要用也一定要确认计算和传输是否在不同 stream 上重叠。3.5 激活检查点重算引入的额外 kernel 和调度开销梯度检查点是标准显存优化手段但它不是免费的。它在反向传播过程中重新执行前向计算这会多出一倍的数学运算量。如果是显存极端紧张场景这可能是唯一解但代价往往隐藏在 “GPU 利用率” 这个指标背后。为什么会直接拉低利用率因为重算产生的前向 kernel 和反向 kernel 之间存在严格依赖GPU 无法提前去执行后面的算子。如果这些 kernel 又小又碎调度器在它们之间反复切换占用的不是显存而是发射槽位。别的 kernel 想趁机并行也很难。实际处理办法是梯度检查点“挑层用”只对占据激活值大头的那几层通常是 Attention 或者深 MLP开而不要无脑全局开启。我在实践中通常会把recompute的层数控制在总层数的 30% 以内显存收益还能保留大半额外的 kernel 开销也相对可控。3.6 LoRA 和微调场景的计算密度天然偏低用 LoRA 微调大模型时模型主体参数是不训练的梯度只针对少量低秩 adapter。这带来的问题是显存占用可以很低但整个 forwardbackward 仍然要把完整的主模型权重和激活从头到尾过一遍。换句话说大部分时间都花在搬数据和跑前向/反向的骨干结构上可真正要更新梯度的参数少得可怜GPU 的算术强度大多数时候处在 memory-bound 状态利用率自然上不去。这也是为什么很多人在显存只有 6G、8G 的卡上跑低显存模型微调时会发现 GPU 一直“吃不满”——它并不是没活干而是大部分活都被卡在带宽和访存上。另一个容易被忽略的细节是LoRA 微调时常为了节省显存把 batch 调得特别小导致 QKV 投影这类 GEMM 的算术强度进一步下降。于是我后来做了一个调整在显存允许范围内尽量增大 batch即使更新频率不变把更多训练样本塞进一个 step也能明显改善 GPU 利用率。3.7 环境配置驱动、频率锁定和容器权限的隐性影响有时候问题根本不在训练配置而在环境本身。GPU 可能因为供电策略进入低功耗模式SM 频率被锁到基础频率而不是 boost 频率NVLink/PCIe 的拓扑在容器里没有被正确暴露通信走了更慢的路径或者 PyTorch 检测不到某个计算能力特性使用了 fallback kernel。这类问题通常用nvidia-smi -q -d PERFORMANCE能看到 PState 是否在 0/1如果长期在 8/12说明显卡没满载。也可以顺带看一眼显卡温度某些散热不好的机器在训练一段时间后触发降频也会表现为利用率波动、算力低迷。排查这层最快的方法是先换一张裸机测试同一个 step如果裸机利用率正常再回容器里对比环境变量和底层驱动差异。4. 实战排查把 GPU 利用率从 50% 拉回 78% 的工具与步骤接下来是实际操作部分也是我认为整篇文章最有复用价值的内容。我会按“先看宏观 → 再抓 kernel → 最后修瓶颈”的顺序把流程讲完整。4.1 用 nvidia-smi 和 PyTorch Profiler 快速采集现场数据先用命令行盯宏观表现nvidia-smi --query-gpuutilization.gpu,utilization.memory,power.draw,temperature.gpu,pstate --formatcsv -l 1如果 utilization.gpu 长时间低于 80%同时 power.draw 也远低于该卡满载功耗基本可以确定 GPU 存在空闲。注意 utilization.gpu 是采样周期内 SM 处于忙碌状态的时间百分比不代表算力峰值它低一定意味着有问题它高也不代表算力一定跑满但作为第一层筛选足够。接下来直接给训练脚本加上 Profiler采样一两个 step 足够from torch.profiler import profile, ProfilerActivity, record_function with profile( activities[ProfilerActivity.CPU, ProfilerActivity.CUDA], scheduletorch.profiler.schedule(wait1, warmup1, active2), on_trace_readytorch.profiler.tensorboard_trace_handler(./trace_log) ) as prof: for step in range(4): train_one_step() prof.step()Profiler 本身有开销因此我建议 warmup 至少一个 step再看后续两个 step 的 trace。不要开太长时间否则采集本身的 overhead 会让你误判性能。4.2 三步定位法看 kernel 时间占比再看访存吞吐最后查同步间隙第一步在 Profiler 的表格输出里找到所有 CUDA kernel 的总墙钟时间。如果 kernel 总耗时为 320ms而 step 总耗时为 600ms说明单是 kernel 之间的空隙和 CPU 提交延迟就占了接近一半。这个空隙一般来自数据加载、NCCL 同步、CPU Python 调度和 GPU 等待 CPU 启动 kernel。第二步如果 kernel 本身占了 90% 以上再进一步看每个 kernel 的访存效率。Profiler 里的 memory bandwidth 或 DRAM throughput 指标可以帮助判断某个 GEMM 或 elementwise kernel 是否已经贴着显存峰值带宽走。比如某个 kernel 跑出 900GB/s而这卡 HBM 带宽约 1TB/s那它已经到顶峰了你换成别的算子也快不了多少只能融合或者减少数据访问。第三步看 kernel 之间是否有明显的空档最常发生在数据加载和 offload 场景。把 timeline 放大如果看到Memcpy DtoH和Memcpy HtoD之间有大量空白再往回查是 CPU 在等数据还是 GPU 在等拷贝完成。到这一步瓶颈已经非常聚焦接下来就可以对症下药。4.3 常见问题速查表为了让你少走弯路我把自己这次的排查结果整理成一张速查表。现象可能原因快速检查方式解决思路显存占用很低但 GPU 利用率只有 40%batch/seq 太小kernel block 数量不足Profiler 查看 GEMM block/wave 数增大 micro-batch 或使用梯度累积凑满 waveStep 时间稳定但每个 step 内部有 300ms 空白CPU 数据处理慢看 Profiler 中 CPU 端耗时/数据队列状态预编码数据、开启 pin_memory、调大 num_workers多卡训练时利用率周期性抖落梯度同步等待最慢节点观察 NCCL 时间戳是否和 kernel 尾部对齐开启计算通信重叠、调整通信 bucket 大小Offload 开启后利用率骤降PCIe 搬运与计算未重叠看 Memcpy HtoD/DtoH 和 kernel 是否串行使用多 stream、异步 prefetch 隐藏搬运开启梯度检查点后利用率下滑重算 kernel 和反向 kernel 串行化Profiler 中重复出现同一批前向算子只对部分层开启检查点显存占用大但 SM 利用率高不起来算子自身 memory-bound看 DRAM 吞吐是否接近峰值换成融合 kernel比如 FlashAttention指标一切正常但整卡 PState 不高显卡驱动或供电限制nvidia-smi -q -d PERFORMANCE解锁功耗、检查卡是否插在直连 PCIe 插槽4.4 一个小实验单一变量对比为了确认瓶颈是搬运而不是计算我做了一个非常简单的对照实验在两个 step 之间把模型权重直接固定在显存里不做 offload其他参数完全不变然后对比 step time 和 GPU 利用率。结果 offload 场景里 kernel 计算时间只占了 step 的 35%非 offload 场景则能升到 72%。这个实验强烈说明当时的问题在数据通路而不是计算单元。5. 针对不同训练场景的“把 GPU 喂饱”配置方案既然明白了瓶颈来自哪里下一步就是针对场景做调整。我这里按单卡低显存、多卡训练、以及框架层优化来拆。5.1 单卡/低显存场景让 block 数量和计算强度回到健康区间低显存场景最容易出现的问题是“为了省显存而把并发度压得太低”。我的建议是先把micro_batch_size调大哪怕配合gradient_accumulation_steps让有效 batch 不变直到单个 step 内生成的 GEMM block 数量达到 SM 数量的两倍以上。比如一张 8GB 显存卡跑 1B 模型微调与其用batch_size1, seq_len512不如试试batch_size4, seq_len512并开启梯度累积你会发现显存没多多少但 kernel 启动密度明显提高。其次检查是否用了融合 kernel。FlashAttention 和融合 MLP 算子能把多个 memory-bound 的算子合并成一个减少中间张量的读写。对低显存场景来说这比单纯省显存更重要因为它直接压缩了 memory traffic。Pytorch 里开启torch.compile常能自动做算子融合但注意它有时候会因为动态 shape 重新编译反而更慢。我的经验是固定训练时的序列长度和 batch 形状给torch.compile提供静态 shape才能稳定启动优化。还有一个偏门但有效的方式把torch.backends.cudnn.benchmark True打开让 cuDNN 在训练开头自动搜索当前 shape 下的最佳算法。很多人在小 batch 下没有开它结果用一个很差的 GEMM 算法跑了整个训练周期。5.2 多卡训练把通信和计算重叠起来多卡场景的核心思路是不要等数据同步完再开始下一步计算。PyTorch DDP 本身会在 backward 过程中插入 allreduce但通信 bucket 的大小会影响同步的粒度。假设默认 bucket 太大你会看到反向传播一路算完最后集中做一次长 allreduce这段时间 GPU 基本闲置把 bucket 调小一点梯度就能分段同步一边算一边通信GPU 的利用率会平滑很多。具体做法是设置环境变量或者在 DDP 初始化时调整参数。一个直观的经验值是把bucket_cap_mb设为 25MB 左右针对多数 Transformer 模型都能取得不错的重叠效果。注意不要调得太小否则通信起步开销会吃掉收益。多卡训练里还可以考虑开启nccl中的内核融合像NCCL_PROTOLL这类选项在特定模型规模下能减少单次通信体积但它依赖具体网络拓扑需要跑一版对比实验再决定。5.3 用算术强度做前置选型确定你的训练到底能不能吃满卡最后想给你一个可以在代码前完成的分析工具。先在目标 GPU 上查到算力和带宽算出理想算术强度然后粗看看你的模型和 batch 配置实际会落到哪个区间。如果当前配置的实际算术强度远小于理想值你优化任何 kernel 细节都没法把 GPU 吃满应该先增大 batch 或 seq或者换模型结构比如更少的 Attention 头、更宽的 MLP 比例。以一张典型显卡为例理想算术强度在 300 FLOP/Byte 左右自回归 LLM 训练的整体算术强度在中等 batch 下通常是 200 到 500 FLOP/Byte。也就是说batch 越小的微调场景实际算术强度越低越容易被带宽限制。这也是“低显存跑模型”时常见瓶颈的数学解释。做这个计算只需要两步第一步从显卡规格算出理论值第二步用torch.utils.flop_counter或 Profiler 统计实际 FLOPs然后除以该 step 的总内存访问量。不用非常精确数量级对了就足够指导选型。6. 调参到后来我个人印象最深的三件事排查完这轮问题后我把心得缩成了三句话都是“早知道就好了”级别的教训。第一显存优化不是免费的它只是把成本从显存空间挪到了带宽、延迟或者计算量上做性能优化时必须同时评估这些代价。第二GPU 利用率是一个整体现象的出口变量可能在数据加载、通信同步、kernel 发射、搬运输送链上的任何一环只盯着 SM 内部数据是找不到答案的。第三一切优化要以 Profile 数据为准不要凭感觉。我一度以为 offload 是主要问题结果追踪后发现水军的数据管道才是隐藏最深的那个短板没做 Profiler 之前我甚至都不知道 GPU 在每个 step 前干等了那么久。最后再分享一个小技巧把torch.profiler的 trace 导出到 Chrome tracing 后不只看 kernel 时间还要留意一下Memcpy HtoD和Memcpy DtoH旁边的小段空白它们通常意味着某个 stream 没被充分使用。如果出现多个 copy 操作排在同一段时间可以尝试为它们单独建立一个 CUDA stream让搬运和计算并行起来。对大多数低显存训练场景这一步调整的收益比调整学习率还明显。
阅读完成 · 觉得有帮助?
咨询建站