如何理解 vLLM 请求调度:负载均衡与资源分配实战指南
【免费下载链接】vllmA high-throughput and memory-efficient inference and serving engine for LLMs项目地址: https://gitcode.com/GitHub_Trending/vl/vllm
vLLM 是一个高吞吐、省内存的大模型推理引擎,它的请求调度子系统直接决定了高并发下的负载均衡与资源分配效果。下面以一个请求的完整旅程为主线,拆解 vLLM 连续批处理原理、分块预填充、KV 缓存管理、抢占机制,并给出 vLLM 调参建议。
为什么请求会"排队":从一个高并发场景说起
想象一个在线客服场景:白天高峰每秒涌进 200 个提问,每个问题几百到几千 token,要求首 token 尽量快出。此时 GPU 面临两道硬约束:
- 每步计算预算有限:一个调度步最多处理固定数量的 token(
max_num_batched_tokens),来多少请求都塞不进去; - KV 缓存容量有限:每个请求生成的每个 token 都要占用 KV 缓存块,显存只够装下有限数量的请求。
请求到达速度 > 处理速度时,排队是必然的。调度的艺术不在于消灭排队,而在于:谁先进批、谁被挪走、谁被重新计算,都要让 GPU 不空转、让重要请求不被饿死。这就是 vLLM 负载均衡的核心职责——把有限的"计算 token 预算"和"KV 缓存块"这两类资源,在源源不断的请求之间做动态分配。
一次请求在 vLLM 里的完整旅程
从等待队列到运行队列
新请求进入引擎后并不立刻被计算,而是先挂在**等待队列(waiting)**上。调度器每个推理步都会做一次决策:先看正在运行的请求能不能继续跑(它们每步各需要 1 个新 token),再把剩下的 token 预算分给等待队列里的新请求。
一个请求被"录取"进当前批次,需要同时满足两个条件:
| 条件 | 含义 | 对应参数 |
|---|---|---|
| 计算预算够 | 本步剩余 token 额度 ≥ 该请求还需预填充的 token 数 | max_num_batched_tokens |
| 内存块够 | KV 缓存空闲块 ≥ 该请求要用的块数(还要扣掉预留水位) | block_size、水印 |
两个条件都满足,请求从等待队列移入运行队列(running),拿到 KV 缓存块,开始预填充;预填充完成后进入逐 token 解码,直到生成结束符或达到长度上限,释放全部资源。这条主路径的代码位于 调度器源码 与 KV 缓存管理器。
WAITING→RUNNING→SWAPPED 状态流转
经典生命周期里出现过SWAPPED状态:V0 引擎会把请求的 KV 缓存换出到主机内存,请求挂起等待换回。V1 引擎把这条路径收敛了——请求状态枚举中只有 WAITING、RUNNING、PREEMPTED 和若干 FINISHED 变体(见 请求状态定义),被抢占的请求直接释放 KV 块、回到等待队列重新排队。也就是说,V1 默认走"重新计算"路线,换出/换入作为可选策略保留在抢占逻辑中。对调度的影响是:V1 的抢占代价更可控,代价是预填充要重算一遍。
连续批处理:让 GPU 不再空等
静态批处理 vs 连续批处理
静态批处理的规则是"一锅端":凑满一批,等最慢的请求做完,整批结束,新请求只能干等下一批。批内请求长短不一时,先完成的请求对应的 GPU 算力就被白白浪费。
连续批处理(Continuous Batching)把"批次"从静态概念变成动态窗口:
| 维度 | 静态批处理 | vLLM 连续批处理 |
|---|---|---|
| 批的边界 | 固定,整批同进同出 | 每个推理步都可变化 |
| 请求完成时机 | 等整批结束 | 谁完成谁立即出批 |
| 新请求进入 | 必须等下一批 | 完成的槽位立即让出 |
| GPU 利用率 | 受最慢请求拖累 | 接近满负荷 |
实现上,调度器每步重新组合批次:完成的请求移出,等待队列里够格的请求补入。这个"批次持久化、成员动态换血"的机制是 vLLM 高吞吐的来源之一。
分块预填充如何避免长请求"霸占"GPU
没有分块时,一个 32k token 的长请求做预填充会独占一个完整的推理步,期间其他请求一个 token 都出不来,首 token 延迟(TTFT)雪上加霜。
分块预填充(Chunked Prefill)的做法是把长预填充切成若干片:每步只处理其中一片(片大小受max_num_batched_tokens约束),剩下的片留在等待队列里排队,与短请求、解码请求交错执行。效果是:
- 长请求不再"一步独吞"预算,短请求的 TTFT 得到保护;
- 预填充(compute-bound)与解码(memory-bound)天然混批,GPU 算力和访存都被利用得更充分;
- 在 V1 引擎中,只要设置了
max_num_batched_tokens,分块预填充就是默认行为,调度器按剩余预算逐请求切分。
调度器如何决定"先处理谁"
优先级与先到先服务
等待队列本身就是一个小调度器。vLLM 提供两种入队/出队策略(见 请求队列实现):
| 策略 | 队列结构 | 出队规则 | 适用场景 |
|---|---|---|---|
| FCFS(默认) | 双端队列 | 严格先到先服务 | 公平性优先的通用服务 |
| PRIORITY | 堆(heapq) | 先比优先级,再比到达时间 | 混合了 VIP 与普通流量的场景 |
优先级策略下,请求自带一个priority数值(数值越小越优先),同优先级内仍按到达时间排序,保证公平底线:
# 优先级队列的排序键(伪代码) order_key = (priority, arrival_time) # priority 小者优先,同级按到达先后注意优先级只影响等待队列的出队顺序,不会跳过 KV 缓存的容量检查——再高优先级的请求,块不够也进不了批。
多队列负载均衡
调度器同时维护三条流水线,每个推理步按固定顺序做"负载均衡":
- 运行队列:优先保障已入批请求每步 1 token 的解码需求,保证已开始的请求尽快完成;
- 等待队列:在剩余 token 预算内,按策略出队新请求做(分块)预填充;
- 被抢占/换出请求:资源宽裕时回收,重新进入等待队列。
这种"先保存量、再收增量"的顺序是多队列负载均衡的关键:它让解码延迟(ITL)稳定,同时让新请求尽快上手。若某步预算不够,运行队列中的请求会被从后往前选出"替罪羊"抢占,把资源让给等待队列里的请求——这是调度器内置的动态再平衡手段。
KV 缓存与内存管理
块式分配与块大小权衡
vLLM 的 KV 缓存不连续分配,而是切成固定大小的"块"(默认 16 个 token/块),按需分配,类似操作系统的分页内存——这正是 PagedAttention 的思想,详见 Paged Attention 设计文档。
块大小是一个典型的"精度 vs 开销"权衡:
| 块大小 | 优点 | 代价 |
|---|---|---|
| 小(如 16) | 内存碎片少,多请求混排更灵活 | 块数多,管理元数据与注意力 kernel 的间接开销略高 |
| 大(如 32/64) | 管理开销小 | 每请求尾部浪费最多一个块,显存利用率下降 |
块大小在 缓存配置 中指定,通常保持默认即可;只有在显存极度紧张、想榨出几个百分点利用率时才值得实验。
水印机制:避免频繁换页
"空闲块刚好够新请求,但请求跑起来又要占块"——这种临界状态会引发连环抢占。为此 KV 缓存管理器引入水印(watermark):为等待/被抢占请求的准入额外预留watermark × 总块数个空闲块作为安全垫。
准入判断可以简化为:
可用块 = 空闲块 − 已运行请求的预留块 准入条件:新请求需要块 + 水印块 ≤ 可用块V1 实现中水印默认为 0(即不额外预留,见 KV 缓存管理器 中的watermark_blocks计算)。当观察到"刚放进一个请求就触发抢占"的锯齿状行为时,把水印调成 0.01~0.05 这类小值,用一点容量换稳定,往往划算。
Swap 与抢占:内存告急的两套方案
显存不够时,调度器必须挑请求"腾地方",有两种腾法:
| 方案 | 做法 | 恢复成本 | 额外开销 |
|---|---|---|---|
| Swap(换出) | 把请求 KV 缓存搬回主机内存,资源宽裕时换回继续 | 一次 PCIe 拷贝 | 占用主机内存,长上下文时拷贝量大 |
| Recompute(重算) | 直接释放该请求的 KV 块,请求回到等待队列 | 重新做一遍预填充 | 浪费算力,但实现简单、内存零占用 |
选择建议:上下文短(几千 token 内)时重算比拷贝更快,V1 引擎默认也走重算;上下文极长且主机内存充裕时,换出能保住已经算好的结果。被抢占多少次、是否被反复"踢出",都可以从指标中追踪(num_preemptions计数器记录在请求对象上)。
前缀缓存与 LRU 驱逐
大量请求共享相同前缀(系统提示词、Few-shot 样例、多轮对话上文)时,逐块重算是纯浪费。前缀缓存(Automatic Prefix Caching)按块对内容做哈希,内容相同的块只算一次、被引用计数共享,命中时预填充直接跳过这部分 token,功能说明见 前缀缓存文档。
配套的回收策略是LRU 驱逐:空闲块池中记录每块最后被引用的时间,需要腾块时优先淘汰最久没被访问的块。LRU 的直觉很直接——"最近被用过"的块更可能再次命中,所以最后才轮到它。命中率与驱逐行为可通过PrefixCacheStats(见 KV 缓存指标)观测。
面向场景的调参实践
核心参数分两类,vLLM 调参先分清"调度类"还是"缓存类":
| 分组 | 参数 | 作用 | 默认量级 |
|---|---|---|---|
| 调度类 | max_num_batched_tokens | 每步 token 预算上限,控制吞吐/延迟平衡 | 数千(随引擎版本) |
| 调度类 | max_num_seqs | 单批最大并发请求数 | 数百 |
| 调度类 | scheduling_policy | fcfs/priority | fcfs |
| 缓存类 | block_size | KV 缓存块大小 | 16 |
| 缓存类 | watermark | 准入预留比例 | 0 |
| 缓存类 | enable_prefix_caching | 是否开启前缀缓存 | 视场景 |
参数定义与校验逻辑见 调度器配置 与 引擎参数文档。
高并发短请求
目标是吞吐最大化、TTFT 稳定:
| 参数 | 方向 | 理由 |
|---|---|---|
max_num_batched_tokens | 调大 | 短请求预填充便宜,预算放大可混入更多请求 |
max_num_seqs | 调大 | 并发上限放宽,减少批内"排队空位" |
enable_prefix_caching | 开 | 系统提示词共享收益大 |
| 抢占模式 | 重算 | 上下文短,重算比换出更快 |
watermark | 观察锯齿后设 0.01~0.05 | 防止临界准入引发连环抢占 |
长序列生成
目标是保长上下文可用、控好显存水位:
| 参数 | 方向 | 理由 |
|---|---|---|
max_num_seqs | 调小 | 长序列单请求吃块多,限并发防 OOM |
max_num_batched_tokens | 适中偏小 | 给分块预填充留余地,避免单步被一个长请求吃光 |
watermark | 适当抬高 | 长请求准入波动大,安全垫更值 |
| 抢占模式 | 换出(主机内存充裕时) | 重算 32k 上下文的代价远高于拷贝 |
| 滑动窗口(模型支持时) | 启用 | 直接压缩 KV 占用 |
监控与持续优化
调参前先看数据。vLLM 内置 Prometheus 指标导出,覆盖请求延迟(TTFT / ITL / E2E)、批大小、KV 缓存使用率、前缀缓存命中率、抢占次数等,使用方式见 监控指标文档,GPU 侧细粒度瓶颈可用 profiling 文档 中的方法定位。
持续优化建议盯住三个信号:
- 抢占次数持续增长→ 容量不足,优先加显存/降并发,其次再调
watermark; - 前缀缓存命中率偏低→ 检查请求前缀是否真的稳定,或块大小是否导致哈希边界错位;
- TTFT 长尾明显→ 大概率长请求预填充干扰,确认分块预填充生效并下调
max_num_batched_tokens。
结语
vLLM 的请求调度可以浓缩为一句话:在每步固定的 token 预算和有限的 KV 缓存块之间,为源源不断的请求做动态、公平且可抢占的分配。连续批处理让 GPU 不空等,分块预填充让长请求不霸占,优先级队列让重要请求不饿死,水印与 LRU 让内存告急时系统抖得尽量小。理解了这条主线,再回头看max_num_batched_tokens、block_size、watermark这些参数,它们就不再是玄学旋钮,而是这条主线上的具体阀门。
【免费下载链接】vllmA high-throughput and memory-efficient inference and serving engine for LLMs项目地址: https://gitcode.com/GitHub_Trending/vl/vllm
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考