跳转至

调参旋钮:扫过吞吐/延迟曲线

基线:vLLM 0.26.0 · 模型 Qwen2.5-7B-Instruct · 单张 RTX 4090 (24 GB)

这里的每个旋钮都经 Context7 针对 vLLM 0.26.0 核实(ADR-0004):gpu_memory_utilization(默认 0.92)、max_num_seqs128)、max_num_batched_tokens2048,自动调)、enable_chunked_prefillTrue)、enable_prefix_cachingV1 默认开)、quantizationkv_cache_dtype="fp8"enforce_eager(关 CUDA graphs)、tensor_parallel_sizemax_model_len。§4 的地图是方向地图,不是 benchmark——它说每个旋钮把曲线往哪个方向推,从不给量级。§5 的 sweep 会产出数字,但那是你的:按 ADR-0004 作者不执行;每个数字都是你在自己机器上对着评测集测出的示例 / 量级参考


1 · 直觉 & 为什么重要

你见过了每个机制(batchingpagingchunked prefillprefix cachingspec decoding)以及它们住在哪的地图。这是收官课:暴露这些机制的旋钮,以及如何为真实 SLO 拧它们。「你在 4090 上服务 Qwen,TTFT 太高——你改什么?」正是整个 Part 5 铺垫的问题,答案从不是单个旋钮——而是知道哪个旋钮移动吞吐↔延迟曲线的哪一端,并去测量那个移动。

一个心态转变:没有普遍意义上「快」的配置——只有为某个目标调出的配置。 每个旋钮都拿一样东西换另一样(吞吐换延迟、VRAM 换质量、TTFT 换 ITL)。所以专业工作流不是「设好魔法值」;而是sweep:固定一个评测集、改一个旋钮、测(质量、吞吐、延迟)三元组、只在这个换值划算时保留。本课给你每个旋钮的方向(好让你 sweep 对的那个)与测量量级的 harness(因为量级永远与机器相关)。→ 度量术语(TTFT、TPOT/ITL、throughput、goodput)见 Glossary

2 · 心智模型

一条曲线、两端、以及哪个旋钮往哪推(旋钮落在光谱上的这张图是概念性布局,按 ADR-0005 用 ASCII):

        吞吐  ◄─────────────────────────────────────►  延迟
        (tokens/s, 多并发)                  (低 TTFT / ITL, 少并发)

  推向吞吐 →                         推向延迟 →
    gpu_memory_utilization ↑            max_num_batched_tokens ↓(更平滑 ITL)
    max_num_seqs ↑                      enforce_eager = False(保留 CUDA graphs)
    max_num_batched_tokens ↑            speculative decoding(单流)
    quantization (INT4) → 更多 KV        更少并发请求
    kv_cache_dtype fp8 → 更多 KV        (所有「容量」旋钮也削减排队延迟)

  免费的赢(两端都帮、几乎无代价):
    enable_prefix_caching(共享前缀上)   quantization(腾 VRAM 且加速 decode)

以及把这张图变成一个设置的方法——sweep(扫)(一个控制回路,按 ADR-0005 用 Mermaid):

flowchart TB
    B["baseline: fix the eval set<br/>+ fixed sampling (temperature=0, seed)"] --> M0["measure (quality, throughput, latency)"]
    M0 --> C["change ONE knob toward your SLO's end"]
    C --> M["re-measure the (quality, throughput, latency) triple"]
    M --> D{"trade worth it<br/>for your SLO?"}
    D -->|"yes"| K["keep the change"]
    D -->|"no"| R["revert"]
    K --> N{"more knobs<br/>to sweep?"}
    R --> N
    N -->|"yes"| C
    N -->|"no"| DONE["ship the defended config"]

三个要记的形状:

  • 旋钮都在一条曲线上;你选一个点,不是「最优」。 推向吞吐(更大批、更多 KV 容量)在饱和时通常代价是每请求延迟;推向延迟(更小 token 预算、spec decoding)通常代价是总吞吐。先命名你的 SLO,再拧移动那一端的旋钮。
  • 容量旋钮是总闸,其中一些近乎免费。 任何能装更多 KV 的——gpu_memory_utilization ↑量化FP8 KV cache——都抬高并发天花板,既提吞吐削减排队延迟。量化与 prefix caching 最接近免费午餐(帮忙却无对称代价,除了一点质量)。
  • 你从不盲调——你对着评测集 sweep。 测量循环 就是方法:基线 → 改一个旋钮 → 重测(质量、吞吐、延迟)三元组 → 留或退。一次一个旋钮、固定采样。数字永远是你的。

