Skip to content
Published on

把 vLLM 调快 —— 配置、内部结构,以及真正值得动代码的地方

分享
Authors

引言 —— 打开代码之前该确认的六行

每次有人问"vLLM 感觉很慢,要不要改源码",我总是先确认同样几件事:日志里有没有出现抢占警告、max_num_batched_tokens 设的是多少、前缀缓存有没有打开、GPU 显存利用率是多少、物理核心有几个,以及现在到底在测什么。

大多数情况下,答案就在这六行里。真正需要动源码的场景比想象中要少得多,即便真的需要动,值得动的地方也就那么几处。

本文就沿着这个顺序走。参照基准是 vLLM v0.26.0(2026 年 7 月 25 日 PyPI 发布,Python 3.10 以上、3.15 以下),内部结构的说明是直接阅读该 tag 的源码写成的。vLLM 大约每两周发一个次要版本,所以本文里的文件路径和参数名过几个月可能就会对不上。养成对照自己所用版本源码的习惯很有必要。

先说一件事。V0 引擎已被完全弃用。官方文档明确写着"We have fully deprecated V0",并指向 RFC #18571。网上还留存的 V0 时代调优文章,大多数现在已经不适用了。

首先,不改代码就能做到的事

按优先级排列。越靠上,收益越大、成本越低。

旋钮改变什么什么时候动它
gpu_memory_utilization用于 KV cache 的显存比例看到抢占警告时。从默认值往上调
max_num_batched_tokens单个 step 处理的总 token 预算决定优先保 TTFT 还是 ITL 时
max_num_seqs同时运行的请求数上限显存不够或批次偏浅时
前缀缓存共享前缀的 KV 复用系统提示很长且被共享时
量化权重与 KV cache 的字节数decode 受限于带宽时
tensor_parallel_size权重在 GPU 间切分的程度模型放不下或 KV 空间不够时
attention backend用哪个 kernel自动选择不是最优时
优化级别 -O0-O3用启动时间换稳态性能开发循环还是生产环境

先消灭抢占

最常见的单一原因。KV cache 不够时,vLLM 会抢占正在运行的请求,之后再重新计算。如果日志里反复出现这样一行,别的调优就没有意义了。

WARNING ... Sequence group 0 is preempted by PreemptionMode.RECOMPUTE mode
because there is not enough KV cache space. This can affect the end-to-end
performance. Increase gpu_memory_utilization or tensor_parallel_size ...

V1 默认的抢占方式不是 swap,而是重新计算(recompute)。被抢占请求的 prefill 会从头再做一遍。所以抢占一旦频繁发生,吞吐量和延迟会一起崩掉。应对方式文档已经直接给出:调高 gpu_memory_utilization,或调低 max_num_seqsmax_num_batched_tokens,或调高 tensor_parallel_size 来增加每张 GPU 的 KV 空间。

用 token 预算权衡 TTFT 与 ITL

max_num_batched_tokens 是单个 step 能调度的 token 总量。官方文档把方向写得很明确。

  • 值较小时(比如 2048)ITL 会变好,因为拖慢 decode 的 prefill 块变小了。
  • 值较大时TTFT 会变好,因为一个 batch 里能塞进更多 prefill token。
  • 如果目标是吞吐量,文档建议设得比 8192 大,尤其是在大 GPU 上跑小模型的情况下。

有一个坑要注意。在关闭 chunked prefill 的状态下,max_num_batched_tokens 必须大于 max_model_len,否则服务器可能在启动时就崩溃。

from vllm import LLM

# 对话式服务: 优先保证单 token 延迟
llm = LLM(model="meta-llama/Llama-3.1-8B-Instruct", max_num_batched_tokens=2048)

# 批处理: 优先保证吞吐量
llm = LLM(model="meta-llama/Llama-3.1-8B-Instruct", max_num_batched_tokens=16384)

启动时间也是性能的一部分

