6.6 vLLM 分布式部署实战
以 vLLM v0.32.0 为基准,在一台 8 卡机上部署 Llama-3.1-70B 的 TP8、TP4×PP2、TP4×DP2 三种布局:启动前估算显存,启动后从日志和指标核对 KV 容量,用 vllm bench serve 在相同负载下扫并发,并介绍用 DCP 消除长上下文的 KV 复制
前五节讲了各种并行方式的原理和多节点的启动方法。本节把它们放到同一台机器上比一比:一台 8 卡机、同一个 Llama-3.1-70B,分别用 TP=8、TP=4 × PP=2、TP=4 × DP=2 三种方式部署,看 KV 容量、延迟和吞吐各差多少。
流程分四步:启动前估算显存怎么分,启动后从日志核对估算,用 vllm bench serve 在相同负载下扫并发,最后整理成可对比的表格。命令按 vLLM v0.32.0 的文档与源码编写,GPU 实验需要在你自己的环境中执行,文中不填写未经实测的成绩。最后一部分介绍 Decode Context Parallel(DCP),它解决 TP 超过 KV 头数后的 KV 复制问题。
⏰ 信息时效:基于 vLLM v0.32.0(2026-10-18 发布)的文档与源码,日志格式取自
vllm/v1/core/kv_cache_utils.py、vllm/v1/worker/gpu_worker.py,Benchmark 参数取自vllm/benchmarks/serve.py和vllm/benchmarks/datasets/datasets.py。
📑 目录
- 1. 固定环境与对照变量
- 2. 启动前估算:显存怎么分
- 3. 三种布局的启动命令
- 4. 启动后核对:日志、显存与指标
- 5. 性能对比:相同负载下扫并发
- 6. 长上下文:用 DCP 消除 KV 复制
- 7. 选型、调优与排错
- 总结
- 自我检验清单
- 参考资料
1. 固定环境与对照变量
1.1 安装并记录版本
和 5.5 节一样,在隔离环境中安装固定版本,把环境信息存档:
python -m venv .venv
source .venv/bin/activate
python -m pip install 'vllm[bench]==0.32.0' # bench 子命令需要额外依赖
mkdir -p results
python -m pip freeze > results/environment.txt
nvidia-smi > results/nvidia-smi.txt
nvidia-smi topo -m > results/topo.txt # 确认 8 张卡之间是 NVLink
vllm serve --help=all > results/serve-help.txt
nvidia-smi topo -m 的矩阵里,GPU 之间显示 NV<数字> 表示走 NVLink,显示 PIX、PXB、SYS 等表示走 PCIe 或跨 CPU。没有 NVLink 的机器上,TP=8 的 AllReduce 会慢很多,本节的结论不能直接套用(6.2 节)。
1.2 只改变并行方式
模型用 meta-llama/Llama-3.1-70B-Instruct,和前几节的估算保持一致:80 层、8 个 KV 头、BF16 权重约 141 GB。它是需要授权的仓库,提前申请权限并下载到本地盘,启动时传本地路径。
| 📊 固定项 | 本例设置 | 原因 |
|---|---|---|
| 硬件 | 一台 8 × H100 80GB(NVLink) | 三种布局都用满 8 张卡 |
| 模型 / dtype | Llama-3.1-70B-Instruct,BF16 | 不混入量化变量 |
| 上下文与显存 | --max-model-len 8192、--gpu-memory-utilization 0.90 | KV 预算的计算口径一致 |
| 调度上限 | 服务级最多 128 个并发序列,每个引擎 --max-num-batched-tokens 8192 | 见第 3 节对 DP 的换算 |
| Prefix Cache | 关闭 | 随机输入本来就很少命中,关掉更容易解释 |
| 负载 | 随机输入 2048 Token、输出固定 256 Token | 各组工作量相同 |
📌 关键点:每次只改并行参数。如果同时换了 --max-model-len 或显存比例,结果的差异就说不清来自哪里。
2. 启动前估算:显存怎么分
2.1 三种布局的账
启动前先算一笔账,知道每种布局大概能放多少 KV,启动后才能判断日志里的数字对不对。下面的脚本只做加减乘除,不需要 GPU:
# 启动前估算:同一台 8 卡机上三种布局的每卡权重、每卡每 Token 的 KV 和服务级 KV 容量
# GPU_GIB 和 OVERHEAD_GIB 都是假设值,按自己机器的 nvidia-smi 和启动日志修改
GPU_GIB = 79.6 # 每卡总显存,按 nvidia-smi 的 memory.total 填写
UTIL = 0.90 # --gpu-memory-utilization
OVERHEAD_GIB = 3.0 # 激活、CUDA Graph、通信缓冲等,假设值
WEIGHT_GIB = 70.6e9 * 2 / 2**30
LAYERS, KV_HEADS, HEAD_DIM = 80, 8, 128 # Llama-3.1-70B
LAYOUTS = { # 名称: (TP, PP, DP)
"TP8": (8, 1, 1),
"TP4×PP2": (4, 2, 1),
"TP4×DP2": (4, 1, 2),
}
print(f"{'布局':<8} {'每卡权重 GiB':>11} {'每卡每Token KV KiB':>17} {'KV池':>4} {'每池 Token':>10} {'合计 Token':>10}")
for name, (tp, pp, dp) in LAYOUTS.items():
weight = WEIGHT_GIB / (tp * pp)
heads = max(KV_HEADS // tp, 1) # TP 超过 KV 头数后每卡至少 1 个头
kv_per_token = (LAYERS // pp) * heads * HEAD_DIM * 2 * 2 # K、V 各一份,BF16
budget = (GPU_GIB * UTIL - weight - OVERHEAD_GIB) * 2**30
per_pool = int(budget / kv_per_token)
print(f"{name:<9} {weight:>11.1f} {kv_per_token / 1024:>18.0f} {dp:>5} {per_pool:>11,} {per_pool * dp:>11,}")
输出:
布局 每卡权重 GiB 每卡每Token KV KiB KV池 每池 Token 合计 Token
TP8 16.4 40 1 1,368,448 1,368,448
TP4×PP2 16.4 40 1 1,368,448 1,368,448
TP4×DP2 32.9 80 2 468,770 937,540
下图把三种布局画在一起,颜色表示 GPU 所属的组,右侧是上面脚本算出的数字:
2.2 从这笔账能读出什么
- TP8 和 TP4×PP2 的容量相同。两者都把整份权重和每个 Token 的 KV 平摊到 8 张卡上:TP8 每卡 1 个 KV 头、80 层;TP4×PP2 每卡 2 个 KV 头、40 层,乘出来一样是 40 KiB。它们的差别在速度,不在容量。
- TP4×DP2 的总容量少约三分之一。每个副本只用 4 张卡存一整份权重,每卡多占 16.4 GiB,留给 KV 的空间相应变小。
- DP 的容量分在两个池里。一个请求只能用它所在副本的 KV,单个请求能用的最大上下文由每池的 47 万决定,不是合计的 94 万。本例的 8192 上限远小于这两个数,不受影响。
OVERHEAD_GIB 是假设值,不同的 CUDA Graph 配置、Batch 上限都会改变它。估算的用途是判断量级,第 4 节用启动日志里的实际值替换它。
3. 三种布局的启动命令
3.1 公共参数
和 5.5 节一样,公共参数放进 Bash 数组,三组只追加并行参数:
MODEL=/models/Llama-3.1-70B-Instruct # 本地路径,提前下载
COMMON=(
--served-model-name llama70b
--host 127.0.0.1
--port 8000
--dtype bfloat16
--max-model-len 8192
--max-num-batched-tokens 8192
--gpu-memory-utilization 0.90
--no-enable-prefix-caching
--seed 42
)
3.2 A 组:TP=8
vllm serve "$MODEL" "${COMMON[@]}" \
--tensor-parallel-size 8 \
--max-num-seqs 128 \
2>&1 | tee results/tp8-serve.log
3.3 B 组:TP=4 × PP=2
vllm serve "$MODEL" "${COMMON[@]}" \
--tensor-parallel-size 4 \
--pipeline-parallel-size 2 \
--max-num-seqs 128 \
2>&1 | tee results/tp4pp2-serve.log
PP=2 时引擎同时让 2 个 Batch 在流水线里(6.3 节),--max-num-seqs 限制的是所有在飞 Batch 的请求总数,所以仍然写 128。80 层在 PP=2 下默认均分为 40,40;只有层数除不尽时,vLLM 才会在日志里打印 Hidden layers were unevenly partitioned 和实际的划分。
3.4 C 组:TP=4 × DP=2
vllm serve "$MODEL" "${COMMON[@]}" \
--tensor-parallel-size 4 \
--data-parallel-size 2 \
--max-num-seqs 64 \
2>&1 | tee results/tp4dp2-serve.log
这里 --max-num-seqs 改成 64。6.4 节说过,这个参数按每个 DP rank 计算,两个副本各 64,服务级上限才和 A、B 组一样是 128。--max-num-batched-tokens 同样按每个引擎计算,两个副本合起来每步最多处理 16384 个 Token,这是 DP 多一套调度器的自然结果,记录时写清楚即可。
vLLM 文档对 Dense 模型更推荐的做法是起两个独立实例,前面放负载均衡器。对照实验里用 --data-parallel-size 2 的好处是只有一个端口,Benchmark 命令不用改;上线时可以换成两个独立实例:
# 两个独立实例的写法,各占 4 张卡、各开一个端口
CUDA_VISIBLE_DEVICES=0,1,2,3 vllm serve "$MODEL" --tensor-parallel-size 4 --port 8000 ...
CUDA_VISIBLE_DEVICES=4,5,6,7 vllm serve "$MODEL" --tensor-parallel-size 4 --port 8001 ...
⚠️ 注意:三组顺序运行。每组测完后按 Ctrl+C 停止服务,用 nvidia-smi 确认 8 张卡的显存都已释放,再启动下一组。
4. 启动后核对:日志、显存与指标
4.1 三行启动日志
等服务加载完、CUDA Graph 捕获结束,从日志里找这几行:
grep -E "Model loading took|Available KV cache memory|KV cache size" results/tp8-serve.log
| 📊 日志 | 打印者 | 用来核对什么 |
|---|---|---|
Model loading took X GiB memory and Y seconds | 每个 EngineCore 中 local rank 0 的 Worker 一次 | 这张卡的权重显存,对照第 2 节的每卡权重 |
Available KV cache memory: X GiB | 每个 EngineCore 中 local rank 0 的 Worker 一次 | 这张卡实际留给 KV 的空间,用它替换估算里的 OVERHEAD_GIB |
GPU KV cache size: N tokens, Maximum concurrency for 8,192 tokens per request: Mx | 每个 EngineCore 一次 | 这个引擎的 KV 池能放多少 Token,以及能同时放下几个满长度的请求 |
最后一行的并发倍数等于 KV 池的 Token 数除以 --max-model-len。按第 2 节的估算,A、B 组应在 167 倍左右,C 组每个引擎在 57 倍左右。C 组有两个 EngineCore,会打印两行,两行相加才是服务的总容量。
实际值和估算差得多时,先看 Available KV cache memory:它比估算小,说明激活或 CUDA Graph 占得比假设的多;每卡权重和估算对不上,就要检查 dtype 和并行参数是不是真的生效了。B 组首段多了 Embedding,末段多了 LM Head,两段的权重会略有差别;这两行日志只由 local rank 0(首段的第一张卡)打印,看到的是首段的数字。
4.2 nvidia-smi 只能看总量
nvidia-smi --query-gpu=index,memory.used,memory.total --format=csv
vLLM 启动时就按 --gpu-memory-utilization 把 KV 空间预先分配好,所以三组的 memory.used 都接近总显存的 90%,看不出权重和 KV 各占多少。这一步的作用是确认 8 张卡都被用上、没有哪张卡空着,权重和 KV 的拆分以 4.1 的日志为准。
4.3 运行中的指标
压测时另开一个终端,定期抓 /metrics:
curl -s http://127.0.0.1:8000/metrics \
| grep -E '^vllm:(kv_cache_usage_perc|num_requests_running|num_requests_waiting|num_preemptions_total)'
| 📊 指标 | 含义 |
|---|---|
vllm:kv_cache_usage_perc | KV 池的占用比例,1 表示 100% |
vllm:num_requests_running / vllm:num_requests_waiting | 正在运行和排队的请求数 |
vllm:num_preemptions_total | 累计抢占次数,取压测前后的差值 |
这些指标带 engine 标签。C 组会看到 engine="0" 和 engine="1" 两组数,可以直接看出两个副本的负载是否均衡。服务日志里默认每 10 秒(VLLM_LOG_STATS_INTERVAL)打印一次的 Running: N reqs, Waiting: N reqs ... KV cache usage: X% 是同一组信息,出现抢占时还会多一段 Preemptions: N。
📌 关键点:压测期间抢占数不为 0,说明 KV 不够用,这一组的延迟数据里混入了重算的开销。记录结果时把抢占数一起记下来。
5. 性能对比:相同负载下扫并发
5.1 在另一个终端运行 Benchmark
当前服务是哪一组,就把 LABEL 设成对应的名字,然后扫 4 档并发:
LABEL=tp8 # 换服务后改为 tp4pp2 / tp4dp2
for C in 1 16 64 128; do
N=$(( C * 8 > 64 ? C * 8 : 64 )) # 每档至少 64 个请求,高并发时每个并发槽约 8 个
vllm bench serve \
--backend openai \
--base-url http://127.0.0.1:8000 \
--endpoint /v1/completions \
--model llama70b \
--tokenizer "$MODEL" \
--dataset-name random \
--random-input-len 2048 \
--random-output-len 256 \
--num-prompts "$N" \
--num-warmups 8 \
--max-concurrency "$C" \
--request-rate inf \
--temperature 0 \
--ignore-eos \
--seed 42 \
--percentile-metrics ttft,tpot,itl \
--metric-percentiles 50,99 \
--metadata layout="$LABEL" gpus=8 \
--save-result \
--result-dir results \
--result-filename "${LABEL}-c${C}.json"
done
几个参数的作用:
--dataset-name random按 Tokenizer 随机生成输入,--seed 42让三组拿到同一批 Prompt。--random-range-ratio保持默认的 0,每个请求的输入、输出长度都是固定值。--ignore-eos让每个请求都生成满 256 个 Token,各组的工作量完全相同。--max-concurrency限制同时在服务端的请求数,--request-rate inf表示请求一完成就补上下一个。这样测的是”给定并发下的延迟和吞吐”。--metadata把布局名和卡数写进结果 JSON 的顶层字段,汇总脚本会用到。
并发 128 正好等于服务级的 --max-num-seqs。再往上加,请求只会在服务端排队,TTFT 变长,吞吐不再增加。
5.2 汇总结果
三组都跑完后,用下面的脚本把 12 个 JSON 汇总成一张表。字段名取自 v0.32.0 vllm/benchmarks/serve.py:百分位字段的格式是 p{百分位}_{指标}_ms,吞吐是 output_throughput(输出 Token/s)。
# 汇总 vllm bench serve 保存的 JSON:每种布局 × 并发一行,吞吐再按卡数归一化
import json
from pathlib import Path
rows = [json.loads(p.read_text()) for p in Path("results").glob("*-c*.json")]
rows.sort(key=lambda r: (r["layout"], r["max_concurrency"]))
print(f"{'layout':<8} {'conc':>4} {'ok/fail':>8} {'TTFT p50':>9} {'TTFT p99':>9} "
f"{'TPOT p50':>9} {'TPOT p99':>9} {'out tok/s':>10} {'tok/s/GPU':>10}")
for r in rows:
gpus = int(r["gpus"]) # 来自 --metadata gpus=8
print(f"{r['layout']:<8} {r['max_concurrency']:>4} {r['completed']:>4}/{r['failed']:<3} "
f"{r['p50_ttft_ms']:>9.0f} {r['p99_ttft_ms']:>9.0f} "
f"{r['p50_tpot_ms']:>9.1f} {r['p99_tpot_ms']:>9.1f} "
f"{r['output_throughput']:>10.0f} {r['output_throughput'] / gpus:>10.0f}")
failed 不为 0 时先查服务日志,这一行的吞吐不能和其他行比。
5.3 记录表
把脚本输出和第 4 节的核对结果填进同一张表。下表只列出要记什么,数字需要你实测后填写:
| 📊 记录项 | TP8 | TP4×PP2 | TP4×DP2 |
|---|---|---|---|
每卡权重(Model loading took) | 实测 | 实测 | 实测 |
服务级 KV Token 数(GPU KV cache size,DP 组两行相加) | 实测 | 实测 | 实测 |
| 并发 1 的 TPOT p50 / p99 | 实测 | 实测 | 实测 |
| 并发 128 的 TTFT p99、TPOT p99 | 实测 | 实测 | 实测 |
| 各并发的输出 Token/s | 实测 | 实测 | 实测 |
| 压测前后的抢占数差值 | 实测 | 实测 | 实测 |
每组至少重复 3 次,看波动有多大。两组之间的差距小于同一组内的波动时,不要下”谁更快”的结论。
5.4 带着假设去看结果
6.3 节的成本模型对前两组给出过预测:同一台机器内,TP4×PP2 在各个并发下都比 TP8 慢,因为 PP 把请求拆成小 Batch,每个小 Batch 都要把本段权重读一遍。C 组不在那个模型里,可以先写下几条假设,再用实测去验证或推翻:
- 并发 1 时 C 组的 TPOT 比 A 组高。一个请求只落在一个副本上,另外 4 张卡空闲;这个副本每卡要读 32.9 GiB 权重,是 TP8 的两倍。
- 高并发时 C 组的吞吐可能追上甚至超过 A 组。两个副本之间没有通信,TP=4 的 AllReduce 也比 TP=8 的参与方少;但每个副本的 Batch 只有一半大,权重读取被摊得更少。两种因素谁占上风,只能靠测。
- 只要 KV 够用,C 组的容量劣势不体现在吞吐上。本例每个副本 64 个请求、每个请求最多约 2300 个 Token,远小于每池 47 万的估算容量。换成长上下文负载,C 组会更早出现抢占。
💡 提示:结论要和负载一起写。“并发 128、输入 2048、输出 256 时 C 组吞吐更高”是一条可以复测的结论;“DP 比 TP 好”不是。
6. 长上下文:用 DCP 消除 KV 复制
6.1 TP 超过 KV 头数以后
6.2 节说过,GQA 模型的 TP 一旦超过 KV 头数,vLLM 会把 KV 头复制到多张卡上,每卡的 KV 不再随 TP 变小。复制的倍数是 tp_size / H( 为 KV 头数)。短上下文时这点浪费无所谓,上下文一长,KV 占满显存,Batch 就上不去。
Decode Context Parallel(DCP)换一个维度来切:同一个 KV 头的 Token 分给几张卡各存一部分。它不增加卡数,复用的是 TP 组里本来就在存重复 KV 的那几张卡。vLLM 文档里有一个现成的例子:Qwen3-235B-A22B 有 4 个 KV 头,-tp 8 时每个 KV 头存两份,加上 -dcp 2 正好消除复制。
下图只画头 0 的 8 个 Token。上半部分是 -tp 8,GPU0 和 GPU1 存的完全一样;下半部分加了 -dcp 2,青色是 GPU0 存的偶数 Token,橙色是 GPU1 存的奇数 Token,灰色虚线格是省出来的空间:
Token 按交错方式分配:默认 --cp-kv-cache-interleave-size 1,第 个 Token 存在 dcp rank 上。这样 Decode 新生成的 Token 也会自然地轮流落到各卡。每一步 Decode,各卡先用本地那部分 Token 算出部分注意力结果,再在 DCP 组内通信合并,这部分通信是 DCP 的代价。
6.2 参数约束
DCP 用 --decode-context-parallel-size(简写 -dcp)开启,默认 1。v0.32.0 在启动时检查这几条(vllm/config/parallel.py、vllm/config/model.py),不满足就直接报错:
| 📊 约束 | 适用模型 | 违反时的报错 |
|---|---|---|
| TP 能被 DCP 整除 | 所有 | tp_size=... must be divisible by dcp_size=... |
| TP 大于 KV 头总数 | GQA / MQA | Decode context parallelism for GQA/MQA requires --tensor-parallel-size (...) to be greater than the model's total number of KV heads |
| DCP ≤ TP ÷ KV 头数 | GQA / MQA | --decode-context-parallel-size (...) exceeds the maximum supported value |
| 每个 KV 头对应的 Q 头数能被 DCP 整除 | GQA / MQA | The model's number of query heads per KV head (...) must be divisible by --decode-context-parallel-size |
MLA 模型只缓存一个潜向量,相当于 1 个 KV 头,不受后三条限制。文档里的几个例子:
| 📊 模型 | KV 头 | 部署 | 复制倍数 | DCP 建议 |
|---|---|---|---|---|
| DeepSeek-R1(MLA) | 1 | -tp 8 | 8 | -dcp 8 |
| Kimi-K2(MLA) | 1 | -tp 16(两台机器) | 16 | -dcp 16 完全消除;-dcp 8 剩 2 倍,但 DCP 通信只在节点内 |
| Qwen3-235B-A22B | 4 | -tp 8 | 2 | -dcp 2 |
回到本节的 Llama-3.1-70B:它有 8 个 KV 头,TP=8 时每卡正好 1 个头,没有复制,-dcp 2 会触发上表第二条报错。要两台机器、-tp 16 时才有 2 倍复制,DCP 最大只能取 2。所以前五部分的实验里用不上 DCP。
6.3 一组 A/B 对照
用 Qwen3-235B-A22B 在一台 8 卡机上对比 -tp 8 和 -tp 8 -dcp 2。BF16 权重约 470 GB,每卡约 59 GB,8 张 H100 80GB 剩给 KV 的空间不多,用 H200 这类显存更大的卡更宽裕。除了 -dcp,两组参数完全相同:
QWEN=/models/Qwen3-235B-A22B # 本地路径
LONG=(
--served-model-name qwen235b --host 127.0.0.1 --port 8000
--max-model-len 32768 --gpu-memory-utilization 0.90
--no-enable-prefix-caching --seed 42
--tensor-parallel-size 8
)
vllm serve "$QWEN" "${LONG[@]}" 2>&1 | tee results/qwen-tp8.log
vllm serve "$QWEN" "${LONG[@]}" -dcp 2 2>&1 | tee results/qwen-tp8-dcp2.log # 换组时先停掉上一组
启动后先比日志:
grep -E "Available KV cache memory|KV cache size" results/qwen-tp8*.log
两组每卡的 Available KV cache memory 应该基本相同,变的是 GPU KV cache size 的 Token 数。DCP=2 时每卡只存一半 Token,同样大小的显存能放的 Token 数预期接近翻倍;倍数以日志为准。源码里一个 KV Block 在 DCP 下代表 block_size × dcp 个 Token,所以监控里的 Block 数和 Token 数换算时要乘上 DCP。
再用长输入负载压测,扫几档并发:
mkdir -p results/qwen # 和第 5 部分的结果分开放,避免被汇总脚本的 glob 匹配到
for C in 8 32 64; do
vllm bench serve --backend openai --base-url http://127.0.0.1:8000 \
--endpoint /v1/completions --model qwen235b --tokenizer "$QWEN" \
--dataset-name random --random-input-len 16384 --random-output-len 512 \
--num-prompts $(( C * 4 )) --max-concurrency "$C" --request-rate inf \
--ignore-eos --seed 42 --percentile-metrics ttft,tpot --metric-percentiles 50,99 \
--metadata layout="$LABEL" --save-result --result-dir results/qwen \
--result-filename "${LABEL}-c${C}.json"
done
LABEL 分别设为 tp8 和 tp8dcp2。看结果时关注两点:
- 抢占和排队。KV 不够时,
-tp 8组会先出现num_preemptions_total增长、num_requests_waiting堆积,高并发档的吞吐上不去。DCP 组的 KV 更多,能同时运行的请求更多。 - 低并发的 TPOT。KV 都放得下时,DCP 多出来的组内通信只是开销,TPOT 可能比
-tp 8略高。
📌 关键点:DCP 用通信换 KV 空间。KV 不是瓶颈时不要开;文档的建议顺序是先加 TP 直到性能满意,再加 DCP 消除复制。
⚠️ 注意:v0.32.0 中 DCP 不能和 --enable-return-routed-experts 同时使用;用 NixlConnector 做 PD 分离(第 7 章)时,DCP > 1 只支持 MLA 模型。DCP 组内的通信默认用 AllGather + ReduceScatter(--dcp-comm-backend ag_rs,个别模型可以在代码里改默认值),MLA 模型可以试 a2a。
7. 选型、调优与排错
7.1 先按装不装得下选布局
把本章的结论按顺序过一遍:
- 一张卡装得下:不切,吞吐不够就多开几个独立实例,前面放负载均衡器(6.4 节)。
- 一台机器装得下:取能装下权重、且留够 KV 的最小 TP,剩下的卡做 DP。本节的 TP4×DP2 和 TP8 就是这一档的两个候选,用第 5 部分的方法实测后再定。同一台机器内一般不用 PP。
- 一台机器装不下:TP 取每节点卡数,PP 取节点数,TP 组留在节点内(6.2、6.5 节)。
- MoE 模型:注意力用 DP、专家用 EP,
--enable-expert-parallel加上合适的--all2all-backend(6.4 节)。 - 长上下文、TP 已经超过 KV 头数:加 DCP 消除 KV 复制(本节第 6 部分)。
| 🎯 目标 | 倾向的布局 | 理由 |
|---|---|---|
| 单请求延迟(低并发 TPOT) | 更大的 TP | 每卡要读的权重最少 |
| 高并发吞吐 | 能装下的最小 TP + DP | 通信少,副本之间零同步;以实测为准 |
| 单请求能用的最长上下文 | 更大的 TP,必要时加 DCP | DP 的 KV 分在多个池里,单个请求只能用一个池 |
7.2 调优清单
- 先定
--max-model-len。它决定一个满长度请求要占多少 KV,也决定启动日志里的最大并发倍数。设得比业务需要大很多,只会让调度器更保守。 --gpu-memory-utilization按实测调。本节固定 0.90,是为了对照公平。上线时看Available KV cache memory和峰值显存,留出的余量够用即可。- DP 下按副本数换算
--max-num-seqs。它是每个 DP rank 的上限;--max-num-batched-tokens也按每个引擎计。 - Prefix Cache 上线时打开。本节关掉是为了让随机负载的结果好解释;有共享 System Prompt 的业务流量,开着通常更划算。
- 每改一个参数,重跑同一组 Benchmark。结果和环境信息(第 1 部分存的
environment.txt、topo.txt)一起归档,方便之后回溯。
7.3 常见问题
| ❓ 现象 | 可能原因 | 处理 |
|---|---|---|
GPU KV cache size 远小于估算 | 激活、CUDA Graph 占得比假设多,或 dtype、并行参数没生效 | 对照 Model loading took 和 Available KV cache memory 两行逐项核对(第 4 部分) |
压测期间 num_preemptions_total 增长 | KV 不够,请求被抢占后重算 | 降低并发或 --max-model-len,加大 TP,长上下文考虑 DCP |
DP 组某个 engine 的 running 明显多于另一个 | 请求长度差异大,两个副本负载不均 | 按 engine 标签看 /metrics;改用独立实例时检查负载均衡策略 |
| TP4×PP2 低并发时比 TP8 慢很多 | 流水线里在飞的 Batch 太小,每段都要读一遍权重 | 单机内优先用 TP;PP 留给跨节点 |
启动报 Decode context parallelism for GQA/MQA requires ... | TP 不大于 KV 头数,没有可消除的复制 | 去掉 -dcp,或加大 TP |
启动报 ... exceeds the maximum supported value | DCP 超过 TP ÷ KV 头数 | 把 DCP 降到这个值以内 |
| 多节点部署后比单节点还慢 | NCCL 退回了 NET/Socket | 按 6.5 节第 6 部分检查 IB 与 GPUDirect RDMA |
📝 总结
- 对照实验每次只改并行参数,
--max-model-len、显存比例、调度上限和负载全部固定;DP 下--max-num-seqs要按副本数换算。 - 启动前先估算每卡权重和每 Token KV:同一台 8 卡机上,TP8 和 TP4×PP2 的 KV 容量相同(约 137 万 Token),TP4×DP2 约少三分之一(两个池各约 47 万)。
- 启动后用
Model loading took、Available KV cache memory、GPU KV cache size三行日志核对估算;nvidia-smi只能确认每张卡都用上了。 - 用
vllm bench serve固定输入输出长度、--ignore-eos、相同 seed 扫并发,结果带上--metadata保存成 JSON 统一汇总,同时记录抢占数。 - 实验前写下假设、实验后用数据验证,结论要带上负载条件;组间差距小于组内波动时不下结论。
- TP 超过 KV 头数后,DCP 沿 Token 维度切分 KV、复用 TP 组内的卡来消除复制;它要求 TP > KV 头数、DCP ≤ TP ÷ KV 头数,代价是组内通信。
🎯 自我检验清单
- 能为一组对照实验列出需要固定的变量,并说明 DP 下
--max-num-seqs怎么换算 - 能手算 Llama-3.1-70B 在 TP8、TP4×PP2、TP4×DP2 下的每卡权重和每 Token KV,并解释前两者容量为什么相同
- 能从启动日志中找到权重显存、可用 KV 显存和 KV Token 数,并用它们修正估算
- 能写出一条
vllm bench serve命令,在固定并发下测出 TTFT、TPOT 的 p50/p99 和输出吞吐 - 能用
/metrics判断压测期间是否发生抢占、DP 副本之间是否均衡 - 能根据模型的 KV 头数和 TP 判断 DCP 是否可用、最大能取多少
- 能根据”装不装得下”和延迟/吞吐目标,为一个模型选出候选布局并设计验证实验
📚 参考资料
- vLLM 文档:vllm bench serve
- vLLM 文档:Production Metrics
- vLLM 文档:Optimization and Tuning
- vLLM 文档:Context Parallel Deployment
- vLLM 文档:Data Parallel Deployment
- vLLM 源码:vllm/benchmarks/serve.py(v0.32.0)
- vLLM 源码:vllm/v1/core/kv_cache_utils.py(v0.32.0)
- vLLM 源码:vllm/v1/worker/gpu_worker.py(v0.32.0)
- vLLM 源码:vllm/config/model.py(v0.32.0,DCP 约束检查)
- Helix Parallelism: Rethinking Sharding Strategies for Interactive Multi-Million-Token LLM Decoding
- The Llama 3 Herd of Models
- Qwen3 Technical Report