3 · 原理——旋钮,按它移动什么分组

3.1 容量旋钮(抬高并发天花板 → 吞吐)

它们都扩大 KV-cache 预算,装下更大的 continuous batch

  • gpu_memory_utilization(0.92)——引擎可用的 VRAM 占比;↑ → 更多 KV block → 更大批。太高 → 启动 OOM。
  • quantization(INT4/AWQ)——缩小权重 → 给 KV 腾预算加速 memory-bound decode。代价是一点质量(去测)。
  • kv_cache_dtype="fp8"——KV 字节减半 → ~2× KV 容量 → 更多序列。代价是一点 KV 精度。
  • max_model_len——限每序列 KV;调低让更多(更短上下文)序列装下。

3.2 批形状旋钮(吞吐 vs 延迟平衡)

  • max_num_seqs(128)——运行集宽度天花板;↑ → 更多并发,但打满算力后每请求延迟上升。
  • max_num_batched_tokens(2048,自动)——chunked-prefill 旋钮:↑ → 更好 TTFT 与吞吐但更差 ITL;↓ → 更平滑 ITL。文档建议对小模型/大 GPU 用 >8192 提吞吐。
  • enable_chunked_prefill(True)——让 prefill 与 decode 共享一步;长 prompt 下保持 ITL 平滑。

3.3 复用与延迟旋钮

  • enable_prefix_caching(开)——为共享前缀跳过 prefill;命中重的流量上近乎免费的吞吐 + TTFT。
  • speculative decodingspeculative_config)——低批量下削单流 TPOT;高批量消退/反噬。
  • enforce_eager(False)——保持 False 以保留 CUDA graphs(更低 decode 延迟);只在需省 VRAM/启动时设 True,代价是 decode 变慢。

3.4 扩展旋钮

  • tensor_parallel_sizeTP)——把模型切到多卡;装下更大模型/增加余量并削减计算延迟,代价是跨 GPU 通信。多卡(按 ADR-0001 属 A100 范畴);单张 4090 上就是 1。

3.5 方法:sweep,别猜

没有一张「推荐值」表能在你的模型、硬件、流量面前存活。耐久的技能是 sweep:挑移动你目标端的旋钮(§2),在几个值上变化它,对着固定的评测集测(质量、吞吐、延迟)三元组——一次改一个旋钮、固定采样(temperature=0、固定 seed)。保留那个你能为其权衡辩护的设置。

3.6 在 vLLM 源码里读它(v0.26.0)

这里每个旋钮都是某个 config dataclass 上的带类型字段——读源码才是弄清你这个版本真实默认值与取值范围的办法,而不是信博客(ADR-0002:读懂 + 会推理):

  • 容量 / KV 旋钮CacheConfigvllm/config/cache.py):gpu_memory_utilization 在那儿字面就是 Field(default=0.92, gt=0, le=1),同处还有 kv_cache_dtypeenable_prefix_cachingblock_size
  • 批形状旋钮SchedulerConfigvllm/config/scheduler.py):max_num_seqsmax_num_batched_tokensenable_chunked_prefilllong_prefill_token_threshold
  • CLI/LLM(...) 粘合层EngineArgsvllm/engine/arg_utils.py):它在 create_engine_config 里把 --gpu-memory-utilization--max-num-seqs 等映射到那些 dataclass。所以一个 flag、它的 LLM(...) kwarg、以及 config 字段,是同一个值的三种视图——而字段的声明才是其默认值的权威来源。

打开 cache.pyscheduler.py 直接读字段默认值;那才是「X 的默认值是多少?」的诚实版本。

4 · 完整可跑代码 + 逐行讲解

一个纯 Python 方向地图:每个旋钮、它往哪推吞吐与延迟、换什么、以及针对给定目标的推荐器。它把 §3 编码,让你 sweep 对的旋钮——离线、无 GPU,且刻意不给捏造的量级(那是 §5 sweep 去测的)。