如果是用同一个模型、同一套配置反复启动的环境,文档给出了三种手段。

  • 复用编译缓存。 torch.compile 的产物会存到 VLLM_CACHE_ROOT(默认是家目录下的一个缓存目录),你可以把这个目录烤进容器镜像,或者在机器之间直接复制。设置 VLLM_FORCE_AOT_LOAD=1 后,一旦缓存未命中就会显式报错,而不是默默重新编译。模型、配置、相关环境变量、torch 版本、GPU 型号中任何一项发生变化,缓存都会失效。
  • --kv-cache-memory 跳过内存性能分析。 启动时 vLLM 会把能重现当前分配结果的数值打到日志里。下次启动时传入这个值就能跳过测量步骤。但这有代价:KV cache 大小从"测量得出"变成"固定为指定值",设得保守会削减并发数,设得乐观则会导致分配失败。只有在同一块 GPU、同样的初始空闲显存条件下才有效。
  • --enforce-eager 把编译和 CUDA graph 捕获都跳过。启动最快,但稳态下的 decode 性能会变差。这是给开发循环用的,也可以用来衡量启动时间里编译占了多大比重。

不要饿着 CPU

一个容易被忽略、值得单独拿出来说的点。vLLM V1 是多进程架构。有 N 块 GPU,就有 1 个 API server、1 个 engine core、N 个 GPU worker,至少 N 加 2 个进程在抢 CPU。

文档把最低要求钉在物理核心这个基准上。如果开了超线程,1 个 vCPU 只等于半个物理核心,所以需要的 vCPU 数量要翻倍。engine core 进程尤其对 CPU 饥饿敏感,因为它跑的是一个忙等待循环。如果在虚拟化环境里 GPU 利用率莫名其妙偏低,应该先怀疑这里。

attention backend 默认自动选择

vLLM 会看 GPU 架构、模型和配置,从优先级列表里选出第一个兼容的 backend。以 v0.26.0 为准,标准 attention 的优先级如下。

架构第 1 优先第 2 优先第 3 优先第 4 优先第 5 优先
Blackwell (SM 10.x)FLASHINFERFLASH_ATTNTRITON_ATTNFLEX_ATTENTIONTURBOQUANT
Ampere / Hopper (SM 8.x–9.x)FLASH_ATTNFLASHINFERTRITON_ATTNFLEX_ATTENTIONTURBOQUANT

手动切换的方式如下。指定一个不兼容的 backend 会带着原因报错。

vllm serve Qwen/Qwen3-8B --attention-backend FLASH_ATTN
# 或者用结构化配置
vllm serve Qwen/Qwen3-8B -ac.backend FLASH_ATTN

PagedAttention —— 消灭碎片化的思路

从这里开始进入内部结构。vLLM 的出发点观察很简单:如果给每个请求的 KV cache 分配一整块连续的大内存,大部分内存都会被浪费掉。

浪费分三种。因为不知道请求会不会跑到最大长度而提前预留的内部预留、实际没用到就结束的过度分配,以及归还和重新申请大小不一的内存块过程中产生的外部碎片化

解法直接照搬了操作系统的虚拟内存。把 KV cache 切成固定大小的 block,再用一张 block table 把逻辑上连续的序列映射到物理上分散的 block 上。

请求 A 的逻辑 KV:  [t0 t1 t2 t3] [t4 t5 t6 t7] [t8 t9 __ __]
                        │              │              │
block table A     →   block 7        block 3        block 12

请求 B 的逻辑 KV:  [t0 t1 t2 t3] [t4 t5 __ __]
                        │              │
block table B     →   block 7        block 5
                如果是共同前缀,就共享同一个 block(前缀缓存)

结果有两个。第一,除了最后一个 block 剩余的那点空间,不再有浪费。原论文将其描述为"near-zero waste in KV cache memory"。第二,block 级别的共享成为可能。使用同一个系统提示的请求会物理共享前缀 block,这就是前缀缓存。论文报告,靠这两点,在同等延迟下相比 FasterTransformer 和 Orca 实现了 2 到 4 倍的吞吐量提升(Kwon et al., SOSP 2023)。

在 v0.26.0 里,这部分逻辑位于 vllm/v1/core/ 下。kv_cache_manager.py 负责按请求分配 block,block_pool.py 负责 block 池与基于哈希的复用,kv_cache_coordinator.py 负责协调同时使用多种缓存的模型(混合 attention)。

