做AI系统性能工程,最容易翻车的不是不会用监控工具,而是没把性能当成一个真正的工程质量问题去闭环管理。前面两篇已经聊过GPU利用率、显存、时延这些基础指标怎么采、怎么看,到了这一篇,我打算把完整的流程串起来讲:指标口径怎么定、瓶颈怎么一层层揪出来、性能回归怎么守,以及怎么把这些东西换算成GPU卡数和成本预算。这篇适合正在做推理服务优化、训练任务提速、模型上线前压测的工程师。如果你是刚入门,建议先花半天把 torch.profiler 和 nvidia-smi 的常见输出看明白,再看这篇会轻松很多;如果你的团队已经上了监控面板,但每次性能出问题还是七嘴八舌说不清,那这篇关于指标口径和回归门禁的部分,应该能解决你80%的痛点。
1. 性能工程的完整闭环:先定指标,再谈优化
1.1 从指标混乱到SLI/SLO:把度量口径钉死
你肯定见过这种场景:业务同学反馈服务变慢了,打开监控面板看到GPU利用率只有20%,但应用指标里p99已经跑到200ms;另一个人跑了个离线测试,说模型推理只花了15ms,性能好得很。两个人都在聊性能,但说的根本不是一回事,原因就是指标口径没对齐。
AI系统的性能指标多到离谱,光是GPU相关就有利用率、显存、功耗、温度、NVLink带宽,应用侧又有QPS、p50/p99时延、排队长度、token吞吐。如果不把“到底看哪个指标、以哪个数值为准”先定下来,后续所有优化都是无底洞。我强烈建议团队在动手之前,至少把下面这套顶层结构写清楚:
| 系统类型 | SLI(用什么测) | 示例SLO(目标值) |
|---|---|---|
| 在线推理 | p99端到端时延 | ≤80ms |
| 在线推理 | 服务吞吐 QPS | ≥100 |
| 在线推理 | GPU利用率 | ≥40% |
| 离线训练 | 每步训练耗时 | ≤1.2s |
| 离线训练 | 数据加载等待占比 | ≤15% |
| 数据管线 | DataLoader预取命中率 | ≥95% |
为什么要用p99而不是平均值?平均耗时会被大量快请求拉低,完全代表不了长尾用户的实际体验。打个比方,一家餐厅平均上菜30分钟,但你每次都等1小时,你会觉得它快吗?p99衡量的就是最惨的那部分用户。对在线服务来说,p99不过关,平均值再好看都是自欺欺人。
提示:SLI和SLO不是定了就完事,最好还要配套“错误预算”。比如SLO要求99%的请求在80ms内完成,那错误预算就是1%的请求可以超时。每次版本上线前先看错误预算还剩多少,而不是上线后才发现超了。这个概念在AI系统里同样适用。
在AI系统领域,指标选择还有自己的行业特性。在线推理场景,除了常规QPS和时延,还要看每token生成时延;离线训练场景,光看step time不够,更推荐看有效吞吐(samples/s)和硬件浮点利用率(MFU/HFU),因为它们能反映算力到底有没有用在刀刃上。先把团队统一到同一套指标语言下,这是性能工程的第一优先级,比任何调优技巧都重要。
1.2 优化循环:度量、剖析、优化、回归
性能优化不是一次性动作,也不是把batch size调大、卡数增多就完事。我习惯把整个工作压成一个循环,每轮只动一个变量:
- 度量:拿到当前系统与SLO的差距,比如p99是150ms,目标是80ms,差了70ms。
- 剖析:逐层拆解,找到最值得改的环节,而不是凭感觉改一个参数。
- 优化:针对瓶颈做一次性改动,比如调整并行策略、优化数据加载。
- 回归:跑同一套基准,对比优化前后的指标曲线和profile产物,确认没有副作用。
这里要特别强调“每轮只动一个变量”。有人喜欢一口气把batch size、worker数量、量化精度全改了,结果性能确实变好了,但你说不清是哪项改动起了作用,下次换个模型照样抓瞎。我在实际工作中吃过亏:曾经同时改了DataLoader的worker数和模型并行策略,结果时延掉了一半,后来回滚数据加载改动,发现性能还是好,才知道瓶颈其实在并行策略上。那次之后,我给自己立了一条规矩——一个PR只解决一个瓶颈假设。
另外,把“优化周期”控制在1到2天也很重要。性能调优特别容易陷入“边际递减”的怪圈,明明已经达标了,还想再抠1ms,结果改了一周收益越来越小,风险却越来越大。我一般做法是:达到SLO之后,立刻固化实验纪录,转入下一个瓶颈,而不是停留在局部最优里打转。
2. 从外到内逐层拆:怎么把瓶颈揪出来
2.1 端到端时延拆解:先定位慢在哪个环节
在线推理服务的完整链路一般包括:客户端发起请求 → 网关/负载均衡 → 服务端入队 → 预处理(tokenize)→ 模型推理(prefill + decode)→ 后处理(detokenize)→ 序列化并返回。遇到时延问题,第一件事是把这条链路拆开,给每个环节打点计时。
用OpenTelemetry做分布式追踪是生产环境的常规做法,能还原一次请求经过的每个节点;如果想看更细的算子级耗时,用PyTorch Profiler生成trace,或者用NVIDIA Nsight Systems做系统级剖析。命令行很直接:
nsys profile --trace=cuda,nvtx,osrt python infer.py torch.profiler --schedule ... python train.py拿到数据后,按耗时占比排序找大头。举个例子,某次压测发现服务端总耗时120ms,拆开看是:入队等待15ms,预处理5ms,模型推理90ms,后处理加序列化10ms。模型推理占了75%,那肯定先查推理内部;但如果查下来发现GPU利用率并不高,说明推理函数内部存在大量小kernel启动开销或同步等待,这时候要继续深挖,而不是盲目增加并行度。
这里有个AI系统的特殊经验:如果是LLM推理服务,建议把模型推理阶段进一步拆成prefill和decode两个阶段来统计。prefill阶段要一次性处理整段prompt,计算量随输入长度线性增长,属于算力密集;decode阶段每生成一个token都要读取整条序列的KV cache,属于访存/带宽密集。混在一起统计,你只会看到一个虚高的GPU利用率均值,但decode局部的时延可能早就爆炸了。对LLM服务,更合理的方式是分阶段设SLO,比如prefill p95耗时≤2s,decode每token间延时≤50ms,这样定位问题会快得多。
2.2 GPU利用率上不去的三个典型现场
很多人一看到GPU利用率低就着急调batch size,但同样是“利用率低”,背后原因可能完全不同。我挑了三种最常见的“现场”,对号入座比瞎调参数有用得多。
现场A:GPU利用率低,QPS也低——小batch串行处理。每个请求单独推理,GPU根本没吃饱。传统CV或NLP模型,可以用动态batching把同时到达的多个请求拼到一个batch里;LLM场景,更推荐continuous batching,在decode阶段动态拼接不同长度的序列,但调度时要同时设max_batch_tokens和max_wait_ms两个阈值。比如max_batch_tokens=4096, max_wait_ms=20,意味着攒批最多等20ms,超过就直接启动,避免为了等一个请求把整体时延拖垮。
现场B:GPU利用率波动剧烈,峰值60%但平均只有20%——CPU侧数据加载成了瓶颈。GPU kernel跑几十毫秒,然后空转几百毫秒等数据,利用率自然被拉平。验证方法是用nsys看时间线,GPU kernel之间的大段gap大概率都是CPU在忙着做数据加载和预处理。解决办法很标准:调大DataLoader的num_workers和prefetch_factor,开启pin_memory,必要的话把图像解码、文本padding这类重活挪到GPU或异步线程里。这一套下来,GPU利用率从20%提到50%是常有的事。
现场C:多卡利用率不均匀,8张卡只有2张在满负荷跑——并行策略或通信问题。常见原因包括模型并行的气泡(bubble)太大、某个PP阶段计算量大,或者NCCL集合通信和计算没有重叠。先别急着改代码,用nvidia-smi topo -m看硬件拓扑,再用NCCL的debug日志看通信耗时占比。如果通信占比高,调整并行切分策略,或者把梯度通信和反向计算做overlap,往往比硬调batch size有效得多。
3. 性能回归测试:把性能当工程质量来守
3.1 把性能断言写进CI,挡住无声回归
性能问题最阴的地方在于:它不是某次重构马上引爆大事故,而是每次改版慢一点点,最后到某次升级,p99从60ms变成200ms,谁也说不清是哪次改动导致的。要根治,就得把性能测试做成自动化回归门禁,嵌进CI流程。
第一步,准备固定benchmark数据集。线上流量随机性太强,不能直接拿生产数据测,最好从公开数据集或业务样本里抽一份大小固定、分布稳定的subset,连同模型权重一起放到Git LFS或者对象存储里。第二步,写基准测试脚本,固定输入shape、固定随机种子,跑若干轮,计算p50/p99时延、吞吐和GPU利用率。第三步,在CI里用一套基线yaml做断言:
model: chat_v3 baseline: gpu: A100-40G batch_size: 16 max_new_tokens: 128 throughput_qps: 50 p50_ms: 70 p99_ms: 120 gpu_util_min: 0.4CI脚本读这份yaml,把本次实测结果和基线对比,超过阈值就阻断合并。核心判断逻辑很简单:
def check_performance(result, baseline): errors = [] if result["throughput_qps"] < baseline["throughput_qps"] * 0.95: errors.append("吞吐回归:{:.1f} < 基线 {:.1f}".format( result["throughput_qps"], baseline["throughput_qps"])) if result["p99_ms"] > baseline["p99_ms"] * 1.1: errors.append("p99回归:{:.1f}ms > 基线 {:.1f}ms".format( result["p99_ms"], baseline["p99_ms"])) if result["gpu_util"] < baseline["gpu_util_min"]: errors.append("GPU利用率下降:{:.1f}% < {:.1f}%".format( result["gpu_util"], baseline["gpu_util_min"])) return errors进入门禁之前,有两个环境稳定性问题必须提前解决。一是测试机器要固定:不同GPU型号、不同CUDA版本、甚至不同CPU型号都会影响结果。稳妥的做法是跑在专门的benchmark机器组上,禁止跑其他任务。二是要解决“冷启动效应”:刚拉起进程时显存分配、kernel编译缓存都是冷的,前几次运行会明显偏慢。正确做法是先跑几轮warm-up,等指标稳定后再正式采集,或者直接取多次运行的中位数而不是第一次值。
3.2 精度与性能的平衡门禁
性能优化很容易走上另一条邪路:GPU利用率上去了,时延唰唰降,但模型精度掉到不可接受。量化、剪枝、蒸馏、混合精度、算子融合,这些手段都会引入数值变化,有些变化是隐性的,不跑评估集根本发现不了。
我的建议是设三道门禁,全部通过才允许合入:
- 功能正确性门禁:跑一小批固定用例,保证输出结构不崩、不出现NaN、不出现明显乱码。
- 精度基准门禁:在固定eval集上对比指标,比如INT8量化后准确率下降不超过0.5个百分点,BLEU下降不超过0.5,loss回退不超过阈值。
- 性能基准门禁:对应上面说的吞吐和时延。
举个例子,INT8量化通常能把显存占用砍掉一半,吞吐提升明显,但如果校准集选得不好,精度损失可能远超预期。有些框架支持per-channel量化,效果比per-tensor好,但有平台兼容性问题。这种优化属于典型的“收益看得见、风险藏得深”,没有精度门禁拦着,生产事故只是时间问题。
提示:精度门禁的阈值不要拍脑袋。最靠谱的来源是业务方给出的核心指标下限,而不是你自己觉得“掉0.5%问题不大”。量化掉0.5%对用户可能无感知,但对一个推荐模型来说可能就是收入曲线的明显下滑。
4. 容量规划与成本治理:让性能预算落到真金白银上
4.1 从QPS和时延反推进卡数
“这个模型上生产要多少卡?”这是性能工程里最常被问到的问题。直接拍脑袋报个数字不专业,我一般按下面四步走。
第一步,先压测单卡在目标时延下的最大吞吐。把batch size从1、4、8、16、32逐步往上加,每个batch下记录QPS和p99,找到满足“p99≤80ms”的最大吞吐点。比如batch=8时QPS约50、p99=75ms,batch=16时p99=130ms超标,那单卡容量就取50QPS,而不是取吞吐最高的那个batch。
第二步,估算业务目标QPS。根据历史日均QPS和峰值倍数来定,比如平均300QPS,历史峰值是平均值的1.8倍,那目标QPS就是540。
第三步,算实例数。公式很简单:实例数 = 目标QPS / 单卡容量,即540 / 50 = 10.8,向上取整到11。考虑到N+1冗余,最终部署12个实例。如果单实例是多卡机器,再按部署形态换算成GPU卡数。
第四步,留出扩缩容缓冲。线上流量永远有毛刺,HPA可以应对短时抖动,但不能把自动扩缩容当成时延兜底——冷启动和排队需要时间,两次弹性的间隙请求早就超时了。所以水位数要留足,但也不要超出太多,否则成本每个月都在流血。
4.2 batch size、并发与时延的三角关系
容量规划绕不开batch size、并发与时延之间的权衡。简单说,并发越大、攒的batch越大,GPU吞吐越高,但等待batch攒齐的时间也在增加,时延自然上升。
这里有一个硬约束公式特别重要:攒批等待时间 ≈ batch_size / 请求到达速率。假设当前请求到达速率是100 req/s,要攒一个batch=16,那平均要等160ms。如果SLO是p99≤80ms,攒批这条路根本走不通。所以动态batching调度不能只看batch数量,要看“最大等待时间”和“最大batch token”双条件,谁先到就触发谁。这也是为什么现代LLM推理框架普遍用continuous batching——它不要求等满一个batch,而是每有token完成生成就动态塞入新序列,把GPU的每一帧都尽量填满,同时把单请求的排队等待压低。
4.3 成本治理和水位线设计
成本治理不是抠门,而是把预算花在该花的卡上。训练侧和推理侧侧重点完全不同。
训练侧,核心是提高端到端有效利用率。与其抠单个算子的耗时,不如先看全局:数据预取够不够、混合精度开没开、梯度通信有没有和计算重叠。一个经验值是,训练集群长期平均GPU利用率做到50%以上算健康,低于30%就有很大优化空间。这不是绝对标准,但可以作为自查起点。
推理侧,要看单位请求成本或者每百token成本。如果GPU利用率长期低于30%还满负荷部署,就该考虑合并实例或走serverless缩容。常见水位线设计如下:
- 扩容水位:GPU利用率持续5分钟超过70%,或请求队列长度超过单实例容量的80%。
- 缩容水位:GPU利用率持续15分钟低于30%,且队列长度健康。
- 显存水位:显存占用超过85%,触发批量调度迁移或限制最大并发。
这里有个容易踩的坑:缩容阈值比扩容阈值要更保守,而且要有更长的观察窗口,因为流量毛刺会导致频繁扩缩容,CPU/GPU热切换反而制造新的抖动。我会把缩容观察窗口拉长到15分钟以上,避免震荡。
5. 常见问题与排查技巧实录
5.1 五个真实案例复盘
案例一:动态batch导致时延抖动。现象:QPS上来之后p99飙升,但GPU利用率并不低。原因:请求到达是泊松分布,攒批等待时间不稳定,某个批次等了很久才凑齐,这一批请求的时延全被拉高。处理:把调度从“攒满batch_size”改成“最大等待时间+最大batch token”双阈值触发,或者直接换continuous batching。改造之后p99趋于平稳。
案例二:数据加载线程瓶颈。现象:GPU利用率平均20%,但峰值有90%。原因:DataLoader的worker太少,预处理全在CPU跑,GPU kernel之间出现大段空闲。处理:num_workers从4调到8,开启prefetch_factor=4和pin_memory,把resize和padding挪到GPU上做。最终GPU利用率稳定到50%以上,训练每轮时间缩短了将近三分之一。
案例三:显存碎片化,训练中段OOM。现象:训练20分钟后才OOM,刚启动时显存占用一切正常。原因:动态shape加上反复申请释放,显存碎片越来越多。处理:固定输入shape或统一padding,设置PYTORCH_CUDA_ALLOC_CONF=expandable_segments:True,并对长期运行任务单独做显存峰值监控。注意expandable_segments不是所有CUDA版本和框架版本都有效,需要实测,别盲目开。
案例四:多卡训练扩展性差。现象:8卡训练只比单卡快3倍,远低于预期。原因:数据并行每一步都要AllReduce梯度,通信时间占比太高;也可能是GPU间P2P带宽不足,NCCL选了低效路径。处理:开启梯度通信和反向计算overlap,检查nvidia-smi topo -m看拓扑,用NCCL_DEBUG=INFO看通信耗时。有时候换一种rank到物理GPU的映射方式就能提升一截。
案例五:LLM推理的KV Cache因素导致p99突刺。现象:长文本请求一多,p99直接几秒。原因:KV cache占满显存后,调度器被迫降低batch或触发重新规划,某些请求被排到队尾。处理:限制单请求最大生成长度,按token数而不是请求数做调度,prefill和decode分别走不同调度窗口。这类问题排查时,光看QPS没用,要按请求长度和token数维度去聚合时延分布。
5.2 排查速查表
| 现象 | 排查工具 | 大概率原因 | 处理建议 |
|---|---|---|---|
| GPU利用率低,CPU爆高 | nsys、top、perf | DataLoader/预处理瓶颈 | 增大worker、prefetch,异步预处理 |
| GPU利用率低,CPU也不高 | torch.profiler、nsys | 小batch、kernel启动开销大 | dynamic batching、算子融合 |
| GPU利用率波动大 | nvidia-smi dmon、DCGM | 排队/调度抖动 | 分析请求到达模式,平滑调度 |
| 显存OOM | torch.cuda.memory_summary | 碎片化或峰值超限 | 固定shape、expandable_segments |
| 多卡通信慢 | nsys、NCCL_DEBUG=INFO | 带宽不足、拓扑不佳 | 查拓扑、调整rank映射 |
| 时延突刺 | tracing、请求采样 | 冷启动、锁竞争、KV cache | 单独采样慢请求profile |
5.3 实操中的三点心得
第一,先固化最小基准集再动手优化。没有固定benchmark,一切优化结论都是“疑似有效”,海量指标只会让你更慌。第二,每个优化动作都要先取证再动手。我见过太多次“这批请求慢所以我改模型并行”的误判,最后发现瓶颈其实在锁竞争或网络连接复用上。第三,每次实验结束时,一定保存profile和trace产物,并且把环境指纹写进文件名——GPU型号、驱动版本、CUDA版本、PyTorch版本、数据集hash。别小看这一行,它救过我很多次。过两周回看,你还能还原当时机器跑的是什么状态,而不是靠聊天记录和记忆去猜测。