tuning_knobs_map.py
"""每个 vLLM 旋钮及它把吞吐/延迟曲线往哪推。
纯 Python、离线——方向地图,不是测出的量级(量级是你去测的)。"""

# 旋钮: (吞吐, 延迟, 换什么, 默认)
KNOBS = {
    "gpu_memory_utilization ↑": ("↑ more KV blocks",   "≈ (risk OOM)",   "VRAM headroom",            "0.92"),
    "max_num_seqs ↑":           ("↑ wider batch",      "↑ at saturation","batch width",              "128"),
    "max_num_batched_tokens ↑": ("↑ + better TTFT",    "↑ ITL",          "TTFT vs ITL",              "2048"),
    "quantization INT4/AWQ":    ("↑ frees VRAM",       "↓ per-token",    "some output quality",      "off"),
    "kv_cache_dtype fp8":       ("↑ ~2x KV capacity",  "≈",              "some KV precision",        "auto"),
    "enable_prefix_caching":    ("↑ on shared prefix", "↓ TTFT on hits", "~nothing (V1 default on)", "on"),
    "enforce_eager=True":       ("↓ no CUDA graphs",   "↑ decode",       "saves VRAM/startup",       "off"),
    "tensor_parallel_size ↑":   ("↑ bigger models fit","↓ (+comm cost)", "multi-GPU + comm",         "1"),
}

def recommend(goal):
    """把某目标('throughput' 或 'latency')往好方向推的旋钮(忽略其代价)。"""
    out = []
    for knob, (thru, lat, *_rest) in KNOBS.items():
        if goal == "throughput" and thru.startswith("↑"): out.append(knob)
        if goal == "latency"    and lat.startswith("↓"):  out.append(knob)
    return out

if __name__ == "__main__":
    print(f"{'knob':<26}{'throughput':<21}{'latency':<17}trades")
    for knob, (thru, lat, trade, _d) in KNOBS.items():
        print(f"{knob:<26}{thru:<21}{lat:<17}{trade}")
    print("\nfor throughput:", recommend("throughput"))
    print("for latency   :", recommend("latency"))

逐行讲解:

  • KNOBS——每个旋钮作 (吞吐方向, 延迟方向, 权衡, 默认)。值是方向(↑/↓/≈),不是数字——因为方向是机制的属性(可验证的推理),而量级是你机器的属性(必须测)。这是调参诚实的那一半。
  • recommend(goal)——按旋钮往哪端推的好方向筛选。注意它忽略权衡——它告诉你要 sweep 的候选、不是答案;权衡(与评测集测量)决定你实际留哪个。
  • __main__——打印表,然后是提吞吐者与提延迟者。有些旋钮(量化、prefix caching、TP)出现在两个列表——那些最接近免费的赢。

预期输出(方向地图,不是 benchmark):

knob                      throughput           latency          trades
gpu_memory_utilization ↑  ↑ more KV blocks     ≈ (risk OOM)     VRAM headroom
max_num_seqs ↑            ↑ wider batch        ↑ at saturation  batch width
max_num_batched_tokens ↑  ↑ + better TTFT      ↑ ITL            TTFT vs ITL
quantization INT4/AWQ     ↑ frees VRAM         ↓ per-token      some output quality
kv_cache_dtype fp8        ↑ ~2x KV capacity    ≈                some KV precision
enable_prefix_caching     ↑ on shared prefix   ↓ TTFT on hits   ~nothing (V1 default on)
enforce_eager=True        ↓ no CUDA graphs     ↑ decode         saves VRAM/startup
tensor_parallel_size ↑    ↑ bigger models fit  ↓ (+comm cost)   multi-GPU + comm

for throughput: ['gpu_memory_utilization ↑', 'max_num_seqs ↑', 'max_num_batched_tokens ↑', 'quantization INT4/AWQ', 'kv_cache_dtype fp8', 'enable_prefix_caching', 'tensor_parallel_size ↑']
for latency   : ['quantization INT4/AWQ', 'enable_prefix_caching', 'tensor_parallel_size ↑']