实务上的含义是这样的:前缀缓存只有在系统提示又长又被共享时才划算。 如果每个请求的前缀都不一样,剩下的就只有哈希计算和 block 管理的开销。开启前后各测一次,是唯一正确的判断方法。

连续批处理调度器实际在决定什么

vllm/v1/core/sched/scheduler.py 里的 schedule() 方法,是每个 engine step 都会执行的逻辑。源码开头的注释精确概括了这套设计。

There's no "decoding phase" nor "prefill phase" in the scheduler. Each request just has the num_computed_tokens and num_tokens_with_spec. At each step, the scheduler tries to assign tokens to the requests so that each request's num_computed_tokens can catch up its num_tokens_with_spec.

这句话是理解 V1 调度器的钥匙。调度器不区分 prefill 和 decode。每个请求只持有"到目前为止已计算的 token 数"和"应该计算到的 token 数"这两个量,每个 step 都在分配 token 让前者追上后者。靠这一个抽象,chunked prefill、前缀缓存、推测解码全都不需要特殊分支就能表达出来。

实际循环的骨架大致如下。

# vllm/v1/core/sched/scheduler.py 结构的概括(非真实代码)
def schedule(self):
    token_budget = self.max_num_scheduled_tokens   # = max_num_batched_tokens

    # 1) 先处理 RUNNING 请求。也就是说 decode 拥有优先权。
    for request in self.running:
        if token_budget <= 0:
            break
        num_new = min(need(request), token_budget)
        if not kv_cache_has_room(request, num_new):
            preempt(self.running.pop())     # 从后往前抢占
            continue
        schedule_tokens(request, num_new)
        token_budget -= num_new

    # 2) 用剩下的预算接入 WAITING 请求。也就是说 prefill 靠后。
    while self.waiting and token_budget > 0:
        if len(self.running) >= self.max_num_running_reqs:   # = max_num_seqs
            break
        request = self.waiting.peek()
        num_new = min(need(request), token_budget)
        # 放不下就切一部分放进去 → 这就是 chunked prefill
        schedule_tokens(request, num_new)
        token_budget -= num_new

由此可以看到,我们在配置里调整的那些值究竟插在哪里。

  • max_num_batched_tokens 就是 token_budget 的初始值,是单个 step 的总工作量。
  • max_num_seqs 就是 max_num_running_reqs,是运行队列长度的上限。
  • long_prefill_token_threshold 是单个 prefill 请求在一个 step 里最多能拿走多少 token 的上限,防止一个长 prompt 独占预算,饿死其他请求。

decode 优先获得分配这一点很重要。策略是先照顾已经在流式输出的用户,再用剩下的预算开始新请求的 prefill。所以负载一上升,TTFT 会先恶化,ITL 相对更扛得住。

Prefill 与 decode 是性质完全不同的工作

这两个阶段使用硬件的方式正好相反。

维度prefilldecode
一次处理的 token整个 prompt(数千个)每请求 1 个
算术强度高。大矩阵乘法非常低
瓶颈计算(tensor core)显存带宽
相关指标TTFTTPOT、ITL
加大 batch 的效果已经饱和,收益很小共享权重读取,收益很大

decode 之所以受限于带宽,原因很简单:每生成一个 token,都要把整个模型的权重读一遍。batch 为 1 时,这次读取只换来 1 个 token;batch 为 64 时,同样这次读取能换来 64 个。所以 decode 的吞吐量几乎与 batch 大小成正比增长,直到贴上带宽的墙。

问题在于,把这两个阶段分开跑,两边都吃亏。只跑 prefill 的 step 里,tensor core 饱和,内存管道闲着;只跑 decode 的 step 里则正相反。

chunked prefill 正面解决了这个问题。把长的 prefill 切开,混入与 decode 请求相同的 batch 里。计算受限的任务和内存受限的任务共存于一个 batch,两种资源就能同时被用上。在 V1 里,只要条件允许,这个功能默认就是开着的。

这里的权衡是显性的。chunk 切得小,decode 受到的干扰少,ITL 会变好,但 prefill 被拆到更多 step 里,TTFT 会变差;chunk 切得大则相反。哪一边正确,由服务本身决定,不是 vLLM 决定的。

