
动态批处理中的抢占与优先级调度Chunked Prefill 消除解码长尾延迟在大模型推理系统迈向万级高并发生产环境的过程中连续批处理Continuous Batching已经成为事实上的工业标准。许多工程团队在部署 vLLM 或 SGLang 时发现即便开启了连续批处理系统的长尾延迟P99 Time Per Output Token, TPOT依然会在流量高峰期出现不可预测的剧烈尖刺。深入分析请求调度流水线可以发现这种延迟毛刺的核心诱因在于计算密集型的首字填充阶段Prefill与访存密集型的逐字解码阶段Decode在同一张 GPU 上无差别混部争抢。当一个包含数千 Token 提示词的新请求插队进入批处理队列时庞大的 GEMM 算子会瞬间霸占所有流式多处理器SM导致正在执行 Decode 的数十个存量请求全部被强行挂起数十毫秒。解决这种资源争抢并实现强 SLA 隔离必须在调度器内部建立精细化的抢占机制Preemption并引入分块预填充Chunked Prefill。Prefill 算力霸占与抢占策略演进在经典的连续批处理迭代中调度器在每一个 Iteration 边界检查显存容量。当显存耗尽无法为正在生成的序列分配新的 KV Block 时系统必须做出抉择是直接拒绝新请求还是抢占Preempt存量低优先级请求换出Swap与重算Recompute的权衡传统的抢占实现主要有两种分支Swap 换出到 CPU 内存将牺牲者Victim已生成的全部 KV Cache 通过 PCIe 总线异步拷贝至宿主机内存腾出 GPU 显存。当显存空闲时再反向换入Swap-in。在 PCIe 4.0/5.0 带宽受限的物理服务器上数百 MB 的 KV 块搬移往往消耗数毫秒且极易引发 PCIe 争用导致 GPU 算子等待。Recompute 丢弃重算直接释放被抢占请求的部分或全部 KV 块仅保留其原始 Prompt 与已生成的输出 Token 记录。等到后续调度周期恢复时将其视作一个新的 Prefill 请求重新跑一遍前向传播。在真实集群中对于生成长度较短的会话重算开销由于高度契合 GPU Tensor Core 的并行吞吐其实际恢复耗时远低于通过 PCIe 换入换出。因此现代调度器更倾向于混合策略对生成步数小于阈值的请求执行 Recompute对长序列执行 Swap。然而无论 Swap 还是 Recompute都属于显存溢出时的“被动急救”。真正引发高频 TPOT 抖动的是 Prefill 巨量计算对 GPU SM 硬件管线的瞬间霸凌。Chunked Prefill 的核心算法思想为了从物理层面平抑 Prefill 对 Decode 的延迟冲击业界在推理引擎中引入了 Chunked Prefill分块预填充与 Piggyback 机制。传统的 Prefill 要求一次性将整个 Prompt例如 4096 个 Token送入注意力算子完成自注意力计算。而在分块调度下调度器会设定一个单次迭代的预算上限max_num_batched_tokens例如 512。当一个 4096 Token 的请求到达时调度器将其切分为 8 个连续的 Chunk每个 Chunk 仅包含 512 Token在第 1 个 Iteration该请求处理 Chunk 0Token 0~511生成的 KV 存入显存但不触发生成同批次中的存量 Decode 请求正常生成 1 个 Token在第 2 个 Iteration处理 Chunk 1Token 512~1023并利用 FlashAttention 的增量机制与 Chunk 0 建立跨块注意力直到第 8 个 Iteration全部 Prompt 分块预填充完成该请求正式产生第一个输出 TokenTTFT 完成随后无缝流转至正常的 Decode 阶段。传统调度 Iteration N: [------ Prefill 4096 Tokens (耗时 85ms) ------] (所有 Decode 全部被阻塞) Iteration N1: [Decode 1][Decode 2]... (恢复解码) Chunked Prefill 调度 Iteration 1: [Chunk 512] [Decode 1]...[Decode 32] (耗时 8ms) Iteration 2: [Chunk 512] [Decode 1]...[Decode 32] (耗时 8ms) ... Iteration 8: [Chunk 512] [Decode 1]...[Decode 32] (耗时 8ms) - 产出首字通过将粗粒度的巨型 GEMM 算子打碎为若干均匀的小块Decode 请求在每一次 Iteration 都能稳定拿到时间片从而彻底抹平了长尾延迟毛刺。生产级分块调度器核心逻辑实现下面基于 Python 演示推理引擎调度器如何协调分块预填充与动态批处理迭代预算from dataclasses import dataclass, field from enum import Enum from typing import List, Optional class RequestStatus(Enum): WAITING 1 PREFILLING 2 DECODING 3 FINISHED 4 dataclass class Request: req_id: str prompt_tokens: List[int] priority: int 0 status: RequestStatus RequestStatus.WAITING chunk_offset: int 0 generated_tokens: List[int] field(default_factorylist) property def remaining_prompt_len(self) - int: return len(self.prompt_tokens) - self.chunk_offset class ChunkedScheduler: def __init__(self, max_batched_tokens: int 512): self.max_batched_tokens max_batched_tokens self.waiting_queue: List[Request] [] self.running_queue: List[Request] [] def add_request(self, req: Request): self.waiting_queue.append(req) # 按照优先级降序排列 self.waiting_queue.sort(keylambda x: x.priority, reverseTrue) def schedule_next_iteration(self): scheduled_decodes: List[Request] [] scheduled_chunks: List[tuple[Request, int]] [] token_budget self.max_batched_tokens # 保证存量 Decode 请求的绝对平稳推进每一个 Decode 请求占用 1 个 Token 预算 for req in self.running_queue: if req.status RequestStatus.DECODING: if token_budget 1: scheduled_decodes.append(req) token_budget - 1 # 在剩余的预算配额中调度 Prefill 分块 # 正在分块处理中的请求 for req in self.running_queue: if req.status RequestStatus.PREFILLING: if token_budget 0: break chunk_size min(req.remaining_prompt_len, token_budget) scheduled_chunks.append((req, chunk_size)) token_budget - chunk_size # 若依然有富余预算从等待队列拉取新请求 while self.waiting_queue and token_budget 0: req self.waiting_queue.pop(0) req.status RequestStatus.PREFILLING self.running_queue.append(req) chunk_size min(req.remaining_prompt_len, token_budget) scheduled_chunks.append((req, chunk_size)) token_budget - chunk_size return scheduled_decodes, scheduled_chunks def step_forward(self, scheduled_decodes, scheduled_chunks): # 推进 Decode 步长 for req in scheduled_decodes: req.generated_tokens.append(101) # 模拟采样产出 Token if len(req.generated_tokens) 50: # 模拟生成完毕 req.status RequestStatus.FINISHED self.running_queue.remove(req) # 推进 Prefill 分块步长 for req, chunk_len in scheduled_chunks: req.chunk_offset chunk_len if req.remaining_prompt_len 0: # 分块全部处理完成正式流转至 Decode req.status RequestStatus.DECODING生产落地压测数据表现在 8 卡 H80080GB集群上部署 DeepSeek 67B 模型以真实线上多轮会话流量进行 30 分钟连续回放测试。测试将并发请求数维持在 120对比开启与关闭 Chunked Prefill 的系统指标调度策略配置整体 Throughput (Token/s)平均首字延迟 TTFTP99 逐字解码延迟 TPOT最大解码停顿 (Jitter)标准连续批处理 (无分块)2840185ms48.6ms115.2msChunked Prefill (512 块预算)2910210ms12.3ms16.8msChunked Prefill (256 块预算)2750245ms9.8ms12.1ms实测数据表明将分块预算收敛至 512 时系统的 P99 逐字生成延迟从 48.6ms 骤降至 12.3ms解码最大抖动由超过 100ms 的明显卡顿被彻底抹平至 16.8ms。虽然大提示词的首字生成延迟TTFT因为切块计算略有微量上升从 185ms 变为 210ms但换来的是整个大模型交互生成过程中如丝般顺滑的流式打字机体验。