仔细读 latency 那行:量化、prefix caching、TP 出现在两个列表——它们既提吞吐削延迟,这就是为什么它们是最先该伸手的。其余都是你必须测的真实权衡。地图告诉你 sweep 哪个旋钮;只有 sweep 告诉你推多远

5 · Lab——对着评测集跑一次真实 sweep

GPU Lab(单卡 sweep,可跑)

  • 最低显存: 读地图不需要;用 Qwen2.5-7B-Instruct(AWQ)跑 sweep 需 ~16 GB
  • 建议 AutoDL 卡型: RTX 4090 (24 GB);tensor_parallel_size sweep 需 ≥2 GPU(A100,ADR-0001)
  • 预估耗时 / 花费: 读 ~20 分钟(免费,无卡模式)· 小 sweep ~20–40 分钟 · 几 ¥(示例)
  • 平台: NVIDIA CUDA(默认)
  • 非 NVIDIA: sweep 逻辑与后端无关;kv_cache_dtype / CUDA-graph 支持与启动时间随后端而异。

sweep 复用评测集测量循环:固定输入、改一个旋钮、记三元组。这个 driver 是诚实的核心——它编排运行并打印 delta;数字来自你的 GPU,不是本页。

knob_sweep.py
# API 针对 vLLM 0.26.0 核实(LLM、SamplingParams)。在 GPU 上跑;数字是你的。
import time
from vllm import LLM, SamplingParams
from score import load_items, summarize      # 来自评测集小集页

items = load_items("small_eval.jsonl")        # 固定输入(评测集)
convos = [[{"role": "user", "content": it["prompt"]}] for it in items]
sp = SamplingParams(temperature=0.0, max_tokens=128, seed=0)   # 固定采样 -> 可比

def measure(**engine_kwargs):
    """一个 sweep 点:用这些旋钮建引擎、跑评测、返回(质量, tok/s)。"""
    llm = LLM(model="Qwen/Qwen2.5-7B-Instruct-AWQ", max_model_len=4096, **engine_kwargs)
    t0 = time.perf_counter()
    outs = llm.chat(convos, sp)                # chat() 应用模板(见评测集)
    dt = time.perf_counter() - t0
    quality = summarize(items, [o.outputs[0].text for o in outs])["accuracy"]
    tok_s = sum(len(o.outputs[0].token_ids) for o in outs) / dt
    return quality, tok_s

# sweep 一个旋钮(gpu_memory_utilization)——两点间别改任何别的。
for gmu in (0.80, 0.90, 0.94):
    q, tps = measure(gpu_memory_utilization=gmu)
    print(f"gpu_memory_utilization={gmu}: accuracy={q:.2%}  throughput={tps:.0f} tok/s (illustrative)")

观察/动手:

  1. 一个旋钮,三个点。 往上 sweep gpu_memory_utilization 应装下更多 KV block(看启动的 "# GPU blocks" 行升高)并提吞吐——直到 OOM。accuracy 应持平(这个旋钮不碰质量);若不然,说明别的东西变了。
  2. 换旋钮。 把循环换成 quantization 开/关、或用共享前缀负载的 enable_prefix_caching、或 max_num_batched_tokens 在 (2048, 8192) 之间并盯 TTFT vs ITL。每个都复现 §4 地图的一行——作为你的数字。
  3. 守纪律。 每次 sweep 一个旋钮、temperature=0 + 固定 seed、前后同一评测集——正是评测集循环。一个你无法归因到单个旋钮的质量 delta 是浪费的实验。