基准测试 —— 该固定什么,该测什么

这是本文最重要的一节。前面所有的调优,都只有在测量诚实的前提下才有意义。

你正在测量的值的精确定义

vLLM 的 benchmark 工具输出的指标,定义都钉死在源码里。

指标定义谁在乎
TTFTTime to First Token。从请求发出到第一个 token用户体感的响应速度
TPOTTime per Output Token,不含第一个 token 的平均值流式输出的体感速度
ITLInter-token Latency。连续 token 之间间隔的分布卡顿。要看尾部,不是看均值
E2ELEnd-to-end Latency。请求整体耗时批处理作业
Output throughput每秒输出 token 数成本

把 TPOT 和 ITL 分开来用很重要。TPOT 是单个请求的平均值,ITL 是每一个间隔的分布。如果平均 TPOT 是 20ms,但 ITL 的 p99 是 400ms,用户感受到的会是"偶尔会卡一下"。只看均值,这个现象根本看不出来。

实际命令

# 1) 启动服务器。要调优的参数在这里固定下来。
vllm serve meta-llama/Llama-3.1-8B-Instruct \
  --max-num-batched-tokens 8192 \
  --max-num-seqs 256 \
  --gpu-memory-utilization 0.90 &

# 2) 施加负载。变化请求速率,打出多个点。
vllm bench serve \
  --backend vllm \
  --model meta-llama/Llama-3.1-8B-Instruct \
  --dataset-name sharegpt --dataset-path sharegpt.json \
  --num-prompts 500 \
  --request-rate 8 \
  --percentile-metrics ttft,tpot,itl,e2el \
  --metric-percentiles 50,90,99 \
  --save-result --result-filename rate8.json

# 3) 如果想知道离线吞吐量上限,用这个
vllm bench throughput --model meta-llama/Llama-3.1-8B-Instruct \
  --dataset-name sharegpt --dataset-path sharegpt.json --num-prompts 1000

必须固定的东西

要相信一次测量,以下这些必须全部固定住。哪怕只有一项在变,比较就失去了意义。

  • 输入与输出的长度分布。 同一个数据集、同一个随机种子。如果用合成数据,要显式固定输入长度和输出长度。长度分布一变,吞吐量可以随意变化。
  • 请求到达速率。 用无限负载去测,能得到吞吐量上限,但延迟会变得毫无意义(全是排队等待时间)。一边变化请求速率一边画曲线,才是唯一有用的形式。单独一个点说明不了任何问题。
  • 热身(warmup)。 最初几个请求会混入编译、CUDA graph 捕获、缓存预热的开销,必须从统计里剔除。
  • 前缀缓存的状态。 如果开着缓存反复用同一个 prompt 测,从第二次请求开始 TTFT 会大幅变好。把这个当成优化成果来汇报就是在撒谎。要么清空缓存重测,要么就测缓存已预热的稳态——二选一,并保持一致。
  • GPU 时钟与"邻居"。 如果是共享硬件,要确保同一时间段没有别的作业在跑。一旦触及功耗上限,时钟频率就会下降。
  • 版本。 把 vLLM、PyTorch、驱动、镜像标签都和结果一起记下来。

当作曲线来读

与其看单点数字,不如把请求速率依次提到 5、10、15、20 再测,会得到这样的形状。

   p99 TTFT
      ^
      |                                    ╱  ← 队列从这里开始堆积
      |                                  ╱
      |                          ______╱
      |     ____________________╱
      +--------------------------------------> 请求速率(req/s)
                              拐点(knee)

  运行点应该落在拐点的左侧。
  拐点右侧是"吞吐量出得来,但延迟已经失控"的区域。

调优的目标不是最大吞吐量,而是满足 SLO 前提下的最大吞吐量。如果条件是 p99 TTFT 不超过 500ms,那么在守住这个条件的前提下能跑出的请求速率,才是真正的指标。一边变化 max_num_batched_tokens 一边画出多条这样的曲线,哪个值适合你的服务,一眼就能看出来。

想定位瓶颈的时候

