资讯详情

资讯详情

LLM推理并发性能测试不可复现?从实验设计到环境锁定全解析

跑过 vLLM 和 SGLang 并发实验的朋友大概都有过这样的体验同一个脚本、同一个模型、同一个并发数上午压出来 A 比 B 快 20%下午再压发现 B 比 A 快 10%把两个服务都重启一遍数字又变了个样。性能数字像薛定谔的猫打开报告之前你永远不知道答案。这个问题不是玄学也不是两个框架谁更好而是实验本身没有被“锁住”。这篇文章不讲广告式 benchmark而是从实验设计、指标口径、环境记录、调度机制和结果解读这条链路聊一聊怎么让 LLM 推理服务的并发性能数字真正可复现、可解释。适合正在做推理框架选型、容量评估和回归测试的工程师。1. 数字漂移不是玄学并发实验不可复现的根源1.1 一次令人崩溃的对比翻车现场先说个真实场景。去年我给某实验室做推理框架选型计划用同一组 prompt 和固定输出长度分别压 vLLM 和 SGLang。第一天晚上跑完vLLM 的吞吐比 SGLang 高 18%我把结果发给组里大家都准备按这个结论定方案。第二天早上我想补一组并发 64 的数据顺手把两个服务用同一份 docker 镜像重启结果完全反过来了SGLang 比 vLLM 高 22%。我当时的第一反应是脚本写错了但检查三遍没有逻辑问题。后来把两次实验的细节拉出来逐项对比才找到原因第一天 vLLM 服务里启动了一个长连接测试任务占住了部分显存和调度 slot第二天重启后缓存状态被清掉SGLang 的前缀缓存命中率比前一天高了一大截。也就是说两次跑的不是同一个实验。这个案例很典型性能数字本身没问题问题是实验对象在静默变化。锁不住前置条件数字就是不可复现的。1.2 四类干扰源状态、请求、并发与环境的叠加大多数人以为不可复现只是 GPU 差异其实干扰源通常来自四个层面。第一是服务端状态包括输入到 KV cache 前缀缓存、连续批处理队列里的请求水位、显存里其他任务残留、调度器的 batch 排列这些都会直接影响每一轮推理的吞吐。第二是请求模型prompt 长度、输出长度、采样参数、stop 逻辑只要有一个变化性能就会有系统偏差。第三是客户端并发模型线程数、连接池大小、请求超时、发起节奏决定了你测的到底是不是框架的极限还是客户端自己的极限。第四是环境状态共享 GPU 的邻居任务、CPU 抢占、网络带宽波动、电源管理降频这些都很难用程序控制。把这些排一遍你会发现很多 benchmark 报告根本没有记录它们。想让数字可复现第一步就是把“没有记录的东西”变成“有记录的东西”。1.3 为什么LLM推理性能对比格外难做相比普通 Web 服务LLM 推理是“动态批处理”加上“状态化缓存”的组合计算量和延迟只有运行到那一刻才能确定。同一个请求排在批次的第一个和第三个消耗的时间完全不同请求之间的 prompt 相似度决定前缀缓存命中多少prefill 阶段占显存、decode 阶段占计算调度器切换策略会大幅改变资源分配。这些都不是两个模型在“算法层面”的差异而是“工程调度层面”的差异。vLLM 和 SGLang 虽然都做连续批处理和 KV cache 复用但实现细节不一样数字自然会随 workload 形态产生锯齿。先理解了这一点再谈对比才有意义否则你只能拿到一组数字无法解释数字为什么是这个形状更别提复现了。2. 实验设计第一课把指标、请求和并发模型钉死2.1 性能指标到底看哪个不同指标回答不同问题。吞吐量回答系统能压多少首 token 延迟TTFT回答用户第一次看到字要等多久单 token 延迟TPOT/ITL回答输出过程是不是流畅端到端延迟回答一条完整请求的耗时。很多对比报告只给一个吞吐数字这远远不够。在并发实验中我建议至少同时记录四类指标吞吐量token/s、TTFT 均值与 p99、TPOT 均值与 p99、端到端延迟 p50/p95。需要特别标注口径吞吐量按生成的 token 数算还是按请求数算TTFT 是从客户端发出请求到收到第一个 token 的时间还是从调度器开始处理的时间同样的数据口径不同结论会完全不同。我自己的习惯是在记录文件的表头写明“tokens, not requests”这类注释并在结果报告里同时保留原始数据和统计脚本方便别人交叉验证。2.2 请求模型固定shape是复现的基石做可复现实验最简单的做法是使用固定输入长度和固定输出长度的请求集。比如一组 512 token 输入、256 token 输出共 200 条另一组 128 输入、128 输出共 200 条。每条 prompt 明确指定 max_tokens并把采样参数设为 temperature0关闭随机采样。这样每次发送的请求内容完全一样输出长度也完全一样框架层无法通过“少生成几个 token”来偷性能。实际压测当然要覆盖真实分布但那是第二阶段。先固定 shape 跑通再逐步引入长度分布你才能知道性能波动是框架差异还是数据差异。另外要注意 stop 参数如果 prompt 里碰巧包含了 stop token输出可能提前结束长度就不可控。请求集文件建议用 JSONL 存储并计算文件 hash实验记录里带上这个 hash后续任何人拿到文件就能确认数据集没变。2.3 并发模型并发数、QPS与客户端线程的关系并发实验最常见的误区是把“每秒请求数”当成“并发数”。比如用脚本 for 循环发 100 个请求耗时 10 秒平均 10 QPS这不代表并发是 10。改成用 10 个线程同时发请求每个线程连续发送才能近似“恒定 10 并发”。更严谨的做法是设置并发数 N开启 N 个客户端协程/线程每个线程循环发送一定数量的请求并让服务端保持持续处理状态。这里有个细节如果客户端用线程池请求在队列里等待线程数才是并发上限如果客户端用异步连接数限制可能成为隐形瓶颈。压测机最好单独一台至少不要让压测客户端和服务端抢同一个 GPU 机器的 CPU。你可以在脚本里打印客户端的 CPU 占用和发送间隔确保瓶颈只在服务端。下面是一个精简骨架核心是“固定并发 固定轮数”而不是无限 while True。import concurrent.futures import requests import time PROMPT the quick brown fox jumps over the lazy dog * 40 # 固定输入 MAX_TOKENS 256 URL http://127.0.0.1:8000/v1/completions def one_call(worker_id): payload { model: default, prompt: PROMPT, max_tokens: MAX_TOKENS, temperature: 0, } t0 time.perf_counter() resp requests.post(URL, jsonpayload, timeout300) dt time.perf_counter() - t0 body resp.json() usage body.get(usage, {}) return dt, usage.get(completion_tokens, 0) def run_concurrency(concurrency, rounds_per_worker20): results [] with concurrent.futures.ThreadPoolExecutor(max_workersconcurrency) as ex: futures [ ex.submit(one_call, i) for _ in range(concurrency) for i in range(rounds_per_worker) ] for f in concurrent.futures.as_completed(futures): results.append(f.result()) return results这个脚本没有统计逻辑但它保证了每个 worker 发固定轮数不会因为客户端重试导致负载无限堆积。统计时再按 p50、p95、平均值分组计算即可。3. 环境锁定框架版本、引擎参数和显存状态都要入档3.1 从镜像到pip freeze环境记录怎么做可复现实验的根基是“环境快照”。我通常用一套脚本自动生成容器镜像 ID、Python 版本、pip freeze 输出、框架版本、git commit 号、驱动版本、CUDA 版本、GPU 卡数量与显存 ID。把这些写进一个env.txt和实验结果放在同一个目录。很多坑都来自版本漂移比如 vLLM 某个 commit 修复了显存碎片SGLang 某个版本改了调度默认值半个月后重跑数字自然不一样。如果你不用 docker至少用 venv并固定依赖如果用了 docker最好把镜像 tag 和 digest 一起记录因为同名 tag 可能被覆盖。给环境打标签的习惯比任何统计修正都管用。我在某公司做推理平台时就是靠这一套环境快照把一次“看起来像框架回退”的线上问题定位成了依赖包版本漂移省了一整周的排查时间。3.2 vLLM与SGLang的引擎参数对齐清单对比两个框架最容易被忽略的是“引擎参数没有对齐”。比如 vLLM 最大批处理序列数是 max-num-seqsSGLang 对应的是 max-running-requests显存上限一个叫 gpu-memory-utilization一个叫 mem-fraction-static上下文长度、并行度也都有对应配置。如果一方默认 batch 更大显存利用率更高性能自然不同但那不是框架本身的差异。我的做法是打开两个框架的帮助信息把影响批处理、显存、前缀缓存、并行度的参数列成一张表逐个对齐参数意图vLLM 侧SGLang 侧最大并行请求数--max-num-seqs--max-running-requests显存上限比例--gpu-memory-utilization--mem-fraction-static前缀缓存开关--enable-prefix-caching默认启用 RadixAttention张量并行--tensor-parallel-size--tp-size最大上下文长度--max-model-len--context-length 或等价配置列完之后每个参数都确认“这个值是否相同、为什么不同”。如果某个参数必须不同结果解读时就要说明差异来源不能混为一谈。第一次做这个对齐花了我一个下午但之后每次对比都省了很多扯皮。3.3 采样参数与随机种子的隐藏影响性能对比还有一个容易被忽略的隐藏变量采样器。temperature、top_p 等采样行为一般不影响计算量但会影响输出长度分布。如果一方使用了贪婪解码另一方使用了随机采样但 max_tokens 兜底最终输出 token 数可能不一样吞吐数字直接失真。更稳妥的做法是让两个服务都使用相同的 temperature0 和相同的 max_tokens必要时关闭 stream使用非流式接口统计整条延迟。如果用 stream 模式TTFT 和 TPOT 才能分别统计但客户端解析逻辑要统一。请求里的随机性也要控制生成请求集的 prompt 时固定随机种子让每次实验的 prompt 集合完全一致。这样至少排除了数据分布带来的随机波动后续分析也能更干净。4. 压测进行时调度器、前缀缓存和并发拐点4.1 连续批处理的水位如何影响吞吐vLLM 和 SGLang 都实现了 continuous batching也就是一条请求完成生成后batch 里立刻补进新请求不用等整个 batch 结束。但“补进新请求”的时机和策略不同直接影响吞吐。当并发数低时batch 很浅GPU 利用率不足吞吐由 prefill 阶段的 token 处理速度决定并发升高后batch 变满decode 阶段的 token 产出成为瓶颈框架的调度开销开始显现。你会发现如果两个框架的批处理水位不同一个先进入满 batch 状态一个还在半空状态同一并发下吞吐差距会被放大。实验时我建议在服务端把max-running-requests和max-num-seqs设为相同值同时观察显存占用和当前 running 请求数才能判断吞吐差异来自调度策略还是容量限制。4.2 前缀缓存从“共享前缀”到性能放大镜LLM 推理有个很容易被忽视的加速器prompt 之间的共享前缀。SGLang 的 RadixAttention 会对 KV cache 做树状前缀复用vLLM 的 prefix caching 也会哈希并缓存公共前缀。当你的压测数据集里所有 prompt 都从同一段固定文本开头时前缀命中率很高缓存带来的加速会非常明显如果每次 prompt 都是完全随机的双方都命中不了性能差距又会变小。所以同样的代码换一个数据集结论可能直接反转。为了可解释压测数据集要记录“前缀命中率”或“prompt 重复程度”并在报告里说明。如果目标是评估真实业务就混合一部分共享前缀请求和一部分随机请求如果目标是复现一个公开结论必须使用完全一致的 prompt 集合。4.3 并发拐点不是并发越高越好看高并发不等于高吞吐。随着并发上升请求会排队显存占用接近上限调度器要频繁做抢占和 batch 重构开销上升最终吞吐出现拐点甚至下降。把并发从 1 逐步增加到 128你会得到一条类似倒 U 的吞吐曲线。vLLM 和 SGLang 的拐点位置通常不一样这不是“谁快”的简单问题而是“在什么负载形态下谁的资源调度更顺畅”的问题。跑分时不要只测一个并发点至少要测 4 个点比如 1、8、32、64并把每秒的吞吐曲线画出来。如果只在某个并发点下比较结论很容易被拐点位置误导。我曾经见过一份报告只测了并发 16结论是 A 远好于 B后来补了并发 64 的数据结论几乎反过来。这提醒我拐点附近的数字是最脆弱的必须多看几个点。5. 读懂数字一份可解释的跑分记录应该包含什么5.1 一次完整实验的配置示例与结果表格下面给出我在实验里用的记录模板数据和环境都是虚构的但结构可以直接抄。环境80GB 数据中心级 GPU 一块驱动版本 535.xCUDA 12.2模型为 7B 规模 dense 模型请求集固定 512 输入/256 输出并发从 1 到 64每个并发点跑 3 次取中位数。客户端和两个服务端分别跑在不同容器避免 CPU 抢占污染结果。并发数vLLM 吞吐 (token/s)vLLM TTFT avg/p99 (ms)vLLM E2E p95 (ms)SGLang 吞吐 (token/s)SGLang TTFT avg/p99 (ms)SGLang E2E p95 (ms)136845/52175037242/4917208154088/1202050149096/1382160322380210/34030502610180/2802780642470520/78051002790340/5204200这个表里的数值只是用来演示曲线形态不具备普适意义。低并发时两边接近并发 32 以后 SGLang 的吞吐优势显现TTFT 也更低说明它的调度策略在高水位下能更快地让新请求进入 prefillvLLM 并发 64 时吞吐增幅很小显存占用接近上限调度开销开始吃性能。5.2 结果解读差异出现的机制而不是胜负看到数字之后不要急着写“A 比 B 好”而要问“差异从哪个环节来”。我通常按三步走。第一步看低并发如果 TTFT 差异明显大概率是 prefill 阶段 kernel 实现、chunked prefill 策略或数据搬运方式不同。第二步看高并发如果吞吐差异明显大概率是连续批处理的水位、KV cache 复用、调度器抢占策略造成的。第三步看显存占用曲线如果某个框架在并发 64 时显存已经逼近上限而另一个还有余量那吞吐差异有一部分就是容量问题不是引擎快慢问题。把这些机制写进报告读者才能知道“为什么是这个数字”而不是只知道“谁赢谁输”。一份没有机制解释的 benchmark本质上只是日志摘抄。5.3 把实验存档让任何人能复现最后把复现路径写进 README使用的镜像 digest、启动服务的完整命令、压测脚本版本、请求集文件 hash、每个并发点运行时长、丢弃了多少个预热请求、统计方法均值还是中位数、p99 计算方式。建议把每次实验结果存成 JSON/CSV文件名带时间戳和 git commit原始日志不要覆盖。这样即使三个月后有人拿着报告来问“这个数字怎么来的”你也能还原每一步。我还要把实验脚本本身纳入版本管理而不是放在临时目录里跑完就丢。这是我在某公司做推理平台时候最受益的一个习惯。经验教训所谓可复现不是“我把代码贴给你你跑一下就能得到一样的结果”而是“你按照我的步骤和环境能进入同一个现象空间”。6. 踩坑实录五个最容易被忽略的复现杀手6.1 没预热就跑数显存分配和CUDA图还没稳定LLM 框架通常在服务启动后会经历一个“冷启动”阶段显存分配、CUDA graph 捕获、模型权重加载都会让前几个请求特别慢。如果不预热直接开始压测吞吐会偏低而且不同框架预热速度不同差距会被放大。我的做法是先用低并发比如 4发 50-100 个请求观察最近几轮吞吐和延迟是否进入稳态然后再开始正式记录。丢弃的请求数要写进实验日志正式结果只统计稳态之后的数据。有些框架支持启动时预热也可以用但要在报告里说明。这个步骤花不了两分钟但对数字稳定性的帮助是巨大的。6.2 GPU降频与功耗波动GPU 不是一直跑在标称频率上的温度、功耗墙、其他容器负载都会让实际频率上下浮动。跑压测时如果不开“性能模式”或者不锁频率早晨和下午测出的数字可能差出 10%-20%。可以在启动压测前用 GPU 厂商的显存与时钟查询工具把时钟和功耗模式固定下来如果权限不允许退一步也要在实验前后各记录一次时钟频率、温度、利用率并把异常波动的数据剔除。我见过一个团队因为没锁频率同一个实验连续三天数字每天都在下滑最后才发现是散热问题。性能实验和环境实验是同时进行的不能只看算力不看物理状态。6.3 客户端自己先扛不住了并发 64 的请求可能不算什么但如果每个请求都带 2048 个 prompt token响应几千个 token客户端网络栈、内存和解析 JSON 的 CPU 都会成为瓶颈。如果压测机和服务端共用一个物理机CPU 抢占了谁的框架都跑不快。观察方法是压测期间看客户端 CPU 是否接近 100%检查单个请求的客户端处理时间是否超过了服务端返回时间的合理阈值。可以适当裁剪响应只保留 usage 字段减少 JSON 解析开销。让客户端简单是让结果干净的第一步。另外压测机和服务端之间的网络要走内网不要经过公网网关否则延迟曲线里会混进完全无关的网络抖动。6.4 队列无限堆积性能数字被“排队”污染很多压测脚本是“持续发送”模式只要请求失败就重试服务端队列越来越长。最终吞吐看起来很高但端到端延迟已经被排队时间撑爆TTFT 不再有参考价值。要区分“系统吞吐极限”和“用户可接受时延下的吞吐”必须在客户端设置超时并统计失败的请求。如果服务端有请求排队日志也要记录队列长度。我在跑并发对比时会固定“每个 worker 发 N 轮请求后结束”而不是让 while True 无限发。这样每个并发点的负载是可控的数字才可解释。否则极端情况下两个框架都会进入 OOM 前的不稳定状态结果没有任何可比性。6.5 实验记录里没有commit号版本漂移后只能重跑最后一个坑最不起眼但最致命。框架更新迭代非常快两周前装的 vLLM 和你今天新装的最新版可能已经是两个世界。如果不记录镜像 ID、pip 包版本、git commit实验结果只能当作“当时的性能切片”无法回溯。我现在做实验前会固定一个 requirements 文件并直接把pip freeze env.txt、git rev-parse HEAD输出放进结果目录。每次跑完对比先检查两份 env.txt 是否完全一致不一致就中止分析。这一步虽然土但比任何复杂的统计手段都更能保证可复现性。最后分享一个让我少踩很多坑的习惯每次实验前花两分钟把环境和版本快照打进日志目录跑分过程中随时看显存、时钟和客户端 CPU跑完后别急着删临时文件。性能数字最终会骗人但完整的记录不会。
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →