跳到主要内容
推理优化

6.6 vLLM 分布式部署实战

以 vLLM v0.32.0 为基准,在一台 8 卡机上部署 Llama-3.1-70B 的 TP8、TP4×PP2、TP4×DP2 三种布局:启动前估算显存,启动后从日志和指标核对 KV 容量,用 vllm bench serve 在相同负载下扫并发,并介绍用 DCP 消除长上下文的 KV 复制

vLLM 张量并行 流水线并行 Benchmark DCP

前五节讲了各种并行方式的原理和多节点的启动方法。本节把它们放到同一台机器上比一比:一台 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. 固定环境与对照变量

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 张卡
模型 / dtypeLlama-3.1-70B-Instruct,BF16不混入量化变量
上下文与显存--max-model-len 8192、--gpu-memory-utilization 0.90KV 预算的计算口径一致
调度上限服务级最多 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 所属的组,右侧是上面脚本算出的数字:

一台 8 卡机上的三种布局:TP=8 和 TP=4 × PP=2 每卡权重都是 16.4 GiB、每卡每 Token KV 40 KiB、一个 KV 池约 137 万 Token;TP=4 × DP=2 每卡权重 32.9 GiB、每卡每 Token KV 80 KiB,两个 KV 池各约 47 万、合计约 94 万 Token

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_percKV 池的占用比例,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 节的核对结果填进同一张表。下表只列出要记什么,数字需要你实测后填写:

📊 记录项TP8TP4×PP2TP4×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(HH 为 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,灰色虚线格是省出来的空间:

Qwen3-235B-A22B 在 TP=8 下每个 KV 头复制到 2 张卡上;加上 DCP=2 后,同一个 KV 头的 Token 按奇偶交错分给两张卡,每卡只存一半,省下的空间可以放更多 Token

Token 按交错方式分配:默认 --cp-kv-cache-interleave-size 1,第 ii 个 Token 存在 dcp rank i mod dcpi \bmod \text{dcp} 上。这样 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 / MQADecode 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 / MQAThe 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 88-dcp 8
Kimi-K2(MLA)1-tp 16(两台机器)16-dcp 16 完全消除;-dcp 8 剩 2 倍,但 DCP 通信只在节点内
Qwen3-235B-A22B4-tp 82-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 先按装不装得下选布局

把本章的结论按顺序过一遍:

  1. 一张卡装得下:不切,吞吐不够就多开几个独立实例,前面放负载均衡器(6.4 节)。
  2. 一台机器装得下:取能装下权重、且留够 KV 的最小 TP,剩下的卡做 DP。本节的 TP4×DP2 和 TP8 就是这一档的两个候选,用第 5 部分的方法实测后再定。同一台机器内一般不用 PP。
  3. 一台机器装不下:TP 取每节点卡数,PP 取节点数,TP 组留在节点内(6.2、6.5 节)。
  4. MoE 模型:注意力用 DP、专家用 EP,--enable-expert-parallel 加上合适的 --all2all-backend(6.4 节)。
  5. 长上下文、TP 已经超过 KV 头数:加 DCP 消除 KV 复制(本节第 6 部分)。
🎯 目标倾向的布局理由
单请求延迟(低并发 TPOT)更大的 TP每卡要读的权重最少
高并发吞吐能装下的最小 TP + DP通信少,副本之间零同步;以实测为准
单请求能用的最长上下文更大的 TP,必要时加 DCPDP 的 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 valueDCP 超过 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 是否可用、最大能取多少
  • 能根据”装不装得下”和延迟/吞吐目标,为一个模型选出候选布局并设计验证实验

📚 参考资料