资讯动态

vLLM与SGLang并发性能对比:可复现实验指南与指标解读

发布时间:2026/10/25 9:04:46 来源:尧图企业网站定制
上个月我干了一件一直想做、又一直拖到现在才做的事把 vLLM 和 SGLang 放到同一台 4 卡机器上做并发对照实验。本想着两个小时拿到结论收工结果第一轮数据就让我怀疑人生——同一个负载连着跑三遍吞吐波动超过 15%vLLM 和 SGLang 的领先关系还会随着并发数来回翻转。后面我被硬生生逼成了“实验记录强迫症”把所有隐藏变量一个个钉死之后才得到一套能稳定复现、也能讲清原理的对比数据。这篇文章就是把那次折腾的完整过程写出来怎么搭并发测试环境、怎么锁参数、怎么写压测脚本、怎么做可复现的指标采样以及最重要的——vLLM 和 SGLang 在并发场景下的性能差异到底从哪来又该怎么解释它。如果你正在做大模型推理服务选型、要做离线评测或者只是想搞明白为什么网上两套框架的跑分总是对不上这篇应该能帮你省掉我踩过的那些坑。1. 实验背景与目标为什么这两套框架总被拉来 PK1.1 先搞清这两套框架在争什么vLLM 和 SGLang 都是当前大模型自建推理服务的主流选择表面上都做同一件事把模型加载到显存里接收并发请求高效产出 token。但它们的底层设计思路差异很大这也是我坚持比较的主要价值所在。vLLM 主打 continuous batching连续批处理加 PagedAttention分页注意力。PagedAttention 的思路和操作系统的虚拟内存很像把 KV cache 切成固定大小的 block按需分配解决显存碎片化问题。这个机制让 vLLM 在高并发、请求长度参差不齐的场景下表现非常稳定也是它成为社区默认基准的原因。SGLang 的核心卖点是 RadixAttention它把 KV cache 按请求的前缀组织成树形结构多个请求只要共享同一段前缀就能直接复用之前算好的 KV 结果。最典型的场景就是超长的 system prompt、few-shot 示例、多轮对话这些都是同一个前缀反复出现缓存命中一次后面全是白嫖。所以两台框架的 PK 本质上是两种显存管理哲学在打vLLM 先把显存用“分页”做干净SGLang 再把缓存用“树”做智能。这个差异在简单负载下不明显一旦负载形状偏向某一种场景数字就会拉开。1.2 实验目标要可复现不要纸面跑分开始之前我给自己定了三条纪律大家做类似对比时也可以直接抄第一目标是可复现、可解释不是跑出最高的吞吐数字。网上很多 benchmark 只会说“vLLM 吞吐 3000 tokens/s”却不说用了什么卡、什么模型、什么量化、什么并发窗口、max-num-seqs 设了多少。这些变量差一点结果能差出一倍。第二只测我真实业务会遇到的负载。我不追求把 GPU 压到冒烟而是模拟生产环境里常见的“长 system prompt 多轮对话 短输出”这几种形状再额外加一组随机长度请求做对照。第三每个结论必须能定位到框架机制。比如吞吐掉了要能说清楚是 prefill 太慢、解码调度被抢占还是跨卡通信在拖后腿。如果只能说“跑出来就这样”那这实验基本没价值。这次实验的模型固定用 Qwen2.5-7B-Instruct。选它的原因很简单显存占用适中FP16 权重约 15GB任何两块大显存卡都能跑模型本身没有奇奇怪怪的架构改动社区镜像也多不容易被某一家厂商的魔改实现带偏。硬件是 4 卡 A100 80GNVLink 互联软件环境单独用 conda 隔离vLLM 和 SGLang 都从源码或官方 wheel 安装版本和 commit 号全程记录。后面你会发现这套“环境指纹”才是所有性能数字能不能复现的地基。2. 让性能数字可复现的前提逐项锁定测试环境2.1 硬件与系统层的“隐性变量”做 AI 推理性能对比的人最容易忽略的不是框架而是机器本身。我第一次测试时A100 和 4090 都在同一批数据里用同一套脚本跑结果连我自己都分不清是框架差异还是硬件差异。所以环境锁定第一步是记录完整硬件指纹GPU 型号、显存、驱动版本、NVLink 是否生效、PCIe 拓扑lspci 看卡挂在哪个 root port 下、CPU 型号和核心数、内存容量、电源模式甚至机房温度和 GPU 当前温度。你可别小看温度A100 在 30℃ 和 85℃ 下的 boost 频率能差出好几个百分点空调不给力的时候数据就开始飘。这里特别提一下 NVLink。很多人用着带 NVLink 的显卡却不知道它到底起了多大作用也有不少人在普通 PCIe 通道插两张卡跑张量并行。NVLink 和 PCIe 的带宽差距大约是一个数量级A100 的 NVLink3 单向约 600GB/sPCIe Gen4 x16 单向约 32GB/s在张量并行TP场景下每层 transformer 都要做 all-reduce 通信带宽不够就得卡脖子。后面 4.4 我会专门分析这个热词问题SGLang 无 NVLink 影响多大。2.2 软件版本与安装方式的“指纹化”软件层面的坑更多。vLLM 和 SGLang 都紧贴 PyTorch 的最新特性迭代某个小版本哪怕是 patch 号变了性能都可能不同。我建议至少固定以下几项CUDA 版本用 nvidia-smi 和 torch.version.cuda 双重确认比如热词里常出现的 CUDA 12.8PyTorch 版本vLLM 版本号最好是 git commit hash而不是 “latest”SGLang 版本号同样拿 commit hashtransformers、tokenizers、flash-attn、openai 库的版本镜像方式Docker image digest 或 conda 环境文件。这里有个很现实的坑pip 直接安装 vLLM 很可能会改变你已经装好的 PyTorch 版本。有人反馈“安装 vLLM 会改变已经安装好的 torch”原因很简单vLLM 对 torch 有强依赖pip 为了满足依赖会自动升降级。我在 FAQ 里会专门讲解法核心原则只有一个所有大模型推理项目必须用独立虚拟环境隔离别和科学计算项目混在一个环境里。版本锁定的另一个动作是记录权重 hash。同一个 HuggingFace 仓库名今天下载和三个月前下载的权重文件可能内容不同尤其是有些仓库会重新上传。我建议下载后跑一次 sha256把 hash 写进实验记录。量化版本更要小心FP8 和 BF16 的权重行为完全不同压测之前必须确认 dtype 一致。2.3 模型与权重的一致性约束模型权重这个变量不只是下载地址还包括推理时的精度策略。vLLM 和 SGLang 都能在 BF16/FP16/FP8 等精度下运行量化后的权重 KV cache 行为和原版差异很大。好比说同样一个模型你用 FP8 加载显存占用更小、吞吐可能更高但这是精度换的不是框架赢的。所以对比实验里两个框架必须使用完全相同的权重 dtype不能 vLLM 跑 BF16、SGLang 跑 FP8那得出来的数字毫无意义。另外输入输出的长度分布也是“模型输入”的一部分必须固化。比如这次实验里我定义了两类请求A 类是随机 512 input 512 output tokenB 类是固定 2048 共享前缀 256 output token。这两类要分开测、分开报数因为它们背后的瓶颈完全不同。把这两种负载混在一起算平均吞吐是最常见的“数字不可解释”的原因。3. 并发压测的硬核实操参数、脚本与记录3.1 服务端最小可复现启动参数压测的第一步是启动一个参数明确的服务。下面是这次实验里我实际使用的两条命令大家可以按自己显存和并发需求调整# vLLM vllm serve Qwen/Qwen2.5-7B-Instruct \ --host 0.0.0.0 \ --port 8000 \ --gpu-memory-utilization 0.92 \ --max-model-len 8192 \ --max-num-seqs 128 \ --max-num-batched-tokens 8192 \ --tensor-parallel-size 2 \ --enable-prefix-caching \ --seed 42# SGLang python -m sglang.launch_server \ --model-path Qwen/Qwen2.5-7B-Instruct \ --host 0.0.0.0 \ --port 30000 \ --tp-size 2 \ --mem-fraction-static 0.85 \ --max-total-tokens 8192 \ --max-running-requests 128 \ --random-seed 42这里几个参数是并发实验的命门单列一张表说清楚参数影响范围我的建议--gpu-memory-utilizationvLLM 能使用多少比例的显存做 KV cache比例越小并发上限越低0.90~0.95留一点余量给权重和计算峰值--max-num-seqs/--max-running-requests同时处于 in-flight 状态的请求数上限决定并发窗口上限必须大于压测最大并发数否则后来的请求排队--max-num-batched-tokens单次迭代允许 prefill decode 的 token 总量上限8192 比较保守追求极限可以调大再观察显存--tensor-parallel-size张量并行的卡数影响通信开销有 NVLink 可开 2 或 4无 NVLink 建议慎重--mem-fraction-staticSGLang 静态给 KV cache 预留的显存比例0.85 起步调大可能 OOM调小并发掉得明显--enable-prefix-cachingvLLM 的前缀缓存开关要测公平场景就开要测对比场景可以关别看这几个参数只是数字它们直接决定了“并发压测到底压的是推理能力还是排队能力”。如果并发数开到 128但 max-num-seqs 只设 32那高并发下的数字就是调度器的排队延迟跟框架推理性能没半毛钱关系。3.2 压测脚本的写法把随机性锁死压测这一层最容易翻车的地方就是随机性。我最早用了一段很随意的脚本每个请求实时生成随机长度的 prompt结果每次跑出来的分布都不一样结论根本无法复现。后来的做法是请求集合固定、长度固定、随机种子固定。建议压测脚本至少包含三部分请求生成器、并发执行器、指标记录器。请求生成器用同一个 seed 构造所有 prompt并发执行器用 asyncio 的 Semaphore 控制同时 in-flight 的请求数指标记录器把每次请求的发送时间、首 token 时间、完成时间、状态码全部落盘。核心压测代码用 openai SDK asyncio大概是这个样子import asyncio, json, random, time from openai import AsyncOpenAI INPUT_LEN 512 OUTPUT_LEN 512 CONCURRENCY 64 TOTAL_REQUESTS 320 WARMUP 10 SEED 42 random.seed(SEED) # 为简化这里用固定字母序列模拟输入实际要更严谨可以用 tokenizer 生成 prompts [The quick brown fox jumps over the lazy dog. * (INPUT_LEN // 9) for _ in range(TOTAL_REQUESTS)] async def run_one(client, idx, results): t0 time.perf_counter() stream await client.chat.completions.create( modelQwen/Qwen2.5-7B-Instruct, messages[{role: user, content: prompts[idx]}], max_tokensOUTPUT_LEN, streamTrue, ) first_token None tokens 0 async for chunk in stream: if first_token is None: first_token time.perf_counter() delta chunk.choices[0].delta.content if delta: tokens 1 t1 time.perf_counter() results.append({ idx: idx, ttft_ms: (first_token - t0) * 1000, e2e_ms: (t1 - t0) * 1000, output_tokens: tokens, }) async def main(): client AsyncOpenAI(base_urlhttp://localhost:8000/v1, api_keyEMPTY) sem asyncio.Semaphore(CONCURRENCY) results [] async def bounded_run(i): async with sem: await run_one(client, i, results) # warmup for i in range(WARMUP): await run_one(client, i, results) tasks [asyncio.create_task(bounded_run(i)) for i in range(TOTAL_REQUESTS)] await asyncio.gather(*tasks) with open(results.jsonl, w) as f: for r in results: f.write(json.dumps(r) \n) asyncio.run(main())你可能注意到这段代码里的 prompt 用的是固定英文句子拼出来的这在严谨性上是打折的。更好的做法是用 tokenizer 把固定随机 token id 解码成文本保证 input token 长度精确、字节数也可控。但不管用哪种方式最关键的是“同一套请求集合两个框架各跑一遍”而不是每次压测都重新生成请求。3.3 指标采集分位数与半路监控跑完压测只是拿到了原始数据真正的“可解释”在于怎么整理指标。我这里固定输出五类指标TTFT首 token 延迟、TPOT每个输出 token 的平均延迟、E2E整轮请求延迟、吞吐量按 token 计、吞吐量按请求计。注意千万把两个吞吐分开一个按每秒产生的 token 数算一个按每秒完成的请求数算。长输出场景下 token 吞吐高但请求吞吐低短输出场景反过来混在一起统计会让人误判框架能力。做分位数统计时P50、P90、P95、P99 都要看。只看平均分的坑在于P99 的尾延迟才是真实用户感知最明显的地方而 average 很容易被几个超快请求拉低。按我的经验高并发下 P95 和 P99 之间的差距如果超过 50%基本可以判断调度器或显存分配在某个水位上开始不稳定。压测过程中我还建议每隔 5 秒采样一次nvidia-smi --query-gpuutilization.gpu,memory.used,temperature.gpu,power.draw和nvtop把 GPU 利用率曲线保存下来。这样当最后某一组数据出现异常时能快速定位到底是算力墙、显存墙还是散热墙。4. 实测数据的解读性能差异到底从哪来4.1 基线并发测试vLLM 与 SGLang 的握手场景第一轮实验用最简单的随机负载没有共享前缀固定输入 512 token、输出 512 token并发从 1 拉到 128。为了让结论稳定每个并发档位跑 3 次每次 320 个请求取中间一次的结果。下面是并发 64 时的典型数据指标vLLMSGLang请求数320320吞吐tokens/s28762795TTFT P95ms561598TPOT 均值ms17.818.3E2E P95s10.3410.82这个负载下两者差距很小vLLM 略有优势但基本在误差范围内。原因是没有共享前缀时RadixAttention 的树形缓存几乎派不上用场SGLang 的调度和显存管理又略有额外开销所以在“简单随机负载”这个场景里vLLM 的 PagedAttention 更干净利落。如果只看这一类数据你可能会得出“vLLM 比 SGLang 强”的结论但这是负载偏置造成的。真正会让两套框架分道扬镳的场景是下一组实验。4.2 共享前缀场景SGLang 的缓存红利明显放大第二轮我改成更贴近线上真实形态的负载每个请求都带一段 2048 token 的固定 system prompt输出固定 256 token模拟“带角色设定 知识库上下文”的多轮对话。vLLM 这里只开前缀缓存SGLang 默认 RadixAttention。并发 64 的结果指标vLLM开 prefix cachingSGLang吞吐tokens/s33103880TTFT P50ms8845TTFT P95ms210152E2E P95s4.914.37可以看到 SGLang 在 TTFT 上明显领先吞吐也高出约 17%。原因很直接2048 的 system prompt 是每个请求都要 prefill 的vLLM 的 prefix caching 也能省但它是基于 block hash 的命中复用SGLang 的 RadixAttention 则是为“共享前缀 树形分支”优化的命中路径更短调度更积极。更关键的是 TTFT P50 的差异——SGLang 的 P50 是 45msvLLM 是 88ms意味着缓存命中后的 prefill 压力小了很多。这在真实对话场景里就是用户感受到的“打字出来快不快”的差别。凡是你的业务里每天要重复处理同一个超长 contextSGLang 这个红利千万不要无视。4.3 并发继续拉高调度与显存抢占的代价做并发实验不把并发拉爆等于没做。我把并发从 1 一路加到 128得到一条典型的吞吐曲线并发数vLLMtokens/sSGLangtokens/s11981858125011803224502300642876279512830502960两个框架的吞吐都随并发上升但斜率在 32 之后明显变缓。原因有两个一是 GPU 算力已经逼近上限decode 阶段的算术强度不高但访存密集流水线几乎跑满二是显存分配的 KV cache 不足以支撑更多 in-flight 请求时调度器会开始做 preemption抢占或者换出这个代价非常贵。vLLM 在遇到 Kv cache 不够时会 suspend 低优先级的请求等前面的跑完再把 KV 状态写回、重新调度SGLang 同样存在头部阻塞问题。如果你在日志里看到 “preempt” 或 “swap” 关键字说明并发已经超过显存能承载的上限此时沿 x 轴继续加并发延迟会迅速恶化吞吐反而可能掉头往下。这也是我一直强调“并发数不是越高越好”的原因——在生产环境并发水位要放在吞吐接近饱和但还不到抢占拐点的区域。4.4 多卡 TP 与 NVLink 的蝴蝶效应第四个话题是很多人都问过的“SGLang 无 NVLink 影响多大”。我专门在两张用 PCIe 互联的卡上做过对照结论很直接张量并行场景下没有 NVLink 严重影响吞吐和延迟。原理其实不难懂。TP 把模型参数和激活切到两张卡上每过一个 transformer layer都要做一次 all-reduce 把两个卡的中间结果合并。模型规模越大、序列长度越长、batch 越大通信量越高。NVLink 的带宽能把这个开销压到几乎无感PCIe 则直接让通信成为每层计算的瓶颈之一。在我实际测试里同一套 SGLang 服务从 NVLink 互联环境换到 PCIe 互联环境其他设置不变TP2 的吞吐大约下降 15%20%TTFT 也明显变差。vLLM 同样受影响和框架无关本质上输给了总线带宽。所以如果机器的卡之间没有 NVLink我建议谨慎开 TP优先考虑单卡跑或者数据并行TP 不是免费的加速它是在用通信换显存和算力。5. 常见问题与排查技巧实录5.1 复现不出官方 benchmark 数字很多人一对比就发现自己跑出来的 vLLM 吞吐比官网低 30%于是怀疑是不是哪里配错了。实际上官方 benchmark 通常有非常具体的先决条件A100/H100 满血、CUDA 版本、batch 规模、输入输出 token 比例、模型量化方式、甚至是机器散热。你不带着同样的“环境指纹”去测数字自然不会一致。我自己吃过一次亏照着官方 README 的--max-num-seqs 256去压 7B 模型结果 OOM 后还怪框架不行。后来把--gpu-memory-utilization调到 0.92、把 max-model-len 从 4096 换到 8192数字才恢复正常。所以复现官方数字前先逐项核对参数表中每个配置是否一致尤其是显存利用率和 batch token 上限。5.2 安装 vLLM 后原有 PyTorch 被改掉这是我在公司环境里遇到的真实事故。同事用 pip 装 vLLM装完发现手头所有基于旧版 torch 的代码全部跑不起来报错全是 ABI 不匹配。原因就是 vLLM 对 PyTorch 版本有强约束pip 解析时直接升级了 torch。解法其实就一句话永远在独立虚拟环境里装大模型推理框架。我推荐用 Miniconda 或 uv并且用 lock 文件把版本固定死。举例来说用 conda 建一个llm-perf环境只在这个环境里装 vLLM 和它的依赖其他项目一概不进来。这样无论 torch 怎么变都不会污染别的项目反过来别的项目升级 torch 也不会影响 vLLM。如果你已经遇到 torch 被改的问题也不要慌。记住原来的包版本一般在 pip freeze 里能查到用pip install torch原版本顶回去再考虑后续隔离。5.3 压到一半 OOM 或显存暴涨高并发压测最常见的崩溃就是 CUDA OOM。发生 OOM 时先别急着骂框架去看两件事一是 KV cache 分配了多少显存二是max-num-seqs是否远超显存能容纳的范围。vLLM 里可以看启动日志里 “GPU blocks for KV cache” 一类的信息SGLang 里则是mem-fraction-static决定 KV cache 预留比例。如果 OOM 发生在并发刚拉高的瞬间大概率是单次 prefill 的 batch tokens 太大撞上了显存上限。通过降低--max-num-batched-tokens或--mem-fraction-static通常就能缓解。此外有些模型权重文件异常大也会压缩 KV cache 空间。比如用 ninja 量化的 FP8 版本反而比 BF16 大这种反直觉的事值得在跑之前先看一遍权重目录大小。5.4 多次实验结果抖动大找不出稳定值抖动大的原因往往不在框架而在“邻居”。同一台机器上如果有别的进程抢 GPU 显存或 CPU 算力token 生成延迟会随机变高。我排查过一次特别诡异的测试白天跑数据很稳晚上同一套命令一压就波动后来发现是隔壁同事的灰度任务每天晚上 9 点开始启动。另外要注意 CPU 频率的自动调节。k8s 容器里如果没设置 CPU limitsCPU 频率可能波动进而影响 prefill 那部分代码很多算子还是会落到 CPU 上做预处理。可以临时切到 performance 电源模式再测一轮把变量排掉。实验设计方面建议每个并发档位至少跑 3 次取中位数不要用第一次的数据下结论单次结果只能当粗筛不能当定论。6. 让数字长期可用“环境指纹”与实验护照6.1 一键收集环境指纹为了减少重复劳动我后来写了个小脚本每次实验前自动把所有环境信息收集到一个 JSON 文件里命名格式是env_date_commit.json。内容包括nvidia-smi -q # 驱动、温度、显存、频率 lscpu # CPU 架构、核心数、频率 python -c import torch; print(torch.__version__, torch.version.cuda) pip show vllm | grep Version pip show sglang | grep Version python -c import transformers; print(transformers.__version__) sha256sum weights/*.safetensors # 权重 hash建议把这些命令写进一个collect_env.sh每次压测之前跑一遍输出保存到实验目录。虽然操作简单但这个指纹文件在以后回看数据、排查复现问题时价值极高。6.2 实验护照模板所谓“实验护照”就是把一次完整的性能实验所有关键元信息汇总成一张表方便别人也方便自己复现。我常用的模板字段如下字段示例值模型名Qwen2.5-7B-Instruct权重 hash3f36c8f...精度BF16CUDA12.8PyTorch2.6.0vLLM commit0.8.1abc123SGLang commit0.4.6def456TP size2数据并发窗口64请求数 / 预热320 / 10input / output token512 / 512GPU 利用率曲线util_64.png结论摘要SGLang 在共享前缀下 TTFT P50 低 49%吞吐高 17%有了这张护照别人拿到你的数字后第一反应不会是“你跑错了吧”而是“我按你的护照复现一下”。这也是我在文章标题里说的“可复现、可解释”的最后一公里。6.3 我的个人体会这轮实验做下来我最深的感受是性能数字从来不是凭空跑出来的它是硬件状态、软件版本、负载形状和框架机制共同作用的结果。vLLM 和 SGLang 各有各的擅长区间单看任何一个数字都容易得出偏颇结论。vLLM 在通用随机负载下的稳定性和生态成熟度仍然有优势SGLang 在共享前缀类的真实对话场景里会有明显红利。最后分享一个小技巧如果你只是想要一个“日常监控用的稳定基线”建议每次都固定同一套请求文件、同一套 prompt、同一个种子先跑一次生成基准值存下来之后每次升级版本或者改参数就拿新数据和基准值对比。这样你不仅能复现历史数字还能在框架升级后几小时内知道性能是涨是跌、跌在哪一段指标上。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