数字不好看又不知道原因时,就该上性能分析器(profiler)了。不过文档先给出了警告:性能分析是给开发者用的,会明显拖慢推理速度,终端用户绝不能开启它。 如果需要低开销,用 Nsight Systems;如果连堆栈和张量形状都需要,就用 PyTorch profiler。

# 给服务器挂上 profiler 再启动(--profiler-config 是 v0.13.0 及以上版本)
vllm serve meta-llama/Llama-3.1-8B-Instruct \
  --profiler-config '{"profiler": "torch", "torch_profiler_dir": "./vllm_profile"}'

# 只截取某个区间来收集
curl -X POST http://localhost:8000/start_profile
# ... 只发送几个请求。trace 会变得非常大 ...
curl -X POST http://localhost:8000/stop_profile

# 也可以配合 benchmark 一起用
vllm bench serve --backend vllm --model ... --profile --num-prompts 2

收集到的 trace 用 Perfetto UI 查看。把请求数控制得少一点很重要。文档提到,在 70B 级别的模型上导出 100 个请求的 trace,在 H100 上大约要花 10 分钟。

真正值得动代码的地方

以上都做完了还是不够的话,那就该看源码了。现实中值得下手的地方一共三处。

1. 自定义 logits processor —— 最安全的位置

推荐它的理由是不用改源码,以插件形式挂上去,不需要 fork vLLM。logits processor 是按 batch 为单位工作的:接收一个形状为"请求数 x 词表大小"的 logits 张量,做变换,再交给 softmax。

继承 vllm.v1.sample.logits_processor.LogitsProcessor,实现五个方法。

# my_pkg/procs.py
import torch
from vllm.config import VllmConfig
from vllm.sampling_params import SamplingParams
from vllm.v1.sample.logits_processor import BatchUpdate, LogitsProcessor


class BanTokenAfterN(LogitsProcessor):
    """输出超过 N 个 token 后禁用某个特定 token(示例)。"""

    @classmethod
    def validate_params(cls, params: SamplingParams):
        # 在入口处提前过滤掉错误的参数。不实现这个的话,
        # 异常值会原样一路流进 kernel。
        v = params.extra_args and params.extra_args.get("ban_after")
        if v is not None and not isinstance(v, int):
            raise ValueError("ban_after must be int")

    def __init__(self, vllm_config: VllmConfig, device: torch.device,
                 is_pin_memory: bool):
        self.device = device
        # batch 索引 -> (禁用的 token、阈值、输出 token 列表的引用)
        self.req: dict[int, tuple[int, int, list[int]]] = {}

    def is_argmax_invariant(self) -> bool:
        # 可能会改变 argmax 对应的 token,所以是 False。
        # 设为 True 的话,只要整个 batch 都是贪心采样,vLLM 就会整个跳过这个 processor。
        return False

    def update_state(self, batch_update: BatchUpdate | None) -> None:
        if batch_update is None:
            return
        # 必须按 removed -> added -> moved 的顺序处理。
        for idx in batch_update.removed:
            self.req.pop(idx, None)
        for idx, params, _prompt_ids, output_ids in batch_update.added:
            self.validate_params(params)
            n = params.extra_args and params.extra_args.get("ban_after")
            if n is None:
                self.req.pop(idx, None)
            else:
                # output_ids 是一个活的列表引用,
                # 所以每个 step 都能看到最新的输出。
                self.req[idx] = (params.extra_args["ban_token"], n, output_ids)
        for a, b, direction in batch_update.moved:
            va, vb = self.req.pop(a, None), self.req.pop(b, None)
            if vb is not None:
                self.req[a] = vb
            if va is not None and direction.name == "SWAP":
                self.req[b] = va

    def apply(self, logits: torch.Tensor) -> torch.Tensor:
        for idx, (tok, n, out_ids) in self.req.items():
            if len(out_ids) >= n:
                logits[idx, tok] = float("-inf")   # in-place 对内存更友好
        return logits

挂载方式有两种:传入完整的类名,或者注册为包的 entrypoint。

vllm serve facebook/opt-125m --logits_processors my_pkg.procs:BanTokenAfterN
# pyproject.toml —— 只要安装好就会自动加载
[project.entry-points."vllm.logits_processors"]
ban_after = "my_pkg.procs:BanTokenAfterN"

