从压测视角窥见vllm推理与模型运行时细节
本文内容由Authropic Claude Opus 4-8模型生成。信息仅供参考,不构成专业建议。
尽管AI尽力确保信息的准确性,但并不能保证其完全无误或适用于所有场景,请结合实际情况审慎使用。
前言
本篇是《从零开始的大模型(Transformer)学习笔记》的补充。此前我们把视角停在”模型是什么、前向传播、训练显存”等基础性基础。
这一篇换一个入口——从压测调优往下钻,看看一个请求打进 vLLM 之后,GPU 内部到底在搬什么、算什么,以及 TTFT / TPOT / max_num_seqs / KV cache / preempt 参数在其运行时流程中承担着什么样的作用。
目标只有一个:把”一深挖就露馅”的那些点,从物理原理往下推通。
压测的本质,是”加压 → 读一堆指标(化验单)→ 定位瓶颈 → 动对应旋钮”。而要读懂这些指标,得回到推理时 GPU 内部那条
搬权重 → 算 token的流水线。
一、前向传播
压测一个模型,本质就是在反复触发”前向传播”这一个动作。 搞懂前向里 GPU 在干嘛,压测的一切指标就都有根了。
- 训练:前向算预测 → 算 loss → 反向传播算梯度 → 更新权重。四步。
- 推理:只有前向传播这一步。输入过一遍网络,吐出”下一个 token 的概率”,挑一个。权重全程不变,没有反向、没有梯度、没有更新。
常见混淆:分布式训练里学的 All-Reduce 梯度同步是训练专属(N 张卡各算梯度要同步)。
压测推理服务根本用不上那套通信——推理不产生梯度。把训练的通信原语套到推理调优上,是典型露馅点。
二、GPU 的地图:HBM、SM、和那条”搬运带”
后面所有故事都在这张图上跑:
1 | |
- HBM(High Bandwidth Memory,高带宽显存):就是
nvidia-smi里那几十 GB。相对 SM 它”慢”,但相对内存/硬盘已经飞快。 - SM(Streaming Multiprocessor,流式多处理器):真正算矩阵乘法的单元,快到大部分时间在等仓库把数据搬过来。
关键就一句:权重只能躺在 HBM,SM 缓存太小装不下,所以每次算都得把权重从 HBM 搬进 SM。 这条”搬运带”的速度(显存带宽 GB/s)是后面所有故事的主角。
三、量化为什么让推理变快
量化是把权重从 FP16 压到 INT4。它加速推理的原因,常被归结成”算得少了”,但真正的关键在别处:
权重从 30GB 变成 ~8GB,那条 HBM 搬运带每次要搬的字节数少了 4 倍 → 搬得快 → SM 等待时间短 → 推理快。
量化的性能收益,主要不来自算力,来自减少搬运量。这也解释了下一节的 decode 为什么慢。
四、前向传播的本质:一连串矩阵乘法
“输入过每一层得到预测”这句话太抽象,具体到底层:
前向传播 = 从头到尾一连串矩阵乘法。 权重是矩阵,那前向传播就是把输入向量,一个接一个乘过这几百个权重矩阵。
以输入 “猪八戒” 为例,模型内部真实发生的:
1 | |
从头到尾,W_Q / W_K / W_V / W1 / W2 … 这几十层、几百个矩阵,就是那 30GB。每过一个 token,就要把这堆矩阵全部乘一遍——这就是”权重每次都要从 HBM 搬进 SM”的原因:前向的每一步都是”输入 × 权重矩阵”,SM 要算就得把权重调进来,30GB 装不进 SM,只能算一层搬一层。
五、同一个前向,跑出 prefill 和 decode 两个阶段
prefill 和 decode 不是两种技术,是每个请求必经的先后两个阶段:一个请求先 prefill 一次,再 decode 很多次,不是”选一个跑法”。
一个请求的一生(时间线)
以请求 “帮我写诗” 为例,到达推理模型时:
1 | |
它俩为什么能配合? 因为是同一条请求里的前后手:prefill 先把 KV 铺好,decode 后面才有得复用。
同一个前向,两种模式
网络的层、权重完全一样,跑的是同一套代码。唯一区别是一次喂几个 token:
| 一次喂几个 token | 对 KV 的动作 | 瓶颈 | |
|---|---|---|---|
| prefill | 整个 prompt(几百个) | 写入(第一次算,存下来) | 算力 compute-bound |
| decode | 1 个 | 读历史 + 追加自己 1 个 | 带宽 memory-bound |
关键澄清:decode 为什么”读”还要”+1”
decode 每步既读又写。新 token “春” 过每一层时,也要乘 W_Q/W_K/W_V 生成自己的 Q、K、V:拿 Q 去和历史 K 做注意力(读),同时把自己的 K、V 追加进缓存(写,+1)。
今天生成的 token,就是明天的历史。 它现在是新 token,下一步就变成”历史 token”要被后面的人查,所以必须把自己的 K/V 留下。这就是每 decode 一步 KV cache 线性 +1 的原因,也是”序列越聊越胖”的机制。
系统层面:它俩”同时”发生
单个请求内是先后;但 vLLM 同时服务很多请求,某一瞬间:请求 A 刚进来在 prefill,请求 B、C 已在 decode。A 的 prefill(吃算力大户)一插进来,可能挤了 B、C 的 decode,导致它们那一下变慢——这就是延迟长尾的来源之一。--enable-chunked-prefill 就是把大 prefill 剁成小块插空进行,别让它一口气把别人饿死。
六、decode 太亏怎么救:batching 与 max_num_seqs
几个容易混的名词,摆在一起对比:
| 词 | 指什么 | 比方 |
|---|---|---|
| 请求 request | 一次用户调用 | 一位客人 |
| 序列 sequence | 请求对应的那串 token,在 GPU 里活着的一条 | 客人的整张账单 |
| batch(一批) | 某个 decode step 被凑到一起同时算的那几条序列 | 这一轮同时服务的一桌客人 |
关系:1 请求 = 1 序列;max_num_seqs 条序列 = 一批的大小上限。
decode 逐 token 很亏:拉一遍 30GB 权重只喂 1 个 token。怎么救?——别只喂 1 个,凑一批一起喂:
把 32 条不同请求的 decode 凑进同一次前向:拉一遍权重,同时给 32 条各生成 1 个 token。带宽成本还是拉一遍,产出 ×32。
这就是 max_num_seqs=32 的物理意义,也是”decode 天生爱 batch”的原因。注意用词——不是”节约带宽”(总量一样),是摊薄(amortization):同一次搬运服务了更多 token,每 token 的搬运成本被摊平。
而 prefill 不爱 batch:单条就已经喂几百 token 把算力吃满了,再堆只是排队。
七、天花板:KV cache 才是 max_num_seqs 的上限
既然 batch 越大吞吐越高,为什么不设成 1000?拦着你的是KV cache 吃显存。
每一条活着的序列,都拖着一坨自己的 KV cache 躺在显存里。序列越长(聊得越久),这坨越大;序列越多(batch 越大),总占用越高。
1 | |
要塞 1000 条序列同时 decode → 1000 坨 KV 同时躺显存。KV 池就那么大,池满 → 新序列进不来(排队),或正在跑的被踢出去(抢占)。所以:
max_num_seqs的真实天花板不是算力,是 KV 显存。你敢开多大的 batch,取决于显存能同时躺下多少坨 KV。
PagedAttention 与一个高频误区
PagedAttention 把 KV 切成 16-token 定长块,逻辑→物理映射像操作系统分页,消灭碎片、能塞更多并发,还让相同前缀的块共享(= prefix caching)。
关键误区:调小 --max-model-len 不能直接换来更多并发。 因为 PagedAttention 是按实际用到的 token 动态分块分配 KV,不是一上来按 max-model-len 预留满。max-model-len 只是”单条最长能到多少”的上限,真正决定占用的是序列实际长到多少。
松绑 KV 天花板的正确手段:--kv-cache-dtype fp8(每坨 KV 从 16bit 压到 8bit,同池躺下两倍序列)、调大 --gpu-memory-utilization(单卡吃更多显存)、少开序列(调小 max_num_seqs,牺牲吞吐)。
八、抢占(preemption):过载时的应急动作
KV 池住满了、还有序列要追加 K/V 时,调度器被迫踢掉某条正在跑的序列腾地方,这就是抢占。两种处理:
- Recompute(重算,默认):丢掉这条序列已存的 KV,打回等待队列,等有空位再从头重新 prefill。之前的 prefill 白算,等于返工。
- Swap(换出):把 KV 搬到 CPU 内存暂存,等空位再搬回。走 PCIe,慢,但比重算省。
不管哪种,抢占都是纯亏。所以 num_preemptions_total > 0 = 这个负载/配置已经过载,是调优时最明确的”踩过线”信号。被抢占的序列会卡住、(若重算)重新 prefill 才恢复吐字,直接制造 TPOT 长尾。
九、压测客户端的指标
p50 / p95 / p99:为什么不用平均值
把 100 条请求的延迟从小到大排队:p50 = 第 50 位(中位数),p95 = 第 95 位,p99 = 第 99 位。
平均值会被”大多数正常请求”稀释,掩盖少数惨案。100 条里 99 条 0.1s、1 条 10s,平均才 0.2s 看着很美——但那个用户等了 10s,体验崩了。p99 专门盯这种长尾。
p50 看典型体验,p99/p95 看最惨那批有多惨。p50 和 p99 差距越大 = 长尾越严重 = 系统越不稳。生产 SLA 通常写 “p99 < 500ms”,不写平均值。
TTFT 与 TPOT
- TTFT(Time To First Token) = 请求发出 → 收到第 1 个 token。物理来源:排队 + prefill 跑完整个 prompt。用户感知的”反应快不快”。prompt 越长 TTFT 越大。
- TPOT(Time Per Output Token) = 第一个字之后,平均每蹦一个新字的间隔。算法上是
(总生成时间 - TTFT) / (token数 - 1)——减 1 是因为第 1 个 token 已算进 TTFT。物理来源:decode 阶段每步搬权重的时间。用户感知的”说话语速顺不顺”。 - 只吐 1 个 token 时 TPOT 算不出(没间隔),所以压测常开
ignore_eos强制吐满输出长度。
TTFT = 等它开口(prefill 主导)。TPOT = 它说话的语速(decode 主导)。 两个阶段,两个瓶颈,必须分开看。
吞吐与服务端指标
- throughput_tok_s:系统每秒吐多少 token(整体产能)。
- 从 vLLM
/metrics抓的四张”化验单”:
| 指标 | 含义 | 怎么读 |
|---|---|---|
kv_cache_usage |
KV 池填充率 0~1 | 越接近 1 越吃紧 |
num_requests_running |
同时 decode 的序列数 | 应 ≈ max_num_seqs |
num_requests_waiting |
排队等准入数 | >0 = 调度打满,开始排队 |
num_preemptions_total |
累计抢占次数 | >0 = 过载 |
长尾是”病症”,
preempt是一张”化验单”:化验单阴性(=0),就把”抢占”这个病因划掉,往别处(prefill 插队、共享卡算力争抢)查。压测盯一堆指标,就是用每张专项化验单,把模糊的”变慢了”拆成能定位的具体病因。
别忘了先看 fail:一旦 >0,说明服务已扛不住,此时延迟数据要打折看(最惨的请求可能直接失败没进 p99 统计,存在幸存者偏差)。
十、调优方法论:一切都在找拐点(knee)
负载从低往高加,系统走过三个区:
1 | |
| 区 | 仪表盘特征 |
|---|---|
| 欠载 | waiting=0、kv_usage 低、preempt=0、延迟平、吞吐还在涨 |
| 拐点 | waiting 刚 >0、吞吐涨势变缓、p99 开始翘、preempt 还=0 |
| 过载 | waiting 堆积、kv_usage→1、preempt>0、p99 爆炸、吞吐饱和/倒退、fail>0 |
实操:一档档加压
方法论一句话:固定一切,只动一个变量,一路加到过载,回头取拐点前那一档。 两根轴分开扫:
- 轴 A — 扫客户端并发(定配置、测容量):保持模型参数不变,并发
32→64→128→256→512,每档读仪表盘。waiting 刚 >0、吞吐涨势缓、preempt=0 那档就是拐点。 - 轴 B — 扫 max_num_seqs(定负载、测最优配置):它是启动参数不能热改,
8→16→32→64→128每值重启、同负载压。判据:吞吐增益 <5% 但 ttft_p99 增幅 >20% 那档,就是过了拐点。
两条铁律:短/长 prompt 各扫一遍(长 prompt prefill 重,拐点更早);按 SLA 选工作点(延迟敏感取拐点偏左留余量,吞吐优先取拐点处甚至偏右)。
共享卡的边界
在共享 GPU 上压测要记住:调度层现象(waiting/kv/preempt/拐点位置)照测有效,因为一个模型的 KV 池是它独占的,别人抢不走;但延迟绝对值(tpot_p99 几毫秒)测不准,别的进程抢 SM/带宽会污染。要落 SLA 数字,必须清场独占再测一遍。
收束:一条打通的因果链
1 | |
从”模型权重是什么”到”压测该看哪个数、动哪个旋钮”,是一条连续的因果链,不是散点。decode 为什么慢、KV 为什么是天花板、p99 为什么比平均值重要、max_num_seqs 与 max_num_batched_tokens 差在哪、抢占是怎么回事——这些点都能顺着 搬权重 → 算 token 这条主线往下推。
理论到这里告一段落,剩下的交给数据:拿压测工具扫一轮,等拐点真的出现,再把这套方法论对到自己的实测上。
附:压测客户端代码
文中所有指标都来自下面这个 async 流式压测客户端——逐块记录 token 到达时间算出 TTFT/TPOT,同时后台采样 vLLM /metrics(KV 用量、队列、抢占、prefix 命中率)。用法:
1 | |
1 | |