接上一篇的基础框架,这次我把重点放在真正动手的部分:拿到一份压测报告之后,怎么从数字里读出问题,怎么定位到具体的瓶颈环节,怎么调整参数,以及怎么确认优化真的生效。写这篇文章的起因,是我发现很多团队做AI应用开发时,对性能工程的理解还停留在"看看指标、调调参数"的层面,压测报告做得像PPT,数据一上生产就原形毕露。这篇会从SLO设计、瓶颈定位、调参实操和故障排查四个维度展开,尽量把那些文档里不会写的细节和踩坑经验都倒出来。
先说明一下,这篇文章默认你已经看过了系列第一篇,知道什么是QPS、p50/p99、GPU利用率这些基础指标。如果还没有,建议先翻一下前面的内容,再回来读这篇会更顺。这篇的核心是把"性能工程"从抽象方法论变成可以落地的操作手册。
1. 先定目标再动手:不同业务形态的SLO该这么定
1.1 三类典型场景的SLO设计差异
做性能工程最忌讳的就是一上来就压测、一上来就调参,连目标都没定清楚。AI系统的表现形式差异巨大,有的是用户直接对话的聊天机器人,有的是后台跑批量的数据任务,还有的是毫秒级响应的风控决策服务,这三种场景对延迟、吞吐、可用性的要求完全不一样。你用一个聊天机器人的SLO去要求风控系统,或者反过来,都是灾难。
先说在线交互类,典型代表是智能客服、AI助手、文档问答这类产品。这类场景用户就在屏幕前等着,体验和延迟强相关。根据大多数产品的用户行为数据,用户等待回答的心理阈值大概在2秒左右,超过这个时间流失率会明显上升。所以核心指标要盯p99延迟,也就是99%的请求都要在目标时间内返回,而不是只看平均值。平均值被一批快请求拉低,掩盖掉大量慢请求的真实体验。目标示例可以是p99小于2秒、p999小于5秒、错误率小于0.5%、月度可用性99.9%。这类场景的优化重心是减少排队、合理缓存、推理加速。
再说离线批处理类,典型代表是批量打标、数据清洗、批量文本生成、视频生成任务。用户提交一批数据之后,关心的是什么时候能跑完,而不是单条数据多快返回。这时候QPS和单条延迟意义不大,核心指标是整体吞吐和完成时间。目标示例可以是10万条数据在2小时内完成,GPU平均利用率不低于70%。这类场景的优化重心是数据并行、任务调度、断点续跑,让批量任务能吃饱资源,同时避免单机故障导致的整体失败。
最后是近实时决策类,典型代表是风控审核、个性化推荐、内容安全检测。这类场景延迟极敏感,用户的操作等不了,哪怕多100毫秒都可能影响收益或风险控制。而且这类服务和在线交互还有一个关键区别:在线交互的请求是"人发起的",天然自带节奏;近实时决策往往被上游系统同步调用,一个请求变慢可能拖垮整个调用链。目标示例可以是p999小于500毫秒、超时快速降级、拒绝率控制在万分之五以内。优化重心是极端时延控制、副本冗余、快速重试。三类场景整理成表格会更直观:
| 场景类型 | 典型业务 | 核心指标 | 目标示例 | 优化重心 |
|---|---|---|---|---|
| 在线交互 | 智能客服、AI助手 | p99延迟、错误率 | p99 < 2s,错误率 < 0.5% | 减少排队、缓存、推理加速 |
| 离线批处理 | 批量打标、数据清洗 | 总吞吐、GPU利用率 | 10万条2小时内完成,GPU利用率≥70% | 数据并行、调度、断点续跑 |
| 近实时决策 | 风控、推荐、审核 | p999延迟、稳定性 | p999 < 500ms,拒绝率≤0.05% | 延迟控制、冗余、快速降级 |
这里有一个实操层面的建议:SLO最好写进监控告警系统里,同时配合错误预算来管理。什么叫错误预算?就是在一个时间窗口内允许SLO不达标的比例。比如一个月内允许p99超过2秒的请求占比不超过5%,那这5%的"额度"可以用于发布新版本、做实验甚至出点小故障。这样团队讨论要不要上线一个影响性能的新功能时,就能拿着错误预算说话,而不是凭感觉吵。
1.2 基线测试:没有基线,一切优化都是空谈
我见过太多团队一上来就调参数,调完发现变好了,但问一句"比之前好多少"就答不上来。原因就是没有基线。基线是什么?是你在一个受控环境下,用固定流量模型、固定数据规模、固定资源配置测出来的一组数据。后续所有优化,都要跟这组数据做对比。没有基线,你所谓的优化就是凭感觉开盲盒。
基线测试设计有几个关键约束。第一,流量模型要固定,请求大小、token数量分布、并发数梯度都要提前定义好。比如并发梯度设定为1、5、10、20、50五档,每档跑5分钟。第二,数据规模要固定,不能用线上随机数据,要准备一份有代表性的测试数据集,请求长度分布要和真实情况接近。第三,硬件资源要固定,最好用独立环境,避免和其他任务争抢。第四,所有软件版本、模型版本、配置参数、压测脚本都要归档。
基线测试要记录哪些数据也值得说清楚。最基础的包括QPS、平均延迟、p50/p95/p99/p999延迟、错误率、CPU利用率、GPU利用率、显存占用。注意p99以上分位数一定要记,后面调优时会频繁用到。基线报告最好做成表格形式,方便对照:
| 并发数 | QPS | 平均延迟 | p99 | p999 | 错误率 | GPU利用率 |
|---|---|---|---|---|---|---|
| 1 | - | - | - | - | - | - |
| 5 | - | - | - | - | - | - |
| 10 | - | - | - | - | - | - |
| 20 | - | - | - | - | - | - |
| 50 | - | - | - | - | - | - |
这里有个特别容易被忽略的细节:基线报告要能还原环境。软件版本、模型版本、参数配置、压测脚本、数据集文件都要归档。别觉得这是小题大做,我有一次回看两个月的基线报告,发现当时压测用的数据集已经换过版本了,导致前后数据根本没法对比。所以请把基线做成一个"可复现的档案",而不只是一张表格。
2. 定位瓶颈的三板斧:火焰图、调用链和延迟预算
2.1 火焰图怎么抓、怎么看
假设你已经有了基线数据,发现系统不达标,接下来第一步就是定位瓶颈在哪。定位瓶颈最直接的工具就是火焰图。很多朋友问我:火焰图到底能看出什么?简单说,它能告诉你CPU时间都花在哪些函数上。注意这里说的是CPU时间,如果你的瓶颈在GPU或IO上,火焰图不一定能直接反映出来,得配合其他手段。
抓火焰图的方法取决于你的服务用什么语言写的。Python服务强烈推荐py-spy,它是一个采样式profiler,不需要改代码,对正在运行的进程直接采样就行。用法很简单:
pip install py-spy py-spy record -p <进程PID> -o profile.svg --duration 60Java服务可以用async-profiler,同样不需要JVMTI重写,直接attach上去采样:
./profiler.sh -d 60 -f profile.svg <PID>如果是系统级问题,或者服务是多语言混合的,用perf抓系统级火焰图。下面是一套常用的命令组合:
perf record -F 99 -a -g -- sleep 30 perf script > out.perf # 使用 FlameGraph 工具生成火焰图 stackcollapse-perf.pl out.perf > out.folded flamegraph.pl out.folded > flame.svg火焰图怎么读?记住两个关键点:横轴不是时间,而是采样数量的占比,越宽的条代表CPU时间越多;纵轴是调用栈深度,从下往上是逐级调用的关系。你优先应该看的是顶部那些宽条,它们占的CPU时间最多,是最大的优化机会。我调过不少服务,最常见的情况是火焰图顶部最宽的条根本不是模型推理,而是JSON序列化、日志格式化、数据拷贝这些看似不起眼的操作。这些不是模型的问题,是工程实现的问题,改掉之后往往立竿见影。
举一个我实际遇到过的案例。之前调一个文本处理服务,火焰图上几乎一半的CPU时间都花在一个叫normalize_text的函数上。点开一看,这个函数对每个字符调用了正则表达式替换和Unicode归一化,处理一条文本要循环几千次。改成批量的向量化操作之后,这个环节的CPU时间直接降了一个数量级,整个服务的吞吐提了将近一倍。这就是火焰图的价值:它不告诉你"应该做什么优化",但它明确告诉你"你的时间都去哪了"。
2.2 区分"计算慢"和"等待慢"
定位瓶颈时,脑海里一定要有一根弦:性能问题其实分两大类,一类是CPU或GPU在拼命算,真算不过来;另一类是CPU或GPU大部分时间在等,等锁、等队列、等下游、等IO。这两种问题的优化方向完全不同,不区分清楚就可能白调。计算慢的优化空间在算法、向量化和并行度;等待慢的优化空间在缓存、异步化和减少锁竞争。
怎么区分?一个简单的判断方法:如果CPU利用率已经打满,火焰图上全是宽条,那大概率是计算慢;如果CPU利用率不高,但延迟就是下不去,那大概率在等待。更精细的方法是用perf stat看instructions per cycle(IPC)指标,IPC很低说明CPU大量时间在等待内存或锁,而不是在算。Linux下直接跑:
perf stat -p <PID> sleep 10看输出里的instructions per cycle那一行,如果IPC低于0.5,通常说明有严重的等待或访存瓶颈。
我见过一个非常典型的"等待慢"案例。某个推理服务的GPU利用率只有40%,但请求延迟非常高。很多人第一反应是换个更快的GPU,或者把模型算子优化一遍。找人看了半天才发现,请求从进入到真正被GPU处理之前,在一个线程池队列里排了很久——线程池只有两个worker,上游并发一上来就堵死了。GPU闲着,请求却在门外排队。这种情况下优化的正确方向是增加worker数或优化上游数据流水线,而不是折腾模型。所以面对性能问题,先问一句:它是在算,还是在等?
2.3 延迟预算表:把宏观目标拆到每个环节
确定瓶颈在哪个环节,除了看火焰图,还有一个非常实用的工具叫延迟预算表。思路很简单:把用户可接受的总延迟,拆解到全链路每个环节上,再拿实测数据和预算对比,一眼就能看出超支的环节在哪。
举个例子。假设你的总延迟预算是2秒,可以这样拆分:网关和登录鉴权50毫秒,中间件和业务逻辑300毫秒,检索或上下文构建300毫秒,模型推理1200毫秒,网络序列化150毫秒,最后留200毫秒buffer给意外情况。整理成表:
| 环节 | 预算耗时 | 当前实测 | 差距 |
|---|---|---|---|
| 网关/鉴权 | 50ms | 45ms | 达标 |
| 业务逻辑/检索 | 300ms | 520ms | 超支220ms |
| 模型推理 | 1200ms | 1150ms | 接近预算 |
| 序列化/网络 | 150ms | 130ms | 达标 |
| Buffer | 200ms | - | 被吃掉 |
看到差距之后,下一步针对性就会非常明确。比如上面这个例子,明显是业务逻辑环节超支了,那就需要深入火焰图看这个环节到底在算什么。这个预算表还有一个好处:做新技术选型或架构变更时,可以先用预算表推演,如果某个环节的预算是硬约束,那新技术在这个环节的表现必须达标才能选择它。把延迟预算表写进接口文档里,每个环节的负责人或者开发人员都很清楚自己的代码最多能花多少时间。
延迟预算表怎么落地?最简单的方式是在代码里埋点,把每个环节的耗时记录到日志或trace里,然后汇总分析。现在很多可观测平台都支持分布式追踪,不需要自己造轮子。我自己的习惯是每次调优后,都把实测值回填到预算表里,几轮下来哪些环节稳定达标、哪些环节波动很大,就一目了然了。
3. 从压测数据反推最优配置:一次完整的调参记录
3.1 场景描述与首次压测
理论讲得再多,不如看一次完整的实际操作。下面分享一个我做过的推理服务调参案例,尽量把当时的推演过程还原出来。场景是一个基于8B参数模型做的文本生成API,部署在一台8核CPU加一张A10 GPU的机器上,业务目标是QPS达到50,p99延迟小于1.5秒。这个服务本身也是很多AI应用开发的典型架构:请求进来,做文本预处理,拼Prompt,调用模型推理,然后返回结果。
第一次压测结果是这样的:并发数提到25时,p99延迟直接飙到4秒,QPS在30左右就上不去了。更诡异的是,GPU利用率只有50%,CPU倒是有一个核打满了。看到这个组合,我的第一判断是:瓶颈不在模型计算能力上。原因很简单,GPU利用率只有一半,说明GPU这个"工厂"的产能是足够的,但原材料送不进来。问题大概率出在GPU前面的整条数据管线——请求解析、预处理、排队等待这些环节。
后来用火焰图扫了一遍,果然发现CPU时间大量消耗在两个地方:一是每个请求进来都要重新做文本归一化和tokenize,二是日志用了同步输出,高并发时大量时间花在等待磁盘IO上。这解释了为什么单核CPU会打满——这两个操作都是CPU密集型的,而且还阻塞在主线程上。这个样例也再次印证了前面的观点:先定位瓶颈环节,不要一上来就怀疑模型算力。
3.2 从指标到配置调优的推演过程
定位到瓶颈之后,我开始逐步调整,每一步只改动一个变量,记录一份数据。这是调参最核心的纪律,否则最后出了问题根本不知道是哪个参数引发的。
第一刀砍在数据管线上。文本归一化和tokenize改成批量预处理,日志从同步输出改成异步。这一刀下去,CPU单核打满的问题消失了,GPU利用率从50%涨到65%。但离目标还差一段,因为新的瓶颈暴露出来了:模型推理的batch配置不对。
这就是第二个关键调整:batch窗口和最大batch大小。原来的配置是max_batch_size等于1,也就是说每个请求进来,模型就单独推理一次。这等于让一个能装8个乘客的车每次都只拉一个人,GPU利用率当然上不去。我把max_batch_size调到了8,同时设置了一个batch_window参数,也就是等待更多请求进入当前batch的时间窗口,调成150毫秒。
这里一定要说说batch_window为什么是150毫秒,而不是随便选的。它是由延迟预算倒推出来的。p99目标1.5秒,单次推理约1.1秒,剩下给排队、batch凑单和数据传输的总预算只有400毫秒。如果batch_window设到300毫秒,并发高峰时排队等待就容易超支。150毫秒是一个相对安全的中间值:既给了batch凑单的余地,又没有吞掉太多延迟预算。
第三刀是调整队列长度和线程池大小。队列长度不是越大越好,queue排太深,请求等待时间就不可控,p99会直接崩掉。一个经验公式是queue_size乘以单请求处理时间约等于最大排队延迟,反过来可以根据可接受的排队延迟估算队列长度。比如可接受排队延迟200毫秒,目标QPS是50,那队列深度大约在10到16之间,留点buffer取16。溢出的请求宁可返回限流错误,也不要让它们无限堆积。
线程池的大小也有讲究。很多人觉得线程池越大越好,但在GPU推理场景里不是这样。模型推理本身在GPU上执行,CPU线程主要干的是预处理和结果返回,线程数设2到4就够,设多了反而增加上下文切换开销。这些参数整理一下就是一组可以复用的配置参考:
| 参数 | 调整前 | 调整后 | 调整依据 |
|---|---|---|---|
| batch窗口 | 无(单请求推理) | 150ms | 延迟预算倒推 |
| 最大batch大小 | 1 | 8 | GPU算力与显存容量 |
| 队列深度 | 默认100 | 16 | 排队延迟≤200ms |
| CPU线程池 | 默认32 | 4 | 避免上下文切换 |
3.3 效果验证与压测对比
调整之后重新压测,QPS达到了50,p99降到了1.2秒,之前的目标全部达标。GPU利用率稳定在85%左右,CPU也没有再出现单核打满的情况。这个结果的对比很直观:
| 指标 | 调优前 | 调优后 |
|---|---|---|
| 目标QPS 50 | 30(上不去) | 50 |
| p99延迟(目标1.5s) | 4.2s | 1.2s |
| GPU利用率 | 50% | 85% |
| CPU单核 | 打满 | 平均60% |
不过压测达标不代表结束,验证效果还有很多细节要做。首先是持续时间,压测至少要跑20到30分钟,观察p99曲线是不是稳定,有没有周期性抖动。其次是尖峰压测,模拟流量突然暴涨的情况,看系统能不能快速恢复,还是直接雪崩。最后是真实流量回放,把线上录制的请求流量重新放一遍到测试环境,这种压力模型比脚本化的均匀压测更接近真实。现在也有一些AI辅助测试工具可以自动生成压测脚本和异常流量模式,但判断依然要靠人来做——工具能替你做更多的测量,但替代不了你理解原理之后的决策力。
4. 高频故障排查实录与经验速查
4.1 GPU利用率长期不高,优先排查这几个环节
GPU利用率几乎是所有AI系统性能问题里被问得最多的一个。现象很统一:GPU利用率在40%以下,吞吐上不去,但谁也不知道瓶颈在哪。排查思路有一个固定的优先级顺序,照着走基本能定位。第一查数据管线,训练场景看dataloader的num_workers是不是太小,有没有开pin_memory;推理场景看请求预处理是不是串行执行。第二查模型侧,看batch是不是太小,如果batch_size等于1那GPU当然吃不满。第三查显存限制,有些时候不是不想开大batch,是显存不够导致batch被卡住,这时候需要看KV Cache的占用情况。第四查数据拷贝,确认一下GPU和CPU之间是不是频繁地在做memcpy,拷贝本身也是需要时间的。
我记得有一次排查一个训练任务,GPU利用率就像心电图一样跳来跳去。用Nsight Systems抓了一下GPU kernel的执行时间线,发现kernel之间有大片空白。这些空白时间都在干嘛?在等CPU侧把batch准备好。最后定位到图像解码和预处理全放在了主进程里做,num_workers设成0,等于CPU在串行准备数据,GPU只能干等。把num_workers调到机器核心数,开启pin_memory之后,训练吞吐直接翻倍。这类问题用nvidia-smi的dmon模式可以观察得更细:
nvidia-smi dmon -s PCUT -f nvidia_smi.log看GPU利用率的同时,盯着CPU利用率、拷贝率这些字段。如果GPU利用率低但CPU忙得要死,问题一定在CPU侧的数据管线上。
4.2 延迟抖动和长尾效应怎么根治
延迟方面最让人头疼的现象是p50非常漂亮,p99却爆炸。很多服务平均延迟200毫秒,但最慢的1%请求要3秒甚至5秒。这种长尾效应靠调平均延迟是解决不了的。根因通常来自三类:第一类资源争抢,GPU所在的物理机上还有其他任务在跑,kernel时间片被抢走;第二类动态shape,模型接收的输入长度波动极大,有些请求特别长导致整个batch的处理时间被拉长;第三类排队堆积,队列里的请求太多,新请求要排很久才轮到。
这三类问题的解决方案各有侧重。资源争抢靠隔离和配额管理,比如把GPU显存使用上限设好,或者干脆部署到独占节点。动态shape可以做长度截断、统一padding到固定长度,或者把变长请求和定长请求分开跑不同的batch。排队堆积则是通过加worker、限流和超时控制来改善,宁可主动拒绝一部分请求,也不让所有请求都在队列里熬成超长延迟。
一个调试技巧:当长尾很明显时,别只看p99曲线,把p50、p95、p999画在同一张图上,观察它们之间的走势。如果p50和p999基本平行,说明是系统性慢请求,和个别请求的特征无关;如果p999单独上翘而p50很平,基本可以确定是特定特征的请求在拖尾,这时候要去查那些慢请求的输入结构。我用这个方法查过一次线上问题,最后发现所有超时请求都是同一个长文档的摘要任务,属于典型的动态shape引起的长尾。
4.3 压测数据和线上表现总是不一致,问题出在哪
最后聊一个很多人都遇到过的困惑:压测环境中一切正常,数据也很漂亮,一到线上就崩。有人怀疑是压测工具的问题,有人怀疑是机器性能差异,其实最常见的根源有三个。第一个是压测流量太均匀了,真实流量是锯齿状的,有高峰有低谷,还有突发尖峰。均匀压测把系统压在一个恒定的负载下,系统可以慢慢"适应";而真实流量中的突发,会让排队和资源争抢突然加剧。解决方式是压测脚本里加入随机化,模拟思考时间,故意设计一段并发尖峰。
第二个是冷启动问题。压测跑上几分钟,模型已经完成热加载,缓存也热了,连接池也建好了;线上流量却是从零开始,每次重启后都要经历一段"爬坡期"。这个问题靠压测脚本里的预热阶段来模拟,也就是正式记录数据之前先跑5分钟的"热身"流量。第三个是多租户资源争抢。压测环境是独占的,线上机器还可能跑着日志采集、监控Agent、定时任务,这些都占用CPU和网络。处理方法是压测不要只在独占环境做,找一台和线上部署形态一样的机器,在并发负载中保留一部分"系统开销余量"。
我自己写压测脚本时坚持一个原则:压测不能只测"每秒50个一样快的问题",要测"大多数正常、偶尔有几个大的、还有一批并发尖峰"的真实分布。现在就有一个AI辅助测试的方向,可以自动从线上流量中学习分布模型然后生成压力模式,但阶段来看,手动把请求大小、到达间隔、并发形态做随机化,仍然是性价比最高的方法。
我还想特别提醒一点:压测报告永远要附带环境快照和配置归档。这是很多人忽略的。一份没有写明GPU型号、软件版本、模型版本、参数配置的压测报告,三个月后回看就是一堆没用的数字。我现在每次做完调优,都会把配置文件和测试环境信息一起存到项目仓库里,之后排查问题、回滚变更都用得上。
做AI系统性能工程这几年,我最大的感受是:性能优化从来不是一锤子买卖。模型版本升级、上游数据分布变化、流量结构变化,任何一个因素变了,都可能让一个原本"调优完成"的系统在几周后重新失配。所以与其追求一次完美的调优,不如建立一套可持续的基线、预算和排查机制。如果你也在维护AI服务,我建议从这个月就开始,先给自己的服务写一份SLO和延迟预算表,再跑一次基线压测。做完这两件事,你对系统的理解会比很多人三个月积累的都深。下一篇我打算聊聊AI系统性能工程的成本治理——GPU资源怎么分配、弹性伸缩怎么做、成本和性能怎么平衡,到时候我们接着聊。