有三点需要注意。

  • logits processor 的集合在引擎初始化时就固定了。 无法按请求事后添加。要按请求开关某个 processor,唯一的办法是在 apply 内部根据 SamplingParams.extra_args 来判断处理。
  • is_argmax_invariant() 必须诚实回答。 设为 true 的话,只要整个 batch 都在做贪心采样,就能整体跳过、白得一份加速;但如果一个实际会改变 argmax 的 processor 返回了 true,就会悄无声息地产出错误结果。
  • apply 在每个 step 都会针对整个 batch 执行一次。 用 Python 循环遍历请求数,循环本身就会成为瓶颈。能用张量运算向量化的地方就应该向量化。
  • 文档自己也明确写着,这个 API "design changes are still in progress and the API may change in the near future"。使用时最好先把版本固定下来。

2. 自定义 attention backend —— 价值大,代价也大

vllm/v1/attention/backends/ 目录下是各种 backend,公共接口在 vllm/v1/attention/backend.py。v0.26.0 里包含 flash_attn.pyflashinfer.pytriton_attnflex_attention.py,以及若干 MLA 专用实现。

值得自己动手写的场景很窄:需要标准 attention 的某个变体、现有 backend 里没有、而且这个变体对性能是决定性的。比如有一种领域特有的稀疏 mask,只需要计算完整 attention 的 10%。

代价要老实地看待。一个 backend 要把元数据构建器、CUDA graph 兼容性、prefill 和 decode 两条路径、与 chunked prefill 的交互、与前缀缓存的交互全部对齐,光写一个 kernel远远不够。而且这套接口每次 vLLM 发版都会变。

3. 调度器策略 —— 最后的手段

vllm/v1/core/sched/ 目录下有 scheduler.pyinterface.pyrequest_queue.py。想用领域规则改写请求优先级时(比如付费用户优先、短请求优先),就会碰到这里。

不过要讲究顺序。vLLM 本身已经提供了请求优先级功能,应该先确认能不能用这个功能表达你的需求。改调度器是风险最高的选项。正如前面所见,这段代码同时与 token 预算、抢占、chunked prefill、前缀缓存、推测解码纠缠在一起。

追随上游的代价

这三个位置有一个共同的代价:fork 出来的 vLLM 会自动老化。

vLLM 大约每两周发一次小版本,期间会加入新模型支持、新 kernel、新量化格式、性能改进。fork 放着不管半年,你就用不上最新模型,用不上最新的 attention kernel,还得一直背着那些上游早就修好的 bug。到那时候,rebase 的成本会变成最初那次修改成本的好几倍。

所以代价的排序是这样的。

方式追踪上游的成本什么时候用
只调整配置参数永远优先尝试
插件(logits processor 等)低。只在 API 变化时才有成本大多数定制化需求
向上游提 PR 并被合并需要花评审时间,之后为 0如果是普遍有用的功能,这是最优选
fork 后自己维护 patch高。每次发版都要 rebase真的没有别的办法时

我想着重强调第三行。如果你需要的功能对别人也有用,推给上游就是最便宜的维护策略。合并的那一刻,维护成本就归零了。

结语 —— 没有测量的调优只是品味的表态

本文讲述的顺序,本身就是结论。先消灭抢占日志,用 token 预算权衡 TTFT 和 ITL,不要饿着 CPU,测一测前缀缓存是不是真的划算。做完这些之后,才有必要去理解 PagedAttention 和调度器的行为;理解了这些之后,才知道该把代码改在哪里。

而在这整个顺序的任何一个阶段,都不能跳过"前后对比测量"这一步。如果把 max_num_batched_tokens 从 8192 调到 16384,吞吐量涨了 12%,但 p99 TTFT 翻了一倍,那这到底是改善还是退步,只有你服务的 SLO 能给出答案。没有数字支撑就做这种判断,不是调优,而是品味的表态。

最后一句话。vLLM 里用得最多的优化手法,至今仍然是"把 batch 调大",而大多数团队还没用完这个空间,就急着去打开源码了。 请先确认那六行。

参考资料