AI 模型推理中的延迟分析与测试
把模型训练出来只是第一步,真正让它跑起来、跑得快、跑得稳,才是工程上最磨人的那个阶段。我过去大半年一直在折腾推理服务,从单卡跑通到对外提供接口,中间踩得最深的一个坑就是延迟——不是模型不收敛,是模型上线之后响应慢得让人心慌。用户那边一压测,P99 直接飙到两秒多,模型本身看起来没问题,显卡利用率也不高,但就是慢。后来把整个链路拆开来看才发现,延迟这个指标根本不是一个数字,而是一堆时间片拼起来的结果。这篇就结合我这段时间的实操,把 AI 推理中的延迟分析和测试好好捋一遍,从怎么测、怎么拆、怎么定位,再到怎么优化,尽量说透。
1. 拆开来看:一次推理请求的时间都去哪了
做延迟分析最忌讳的就是把“推理延迟”当成一个黑盒。你看到的响应时间是一个总数,但背后起码有四五段不同的时间消耗叠在一起。如果不拆开,你只会看到一个笼统的“慢”,完全不知道慢在哪。
我习惯把一次完整的推理请求按时间顺序拆成六个阶段:
- 网络传输:客户端到服务端的请求字节流传输时间,局域网内很小,公网或跨机房就不可忽略。
- 请求排队:请求进入了服务端队列,等待被某个 worker 或推理进程取走的等待时间。并发一高,这一段的占比会迅速膨胀。
- 输入预处理:文本要 tokenize,图像要 resize、归一化、转张量格式,这些操作在 CPU 上完成,偶尔很慢。
- 模型计算:张量在 GPU(或 CPU)上的实际前向传播时间,也就是大家最常说的“推理时间”。这里面又可以进一步拆成 prefill 阶段和 decode 阶段,LLM 场景尤其明显。
- 输出后处理:从张量到最终结果,比如去 vocab 映射、采样、beam search 的收尾,有时还有 JSON 序列化。
- 返回传输:结果数据回传,对长输出文本来说,这部分往往被忽略但实际不小。
这里我要强调一点:第 4 步的模型计算时间,不一定是延迟的大头。很多情况下,排队时间和预处理时间合计起来比 GPU 计算时间还长。我见过最夸张的一个案例,一个文本分类接口,模型本身跑一次只要 8 毫秒,但整个接口的耗时却到了 180 毫秒——最后发现光是 JSON 序列化和日志打印就占了 70 多毫秒。
所以做延迟分析的第一步,不是优化模型,而是在服务的入口和出口把各阶段的时间戳埋好。你在入口记录请求到达时间 t0,在预处理结束时记录 t1,在模型推理前后分别记录 t2、t3,在后处理结束时记录 t4,在出口准备返回时记录 t5。这样任何一次请求的耗时分布都清清楚楚。
埋点的方式可以去改服务代码,也可以借助成熟的 tracing 系统来打 span。我建议两种都做:代码里加简单的时间戳日志最适合快速排查,而正式的 tracing 系统(比如基于 OpenTelemetry 的链路追踪)适合长期监控和对比。
2. 延迟测试的正确打开方式:并发、百分位数与口径陷阱
延迟测试不是一个“发几个请求看平均响应时间”这么简单的事。这里面的门道主要在三个方面:并发模型怎么设计、指标怎么选(平均还是百分位)、测试口径怎么统一。任何一个环节偷懒,都可能得出误导性的结论。
2.1 平均延迟会骗人,尾延迟才是真相
先说明一个我自己的观点:做推理服务性能评估,平均延迟(Avg)基本可以不用看。原因很简单,推理服务的延迟分布通常严重右偏——绝大多数请求很快,但少量请求会特别慢。平均值会被拖得没什么代表性,而且它掩盖了最影响用户体验的那部分问题。
真正要盯的是百分位数:P50、P90、P95、P99,甚至 P999。P99 的意思是 99% 的请求延迟都低于这个值,也就意味着 1% 的请求比这个值更慢——对于高并发服务来说,这 1% 的用户体验往往决定了口碑。我在实际测试中就碰到过这种情况:平均延迟只有 120ms,看起来挺健康,结果 P99 是 980ms,几乎要破一秒大关。这要是不看百分位,根本发现不了问题的严重性。
所以我的建议是,每一次压测报告里至少要给出四列数据:P50、P90、P95、P99,有条件的话把 P999 也带上,并且附上最大延迟值。对照着看,就能快速判断延迟瓶颈是存在于整体(所有百分位都高)还是尾部(只有高百分位异常)。
2.2 并发模型:固定并发才是压测的第一课
很多人在压测时习惯用“逐渐增加 QPS”的模式,从一个很低的速率慢慢往上加,看系统最多能顶多少 QPS。这个模式适合去看吞吐量上限,但不太适合严格评估延迟。因为它掩盖了一个关键事实:当系统过载时,新请求不是立刻得到响应,而是在队列里堆积,延迟会呈现“雪崩式”增长,速率模型下这些堆积请求会无限拉长响应时间,数据一塌糊涂。
我更推荐用固定并发数的压测模型,即同时有 N 个请求在途(in-flight),一个请求完成后立刻发出下一个。这个模型下,系统的排队状态保持稳定,测出来的延迟才是这个并发水平下真实的延迟表现。具体做法可以用压测工具直接把并发数固定,比如 wrk、k6、ghz 或者 Locust 都能配置。
举一个我常用的压测矩阵设计思路,适合服务上线前做一轮摸底:
| 并发数 | 持续时间 | 关注指标 |
|---|---|---|
| 1 | 2 分钟 | 基准延迟,无排队干扰,看模型本身的极限 |
| 8 | 5 分钟 | 一般线上水平,关注 P50/P95 |
| 32 | 5 分钟 | 中等压力,开始观察排队时间占比 |
| 64 | 5 分钟 | 高压场景,看 P99 是否恶化、是否抛错 |
| 128 | 5 分钟 | 过载测试,找服务能承受的并发上限 |
这个矩阵的关键是:先拿到单并发基准值,再往上叠加并发压力,这样每一层测试都能算出“排队带来的额外延迟”是多少。如果从 8 并发到 32 并发,P99 从 200ms 涨到 600ms,而 P50 只从 80ms 涨到 100ms,那就说明排队开始吃人了。
2.3 测试口径:冷启动和热启动必须分开算
延迟测试里还有一个大坑:冷启动 vs 热启动的混淆。模型服务第一次接收到请求时,往往需要加载模型、初始化 CUDA context、分配显存、做 kernel warm-up 等操作,这些开销和正常运行时的开销完全是两码事。如果你开压测之后立刻开始计时,前面的数据大概率会严重虚高,带偏整体统计。
解决方法是正式压测前先跑几轮“预热请求”,让服务完成初始化,同时也让 GPU 的 kernel 编译缓存生效(很多框架第一次跑某个 shape 时需要即时编译,之后就快了)。我一般预热 50 到 100 个请求,然后再开始正式计时。
另一个相关口径是“稳态 vs 瞬时”。如果压测只跑 30 秒,可能只覆盖了系统表现最好的阶段;持续 5 到 10 分钟才能暴露显存碎片、内存泄漏、连接数堆积这类慢速恶化的隐患。所以延迟测试建议至少跑满 3 分钟,正式评估跑 5 分钟以上。
提示:压测脚本里最好加一个“忽略预热期数据”的逻辑,或者把预热请求和正式请求分批统计,否则最后看报告时根本不知道哪些数据有效。
3. 定位瓶颈:从指标到根因的系统排查链路
测试数据出来之后,下一步就是定位瓶颈在哪一段。我有一套固定的排查链路,基本能覆盖 90% 的问题场景。
3.1 先看 GPU 利用率,再决定往哪查
这是最快的一刀切分法。如果压测时 GPU 利用率(利用 nvidia-smi 或 DCGM 查看)一直很低,比如不到 30%,那说明瓶颈根本不在计算,而在于数据喂不进去——要么是预处理太慢,要么是排队太严重,要么是网络传输卡住了。如果 GPU 利用率已经顶到 90% 以上,那就是模型计算本身的瓶颈,要考虑的是怎么把单次推理时间降下来。
举一个实际例子:我做一个中文情感分类模型的推理服务,压测发现 GPU 利用率才 15%,QPS 只有 20 多,但单请求模型推理只要 3 毫秒。怎么排查的呢?先看服务日志,发现请求在大批量进入时排队时间占了总共耗时的 40%,再往下追,发现排队是因为服务开的 worker 进程太少,默认只开了 2 个进程,每个进程同时只能处理一个请求。把 worker 数调到 8 之后,GPU 利用率立刻上到 60% 以上,QPS 翻了近三倍。这个案例说明了一个道理:模型快不等于服务快,瓶颈往往在你意想不到的调度层。
3.2 利用阶段耗时占比,锁定拖后腿的环节
这一招是我日常最常用的。拿到埋点数据后,直接计算每个阶段的耗时占比,按占比排行来分配精力。占比最大的那一段就是优化优先级最高的那一段,这个逻辑听起来像废话,但很多人实际操作时还是习惯性地先去改模型结构,而不是先看数据。
我梳理过几次延迟优化项目,最后结论差不多都是下面这种分布:
| 阶段 | 通常耗时占比(文本模型典型情况) | 优先优化建议 |
|---|---|---|
| 网络传输 | 1%-5% | 少见瓶颈,通常无需处理 |
| 请求排队 | 5%-40% 不等,并发高时飙升 | 增加 worker、调并发数、做动态批处理 |
| 预处理 | 5%-20% | 缓存 tokenize 结果、批量预处理 |
| 模型推理 | 30%-70% | 量化、蒸馏、kernel 优化 |
| 后处理 | 5%-20% | 简化 JSON、避免不必要的采样逻辑 |
| 返回传输 | 1%-5% | 长输出时压缩或流式返回 |
这个表格只是典型参考,实际占比会因模型类型、数据规模而大不相同。图像类模型的预处理往往更重(解码、resize),LLM 的 decode 阶段则是真正的耗时大头。一定要基于自己的实测数据来排优先级。
3.3 用压测工具做“逐步排除法”的精确定位
除了埋点数据,我还会用压测工具配合“排除法”做交叉验证。具体做法是:用一个 mock 服务替代真实的推理服务,接口返回同样的响应体但内部不做模型推理,然后跑同样的压测——如果 mock 服务的延迟和真实服务差异不明显,说明瓶颈基本不在模型;如果差异巨大,那就集中精力优化模型层。这个验证在服务改造前做一次,改造后再做一次,能非常直观地判断改动是否有效。
排除法还可以继续往下钻。比如你把预处理阶段直接砍掉,输入固定的 token 序列,看延迟变化;再把后处理砍掉,直接返回原始张量,看延迟变化。每一次“手术”都对应一次压测,数据的对比会清清楚楚地告诉你每一段代码的真实成本。
这套链路走下来,大概花半天时间就能把瓶颈锁得很死。之后动手优化时,每一步改动都可以用同一套测试口径复测,避免“感觉变快了”这种模糊判断。
4. 常用推理延迟测试工具与参数解读
工具不用贪多,关键是会用、会读数据。我把常用工具按使用场景分成三类,每类介绍几个我实际验证过的,并说明怎么解读它们输出的参数。
4.1 轻量级命令行工具:wrk 与 ghz
wrk 是我做 HTTP 服务压测的首选,它用 C 写,压测性能极高,单机就能打出很高的并发。常用参数就几个:
-t线程数:一般设成 CPU 核数-c连接数:即并发数-d持续时间:建议 30s 或以上-s脚本文件:可以自定义请求体,适合 post JSON
启动命令大致长这样:
wrk -t4 -c64 -d30s -s post.lua http://127.0.0.1:8000/predictpost.lua 里定义请求方法和 body:
wrk.method = "POST" wrk.headers["Content-Type"] = "application/json" wrk.body = '{"text": "今天天气不错,适合出门走走"}'wrk 输出的核心数据包括:Requests/sec(QPS)、Transfer/sec(带宽)、Latency 分布(Avg、Stdev、Max、正负一个标准差内的请求占比)。注意它默认打印的是平均值和标准差,不会直接给出 P99,需要加--latency参数才会打印延迟分布直方图。
如果压测的是 gRPC 接口,wrk 就不合适了,我用的是 ghz。ghz 是专门给 gRPC 做的压测工具,参数风格和 wrk 很像,核心选项:
ghz -c 32 -n 10000 -z 30s \ --proto ./service.proto \ --call your.package.PredictService/Predict \ -d '{"text": "测试文本"}' \ 127.0.0.1:50051ghz 的输出非常友好,会把 50th、90th、95th、99th、99.9th 都列出来,而且自带错误率和耗时分布直方图,是我目前见过最省事的 gRPC 压测工具。
4.2 分布式压测:Locust
如果目标是模拟更接近真实用户的行为(比如思考时间、不同接口的混合调用),或者需要多台机器同时加压,Locust 会是更好的选择。它是 Python 写的,通过写脚本定义用户行为,非常灵活。
下面是一个非常精简的 Locust 压测脚本:
from locust import HttpUser, task, between class PredictUser(HttpUser): wait_time = between(1, 3) @task def predict(self): self.client.post("/predict", json={"text": "测试文本,用于模拟请求。"})启动时指定并发数和生成速率:
locust -f locustfile.py --host http://127.0.0.1:8000 --headless -u 32 -r 4 -t 5m-u 32表示模拟 32 个并发用户,-r 4表示每秒增加 4 个用户,-t 5m表示运行 5 分钟。Locust 在 headless 模式下也会输出统计摘要,包含各类百分位数,不过它更适合跑长稳测试,短期高吞吐压测它的性能不如 wrk。
4.3 监控侧:别只盯着压测工具的输出
压测工具的量测都是从客户端视角出发的“端到端延迟”,但这个数据不足以支撑根因分析,还需要配合服务端监控。GPU 侧我用 DCGM 采集利用率、显存、温度、功耗;CPU 侧看服务进程的 CPU 使用率和内存占用;系统侧看网络连接数、句柄数、上下文切换。这些数据可以用 Prometheus + Grafana 部署一套现成的监控,不用自己造轮子。
我强烈建议压测期间实时开着监控面板。很多诡异的问题,比如“延迟周期性波动”,你只看压测工具的输出根本找不到原因,但一看监控面板就会发现 GPU 利用率周期性跌落,原因可能是别的大任务在抢占显存,或者服务内的缓存周期性清理导致 CPU 暴增。
5. 从延迟数据到优化动作:我踩过的坑和验证方法
有了数据、定位了瓶颈之后,优化手段其实是有固定套路的。我把常用的优化方案按“见效速度”排个序,同时提醒几个容易踩的坑。
5.1 最优先:动态批处理与并发配置
如果你用的是 PyTorch 或 TensorFlow Serving 这类服务框架,且模型推理是瓶颈,那么第一个优化动作就是动态批处理(dynamic batching)。它能让你把并发请求攒起来,在一次前向传播里同时处理多个输入,从而摊薄 kernel 启动开销,大幅提升吞吐。
但这里必须提醒一个大坑:动态批处理提升的是吞吐,不是单请求延迟。攒批会引入等待时间——后面的请求要等前面的请求到齐才能一起发车。如果一个用户单独发一个请求,他反而可能更慢,因为服务在“等更多的人上车”。所以做动态批处理时,一定要设置合理的最大批处理等待时间(比如 5ms/10ms/20ms),在吞吐和延迟之间做权衡。
我做过一个对比测试:在推荐模型服务上,关闭动态批处理时单请求延迟 30ms,QPS 只有 120;开启后,最大等待设 10ms,P50 延迟涨到 40ms,但 QPS 提升到了 400 多。对大多数线上场景来说,这个取舍非常划算,前提是用户能容忍这 10ms 的额外等待。
与之配套的还有并发参数调优:进程数、线程数、gRPC 最大并发流数,这些都要根据压测矩阵结果逐档调整。通常的做法是“两倍两倍地往上加,加到性能不升反降为止,再回退一档”。
5.2 模型侧:量化与 kernel 优化的真实收益
模型推理本身占大头时,优先做量化。以 Float16 换 Int8 为例,通常能把模型体积压缩到一半左右,推理速度提升 1.5 到 3 倍(取决于算子和硬件支持程度)。但量化不是无损的,精度会掉,必须在测试报告里附上精度对比数据。
我在一个 CV 分类模型上做过量化前后的对比:原始 FP16 模型推理延迟 15ms,Int8 量化后降低到 6ms,精度从 92.4% 掉到 91.8%,降幅可接受,上线后观察一段时间也没有异常。所以只要你评估过精度损失在业务容忍范围内,这个优化就很值得上。
除了量化,还有一个经常被忽略的优化:固定输入 shape。动态 shape 会导致框架反复做算子选择甚至动态编译,固定 shape 后许多框架能提前生成最优 kernel,延迟能降低 10%-20%。我处理的几个服务都有这个改动,收益稳定且零风险。
5.3 容易被忽略的隐形杀手:预热、缓存与日志
这个部分想专门说说那些“不在模型里,却拖垮延迟”的隐形杀手。我踩过的坑有以下几类:
第一,缺少预热导致首请求延迟爆炸。服务刚启动时,第一个请求要完成 CUDA context 初始化、模型权重加载、kernel 编译等,耗时可能高达几十秒。所以服务脚本里一定要写预热逻辑——启动后自动发几个假请求,把该初始化的都初始化完,再对外宣称“就绪”。
第二,重复 tokenize 和重复解码。如果你的服务会反复处理相近的输入,可以考虑在预处理层做缓存。比如用户短文本分类场景,相同或相似的文本反复出现时,直接用缓存结果,能削掉很大一部分重复计算。缓存方案的 TTL(过期时间)要设计好,避免占内存过多。当然,要谨慎使用,适合查重率高的场景,不适合开放性生成类任务。
第三,日志和序列化的开销超出你的想象。我曾在一个接口里发现,把完整请求体和响应体打进日志,占了总耗时的将近 20%。日志该打,但别打全量 body,用摘要、长度、耗时这些关键信息就够了。JSON 序列化也要留意,大响应体的序列化时间不短,可以考虑精简字段、减少嵌套。
5.4 复测方法论:每次改动的效果必须跟同一把尺子比
优化做完不是结束,验证才是关键。我自己的习惯是:每次改动只动一个小模块,然后立即用同一套压测矩阵复测,对比数据和改动前的基线。如果一次改了多个模块,哪个改动带来了提升根本说不清楚。
复测时我会做这样一份对比记录:
| 改动项 | 并发数 | P50 (ms) | P90 (ms) | P99 (ms) | QPS | 备注 |
|---|---|---|---|---|---|---|
| 基线(无改动) | 32 | 85 | 160 | 420 | 180 | 排队较高 |
| 调整 worker 数 | 32 | 60 | 110 | 210 | 320 | 排队显著下降 |
| 动态批处理 | 32 | 70 | 125 | 260 | 500 | 单请求延迟微升 |
这份记录同时体现了延迟和吞吐的变化,两列数据一起看,避免只优化延迟却牺牲吞吐,或者反过来。对照清晰的数据,上线与否的决策就有了依据。
6. 测试报告的呈现方式与汇报要点
最后一个环节,也是容易被低估的环节:怎么把延迟测试的结果有效地呈现出来。数据是一回事,能不能让团队理解和信任你的结论,是另一回事。
一份能说服人的延迟测试报告,至少要有三块:
测试环境描述,包括硬件型号(GPU/CPU)、框架版本、模型版本、服务配置、压测工具及参数。没有这些,所有数据都无法复现,也无法横向比较。我见过太多报告只写“P99 800ms”,却不写是什么 GPU、什么批大小、什么并发,等于白测。
多维度指标对比,至少包含 QPS、P50/P90/P95/P99、错误率这几个核心项,并配一张延迟分布直方图。分布形状能直观反映是否存在异常抖动。如果在各并发档位下分别测试,还要把不同档位的表格放全。
结论和风险说明。结论用来明确某一个配置是否满足既定 SLA;风险说明用来提示已知问题,比如“当前显存只够支撑 64 并发,再高会 OOM”或“量化后精度下降了 0.6 个百分点,需要业务侧确认”这类信息。
汇报的时候还有一个小技巧:把“无法满足 SLA”的档位和满足 SLA 的档位之间的分界点标出来。这能直接回答“我们能承受多大的并发”这个问题,是团队最关心的核心结论。
我在文档里还会额外加一个“复现步骤”区块,把完整的压测命令、预热脚本、监控采集方式都写进去。原因很简单,延迟问题往往在环境变动后复发(比如换了 GPU 驱动、升级了框架、调整了批大小),到时候拿着旧报告却想起当时用的什么参数,会让人非常头疼。有了一键可执行的复现步骤,后续做性能回归就省事得多。
关于延迟分析,我最想对后来者说的一点体会是:永远不要在没有拆解阶段耗时的情况下直接优化模型。很多时候你觉得是模型慢,其实根本是排队和序列化的问题;反过来,也有你以为服务配置不够,结果模型量化一发入魂的情况。数据永远先于直觉。现在我自己接手任何一个推理服务,第一件事就是埋点、建压测矩阵、跑基线、拆耗时,这一套流程走下来,至少能避免 80% 的无头苍蝇式优化。希望对正在折腾推理服务性能的你也有帮助。