6 · 常见坑 / 反直觉点

  • 一次改好几个旋钮。 那你没法归因——质量掉了,是 INT4 还是更高的 gpu_memory_utilization?每次 sweep 一个旋钮;慢,但这是学你曲线的唯一办法。
  • 追某博客的「最优」值。 别人的 max_num_batched_tokens=16384 是为他们的模型/GPU/流量调的。复制量级跳过了让它有意义的测量。复制方法(sweep),不是数字。
  • gpu_memory_utilization=1.0 给激活尖峰/分配器碎片不留余地 → OOM。小步往上推并盯着。
  • 延迟受限时却优化吞吐(反之亦然)。 提高 max_num_seqs 提吞吐但在饱和时恶化每请求延迟——用户抱怨响应慢时这是错招。先命名 SLO。
  • 忘了三元组里的质量。 吞吐与延迟不是全部——一个加速却拉垮 accuracy 的旋钮(激进量化、把 prompt 截断的过小 max_model_len)是回归。永远对着评测集测(质量、吞吐、延迟)三元组
  • 非确定性 sweep。 temperature>0 让重跑随机不同,一个「回归」可能是噪声。每次对比固定采样(temperature=0seed)。
  • 生产里留着 enforce_eager=True 它是调试/省内存 flag;它关掉 CUDA graphs 并抬高 decode 延迟。除非真需要那点 VRAM,别上线它。
  • 信记忆里的默认值,而非配置源码。 默认值会跨 vLLM 版本漂移,而一个旋钮的默认值决定了你的 sweep 以什么为基准。权威来源是 dataclass 字段——例如 0.26.0 里 gpu_memory_utilizationCacheConfig 上的 Field(default=0.92)(§3.6);引用默认值或设基线前,先在 vllm/config/ 里确认你这个版本的默认值/范围。

7 · 面试连线

8 · 小结 & 延伸阅读

一句话: 没有普遍意义上快的配置——只有为某个 SLO 调出的,所以耐久的技能是 sweep:知道每个旋钮移动吞吐↔延迟曲线的哪一端(gpu_memory_utilization/量化/FP8-KV 这类容量旋钮抬高并发天花板;max_num_batched_tokens 拿 TTFT 换 ITL;enforce_eager 与 spec decoding 碰 decode 延迟;TP 跨 GPU 扩展),然后固定评测集、改一个旋钮、测(质量、吞吐、延迟)三元组——只在权衡划算时保留,每个数字都在你自己机器上测。

延伸阅读:

9 · 自测小问

单张 4090 服务 Qwen2.5-7B,TTFT 太高。说出你会考虑的旋钮、它们的方向、以及你会跑的 sweep。

首先,TTFT 由 prefill 主导。候选:(1)max_num_batched_tokens ↑——让每步跑更多 prompt 的 prefill → 更低 TTFT(权衡:运行流的 ITL 更差)。(2)enable_prefix_caching——若 prompt 共享前缀(system prompt / few-shot),命中就完全跳过那段 prefill → TTFT 大降,近乎免费。(3)容量旋钮gpu_memory_utilization ↑、量化、FP8 KV)——若 TTFT 高是因为请求在排队等 KV 空间(准入),更多容量削减排队延迟。sweep:固定评测集与固定采样配置,一次改一个、在几个值上变化,记(accuracy、TTFT、吞吐)。保留那个 TTFT 收益值其 ITL/质量代价的设置——在你机器上测,不是假设。

哪些旋钮同时帮吞吐与延迟?为何它们特殊?

量化(INT4/AWQ)、prefix caching、以及可用时的 FP8 KV cacheTP。它们特殊,因为它们不坐在吞吐↔延迟的权衡上:量化腾 VRAM(→ 更大批 → 吞吐)缩小权重读取(→ 更快 memory-bound decode → 延迟);prefix caching 跳过冗余 prefill(→ 更高吞吐命中时更低 TTFT);FP8 KV 抬高容量(→ 吞吐)而延迟代价可忽略;TP 把计算切到多卡(→ 更低延迟)同时增加余量(→ 吞吐)。它们换的是别的东西——一点输出/KV 质量,或通信/硬件——而不是曲线的另一端。这就是为什么它们是最先该伸手的旋钮,纯权衡旋钮(max_num_seqsmax_num_batched_tokens)排在后面。

为什么「设这些最优值」是坏建议?正确的工作流是什么?

因为最优是你模型、硬件、流量的函数——在别人长 prompt 的 A100 上理想的 max_num_batched_tokensgpu_memory_utilization,在你短 prompt 的 4090 上可能是错的,还可能悄悄损质量。量级不迁移;只有方向迁移。正确工作流是对固定评测集 sweep:用固定采样(temperature=0seed)建立基线(质量、吞吐、延迟),一次改一个旋钮、在几个值上、重测三元组、只在其权衡对你 SLO 可辩护时保留——然后换下一个旋钮。你复制方法、从不复制数字;每个数字都在你自己的环境测(ADR-0004:作者不把任何数字当事实陈述)。