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

资讯详情

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

单机不调优,多卡也白搭:GPU利用率与训练性能实战指南

单机不调优,多卡也白搭:GPU利用率与训练性能实战指南 上次在客户现场排查一个 Llama 微调任务我发现了一个特别典型的场景一台 8 卡 A100 机器nvidia-smi 看过去显存全占满了但利用率只有个位数而且 8 张卡里 4 张在动、3 张在围观、1 张在摸鱼。运维兄弟的第一反应是“网络瓶颈”第二反应是“分布式策略有问题”结果查了一圈发现问题压根不在多卡通信上而是单卡的数据加载和算子执行效率拖了后腿。这个现象在 AI Infra 领域太常见了。大家都盯着“万卡集群”“通信拓扑”这些宏大叙事却忽略了最基础的一件事单机单卡的性能有没有榨干。标题里那句“8 张卡 7 张闲”说白了就是分布式时代的第一笔糊涂账——你连一张卡都喂不饱凭什么觉得问题在网络上这篇文章我就把单机调优这块的实操经验掰开揉碎讲清楚包括怎么定位瓶颈、怎么调数据管道、怎么抠算子细节以及如何用一套可复现的流程把单卡性能拉满。1. 一张卡都喂不饱先别急着怪网络很多人在多卡训练遇到性能问题时第一反应是检查 NCCL、检查 IB 网络、检查拓扑结构。但根据我过去几年的排障经验超过一半的“分布式性能问题”最后定位到的根因都在单机内部甚至单卡内部。这个结论不是拍脑袋而是有数据支撑的如果单卡训练时 GPU 利用率本身就低于 90%那就算给你 1000 张卡利用率也上不去只是把浪费的面积放大了而已。1.1 为什么分布式瓶颈往往藏在单机里理论上多卡训练的加速比取决于通信开销和计算开销的比例。通信占比越低加速比越接近线性。但如果单卡的计算效率本身就低下比如某个算子只发挥了 GPU 算力的 20%那通信开销再低也没用——因为计算路径本身就是个瓶颈。举一个我实际遇到过的例子有个训练任务用 DeepSpeed ZeRO-3 跑 8 卡吞吐量始终上不去。当时团队怀疑是 ZeRO 的通信量太大把 offload 参数调了好几轮效果甚微。后来我用 nsys 抓了一遍 profile 才发现罪魁祸首是 PyTorch DataLoader 的 num_workers 设置过低GPU 每轮迭代要白白等 300 毫秒的数据预处理。这个时间占整个 step 时间的 40%。也就是说即便通信完全优化到零开销整体性能也最多提升 1.6 倍左右。这个案例说明一个很朴素的道理分布式训练的性能天花板首先取决于单机训练时的 GPU 利用率。如果单机跑不满先解决单机的问题再考虑多卡的事。这也是为什么这篇要单独把“单机调优”拎出来讲——它是所有分布式优化的地基。1.2 先给机器做个“流量体检”那怎么判断一台机器的 GPU 到底有没有被喂饱我一般分三步来做体检第一步看基础指标。用nvidia-smi dmon -s pucvmet -d 1持续采样重点看 SM 利用率sm、显存占用mem、PCIe 读写rx/tx和温度temp。如果 SM 利用率持续低于 80%而显存占用又很高大概率是数据加载或者线程同步的问题如果 SM 利用率低且显存占用也不高那可能连数据都没送到 GPU 上问题出在 CPU 侧的数据管道。第二步做单卡基准测试。把模型的分布式部分全部去掉用单卡、单进程跑 50 个 step记录稳定的 step 时间。然后把这个时间和理论最优值做对比。理论最优值怎么估用模型的计算量除以 GPU 的峰值算力再乘以一个 0.4~0.6 的系数实际能达到的利用率。如果实测时间是理论值的 3 倍以上说明单机内部有明显的效率损耗。第三步用性能分析工具定位具体位置。PyTorch 自带torch.profilerNVIDIA 的nsys和ncu更专业。nsys 可以看到 kernel 的时间线、CPU 端和 GPU 端的重叠情况ncu 可以看单个 kernel 的 SM 利用率、访存效率、warp 占用率等细节。大部分时候跑到这一步就能定位到问题了。这个方法我在不同的客户现场用了很多次基本上没有失手过。下面我详细拆解每一步怎么操作以及常见的坑在哪。2. 把时间花在刀刃上读懂 GPU Profile 报告有些朋友一听到“性能分析”就头疼觉得太复杂。但其实掌握几个关键指标就够了不需要把 ncu 的所有 output 都研究一遍。我按优先级排序先看这几个东西。2.1 SM 利用率不等于 GPU 利用率先说一个最常见的误区很多人看nvidia-smi里的 “Volatile GPU-Util” 或者nvidia-smi的利用率以为这个数字高就代表 GPU 用得好。实际上这个利用率统计的是“在过去的时间窗口内有 kernel 在执行的时间比例”它并不反映 GPU 的计算单元到底跑得多饱和。举个例子一个 kernel 只用了 10% 的 SM 资源但它在显存上搬运数据搬了整整一秒那在 nvidia-smi 里看到的利用率可能是 100%但实际算力利用率极低。这种情况我记得特别清楚有一次排查一个推理服务的性能问题nvidia-smi显示 GPU 利用率 98%但 QPS 就是上不去。后来用 ncu 一看发现是 Embedding 层在频繁做小张量的 gather 操作SM 大部分时间在等待显存访问真正在算的时间不到 15%。所以诊断问题时不要只看nvidia-smi的利用率要深入看 SM Active、SM Busy、Mem Busy 这些细粒度指标。指标含义健康值参考SM BusySM 有活动 warp 的时间占比 60% 算正常SM ActiveSM 上至少有一个 warp 被调度的时间占比越高越好Mem Busy显存控制器忙的时间占比与算子类型相关Achieved Occupancy实际达到的 warp 占用率50% 以上为宜DRAM Throughput显存带宽利用率结合算子类型判断2.2 用 torch.profiler 快速定位 CPU 瓶颈在调优初期我强烈建议先用torch.profiler做一次粗筛因为它不需要 root 权限也不需要安装额外的 Python 包PyTorch 2.x 直接内置。import torch from torch.profiler import profile, ProfilerActivity with profile( activities[ProfilerActivity.CPU, ProfilerActivity.CUDA], scheduletorch.profiler.schedule(wait5, warmup3, active10, repeat2), on_trace_readytorch.profiler.tensorboard_trace_handler(./log) ) as prof: for step, batch in enumerate(dataloader): loss model(batch) loss.backward() optimizer.step() prof.step() if step 30: break跑完之后看输出里的Key Average表重点关注几个字段CPU Total 时间排名靠前的操作如果某个操作在 CPU 上消耗的时间远超 GPU 时间说明数据预处理或张量搬运形成了瓶颈。CUDA 时间排名靠前的 kernel把这些 kernel 记下来后续用 ncu 深入分析。Self CPU Time 高的张量操作比如频繁的.cpu()、.cuda()、.item()、.numpy()调用这些都是同步操作会阻塞流水线。我见过最夸张的一个案例是有人在训练循环里为了打印 loss 调了一次loss.item()这本来没啥问题问题是他每一次 iteration 都调用了torch.cuda.synchronize()。这个操作会让整个流水线停顿GPU 每步等 CPU 把统计信息打印完才开始下一轮直接把训练速度拖慢了 5 倍。这种问题用 profiler 一眼就能看出来。2.3 nsys 看全局流水线重叠torch.profiler 适合看算子级别的细节但要理解 CPU 和 GPU 的整体流水线是否重叠nsys更擅长。命令也很简单nsys profile --tracecuda,nvtx,osrt --outputprofile_out python train.py然后用nsys stats --reportcuda_gpu_trace profile_out.nsys-rep查看 GPU kernel 时间线或者直接打开 Nsight Systems 的 GUI 看可视化时间轴。核心看一个东西CPU 的启动间隙gap。在时间轴上如果 GPU 的一串 kernel 之间存在明显的空隙说明 CPU 在下一个 kernel 启动前有一段空白时间——也就是数据还没准备好。这个空隙如果是周期性的而且每次空隙时间差不多那基本可以断定是 DataLoader 的吞吐跟不上或者 Python 侧有同步点导致流水线中断。用 nsys 看时间轴有个小技巧把 view 的缩放级别调到一个 step 的区间内观察 CPU 端是不是有连续的DataLoader或者collate操作占住了单线程。num_workers设置不正确时CPU 侧的数据加载操作会周期性地挤占主线程导致 GPU 空闲。这种问题从时间轴上很容易识别CPU 主线程在迭代间隙出现明显的密集计算而 GPU kernel 在此期间完全停顿。3. 数据管道调优把“喂饭”速度提上去在单机调优里我踩过最深的坑就是数据加载。很多人以为 DataLoader 的num_workers开到 CPU 核数就行但实际完全不是这么回事。数据管道的调优有几个层次worker 数量的合理设置、数据预取策略、存储介质的选择以及数据格式对解码速度的影响。这几个层面相互影响需要综合考虑。3.1 num_workers 不是越大越好num_workers的官方文档说默认是 0也就是在主进程里做数据加载。这在数据量小的时候没问题但训练场景下数据量大、preprocessing 复杂就必须开多进程。但开多少合适我的经验法则先按 CPU 物理核数的 1/2 到 2/3 设置然后实测。为什么不是越大越好因为每个 worker 都是独立的进程它们需要和主进程通信通过 shared memory进程多了之后IPC 开销和 CPU 上下文切换成本会吃掉数据加载带来的收益甚至会挤占主进程做 forward/backward 的 CPU 资源。我通常用一个简单的脚本测试不同num_workers下的吞吐量import torch from torch.utils.data import DataLoader, TensorDataset import time dataset TensorDataset(torch.randn(100000, 3, 224, 224), torch.randn(100000, 10)) for nw in [0, 2, 4, 8, 16, 24, 32]: loader DataLoader(dataset, batch_size64, num_workersnw, pin_memoryTrue, prefetch_factor4) start time.time() for batch in loader: pass elapsed time.time() - start print(fnum_workers{nw}: {elapsed:.2f}s)注意这里只测了纯数据加载的耗时没有计算 GPU 部分的耗时。在实际训练中DataLoader 的加载可以和 GPU 计算重叠所以判断标准不是加载耗时本身而是加载耗时是否小于 GPU 计算耗时。如果数据加载的时间小于每步计算时间说明数据管道不是瓶颈num_workers不用再调了。我见过一个项目为了追求极致的加载速度把num_workers设成了 CPU 核数 × 2结果每个 epoch 的数据加载时间确实缩短了 30%但训练的总时间反而增长了 15%。原因是 worker 进程太多和主进程抢 CPU 资源导致 PyTorch 的 autograd 和 Python 侧的逻辑执行变慢。所以调优的指标应该是“整体 step 时间”而不是单纯的“数据加载时间”。3.2 pin_memory 与 non_blocking 的组合拳pin_memoryTrue是 DataLoader 里一个容易被忽略但非常重要的参数。它的作用是把数据放在页锁定内存page-locked memory中这样 GPU 可以通过 DMA 直接访问不需要经过 CPU 的分页拷贝。如果不设置数据在 CPU 和 GPU 之间传输时要多一次内存拷贝。配合pin_memory在 GPU 侧搬运数据时建议加上non_blockingTruefor batch in dataloader: x, y batch x x.cuda(non_blockingTrue) y y.cuda(non_blockingTrue)加上non_blockingTrue之后.cuda()调用会立即返回数据传输在后台通过 CUDA stream 异步执行。这意味着 CPU 可以继续执行下一个 batch 的预处理而不会卡在等待数据传输完成上。这里有一个常见的反模式有些人用tensor.cuda()不带 non_blocking然后在代码里又没有其他计算来掩盖这个同步等待。这时候 GPU 会在数据拷贝的整个时间段内处于空闲状态等价于一次同步操作。尤其对小 batch 的训练任务这种开销占比很可观。所以我通常建议只要用了pin_memoryTruecuda()调用一律加non_blockingTrue这是没有副作用的优化。3.3 WebDataset 或 TFRecord 格式IO 才是终极瓶颈当数据量上了 TB 级别单张图片或单条样本作为一个独立文件存储时文件系统的 inode 开销会成为新的瓶颈。尤其是数据在机械硬盘或者网络文件系统上时每次打开文件都有寻道延迟几万个小文件加起来光 IO 等待就够 GPU 喝一壶。这种情况我有两个推荐方案WebDataset把样本打包成 tar 文件每个 tar 里放 1000~10000 个样本。训练时随机读取 tar 文件用顺序 IO 替代随机 IO吞吐量可以提升一个数量级。TFRecord 方案TensorFlow 生态的 TFRecord 本质上也是大文件顺序读。PyTorch 里可以用 tfrecord 库或者转成 WebDataset。实际效果对比我做过一次同样一份 2TB 的图像数据集用原始小文件格式时8 个 worker 的加载速度大约是 200MB/s转成 WebDataset 之后同样 8 个 worker加载速度跑到了 1.2GB/s。这个差距在训练时直接体现为 GPU 利用率的差异前者 SM 利用率只有 50% 左右后者可以稳定在 90% 以上。3.4 prefetch_factor 的微妙影响PyTorch 1.11 之后 DataLoader 新增了prefetch_factor参数含义是每个 worker 预先加载的 batch 数量。默认是 2意味着每个 worker 会在当前 batch 处理完之前提前准备 2 个 batch。调大prefetch_factor的作用是增加数据缓冲区的深度让 CPU 能更早地开始下一批数据的预处理从而掩盖加载延迟。但如果设置得太大会占用更多的 shared memory。在 Docker 容器里跑训练时尤其要注意因为 Docker 默认的/dev/shm容量只有 64MBprefetch 太多数据可能导致 shared memory 溢出报RuntimeError: DataLoader worker (pid xxxx) is killed by signal: Bus error。遇到这个报错最简单的方式是启动容器时加上--shm-size32g或者在代码里手动设置临时目录import tempfile tempfile.tempdir /path/to/large/disk另外prefetch_factor调大后CPU 内存占用会相应增加。如果机器内存紧张要权衡一下。我的一般经验小 batch64 以下用默认的prefetch_factor2就够了大 batch256 以上可以试试 4但不要超过 8因为收益递减很明显。4. 算子级别的优化从 PyTorch 源码到底层 CUDA Kernel数据管道调好之后下一步是抠 GPU 侧的计算效率。这一层的优化是收益最明显的也是最需要耐心的。下面我会从三个层面来讲框架层算子替换、显存布局与算子融合、CUDA Graph 的使用。4.1 算子替换同样的效果不同的速度PyTorch 有很多功能等价但性能差别很大的操作。最典型的几个torch.mmvstorch.bmmvstorch.einsum矩阵乘法是最常用的算子但很多人不知道einsum在某些 shape 下会比bmm慢很多。虽然 einsum 语法简洁但它会走通用的路径处理不好会引入中间张量增加显存开销。我的建议是能用bmm或matmul表达的矩阵乘法不要用einsum。F.interpolatevsF.upsampleinterpolate支持更多模式但老代码里的upsample在某些场景下会有 kernel 融合的优化。PyTorch 1.11 之后建议统一用F.interpolate并优先使用align_cornersFalse的 bilinear 模式因为它在 GPU 上的 kernel 效率更高。torch.catvs 预先分配显存频繁的torch.cat会反复申请和释放显存触发缓存碎片化。如果某些张量的拼接发生在训练循环内且 shape 不变可以预先分配一块大显存用切片赋值替代cat。举一个更具体的例子Transformer 里的 attention score 计算如果直接按 naive 的方式写scores torch.matmul(query, key.transpose(-2, -1)) / math.sqrt(d_k) probs torch.softmax(scores, dim-1) output torch.matmul(probs, value)这个写法没问题但显存会额外占用batch * heads * seq_len * seq_len大小的中间张量。如果在推理阶段可以换成 flash-attention 的实现在训练阶段也可以考虑用memory_efficient_attentionPyTorch 内置的torch.nn.functional.scaled_dot_product_attention它会把 scaled dot product attention 融合成一个 kernel省去中间张量的读写。实测在大 batch、长序列场景下这个替换可以把 attention 部分的耗时降低一半以上。4.2 显存布局channel last 带来的免费午餐PyTorch 在 1.9 以后原生支持 Channels Last 内存格式NHWC但很多 CV 训练代码默认还是 NCHW。对于卷积神经网络NHWC 在某些 GPU 架构上有更高效的 kernel 实现因为卷积在 NHWC 下访问显存时局部性更好能明显减少 L2 缓存的 miss。开启方法很简单model model.to(memory_formattorch.channels_last) input input.to(memory_formattorch.channels_last)但注意不是所有模型都适合换格式。我在 ResNet、EfficientNet 上测试过NHWC 的收益在 10%~20% 之间。但到了 Transformer 类模型上这个收益不明显因为 transformer 的核心是矩阵乘它的显存访问模式不依赖通道布局。所以CV 模型可以试试 channels lastNLP 模型就没必要折腾了。另外一个显存布局的坑是如果模型里用了自定义的nn.Module里面写了硬编码 NCHW 的 view/permute切换到 channels last 之后可能报 shape 不匹配的错误。遇到这种情况需要逐个排查自定义层的实现或者用torch.jit.script把模型脚本化之后再做格式转换脚本化后的模型对内存格式的处理更鲁棒。4.3 CUDA Graph把 kernel 启动开销彻底藏起来CUDA Graph 是 NVIDIA 提供的一项能力用来降低 kernel 启动的开销。GPU 上每个 kernel 的启动需要 CPU 端发指令这个开销在几十微秒级别。对于深度学习训练来说一个 step 可能包含几百个 kernel累计的启动开销占比可能达到 10%~20%。PyTorch 2.x 对 CUDA Graph 的支持比较完善了可以用torch.cuda.graphs来捕获整个训练 stepg torch.cuda.CUDAGraph() # warmup for _ in range(3): static_input next(iter(dataloader)) static_input [t.cuda(non_blockingTrue) for t in static_input] loss model(static_input) # capture with torch.cuda.graph(g): static_output model(static_input)捕获完之后在训练循环里直接重放g.replay()kernel 启动的 CPU 开销就省掉了。实测在 NLP 小 batch 场景下CUDA Graph 可以把 step 时间缩短 15%~30%。但 CUDA Graph 有一个重要的使用限制捕获期间不能有动态 shape、不能有数据依赖的 if/else 分支、不能有 CPU 同步点和 GPU 内存分配。这些限制在实际模型里很难完全满足。我的建议是先尝试捕获整个训练 step如果失败可以退而求其次只捕获模型 forward 的部分不包含 loss 和 backward这样也能获得一部分收益。在使用 CUDA Graph 时有一个很隐蔽的坑是静态输入内存。捕获时输入的张量是固定的显存地址每次 replay 时要确保新数据拷贝到这个静态内存中而不是传给一个新的张量。也就是说要写成static_input.copy_(new_batch)然后g.replay()。很多人在这一步踩坑导致每次 replay 用的都是第一次捕获时的数据loss 始终不变还以为模型坏了。4.4 混合精度不只是 fp16 而已混合精度属于老生常谈但不少人在实际使用中仍然会用错。AMPAutomatic Mixed Precision的正确用法是scaler torch.cuda.amp.GradScaler() for batch in dataloader: with torch.cuda.amp.autocast(): loss model(batch) scaler.scale(loss).backward() scaler.step(optimizer) scaler.update()几个容易被忽略的细节autocast的作用范围要尽量覆盖 forward 和 loss 计算的完整部分不要只在模型里用。如果 loss 计算中有交叉熵外部的手动计算最好也放进autocast上下文里。GradScaler的backoff_factor默认是 0.5这个参数控制的是梯度溢出时缩放因子的衰减速度。训练不稳定时可以调大到 0.8减少缩放因子波动带来的精度损失。在torch.compile出来之后很多人问 AMP 还要不要手动写。答案是用torch.compile依然建议保留手动 AMPtorch.compile不会自动帮你做混合精度处理。还有一个 fp16 训练中常见的坑某些自定义 Op 不支持 fp16 输入。这时候需要在autocast上下文里用torch.cuda.amp.custom_fwd装饰器或者手动float()转换。如果不管三七二十一直接跑经常会遇到显存溢出或者数值异常排查起来很费劲。5. 实测排障从“8 张卡 7 张闲”到“8 张卡全部拉满”前面讲了这么多理论下面结合一个实际的排障案例把整套流程串起来。这个案例来自我在某客户现场遇到的一个训练任务现象就和标题写的一模一样8 张 A100 卡显存全占但 SM 利用率领域在 5%~30% 之间波动7 张卡基本是“陪跑”状态。5.1 现象确认先用工具说话不要猜我的第一步是用 nsys 对整个训练流程做 profile同时用nvidia-smi dmon -s pucvmet -d 5记录实时的指标变化。结果很快看到两个关键线索第一GPU 的 SM 利用率呈现明显的周期性波峰波谷。每个 step 的前 600ms 稳定在 90%后面 800ms 直接掉到 10% 以下。这个波形说明 GPU 在持续等待某个外部资源。第二CPU 的利用率同样呈现周期性变化而且波峰和 GPU 的波谷高度重合。这说明 GPU 空闲的时间段CPU 在做密集计算。这两个线索合在一起基本可以确定瓶颈不在 GPU kernel 内部而在 CPU 侧的数据流水线。5.2 逐层定位DataLoader 是第一个嫌疑人为了验证 DataLoader 是不是瓶颈我写了一个简单的基准单独跑 DataLoader统计每个 batch 的取数耗时。同时对比训练循环里一个 batch 的总耗时。结果发现取一个 batch 的平均耗时是 900ms而训练一个 step 的总耗时是 1.4s。也就是说数据加载时间占了整个 step 的 64%。接下来再看 DataLoader 的内部参数num_workers8prefetch_factor2看起来配置不算离谱。但用 py-spy 看一下 CPU 的调用栈发现每个 worker 在解码图像时调用了大量的 Pillowresize和convert操作每次解码耗时在 50ms 以上。问题很明显了JPEG 解码和图像 Resize 这两个操作消耗了绝大部分 CPU 时间。5.3 解决方案一图像解码前置我先想到的方案是在数据预处理阶段提前把图片解码成 RGB ndarray缓存成.npy文件或者 WebDataset这样训练时跳过 JPEG 解码。但客户的数据集有 2TB重新预处理一遍要花将近一天的时间不太划算。于是换了个思路用 TurboJPEG 替代 Pillow 的 JPEG 解码。TurboJPEG 是一个基于 libjpeg-turbo 的 Python 库解码速度比 Pillow 快 3~5 倍。安装和调用都很简单from turbojpeg import TurboJPEG jpeg TurboJPEG() def decode_jpeg(img_bytes): img jpeg.decode(img_bytes, pixel_formatTJPixelFormat.RGB) return cv2.resize(img, (224, 224))配合cv2.resize替代 Pillow 的resize单张图片的解码缩放耗时从 50ms 降到了 12ms。DataLoader 的取数耗时立刻从 900ms 降到了 220ms训练整体的 step 时间从 1.4s 降到了 0.8s。5.4 解决方案二调整 DataLoader 参数和共享内存在换了解码方案之后我又把num_workers从 8 调到 16prefetch_factor从 2 调到 4。这次调整直接把取数耗时压到了 130ms。但很快遇到了新的问题容器里的/dev/shm被占满了报出了DataLoader worker (pid xxxx) is killed by signal: Bus error。这就是之前提到的共享内存问题把容器启动参数加了--shm-size32g之后解决。还有一个细节值得强调在很多云上 GPU 环境里/dev/shm默认只有 64MB所以在容器里跑训练一定要主动设置--shm-size否则哪怕 DataLoader 参数合理也会被这个限制卡死。5.5 最终验证回归基线对比调优完成后我用同样的训练配置跑了一遍对比结果如下指标调优前调优后提升幅度单步耗时1.4s0.65s2.15xGPU SM 利用率30%~50%88%~94%显著提升单卡吞吐样本/秒45982.18x预估 8 卡加速比3.2x7.1x接近线性这里有一个很关键的变化调优前 8 卡跑出来的实际加速比只有 3.2 倍远低于理想值。而调优后单卡的效率拉上去了8 卡的加速比直接到了 7.1 倍。这就印证了开头的观点多卡加速比的天花板很大程度上由单卡效率决定。6. 单机调优的“长期主义”把它变成规范化动作调优不是一锤子买卖。模型结构在变、数据规模在变、GPU 驱动和 PyTorch 版本也在变单机性能随时可能回退。我看到很多团队做了一次性能优化过了一个月性能又变差了原因是改了某个算子或者升级了框架老问题又冒出来了。6.1 建立性能回归基准我建议每个训练项目都建立一个简单的性能回归脚本每次改动代码合并前自动跑一遍。这个脚本不需要太复杂核心就几件事用固定的伪随机数据跑 50 个 step记录稳步时间。统计 GPU SM 利用率、显存峰值。对比上一次的 baseline如果 step 时间退化超过 10%就拒绝合并。这个基准的关键是“固定数据”。用真实数据的话每次运行的 IO 波动会掩盖真实的计算性能变化。所以用torch.randn直接生成一批固定 shape 的伪数据放在内存里反复用这样才能准确地测量计算路径的性能。6.2 日常训练时的监控习惯性能问题最好在发生初期就发现而不是等训练跑了好几天之后才察觉。我在服务器上常驻一个监控脚本定时采集 GPU 利用率、SM 利用率、显存温度、功耗等数据输出到 influxdb grafana。配置一个告警规则如果 SM 利用率持续 5 分钟低于 60%并且不是预期中的验证阶段就通知值班人员检查。另外推荐一个小工具gpustat它可以快速查看多张卡的实时状态。不像nvidia-smi那么冗长它的输出更紧凑watch -n 1 gpustat --show-user --show-cmd这个命令可以实时看到是哪个用户、哪个进程在占用 GPU排查多用户共享机器时的资源抢占有奇效。6.3 把调优经验沉淀成团队文档单机调优的知识点很零散很容易踩一次坑、记一次教训然后过几个月又忘了。我建议团队里维护一份“训练性能调优手册”把每次定位问题的过程、关键指标数据、最终解决方案都记录下来。这个文档不需要写得多么正式关键是记录“为什么”比如“为什么在这里用 ncu 而不是 torch.profiler”“为什么这个模型适合 channels last 但另一个不适合”。文档沉淀的时间越长团队排查问题的速度越快。我所在的团队现在平均定位一个单机性能问题只需要半天时间其中很大一部分原因是我们的调优手册里已经收录了各种常见坑的排查路径。6.4 最后提醒别把时间浪费在无效调优上说了这么多也提醒一句单机调优要分清主次不要本末倒置。有些性能问题确实是模型本身的计算量太大再怎么调数据管道、算子融合也没用因为 GPU 已经在满负荷算数学了。这时候应该考虑的是模型结构优化、量化、剪枝而不是继续在数据加载上死磕。我在实践中常用的判断逻辑很简单先用 ncu 看 GPU 的 SM Busy 是否达到 90% 以上。如果达到了说明计算路径已经接近饱和再去优化数据加载没有意义。如果 SM Busy 低于 70%同时 Mem Busy 也不高说明 GPU 大部分时间在等待外部数据这时候花时间调数据管道、调算子替换的收益就非常大。一句话概括我这几年的心得体会单机调优不是在本地机器上做苦力而是为分布式训练打地基。地基不稳上层再漂亮的架构也是空中楼阁。把这个步骤做扎实后面跑大规模训练时才能真正体现并行计算的威力。
返回列表