vLLM 请求调度拆解:一条请求的 5 个生死关卡
【免费下载链接】vllmA high-throughput and memory-efficient inference and serving engine for LLMs项目地址: https://gitcode.com/GitHub_Trending/vl/vllm
vLLM 的请求调度是引擎里最容易被低估、上线后最容易被骂的模块。它要同时伺候两个互斥的目标:吞吐量——单位时间吐出多少 token;延迟——每个请求等多久拿到下一个 token。想把 batch 堆大,单请求就变慢;想把单请求喂饱,吞吐就塌下来。vLLM 的取舍逻辑很直白:以「单次 forward」为调度单位,每一步在 token 预算内重新组批,谁都不能长期霸占 GPU。下面跟着一条请求,走完它在调度器里经历的五个生死关卡:入队 → 组批 → 分块预填充 → 抢 KV 块 → 被抢占后重算。
一个 token 预算,撑起连续批处理
先给结论:vLLM 的调度粒度是 token,不是请求。
调度器在每个 forward 之前调用一次schedule(),产出物本质上是一张表:{请求ID: 这一步要处理的 token 数}。新请求可能是几百个 prompt token,生成中的请求就是 1,分块预填充中可能落在中间。骨架大致长这样:
class Scheduler: self.waiting = create_request_queue(policy) # 新请求 + 被抢占的请求 self.running = [] # 正在 GPU 上的请求 def schedule(self): token_budget = self.max_num_batched_tokens # 1. 先服务 running:每个请求本步 +1 token,扣预算 # 2. 预算还剩,就从 waiting 按策略拉新请求进来 # 3. 拉不进的留在队列,输出本步 {req: num_tokens}这就是连续批处理的全部秘密——不等整批跑完,新请求随时插进正在跑的批次;某个请求生成完最后一个 token,它的坑位下一步就能被别人顶掉。对比静态批处理:整批一起进、整批一起出,最先完成的那个请求也得陪着最慢的等。GPU 在等的人身上空转,连续批处理把这堆空转填满了。
vLLM 的schedule()里,running 永远排在 waiting 前面。这是第一层资源隔离:已进入解码的请求每个 step 都能领到 1 个新 token,新来的 prefill 只花预算的剩余部分。预填充是计算密集型,解码是访存密集型,两者混在一个 batch 里,计算空隙被访存摊掉,GPU 利用率才稳得住。
长请求不拖垮短请求:分块预填充
一个 3 万 token 的长 prompt 进来,如果必须一口气 prefill 完,这一步的延迟就是别人的十倍,后面排队的短请求集体陪葬。分块预填充(chunked prefill)把这个长任务切成 N 片,每片只占一个调度 step 的一部分 token 预算,跨多个 step 消化完。
它的代价是首 token 延迟(TTFT)变长——prompt 要分好几步才处理完。收益是单步延迟上界被max_num_batched_tokens锁死,任何单个请求都无法击穿。所以这个参数是延迟与吞吐之间的总阀门:调大,一步吃更多,吞吐上去了,但步时变长,短请求的 ITL(token 间隔)跟着抖;调小,反之。默认 2048 只是测试兜底值,真实部署应该按硬件标定。
vLLM 还留了一个long_prefill_token_threshold(默认 0,即关闭),给长 prompt 打标记,避免多个超长请求的 chunk 同时挤进同一个 step 把预算吃满。另外值得看一眼max_num_queued_reqs和max_num_queued_tokens:这是 API server 侧的容量闸门,超了直接 503 拒绝,把压力挡在引擎外,而不是让队列无限积压。
组好批只是拿到了入场券,下一步要付钱——显存里的 KV 块。钱不够的时候会发生什么?
GPU 快不够用时:块、水位线与抢占
KV 缓存是 LLM 推理里最大的显存开销,vLLM 的管理单位是块:整块显存切成固定block_size个 token 一格,请求按 token 数领块,每多一个 token,多领的格子数就是ceil(token数 / block_size)。
块写满之后,vLLM 会把它登记进前缀缓存的哈希表(默认开启):键是块内 token 的哈希,值是块 ID。之后任何请求只要前缀的哈希撞上了,直接复用已有块,对应那部分 prompt 一步不用算。共享系统提示词、多轮对话、Agent 循环场景,命中率可以非常高。
新请求(以及被抢回来的请求)准入时要过一道检查,核心是水位线(watermark)——给等待队列预留的缓冲,防止"放进来一个新请求,下一步就被迫抢占一个老请求"的抖动:
# 只对 WAITING / PREEMPTED 状态、且已有请求在跑的请求生效 watermark_blocks = int(watermark * total_blocks) if num_blocks_to_allocate + watermark_blocks > num_free_blocks: return None # 本步不放进来,等下一轮👉 注意这条规则只拦「新面孔」。正在解码的请求追加块时不吃水位线,所以水位线不是全局配额,只是准入缓冲。
块真的耗尽时,vLLM 会抢占。这里它做了一个相当激进的选择:v1 架构干脆移除了 v0 时代的 GPU↔CPU 交换,只剩重算这一条路。选谁当受害者有讲究——FCFS 下踢 running 列表里最后一个(最晚进入的),priority 模式下按优先级挑;被抢的请求num_preemptions += 1,块全部释放,然后插回 waiting 队首,从头重算。
为什么不做交换?把整段 KV 经 PCIe 搬到 CPU 再搬回来,带宽成本经常高于直接重算一遍,何况还要占主机内存。代价是前缀缓存命中的部分也要重算——所以缓存命中率直接影响抢占的实际痛感。块被释放后回到空闲队列,前缀缓存的驱逐走 LRU:最久没被复用的块先被顶掉腾地方,缓存命中率和重算开销就在这儿平衡。
调优与监控:两个场景的参数基线
上面所有行为都由下面这张表里的几个旋钮决定。
| 参数 | 作用 | 默认值 | 调大或调小的影响 |
|---|---|---|---|
max_num_batched_tokens | 单个调度 step 的 token 总预算 | 2048(真实值在启动时按硬件重算) | 调大:吞吐升、TTFT 降,但单步时延变长、decode ITL 抖动;调小:延迟平稳、吞吐掉 |
max_num_seqs | 同时 running 的请求上限 | 128 | 调大:并发上限升,单请求分到的算力稀释;调小:长输出请求的 TTFT 更稳 |
gpu_memory_utilization | 分配给权重+KV 的显存占比 | 0.9 | 决定 KV 块总数,调大意味着块更多、更少抢占,但离 OOM 更近 |
block_size | 每块 token 数 | 16 | 调大:管理开销降、前缀缓存粒度变粗(浪费尾部);调小:分配细、命中灵活,块表更大 |
long_prefill_token_threshold | 多长的 prompt 算"长请求" | 0(关闭) | 调大:更多请求被当长请求限流;调小:更严格地压住长 prefill 的并发 |
policy | 等待队列策略 | fcfs | 换成 priority 后按优先级值排序(小者先),同级再按到达时间;无 SLA 分层需求别开 |
max_num_queued_reqs | 引擎内排队请求上限 | None(不限) | 调小:超载时直接 503 挡在门外;调大:队列积压变深,TTFT 恶化更慢但更久 |
enable_prefix_caching | 前缀缓存复用 | True | 关掉则每次全量重算,只建议在对延迟抖动极敏感且无重复前缀时考虑 |
场景 A:高并发短请求(客服问答、分类打标)
# 目标:吞吐拉满,同时别让 TTFT 被长 prompt 击穿 max_num_batched_tokens = 16384 # 一步吃更多 max_num_seqs = 256 max_num_queued_reqs = 512 # 排队超深直接 503,保 SLA policy = "priority" # 高优业务先走 enable_prefix_caching = True场景 B:长序列生成(长文档问答、Agent 长上下文)
# 目标:显存扛得住长 KV,步时别被单条长请求拖垮 max_num_batched_tokens = 4096 # 压低单步预算,保住 ITL max_num_seqs = 64 gpu_memory_utilization = 0.92 enable_prefix_caching = True # 系统提示词 + 历史轮次全靠它省重算 long_prefill_token_threshold = 8192 # 长 prompt 限流,防 chunk 挤爆单步上生产后值得盯的指标:运行中请求数与等待队列深度(调度是否饱和)、KV cache 使用率(离耗尽多近)、前缀缓存命中率(复用在不在省算力)、抢占次数(👉 它持续上涨就是块不够的信号,优先查gpu_memory_utilization和块数,其次才是并发量)、TTFT 与 ITL 分位数。
落地清单
- 前缀缓存默认开着,先确认没被关掉;共享系统提示词的服务这是最大的一笔免费算力。
max_num_batched_tokens按硬件实测标定,别用默认值裸奔;它是延迟与吞吐的总阀门。- 有延迟 SLA 就设
max_num_queued_reqs/max_num_queued_tokens,超载时 503 比积压好。 - 抢占次数、等待队列深度进告警——它们涨起来的时候,调参窗口往往只剩几分钟。
- 长短请求混跑严重的服务,试试
long_prefill_token_threshold,再考虑 priority 队列。
最后一个问题留给读者:v1 用"重算"彻底换掉了"交换",赌的是重算比 PCIe 往返便宜。这个赌注在你的互联带宽和你的模型尺寸下还成立吗?跨机 prefill/decode 分离把 KV 搬上了网络之后,抢占的成本函数已经不是 v1 写下的那个样子了。
【免费下载链接】vllmA high-throughput and memory-efficient inference and serving engine for LLMs项目地址: https://gitcode.com/GitHub_Trending/vl/vllm
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考