QLoRA微调GPU利用率低?显存省了算力却没吃满的排查指南 我先交代一下背景。这台机子是笔记本 RTX 4060 Laptop 8GB模型是 7B用了 QLoRA 梯度检查点 8bit 优化器状态总算把训练显存从“压根放不下”压到了 5.8GB 左右。那一刻我很开心心想显存都省成这样了后面总该放开了跑吧。结果打开nvidia-smi一看GPU 利用率在 20%~40% 之间来回荡风扇倒是转得很欢显存却像个阔佬一样坐着看戏。省下来的显存没有变成算力反而变成了一场新的面试题GPU 到底在等什么。这就是 LLM Training Lab 第 10 篇想聊的问题。如果你也遇到过“显存省下来了GPU 却吃不满”的情况这篇文章会把我实测的排查路线、工具和结论全部摊开讲。1. 先厘清一件事显存省下来了不等于 GPU 算力被用起来了1.1 显存是货架GPU 利用率是厨师炒菜的速度很多人在“显存”和“GPU 利用率”这两个词之间画了等号这是最要命的误解。显存VRAM是数据放着的房间模型权重、梯度、优化器状态、激活值全部摆在这个房间里。nvidia-smi里看到的 memory-used 只是这个房间堆了多少东西。GPU 利用率则是另一码事它表示的是计算单元CUDA Core / SM在一段时间内实际处于工作状态的比例。举个例子你把一张巨大的菜谱放进房间抽屉里塞得满满当当这代表的是“原料都准备好了”不代表厨师在炒菜。省显存的技术比如梯度检查点、量化、CPU offload、LoRA本质上都在改变“原料怎么摆放、什么时候拿取”它们不一定让厨师更忙反而可能让厨师在同一个时间里反复去仓库取东西。nvidia-smi里的utilization.gpu也不是一个精确的“算力利用率”。它是驱动层通过采样 SM 活动得出的近似指标反映“设备在采样间隔里有没有干活”不是“干得有多满”。所以你会见到一种很典型的情况显存占用只有 5GB利用率 35%SM 里其实一半时间在等数据、等指令、等同步这就是典型的“吃不满”。1.2 为什么“省显存”反而让 GPU 更闲了这里有一条我反复体会到的规律节省显存的操作几乎每一步都在给 GPU 增加“等待时间”。量化让权重变小但计算前需要反量化反量化也是一个额外 kernel梯度检查点让激活值不用全存但反向传播时要重新前向计算一遍重算的部分同样是算力开销LoRA 把一个大矩阵乘法拆成两个小矩阵乘法再相加算子变多了kernel 启动次数也变多了。换句话说显存降下来了但 GPU 的“有效工作时间占比”可能跟着降下来。这就像你把仓库搬到了离厨房更远的地方货架上的东西少了但厨师来回取货的时间反而变多。接下来几节我会按自己踩坑的先后顺序把几个最典型的瓶颈逐个拆开。2. 省显存之后最先暴露的瓶颈CPU 与数据管线拖住了显卡2.1 token 化和预处理正在吃掉前向传播的“轻快”我第一次做 QLoRA 微调时数据是一堆 JSON 对话样本训练脚本里直接在每次迭代时用 Python 循环做 tokenizer 调用。当时我天真地以为model.forward快整步训练就快。用torch.profiler一测才发现单次迭代里 tokenizer 和数据后处理能占 CPU 时间的三分之一以上。不要小看 tokenizer。对于 7B 模型单次前向传播在 GPU 上可能只需要几十毫秒但一段对话样本要拆分成几千个 token中间还涉及特殊 token、attention mask、labels 对齐。这个流程如果在训练循环里每次现算CPU 就成了瓶颈。GPU 把一次前向跑完之后只能干等 CPU 把下一批样本处理完再送过来。解决思路很简单把 tokenizer 的结果提前算好并缓存。如果你用的数据集不大完全可以在训练开始前一次性完成 tokenize 和 packing存成.bin或 Arrow 格式。如果数据集很大、放不进内存至少也要把预处理从训练循环里挪到 DataLoader 的 worker 进程里而不是主进程里一个个处理。2.2 DataLoader 配置错误num_workers0 是省显存玩家的第一杀手我发现很多人把注意力全放在bitsandbytes配置、梯度检查点开关上却完全忽略 DataLoader。PyTorch 默认的num_workers0意味着数据加载发生在主进程里而且和模型前向是严格串行的。GPU 在算完这一批之后必须等主进程去磁盘读数据、做 collate、把 tensor 放到 GPU 上这一段全是空的。在我自己的实测里只做了两个改动训练速度就从“肉眼可见的卡顿”变成了明显顺滑num_workers调到 4 或 8取决于 CPU 核数加上persistent_workersTrue和prefetch_factor4让 worker 进程在 GPU 还没吃完当前 batch 时就开始准备下一批。还有一个被忽略的点是pin_memoryTrue。开启它之后CPU 上的 tensor 会锁页从 CPU 到 GPU 的拷贝走更快的数据通路。对于小显存玩家来说这一项几乎是零成本提升。注意pin_memoryTrue不会显著增加显存占用它增加的是 CPU 内存那边的固定内存量级可以接受。2.3 先用便宜方法判断瓶颈在 CPU 还是 GPU我不建议一上来就开 Nsight先用系统自带的工具快速定位方向。nvidia-smi dmon可以每秒打印 GPU 的利用率、显存读写、PCIe 流量。nvtop则能直观看到 CPU、GPU、内存三者的活动情况。我常用的一个判断方法跑训练时同时开两个终端一个跑nvidia-smi dmon -s puc 1一个跑系统监控。如果GPU%低、同时某个 CPU 核跑满、PCIe 读写很高那瓶颈基本在数据链路如果 GPU 利用率低、CPU 也不忙、GPU 上又有 kernel 在排队那问题大概率在 kernel 本身。用这个方法可以快速把排查方向从“玄学”变成工程问题。3. 省出来的显存是资产不是摆件——batch size 和梯度累积要重新配平3.1 显存腾出来了batch 却还按“省显存”时的老规矩走这是我在“显存省下了但 GPU 吃不满”里最常犯的错误。之前为了塞进 8GB 显存我把 batch size 压到了 1开启了梯度检查点还用了 4bit 量化。后来显存确实降到 5.8GB但我忘了省下显存的目的本来就是为了把 batch size 提上去。GPU 的算力吃满前提是每个 kernel 都有足够大的数据块去填满计算单元。batch size 1 时矩阵乘法的 M 维度很小SM 里大量计算单元处于空转状态。这不是 GPU 不想干活是你只给了它一小份活。把 batch size 从 1 提到 4 或 8你不是在增加显存压力而是在把之前省下来的显存重新兑换成真正的吞吐量。这里有一个关键原则先看torch.cuda.max_memory_allocated()返回的峰值再估算空余显存用“空余显存 × 0.9 / 当前激活占用”推算一个新 batch 的起点。不要一上来就拍脑袋翻倍容易直接 OOM。3.2 梯度累积的串行本质不是并行而是分批排队很多人以为梯度累积可以让“小 batch”模拟“大 batch”同时还提高 GPU 利用效率这是两回事。梯度累积的逻辑是跑 N 个 micro-batch攒够梯度再更新一次参数。它带来的收益是优化器状态更新得更稳定等效于更大的 batch但 GPU 在这 N 个 micro-batch 之间依然是串行执行的并不会因为“累积”而把 N 个 batch 塞进同一个 kernel 一起算。更麻烦的是梯度累积会让每个 micro-batch 都要做一次完整的前向和反向算力利用率取决于单个 micro-batch 的规模。如果你的 base batch size 只有 1累积 8 步和直接跑 batch size 8 的差距非常大。前者是 8 个小 kernel 排队后者是 1 个大 kernel 跑满 SM。所以我的建议是优先把单次 batch size 提到显存允许的上限再用梯度累积去凑你想要的等效 batch。这个顺序不能反。先调 batch再调累积步数才是把显存余额兑换成算力的正确路径。3.3 三步实验法把 batch size 顶回安全边界我自己用一套很土但很有效的三遍实验法。第一步保持当前 batch size跑一百步记录max_memory_allocated和峰值显存。第二步把 batch size 乘一个系数比如 1.5 倍往上试如果 OOM 就回退直到找到能稳定跑完的临界值。第三步在临界值基础上留 10% 余量把梯度累积步数反过来调小。这套方法看起来原始但比任何理论估算都靠谱。原因是激活值内存和 batch size 的关系并不是严格的线性它还会受 sequence length、attention 计算方式、是否使用torch.compile影响。理论公式给个方向实测数值给最终结论。4. 被忽略的头号“吃不满”元凶kernel 启动开销与算子粒度4.1 CPU 下发指令比 GPU 跑算子还慢这事常被当成玄学如果你用 Nsight 或者 PyTorch Profiler 去看时间线会看到一种非常经典的图GPU 上每个 kernel 只跑了几十微秒但两个 kernel 之间留着一大段空白。这些空白大部分是 CPU 在“指挥”——PyTorch 需要把算子派发到 CUDA 后端分配临时内存检查 tensor 设备和形状然后调用驱动 API 把 kernel 排队到 GPU。一个 CUDA kernel 的启动开销大约在 5~10 微秒这个量级。如果 kernel 本身执行时间只有 20~30 微秒那启动开销就占据了 20% 以上的时间。当你的模型里充满大量小算子比如逐元素相乘、归一化、LoRA 拆分出来的低秩矩阵乘法CPU 下发指令的速度就会成为真正的瓶颈GPU 只能空转等着被喂食。4.2 LoRA 和量化把大算子拆成了小算子反而放大启动开销这是 QLoRA 最讽刺的地方。我们用 LoRA 是为了省显存和加速但 LoRA 的计算会把原来一个大的线性层y Wx变成y Wx (B(Ax))中间还要加一个合并相加操作。模型原本一层只有一个 matmul现在变成两个小 matmul 加一个 addkernel 数量翻了倍每个 kernel 的数据规模还更小。4bit 量化更狠。原始权重量化后计算前需要反量化某些实现里还会在量化和浮点之间来回切换导致额外的 kernel 和内存搬运。这些操作单个看都很快但叠加起来GPU 的时间轴被切得非常碎利用率自然上不去。4.3 CUDA Graph 与 torch.compile到底能救多少torch.compile的核心价值不是把代码“编译”成更快的神秘二进制而是做算子融合和减少 kernel 启动次数。它会尝试把连续的逐元素操作、归一化、激活函数合并成一个 kernel减少 CPU 派发次数。对于 LoRA 和量化这种“碎算子遍地走”的场景融合收益通常很明显。CUDA Graph 则是另一个思路把一串 kernel 的启动计划一次性捕获成一张图之后每次执行只提交一次图启动。它会消除大量 CPU 启动开销但要求模型的正向和反向过程中 tensor shape 和分支结构必须是静态的。在实际训练里如果关闭动态 shape、固定 batch size 和 sequence lengthCUDA Graph 能带来 10%~30% 的吞吐提升。我亲测过提升幅度取决于模型中 kernel 碎碎的程度。注意CUDA Graph 和某些梯度检查点实现、动态 padding 会打架需要逐个开关验证。5. 那些“省显存”的技术正在怎么把 GPU 晾着5.1 梯度检查点重算换来显存代价是 GPU 时间碎片化梯度检查点的原理是前向传播时不保存所有中间激活值只保存少量检查点反向传播时遇到没有保存的激活值就重新算一遍。这样显存占用大幅下降但代价是反向传播期间要额外执行一次前向计算GPU 的算力被重复使用了。更隐蔽的是梯度检查点会让反向传播的时间分布变得不均匀。每一步反向传播里会突然插入一大段“重算前向”的 kernel这些 kernel 和真正的反向 kernel 交错出现GPU 的调度器需要来回切换上下文。实测下来无脑对全部层开梯度检查点训练时间可能增加 20%~40%。这还不是最坑的最坑的是你省下来的显存如果没换成更大的 batch那这部分重复计算的损失就纯亏了。合理做法是只挑激活值占用最大的层开检查点。在 Transformers 里通常是 attention 层之后的大块 MLP 激活。你可以逐层测量torch.cuda.memory_allocated找到显存大头再决定在哪几层重算。5.2 卸载到 CPU/NVMe显存够了总线却堵了CPU offload 和 NVMe offload 被很多低显存玩家当宝贝用因为它确实能把模型塞进小显存。但请记住一句话显存问题被转移成了带宽问题。模型权重存放在 CPU 内存或固态硬盘上每次计算前要把权重通过 PCIe 总线搬到 GPU。PCIe 4.0 x16 的理论带宽大约是 32GB/s听起来不小但 7B 模型哪怕只搬一半权重也是好几个 GB 的量级搬运时间足够 GPU 完成大量计算了。我测试过把部分层 offload 到 CPU 的配置训练时 GPU 利用率经常会掉到 20% 以下因为设备在大部分时间里都在等 PCIe 传输。这类方案的定位应该是“能跑通、能复现实验”它不适合作为长期训练方案。如果你真的只能靠 offload 跑通建议至少把 optimizer state 留在 GPU让每一次迭代里只有权重传输而不是梯度、权重、优化器三样东西都走 PCIe。5.3 多卡场景同步与通信让 GPU 空转如果你上了多卡还有一个更隐蔽的吃不满来源通信同步。在 DDP 模式下每个 step 反向传播结束后所有 GPU 要做一次 all-reduce把梯度同步到所有节点。假设模型有 7B 参数一个梯度张量就是 14GB 的通信量所有权重都对应一个梯度即使 4 卡之间用高速互联同步时间也相当可观。这时候 GPU 不是闲着而是在“等待队友”。你会看到每步训练里有一段明显的低利用率窗口反向传播完成、all-reduce 开始、GPU 在那傻等通信结束。梯度累积可以降低同步频率因为你可以累积多个 micro-batch 的梯度后只做一次 all-reduce。这也是前面说梯度累积不完全只有坏处的原因它本质上是拿更长的单步时间来换更少的同步次数。多卡场景一定要先量通信占比不要无脑加卡。6. 一套实用的定位实战从“省下显存但吃不满”到“把 GPU 喂到八九成”6.1 第一轮秒级仪表看空闲发生在哪个阶段我建议所有排查都从最粗的粒度开始不要一上来就抓 kernel。先用nvtop或者nvidia-smi dmon -s puc 1跑一轮训练观察显存占用、GPU 利用率和 PCIe 流量。这一段是要回答一个问题GPU 是在整个训练过程中都吃不满还是只在某些阶段吃不满如果是周期性吃不满很可能和数据加载或同步协议有关如果是持续吃不满大概率是 batch size 太小或 kernel 太碎。另一个值得做的实验是把训练脚本里所有数据加载、CPU 预处理逻辑注释掉改用随机生成的假 tensor 跑一百步。如果换掉数据之后 GPU 利用率明显上升那说明瓶颈在数据链路如果利用率还是低那问题就出在模型计算图本身。6.2 第二轮用 PyTorch Profiler 看迭代内部时间分布假数据实验只能粗分方向要定位到具体算子我建议打开 PyTorch Profiler。在训练循环里包一段代码跑二三十步输出按 CUDA 时间排序的 kernel 表。我自己的 4060 笔记本上跑 QLoRA典型的结果是大量时间花在aten::_scaled_dot_product_attention、dequantize、small matmul这类小 kernel 上CPU 时间表和 CUDA 时间表之间有明显的大段空档。代码很简单from torch.profiler import profile, ProfilerActivity with profile(activities[ProfilerActivity.CPU, ProfilerActivity.CUDA]) as prof: for step in range(30): loss model(batch) loss.backward() optimizer.step() print(prof.key_averages().table(sort_byself_cuda_time_total, row_limit40))注意Profiler 本身会拖慢训练所以只要在需要定位时打开定位完就关掉。它给的是“相对时间分布”不是性能基准别拿它和正常训练比速度。6.3 第三轮针对表格里的异常项动手拿到 kernel 时间表后我一般按下面这个顺序排查效果非常好现象大概率原因对应处理GPU 低利用率 CPU 高占用数据加载/tokenizer 瓶颈预 tokenize、开 DataLoader worker、pin_memoryGPU 低利用率 大量小 kernelkernel 启动开销太碎torch.compile、CUDA Graph、算子融合GPU 低利用率集中在反向阶段梯度检查点重算过多减少检查点层数只保留激活大头层GPU 低利用率呈周期性波动多卡通信 / 每 N 步同步梯度累积降频通信或先用单卡确认显存有余量但利用率低batch size 没顶上去按 3.3 的三步法重调 batch6.4 不要盲目追求 100% 利用率这台笔记本我折腾到最后把 GPU 利用率从 30% 提到了 80% 左右但我没再往上追。原因很简单利用率接近 100% 只说明 SM 一直在跑不代表跑的指令都是有效计算。当 batch size 已经顶满、kernel 已经融合、瓶颈已经移除之后继续抠那 20% 的利用率性价比很低。你还会碰到显存余量接近临界、OOM 风险上升、实验迭代变慢的问题。实际工程里把利用率稳定在 75%~90%显存占用控制在 90% 以内就是一个非常健康的训练状态。7. 几条让我直接受益的经验这一节不讲框架只讲我在反复折腾里沉淀下来的三条土经验。第一省显存和提算力要当成两件事做。先看显存再看利用率最后才调 batch 和 kernel。顺序反了你会在一堆开关之间反复横跳却不知道瓶颈到底在哪。第二任何“显存优化技巧”都要先问一句它让 GPU 多干了什么活还是少干了什么活梯度检查点让 GPU 多算一遍量化反量化让 GPU 多跑 kerneloffload 让 GPU 多等总线。想清楚这一点很多方案你一眼就能判断值不值得上。第三工具比感觉可靠。我踩过最大的坑就是“感觉显存省了训练应该快了”结果一测才发现根本没有任何提升。现在我会默认在训练脚本里留一段 Profiler 和max_memory_allocated的输出每次调完配置都跑几十步看数据。最后分享一个小技巧调 batch size 时别只看nvidia-smi里的显存占用要看torch.cuda.max_memory_allocated()因为 PyTorch 的显存分配器会缓存已释放的显存块nvidia-smi显示的数字往往偏大会误导你把 batch 调小。用max_memory_allocated()看真实峰值再用torch.cuda.reset_peak_memory_stats()重置测量点你会得到一个更真实的内存画像batch 也能顶得更高一些。