首页 / 资讯中心 / 文章详情

模型推理服务压测陷阱:单开关对比为何总得出错误结论

模型推理服务压测陷阱:单开关对比为何总得出错误结论 ★ FEATURED ARTICLE
前几天一个朋友火急火燎地来找我同一个模型、同一台机器他只是把服务配置里的一个“缓存”开关打开压测结果反而显示吞吐量掉了四成。他问我是不是这个开关有坑。我让他把压测过程原样复述了一遍听完就明白了——问题不在开关在他的压测方法。这个场景太常见了。做模型推理服务性能调优的人几乎都经历过这种“单开关压测”的信任危机明明只动了一个配置项结果却完全无法解释要么性能暴跌要么提升快得让人不敢信重跑一遍又不复现。更麻烦的是一旦结果不可信你接下来所有的决策——要不要上这个特性、要不要调整参数、要不要扩容——全都会建立在沙子上。我在这个坑里栽过好几次跟头。后来总结下来核心原因其实可以归为三类系统状态没控制住、统计口径选错了、负载特征变了。这篇文章就围绕一个具体场景——模型推理服务的压测——把这三类原因逐一拆开讲后面还附了一份我常用的压测操作纪律和问题速查表。适合正在做推理服务性能优化的人也适合刚开始接触 JMeter、k6 这类压测工具的新手。1. 现象复盘同一个模型、同一台机器、只动一个开关1.1 一个典型的“坏数据”现场先说我自己的一次实测经历。当时我在测一个基于 transformer 的生成模型服务模型文件完全没动机器也没换只是把服务端的一个配置开关——前缀缓存——打开。按道理如果测试请求里有大量共享前缀缓存应该明显降低首 token 延迟。结果第一轮压测数据出来p99 从原来的 180ms 直接飙到 2200ms吞吐量掉了一半。我第一反应是回滚心想这开关怕不是有 bug。但冷静下来我看了一眼服务端的时间序列曲线发现前两分钟确实很难看两分钟之后曲线又慢慢恢复了。也就是说我测的根本不是“开缓存之后的稳态性能”而是“冷缓存预热过程中的过渡性能”。我把过渡态当成了目标态得出了一个完全错误的结论。后来我又把负载换成了 100 条毫无共性的随机提示词开缓存的收益直接变成负数因为查缓存的开销比收益还大。同一个开关两种负载两种结论——这也是压测最迷惑人的地方。1.2 “单开关”在系统里从来不是单变量很多人把“只改一个开关”理解为“只改变一个变量”这是最大的误区。一个配置开关哪怕官方文档只描述了它的一个功能在实际运行中也会牵动一串连锁反应。就拿前缀缓存来说表面上是“命中缓存就复用计算结果”实际上它同时改变了服务的内存占用、KV cache 的分配策略、调度器对请求的排班方式甚至改变了 GPU 上 kernel 的执行顺序。这些变化叠加起来可能让系统从一个工作点跳到另一个工作点而中间经历的路径完全不同于你以为的那条平滑曲线。用生活类比来说你往一杯水里加了一勺糖你以为是只加了糖但实际上水的甜度、黏度、密度、冰点全都变了。系统层面的“一个开关”本质上是一次小范围的系统重构不是单变量实验。1.3 压测工具自己也有“开关”更隐蔽的是压测工具自身也有开关。同样一份压测脚本JMeter 里勾不勾 HTTP Keep-Alivek6 里开不开连接复用测出来的结果可以差出好几倍。很多人做“开关对比”时服务端开关变了压测端开关也跟着变两组数据就成了变量混杂的实验谁得出的结论都站不住。这一点特别重要你在对比“开关 A 开/关”两组数据时必须保证压测脚本、压测工具参数、请求内容完全一致。否则你根本说不清差异到底是服务端开关导致的还是压测端条件变了。我在实际操作中会把压测脚本 commit 到仓库里连参数一起固化每次对比都走同一个脚本只允许改服务端配置这一处。2. 三个隐藏变量系统在你背后做了什么2.1 预热与冷启动你测的是过渡态不是稳态模型推理服务比普通 Web 服务更容易出现“冷启动-预热”的大陡坡原因是多方面的。首先是 CUDA/cuDNN 的 kernel 自动调优第一次跑某些算子时框架会尝试多种 kernel 实现并从中选优这个过程本身就会消耗大量计算资源。其次是算子编译使用 torch.compile 这类技术时首次请求会触发即时编译耗时可能从几十秒到几分钟不等。然后是各种缓存——前缀缓存、KV cache、OS 页缓存、磁盘缓存——全部要从零开始填充。最后还有连接池和线程池它们通常懒初始化第一次高并发到来时才逐步扩容。如果你在这些机制完成之前就开始采集数据那结果里就混入了大量“启动噪声”。我的稳妥做法是先跑一段不计时的预热流量用一个 10 秒滑动窗口统计吞吐量的滚动均值等连续 5 个窗口的波动掉进 ±5% 以内再开始计时。这个“稳态判定规则”最好在测试开始前就定好不然后补的判定很容易变成择对自己有利的时间段。2.2 开关引发的级联效应一个变量牵动全局开关从来不是孤立变更这一点值得反复强调。举例来说如果你把服务的 worker 数从 1 改成 4看似只是一个参数实际上 CPU 上的多核调度、锁竞争、缓存一致性开销、线程上下文切换全变了甚至可能触发 NUMA 访存问题。在 GPU 上开启动态批处理会让请求先排队排队等待时间会直接变成尾延迟的一部分关闭之后每个请求独立执行延迟平稳但吞吐上不去。这就是为什么“开关开了之后 p50 更好、p99 更差”的现象极其常见——它不是矛盾而是两个不同的指标在同一个开关下走向了不同的方向。我实际测过最典型的案例开了动态批处理之后吞吐量提升约 40%p50 延迟反而下降了不少因为单个 batch 内算子的并行度更高了但 p99 暴增了 5 倍。如果你只报告平均延迟或者只报告吞吐量会得出两个完全相反的结论。所以当别人问你“这个开关到底有没有用”时你首先应该反问你的 SLO 是哪个指标2.3 “同一个模型”不等于“同一个运行状态”还有一个很多人忽略的点模型文件相同不代表推理服务每次启动后的运行状态也相同。GPU 的时钟频率受温度和功耗影响今天跑和昨天跑同一批请求的耗时就可能差出 10% 以上。操作系统的页缓存状态、内存碎片程度、磁盘读写的位置都会影响结果。更隐蔽的是某些框架在启动时会做随机初始化或者依赖哈希函数同一个模型加载两次KV cache 的分配布局都可能不同实测性能自然有差异。这提醒我们做对比压测时不仅要做到同一个模型、同一台机器还要尽量做到同一次开机、同一个温度区间、同一段页缓存状态。我在最严格的一次对比测试里是每轮测试前重启服务、清页缓存甚至还跑一轮校准请求记录基线偏移量再决定这轮数据能不能信。虽然繁琐但换来的是可以放心拍板的结论。3. 统计陷阱数据是怎么把自己骗住的3.1 平均值会撒谎分位数才会说实话先看一张我整理的对比表来自一次真实的动态批处理开关压测。指标开关关开关开变化平均延迟120ms160ms33%p50100ms90ms-10%p95180ms260ms44%p99220ms1900ms763%吞吐量38 req/s53 req/s39%如果你只看平均延迟结论是“开关让性能变差了”只看吞吐量结论是“开关让性能变好了”只有把分位数拉出来才能看见真相这个开关是把一部分请求的延迟推到了极值换取了整体吞吐的提升。如果你给线上用户承诺的是 p99 延迟那这开关对你就是毒药如果你承诺的是大批量离线处理那这开关就是好工具。推理场景尤其如此用户能感知到的是尾延迟不是均值。我习惯把 SLO 定义为“在 X QPS 下p99 低于 Y ms”压测报告的第一行永远写“本次是否满足 SLO”再看其他指标。3.2 采样窗口你在哪个时间段里取数另一个大坑是采样区间。整个压测过程一般可以分成爬坡期、稳态期、收尾期三个阶段。如果把整个过程的响应时间聚合成一个平均数爬坡期的数据会把结果拉高如果只取最后 30 秒又可能错过前期的异常波动。我的经验是提前约定一个“稳态判定规则”。比如以 10 秒为一个窗口连续 5 个窗口的吞吐量波动在 ±5% 以内且错误率低于 0.1%就判定进入稳态然后从稳态起点开始计时 N 分钟。这个规则会在压测方案里书面写清楚。为什么不后补因为后补的规则很难让团队信任大家都会怀疑你是挑了“最有利于结论”的那段时间。先定规则再跑数据是让结果可信的第一步。3.3 重复次数、重试机制与“假成功”压测结果不可信很多时候是实验次数太少。只跑一轮就下结论和买彩票看一次结果就总结规律差不多。系统性能有天然抖动一轮好一轮坏太正常了。正确做法是每个配置至少跑 3 轮取中位数作为代表值并且两两交错跑——先 A后 B再 A再 B——这样可以对冲机器在一段时间内的缓慢漂移比如温度升高导致的降频。另外要特别小心压测工具的重试机制。JMeter 和 k6 遇到连接失败或超时时默认行为都可能是自动重试。重试会把失败的请求改写成“成功但延迟更高的请求”于是错误率看着很低尾延迟却被严重污染。如果你的测试开着重试请确认自己采集的指标里是否包含原始失败计数或者干脆关掉重试让失败直接暴露出来。我自己在压测脚本里永远单独统计“首次尝试失败数”这个词比错误率诚实得多。4. 还原现场一次开关对比压测的完整复盘4.1 场景与开关选择这里我拿一个具体的配置举例方便你直接照抄。测试对象一个基于 transformer 的生成模型服务部署在一台 24GB 显存的 GPU 服务器上。压测工具用 JMeter用 k6 也一样原理没有差别。被测接口流式文本生成。测试数据100 条真实业务提示词长度分布从 50 token 到 500 token 不等。压测脚本固定 32 并发每轮跑 10 分钟。要对比的开关是服务端的前缀缓存。开启后缓存命中需要请求的前缀一致所以我在测试数据里故意放了一批共享长前缀的提示词比如同一条系统提示词加上不同的用户输入。这样开关才有机会发挥作用。如果测试数据全是随机不重复的文本那缓存命中率就是零这个开关测了也白测。4.2 第一轮我被数据骗了先测开关关闭10 分钟压测结果很稳平均延迟 120msp99 是 220ms吞吐量 38 req/s错误率为 0。数据看起来非常健康。然后我把开关打开用同样的脚本、同样的并发、同样的时长立刻开始第二轮。前两分钟p99 直接冲到 3 秒错误率 1%吞吐掉到 15 req/s。我当时差点就要下结论“前缀缓存让性能崩了”。但好在我习惯把每秒的指标都记录成时间序列多看了一眼曲线发现从第三分钟开始p99 逐渐回落到 180ms 左右吞吐量爬回 52 req/s。为什么因为开关打开后服务需要重建缓存、额外分配显存、编译器还重新选择了 kernel整个过程大约持续了 90 秒。前两分钟测的是“改造后的冷启动”而不是“改造后的稳态”。4.3 第二轮修正之后数据才说实话修正的方法很简单每轮压测前先跑 3 分钟的预热流量不计时然后清掉压测工具的采样数据从第 4 分钟开始采集 6 分钟。每种开关状态各跑 3 轮取中位数。修正后的数据是这样的指标开关关3轮中位数开关开3轮中位数p5098ms92msp95175ms166msp99218ms201ms吞吐量38 req/s50 req/s错误率0%0%修正后的结论很清楚这个开关在“有共享前缀”的负载下确实有正收益但没有第一轮冷启动数据显示的那么夸张也没有“性能崩了”那么吓人。如果把负载换成 100 条完全随机的提示词共享前缀为 0开关打开后的收益就变成负的了因为多了一笔查缓存的开销。这就是我想强调的同一个开关的结论必须绑定负载特征才有意义。脱离负载谈开关好坏等于脱离剂量谈毒性。5. 让结果可信的六条实战纪律5.1 压测前定标、预热、控状态第一动手之前先定 SLO。这个服务对外承诺的是什么是 90ms 的 p50还是 300ms 的 p99承诺不同你要盯的指标就不同。把 SLO 写下来压测报告的第一行就应该是“本次是否满足 SLO”。第二把预热变成固定流程而不是可选项。我用的是前面提到的滑动窗口稳态判定法先打 3~5 分钟接近真实并发的流量直到 10 秒滑动窗口内的吞吐量连续 5 个窗口波动不超过 ±5%才开始计时。这个判定规则要提前写进压测方案。第三控制机器状态。尽量在每次对比前重启服务必要时清页缓存用 nvidia-smi 记录显卡温度和显存占用。如果发现 GPU 温度跨过了降频阈值先等温度下来再跑下一轮。宁可慢一点也不要让温度成为两组数据之间的隐藏差异。5.2 压测中重复、交错、多指标每个配置至少跑 3 轮报告里写中位数而不是第一轮的数据。条件允许的话A/B 交错跑基准、开关开、基准、开关开。这样即使机器有缓慢漂移也能在对比中被抵消。采集指标的时候不要只看应用层的延迟和吞吐同时用 nvidia-smi、top、dstat 记录 GPU 利用率、显存、功耗、CPU 和磁盘 I/O。我曾经碰到过一种情况开关开了之后应用层延迟没有明显变化但 GPU 利用率从 40% 涨到 90%——这个信息如果只看应用层输出永远发现不了但它是判断开关是否值得上的重要中间变量。另外压测脚本里强制关闭自动重试单独统计首次失败数。5.3 压测后汇报分位数、记录上下文汇报结果时至少给出 p50、p95、p99、吞吐量、错误率这五个数不允许只报一个平均值。如果团队里有爱看平均值的人把那张“平均值 vs 分位数”的对比表甩给他看比讲解一百遍都有用。同时把每次压测的上下文记录清楚负载特征是什么共享前缀比例、token 长度分布、请求间间隔、预热了多久、跑了多少轮、取的是哪个分位数、机器温度和 GPU 利用率如何。我自己的做法是在测试报告末尾附一栏“上下文信息”把上面这些全部写进去。没有上下文的结果三个月后回看就是一堆无意义的数字。6. 常见问题速查表6.1 五种高频症状与排查方向现象常见原因处理方法前几分钟延迟爆炸后面慢慢恢复冷启动、kernel 自动调优、缓存预热未完成压测前先预热稳态后再计时p50 很好p99 特别差排队或动态批处理引入尾延迟用分位数汇报结合并发模型分析吞吐量上升但延迟也上升批处理收益与排队代价并存明确 SLO 偏重哪一边必要时做容量规划同一配置两次跑结果差很多机器状态漂移、温度降频、页缓存差异多轮取中位数A/B 交错跑控制机器状态错误率很低但线上用户觉得卡压测工具自动重试掩盖了失败关闭自动重试单独统计首次失败数6.2 三个容易被忽略的细节第一“开关收益在另一个负载下消失”不一定是 bug。如果测试数据的缓存命中率为零那缓存类开关的收益必然归零甚至为负。报告的结论必须写明是在什么负载特征下得到的否则别人拿去复用一定会踩坑。第二换了个时间点跑同一测试结果不一样先别急着怀疑代码。查一下 GPU 温度和频率看看后台有没有定时任务抢资源确认一下机器是不是进入了省电模式。我在一次跨日对比中就发现晚上部署的自动备份任务把磁盘 I/O 占满了前一天的压测数据全部作废。第三压测过程中记得查看压测接口实际的响应内容而不只是响应时间。开关类改动最容易引入的是“看似正常、实则错误”的输出。比如动态批处理某些实现若没处理好 padding可能产生乱码缓存类开关若 key 设计错了可能返回旧结果。所以我的压测脚本里始终有一个断言环节校验响应内容的关键字段。延迟再漂亮内容错了也是白测。结尾数据会骗人但完整的时间序列很少骗人说实话我在压测这条路上交的学费不少。一开始我也是那种“跑一轮、看一个平均值、拍板下结论”的人后来在线上被尾延迟坑过一次才真正把分位数和重复实验当回事。现在我的习惯是任何一个开关的结论都必须带着“负载特征是什么、预热了多久、跑了多少轮、取的是哪个分位数”这一串上下文否则我宁可说不知道。最后分享一个小技巧把每次压测的每秒原始数据——延迟、p99、吞吐量、GPU 利用率——导出成文件存档别测完就删。前几天我就是靠翻旧数据发现某个开关的收益只在“共享前缀比例高于 30%”时才存在。这个结论如果只看当时的汇总表是永远找不出来的。压测里你会遇到很多看似诡异的数据别急着怀疑模型或者开关先去怀疑你的测量过程。测量过程干净了数据自然会开始说实话。
阅读完成 · 觉得有帮助?
咨询建站