1. 长序列推理的瓶颈到底卡在哪
做长上下文推理优化的人,大概率都经历过这样一个阶段:单卡跑 7B 模型,输入长度一上到 32K、64K,显存就像被戳破的气球一样往下掉,吞吐量断崖式下跌,首 token 延迟从几百毫秒飙到好几秒。你盯着nvidia-smi看,发现显存占用里有一大块既不是模型权重,也不是激活值,而是那个让人又爱又恨的KV Cache。
vLLM 作为当前最主流的高吞吐推理引擎,它的 PagedAttention 已经把 KV Cache 的显存碎片问题解决得相当漂亮了。但到了长序列场景,问题性质变了——不再是碎片,而是绝对容量和跨阶段传输的问题。一个 70B 模型在 128K 上下文下,单条请求的 KV Cache 就能吃掉几十 GB 显存,这时候你就算把 PagedAttention 用到极致,物理显存也就那么多,怎么办?
这就是KVPP和LayerSplit这两个优化方向要解决的核心矛盾。KVPP 全称是 KV Cache Parallelism / Pipelining 这一类思路的统称,核心是把 KV Cache 按某种维度切分到多个设备或存储层级上;LayerSplit 则是把模型的层(Layer)拆分到不同设备,让每一段只持有自己那部分层的 KV Cache。两者经常配合使用,尤其在多卡长序列推理的部署里。
这篇文章我打算把这两个东西掰开揉碎讲清楚:它们各自解决什么问题、底层原理是什么、在 vLLM 里怎么落地、参数怎么算、踩过哪些坑。适合已经能跑通 vLLM 基础部署、想进一步啃长序列多卡优化的同学。如果你还在纠结 vLLM 是什么、怎么装,那建议先把基础部署跑通再回来看,不然容易云里雾里。
先说结论性的判断:长序列推理的优化,本质是在显存容量、计算效率、通信开销这三者之间做权衡。KVPP 和 LayerSplit 不是银弹,它们各自有明确的适用边界,用错了场景反而更慢。下面我逐个拆。
2. KVPP 与 LayerSplit 的核心思路拆解
2.1 为什么长序列必须动 KV Cache 的脑筋
要理解这两个方案,得先回到一个基本事实:Transformer 的自回归解码阶段,每生成一个 token,都要和之前所有 token 的 Key、Value 做注意力计算。为了不重复计算,我们把历史 token 的 K、V 缓存下来,这就是 KV Cache。
它的显存占用公式大致是:
KV Cache 大小 = 2 × num_layers × num_kv_heads × head_dim × seq_len × batch_size × dtype_bytes拿一个具体的例子算一下。假设模型是 32 层、32 个 KV head、head_dim 128、FP16(2 字节),单条请求序列长度 128K:
2 × 32 × 32 × 128 × 131072 × 1 × 2 字节 = 2 × 32 × 32 × 128 × 131072 × 2 ≈ 68.7 GB单条请求就接近 70GB,这还没算模型权重和激活。一张 80GB 的卡,光 KV Cache 就快撑爆了,batch 根本无从谈起。这就是长序列推理最硬的墙。
注意:这里的 num_kv_heads 如果用了 GQA(分组查询注意力),会比 num_attention_heads 小很多,这也是为什么现在主流模型都上 GQA——它直接把 KV Cache 砍到几分之一。但即便如此,128K 场景下依然吃紧。
所以优化方向无非三条路:减少 KV Cache 的绝对量(量化、稀疏化)、把 KV Cache 分散到更多设备(并行)、减少 KV Cache 的重复搬运(复用、分层存储)。KVPP 和 LayerSplit 主要落在第二条和第三条上。
2.2 KVPP 到底在并行什么
KVPP 这个名字在不同资料里指代略有差异,但核心思想一致:把 KV Cache 沿某个维度切分,让多张卡共同承担。常见的切分维度有三个:
- 按 head 切分:每张卡负责一部分 attention head 的 KV。这是最自然的切法,因为不同 head 之间本来就没有依赖,切完各自算各自的注意力,最后 all-reduce 或者 all-gather 一下就行。这其实就是 Tensor Parallelism 在 KV 上的体现。
- 按序列维度切分:把长序列切成若干段,每张卡存一段的 KV。这个在 prefill 阶段特别有用,因为 prefill 是并行的,切完各算各的,最后拼起来。但 decode 阶段每生成一个 token 都要访问全部历史 KV,序列切分就会引入大量跨卡通信。
- 按层切分:这就是 LayerSplit 的思路,每张卡负责一部分层的 KV Cache。
我个人的经验是,head 切分(TP)是基础,LayerSplit 是长序列场景下的关键补充。为什么?因为 TP 切分有个硬约束——它要求每张卡都完整地过一遍所有层,只是每层算一部分 head。这意味着每张卡的显存里,KV Cache 的层数是完整的,只是 head 数变少了。当序列长度继续增长,head 切分带来的收益会被序列长度稀释掉。
举个直观的例子:8 卡 TP,每卡 KV head 从 32 降到 4,省了 8 倍。但如果序列长度再涨 8 倍,你又回到原点。而 LayerSplit 是另一个维度的切分,它让每张卡只持有部分层的 KV,层数从 32 降到 4,又是 8 倍。两个维度乘起来,才是真正的线性扩展。
2.3 LayerSplit 的取舍逻辑
LayerSplit 说白了就是流水线并行(Pipeline Parallelism)在推理场景的变体。把模型的 32 层切成 4 段,每张卡放 8 层,数据像流水线一样从第一段流到最后一段。
它的好处很直接:每张卡的模型权重和 KV Cache 都只占 1/4。对于长序列,KV Cache 的节省是线性的,这是它最大的价值。
但代价也很明显:
- 流水线气泡:decode 阶段每个 token 都要走完所有段,段与段之间要传激活值(hidden states)。如果段数多、单段计算量小,通信开销占比就会很高,GPU 利用率上不去。
- 负载不均:如果各段层数一样,但第一段和最后一段还要处理 embedding 和 lm_head,实际负载会有偏差。
- 调度复杂:vLLM 的 scheduler 要协调多个 pipeline stage 的请求调度,micro-batch 的切分和填充都需要精细控制。
所以 LayerSplit 不是无脑上就快。它的甜点区是:序列极长、单卡显存完全放不下、且卡间互联带宽足够(比如 NVLink)。如果是 PCIe 互联的多卡,LayerSplit 的通信开销可能直接把收益吃光。
2.4 两者组合的完整图景
把 KVPP(head 切分)和 LayerSplit(层切分)组合起来,就得到一个二维的并行网格。假设 8 卡,可以配成 TP=2 × PP=4,也可以 TP=4 × PP=2,甚至 TP=8 × PP=1。
| 并行配置 | 每卡 KV head 数 | 每卡层数 | KV Cache 相对单卡 | 通信模式 |
|---|---|---|---|---|
| TP=1, PP=1 | 32 | 32 | 1x | 无 |
| TP=8, PP=1 | 4 | 32 | 1/8 | 每层 all-reduce |
| TP=1, PP=8 | 32 | 4 | 1/8 | 段间点对点 |
| TP=4, PP=2 | 8 | 16 | 1/8 | 混合 |
| TP=2, PP=4 | 16 | 8 | 1/8 | 混合 |
注意最后三行,KV Cache 的总节省都是 1/8,但通信模式完全不同。选哪个配置,取决于你的瓶颈是显存带宽、卡间带宽还是计算。这是调优的核心决策点,后面我会展开讲怎么选。
3. 核心细节解析与实操要点
3.1 KV Cache 的显存账要算清楚
动手之前,先把账算明白。很多人上来就调参数,结果发现显存还是不够,就是因为没搞清楚 KV Cache 到底占多少、模型权重占多少、激活占多少。
vLLM 启动时会打印一个显存分析,类似这样:
GPU KV cache size: 320,000 tokens Maximum concurrency for 128,000 tokens per request: 2.50x这个Maximum concurrency就是关键指标——它告诉你当前配置下,128K 的请求能同时跑几条。如果这个数字小于 1,说明单条请求都放不下,必须上并行。
计算可用 KV Cache 显存的公式:
可用 KV 显存 = 总显存 - 模型权重 - 激活峰值 - 框架开销其中模型权重可以精确算:参数量 × dtype 字节数。比如 70B FP16 就是 140GB,单卡根本放不下,必须 TP 或 PP。激活峰值和 batch、序列长度相关,vLLM 里通过gpu_memory_utilization参数控制预留比例,默认 0.9,意思是拿 90% 显存来用,剩下 10% 留给激活和碎片。
实操心得:
gpu_memory_utilization不要盲目设 0.95 以上。我试过设 0.95,短序列没问题,一上长序列就 OOM,因为激活峰值被低估了。长序列场景建议 0.85 到 0.9 之间,留足余量。
3.2 并行配置的选择依据
选 TP 还是 PP,核心看两个指标:卡间互联带宽和序列长度。
- NVLink 互联(比如 H100 集群):带宽高,TP 的 all-reduce 开销可以接受,优先 TP,因为 TP 的负载均衡更好、流水线气泡少。
- PCIe 互联:带宽低,TP 的 all-reduce 会成为瓶颈,这时候 PP 的点对点通信反而更友好,优先 PP。
- 序列超长(>64K):即使 NVLink,纯 TP 也可能不够,因为 head 切分的收益被序列长度稀释,需要 PP 补上。
一个实用的判断方法:先算单卡在目标序列长度下的 KV Cache 需求,如果超过单卡可用显存的 60%,就考虑加 PP。为什么是 60%?因为要给 batch 留空间,还要留激活余量。
3.3 vLLM 里的关键参数
vLLM 启动时和并行、KV Cache 相关的参数主要有这些:
python -m vllm.entrypoints.openai.api_server \ --model /path/to/model \ --tensor-parallel-size 4 \ --pipeline-parallel-size 2 \ --max-model-len 131072 \ --gpu-memory-utilization 0.88 \ --enable-chunked-prefill \ --max-num-batched-tokens 8192 \ --block-size 16逐个说:
--tensor-parallel-size:head 维度的切分,对应 KVPP 的 head 切分。--pipeline-parallel-size:层维度的切分,对应 LayerSplit。--max-model-len:最大序列长度,直接决定 KV Cache 上限,设太大浪费显存,设太小请求被拒。--enable-chunked-prefill:长序列 prefill 分块,避免一次性吃满显存,长序列必开。--max-num-batched-tokens:单批次最大 token 数,chunked prefill 的块大小,影响吞吐和延迟的平衡。--block-size:PagedAttention 的块大小,默认 16,长序列下可以适当调大减少块管理开销。
注意:
tensor-parallel-size和pipeline-parallel-size的乘积必须等于你用的 GPU 数量。比如 8 卡,可以是 4×2、2×4、8×1、1×8,但不能是 3×3。
3.4 Chunked Prefill 与 KVPP 的配合
长序列的 prefill 阶段是个大麻烦。128K 的输入,如果一次性 prefill,激活值和中间结果会瞬间吃满显存,即使 KV Cache 已经切分了也没用。Chunked Prefill 就是把长输入切成若干块,逐块 prefill,每块算完的 KV 存下来,下一块继续。
这个机制和 KVPP 是天然搭配的:KVPP 解决了 KV Cache 的存储问题,Chunked Prefill 解决了 prefill 阶段的激活峰值问题。两者一起用,长序列才真正跑得起来。
--max-num-batched-tokens这个参数就是控制 chunk 大小的。设太小,chunk 多,调度开销大;设太大,激活峰值高。我的经验值是 4096 到 8192 之间,具体看卡的显存和序列长度。128K 序列配 8192 的 chunk,大概 16 个 chunk,调度开销可以接受。
4. 实操过程与核心环节实现
4.1 环境准备与基线测试
先跑一个单卡基线,搞清楚瓶颈在哪。用一张卡,不加任何并行,跑一个长序列请求,记录显存和延迟。
# 单卡基线,先看能不能跑起来 python -m vllm.entrypoints.openai.api_server \ --model /path/to/qwen-7b \ --max-model-len 32768 \ --gpu-memory-utilization 0.9然后用一个长 prompt 压测:
from openai import OpenAI client = OpenAI(base_url="http://localhost:8000/v1", api_key="dummy") # 构造一个 32K 长度的输入 long_prompt = "请分析以下内容:" + "这是一段测试文本。" * 4000 resp = client.chat.completions.create( model="/path/to/qwen-7b", messages=[{"role": "user", "content": long_prompt}], max_tokens=256, ) print(resp.choices[0].message.content[:100])跑完看 vLLM 日志里的Maximum concurrency。如果这个值小于 1,说明单卡放不下,必须上并行。如果大于 1 但很小(比如 1.2),说明勉强能跑但 batch 上不去,吞吐会很差。
4.2 从 TP 开始逐步加并行
假设单卡不够,先上 TP。4 卡 TP:
python -m vllm.entrypoints.openai.api_server \ --model /path/to/qwen-7b \ --tensor-parallel-size 4 \ --max-model-len 131072 \ --gpu-memory-utilization 0.88 \ --enable-chunked-prefill \ --max-num-batched-tokens 8192启动后重点看两个东西:一是Maximum concurrency有没有上去,二是实际压测的吞吐和延迟。如果 concurrency 上去了但吞吐没涨,说明通信成了瓶颈,这时候考虑换 PP。
4.3 引入 LayerSplit 的配置
如果 TP 到 8 卡还是不够,或者 PCIe 互联下 TP 通信太重,就上 PP。比如 8 卡配 TP=2, PP=4:
python -m vllm.entrypoints.openai.api_server \ --model /path/to/qwen-7b \ --tensor-parallel-size 2 \ --pipeline-parallel-size 4 \ --max-model-len 131072 \ --gpu-memory-utilization 0.88 \ --enable-chunked-prefill \ --max-num-batched-tokens 8192 \ --distributed-executor-backend ray注意 PP 通常需要 Ray 作为分布式后端,vLLM 会自己拉起 Ray 集群。启动时间会比纯 TP 长一些,因为要建立 pipeline 的通信拓扑。
实操心得:PP 的段数不要设太多。我试过 PP=8 跑 7B 模型,每段只有 4 层,计算量太小,通信开销占比超过 40%,吞吐反而比 PP=4 低。PP 段数的经验上限是让每段至少 8 层,低于这个数流水线就不划算了。
4.4 参数调优的迭代过程
配置不是一次调对的,要迭代。我的调优顺序是:
- 先定 max-model-len:根据业务需求定,不要盲目拉满。业务只要 32K,就别设 128K,省下的显存全给 batch。
- 再定并行配置:从 TP 开始,不够再加 PP。每次只改一个维度,观察吞吐变化。
- 然后调 gpu-memory-utilization:从 0.85 开始,逐步往上试,直到 OOM 前一个值。
- 最后调 chunked prefill 参数:max-num-batched-tokens 从 4096 到 16384 扫一遍,找吞吐峰值。
每一步都记录数据,做成表格对比。我一般会记录:配置、concurrency、首 token 延迟(TTFT)、每 token 延迟(TPOT)、吞吐(tokens/s)。这四个指标能完整反映长序列推理的性能。
| 配置 | Concurrency | TTFT(ms) | TPOT(ms) | 吞吐(tokens/s) |
|---|---|---|---|---|
| TP=4,PP=1 | 1.8 | 1200 | 45 | 320 |
| TP=2,PP=4 | 2.4 | 1500 | 52 | 380 |
| TP=4,PP=2 | 2.2 | 1350 | 48 | 360 |
从这组模拟数据能看出,PP=4 的 concurrency 最高(KV Cache 省得多),但 TTFT 和 TPOT 都变差了(流水线气泡和通信)。TP=4,PP=2 是个折中。没有绝对最优,只有最适合你业务延迟/吞吐要求的配置。
4.5 监控与验证
配好之后要持续监控。vLLM 暴露了 Prometheus 指标,重点看这几个:
vllm:gpu_cache_usage_perc:KV Cache 使用率,长期接近 100% 说明该加卡了。vllm:num_requests_waiting:排队请求数,持续大于 0 说明吞吐到顶了。vllm:time_to_first_token_seconds:TTFT 分布,长序列下这个值会明显偏高。
# 简单验证:并发压测 python -m vllm.entrypoints.openai.api_server ... & # 用 benchmark 脚本压 python benchmarks/benchmark_serving.py \ --backend openai \ --model /path/to/model \ --dataset-name random \ --random-input-len 32768 \ --random-output-len 256 \ --num-prompts 50 \ --request-rate 2这个 benchmark 会输出完整的延迟和吞吐统计,比自己手写压测脚本靠谱。
5. 常见问题与排查技巧实录
5.1 启动就 OOM 怎么办
这是最常见的问题。分几种情况:
- 模型权重就放不下:比如 70B FP16 单卡 80GB 放不下,必须 TP 或 PP。先算权重:70B × 2 字节 = 140GB,至少 2 卡。
- 权重放得下但 KV Cache 不够:调小
max-model-len,或者加并行。 - 启动时 OOM 但推理时正常:
gpu-memory-utilization设太高,启动时预分配了太多 KV Cache 块。降到 0.85 试试。
踩坑记录:有一次我设
max-model-len=131072但业务实际只用 8K,结果启动时 vLLM 按 128K 预分配 KV Cache 块,直接 OOM。后来把 max-model-len 改成 16384,问题解决。max-model-len 要贴合实际业务,不要为了"以后可能用得上"而拉满。
5.2 吞吐上不去,GPU 利用率低
GPU 利用率低通常有两个原因:通信瓶颈或调度瓶颈。
- 通信瓶颈:TP 的 all-reduce 太频繁,或者 PP 的段间传输太慢。用
nvidia-smi topo -m看卡间互联类型,PCIe 的话考虑换 PP。 - 调度瓶颈:请求太少,GPU 空转。看
num_requests_waiting,如果是 0,说明请求不够,不是配置问题。
还有一种隐蔽情况:chunked prefill 的 chunk 太小,导致调度开销占比过高。把max-num-batched-tokens调大试试。
5.3 长序列首 token 延迟特别高
TTFT 高是长序列的固有特性,因为 prefill 要处理所有输入 token。但可以通过这些手段缓解:
- 开 chunked prefill:把长 prefill 分块,虽然总时间不变,但能让 decode 请求插进来,整体吞吐更好。
- prefix caching:如果多个请求有相同前缀,vLLM 的 prefix caching 能复用 KV,大幅降低 TTFT。开
--enable-prefix-caching。 - 调大 chunk:chunk 大,prefill 并行度高,单请求 TTFT 低,但会挤占 decode。
这是个权衡,没有免费午餐。业务对 TTFT 敏感就调大 chunk,对吞吐敏感就调小。
5.4 常见问题速查表
| 现象 | 可能原因 | 排查方法 | 解决方向 |
|---|---|---|---|
| 启动 OOM | 权重/预分配过大 | 看启动日志显存分析 | 加并行/降 max-model-len |
| 吞吐低 | 通信瓶颈 | nvidia-smi topo | 换 PP 或减并行度 |
| TTFT 高 | prefill 太长 | 看 TTFT 指标 | 开 chunked prefill |
| TPOT 抖动 | 流水线气泡 | 看 PP 段数 | 减 PP 段数 |
| concurrency<1 | KV Cache 不足 | 看启动日志 | 加卡/降序列长度 |
| 请求被拒 | 超 max-model-len | 看错误日志 | 调大 max-model-len |
5.5 几个容易忽略的细节
block-size 的影响:默认 16,长序列下块数量巨大,块管理本身有开销。调到 32 或 64 能减少块数量,但会浪费一些显存(最后一个块可能填不满)。长序列场景我一般用 32。
dtype 的选择:FP16 和 BF16 显存一样,但 BF16 数值范围大,长序列下更稳。如果卡支持,优先 BF16。FP8 能再省一半 KV Cache,但需要模型和硬件支持,且精度有损,要评估。
prefix caching 的坑:开了 prefix caching 后,KV Cache 的块会被复用,显存管理更复杂。如果发现显存碎片化严重,可以关掉对比一下。
Ray 后端的开销:PP 用 Ray 会引入额外的进程间通信开销。如果卡在同一台机器上,可以试试--distributed-executor-backend mp(多进程),比 Ray 轻量。
6. 我踩过的坑和几条实在建议
聊了这么多原理和配置,最后说点实在的。KVPP 和 LayerSplit 这套东西,理论看着清晰,实操全是细节。
第一个坑是盲目追求并行度。我一开始觉得卡越多越好,8 卡全上 TP,结果 PCIe 互联下 all-reduce 把带宽吃满,吞吐还不如 4 卡。后来才明白,并行的收益是有边际递减的,通信开销会吃掉一切。选配置前先看互联拓扑,这是硬约束。
第二个坑是忽略 prefill 和 decode 的差异。长序列的 prefill 是计算密集型,decode 是访存密集型,两者的瓶颈完全不同。KVPP 主要优化 decode 阶段的 KV 存储,LayerSplit 对两者都有影响。调参时要分开看 TTFT 和 TPOT,不能只看总吞吐。
第三个坑是max-model-len 设太大。这个前面提过,但值得再强调。vLLM 按 max-model-len 预分配 KV Cache 块,设太大直接浪费显存。按业务 P99 序列长度来设,留 20% 余量就够。
几条建议:
- 先用单卡跑通,搞清楚瓶颈,再上并行。不要一上来就 8 卡。
- 每次只改一个参数,记录数据,做成表格。凭感觉调参是玄学。
- 长序列场景必开 chunked prefill 和 prefix caching,这两个是刚需。
- PP 段数控制在每段 8 层以上,低于这个数不划算。
- 监控指标要常看,尤其是
gpu_cache_usage_perc和num_requests_waiting,它们能提前告诉你该扩容了。
这套东西后续还能往深了做,比如 KV Cache 量化(FP8 KV)、分层存储(把冷 KV 放到 CPU 内存或 SSD)、以及和投机解码结合。但那是另一个话题了,先把 KVPP 和 LayerSplit 这两个基础打牢,长序列推理的显存墙就算翻过去一半了。