正在获取所在地区…
正在读取天气
音乐
暂未播放
SGLang 源码完全拆解(二十七):调度策略——FCFS、LPM、DFS 权重与 HRRN,一张等待队列的排序账
写在前面
前一篇(二十六)把 MoE 算子从模型层到后端分派的那条路径拆完了,文末提到下一步要回到调度器,看一个更靠前、更容易被忽略的问题:同一个 waiting queue 里明明躺着几十条请求,调度器凭什么让其中某几条先跑?
这个问题的答案在 SGLang 里集中在一个类里——sglang/srt/managers/schedule_policy.py 的 SchedulePolicy。它的接口小到只有几个排序函数,但每一个排序函数背后都对应一个调度理念:从最简单的先来先服务,到为 RadixAttention 前缀缓存量身定制的 LPM,再到 DFS 权重与 token 计的 HRRN,以及在生产环境中大量使用、却少有人细看的”同批前缀去重”和”队列过长自动降级”。
这一篇专门讲这一层。它不是 PrefillAdder 的预算账(第四篇已经拆过),也不是 Scheduler 的状态机(第三篇拆过),而是 预算算完之前、每轮开始时对等待队列做的第一次重排。把这层讲透之后,再看 Scheduler._get_new_batch_prefill_raw 里那段”for req in self.waiting_queue”的循环,次序就不再是黑盒。
文中所有代码都来自 SGLang 主分支当前版本,行号以 schedule_policy.py 与 scheduler.py 为准;涉及 DP 延迟器时引 prefill_delayer.py。
排序发生在哪里:一轮调度的三段
先把位置摆准。SGLang 单机 TP 模式下,每跑完一次前向,Scheduler.event_loop 会调用 get_next_batch_to_run(scheduler.py:4147),它大致做三件事:
- 合并与过滤:把上一轮 prefill batch 里的请求并进 running batch,剔除已完成的请求。若上一轮留了
chunked_req,先把它从 batch 里摘出来,以免它在合并时被当成已完成请求清掉。 - 决定这一轮跑谁:若
_should_defer_prefill(running_batch)为真(比如 prefill 延迟器在起作用),new_batch置空,这一轮只跑 decode;否则调用get_new_batch_prefill尝试装一个新的 prefill batch。只要有新 batch,这一轮就 prefill 优先;没有才去update_running_batch跑 decode。 - DP/MoE 适配:
maybe_prepare_mlp_sync_batch、maybe_convert_decode_to_extend等收尾。
排序发生在第 2 步的内部,_get_new_batch_prefill_raw(scheduler.py:4334)里:
1if (running_batch.batch_is_full or len(self.waiting_queue) == 0) and self.chunked_req is None:2 return None, running_batch3...4# Get priority queue5self.policy.calc_priority(6 self.waiting_queue,7 running_batch,8 processed_tokens=self.processed_tokens_counter,9 bootstrap_queue=(10 self.disagg_prefill_bootstrap_queue.queue11 if self.disagg_prefill_bootstrap_queue is not None12 else []13 ),14)15
16adder = self.get_prefill_adder_class()(...)17if self.chunked_req is not None:18 self.chunked_req.init_next_round_input()19 adder.chunked_req_limit = self.policy.shortest_prefill_chunk_limit(...)20 self.chunked_req = adder.add_chunked_req(self.chunked_req)21...22for req in self.waiting_queue:23 ...24 req.init_next_round_input(self.tree_cache)25 res = adder.add_one_req(...)26 if res != AddReqResult.CONTINUE:27 ...28 break注意三件事。第一,排序发生在装批之前,只对 waiting queue 生效。running batch 里的请求这一轮一定会跑(除非被 retract),排序动不了它们。第二,排序之后才切 chunked request、才创建 PrefillAdder。这意味着 LPM、DFS 这类需要访问 tree_cache 的策略,此时读到的缓存树是这一轮前向之前的快照,不会被本轮即将写入的新 KV 污染。第三,排序只是一次置换,不保证排在前面的请求一定被接纳。add_one_req 仍可能因为 KV 预算或计算预算拒绝它,此时循环 break,后面的请求根本不会被考察——这是”排序决定命运”的真正原因。
策略表:缓存无关与缓存感知
SchedulePolicy 把策略分成两组(schedule_policy.py:164):
1class CacheAwarePolicy(Enum):2 LPM = "lpm" # longest prefix match3 DFS_WEIGHT = "dfs-weight" # depth-first search weighting4 HRRN = "hrrn" # highest response ratio next, token-based aging5 SHORTEST_PREFILL_FIRST = "shortest-prefill-first"6
7class CacheAgnosticPolicy(Enum):8 FCFS = "fcfs"9 LOF = "lof" # longest output first10 RANDOM = "random"11 ROUTING_KEY = "routing-key" # prioritize by routing key frequency in running batch| 策略 | 排序键 | 需要访问前缀树 | 主要目标 | 典型代价 |
|---|---|---|---|---|
fcfs | 优先级,再按 wait_queue_entry_time | 否 | 公平、易推理 | 长提示堵队头 |
lof | -max_new_tokens,可叠加优先级 | 否 | 让长输出先起步 | 短请求 TTFT 变差 |
random | 随机打散 | 否 | 测试、消融 | 无 SLO 保证 |
routing-key | 与 running batch 相同 routing key 的数量,降序 | 否 | 同一租户/前缀在同一 DP rank 上聚拢 | 依赖客户端传 key |
lpm | num_matched_prefix_tokens,降序 | 是 | 最大化前缀命中 | 冷请求可能长期等待 |
dfs-weight | Radix tree DFS 顺序(子树权重优先) | 是 | 让同分支请求连续跑 | 依赖树形态 |
hrrn | 等待 token / 未缓存 token,降序 | 是(用命中长度) | 兼顾短作业与防饥饿 | 需要有可靠工作量估计 |
shortest-prefill-first | 未缓存 prefill 工作量,升序 | 是(用命中长度) | 最小化短提示的 TTFT | 长提示需额外防饥饿 |
命令行上对外的名字在官方文档里写作 fcfs / lpm / dfs-weight / lof / priority / routing-key(见参考资料里的 Server Arguments)。其中 priority 并不是枚举成员,而是 fcfs 叠加 --enable-priority-scheduling 之后的实际行为;hrrn 与 shortest-prefill-first 是较新的补充,文档表格里不一定列出。
构造时的合法性洗牌
SchedulePolicy.__init__ 第一件事是调 _validate_and_adjust_policy(schedule_policy.py:330):
1def _validate_and_adjust_policy(self, policy: str, tree_cache: BasePrefixCache) -> Policy:2 try:3 policy_enum = CacheAwarePolicy(policy)4 if getattr(tree_cache, "disable", True):5 if policy_enum == CacheAwarePolicy.SHORTEST_PREFILL_FIRST:6 raise ValueError(7 "Experimental shortest-prefill-first requires prefix caching"8 )9 # If tree_cache is disabled, using CacheAgnosticPolicy policy10 return CacheAgnosticPolicy.FCFS11 return policy_enum12 except ValueError:13 try:14 return CacheAgnosticPolicy(policy)15 except ValueError:16 raise ValueError(f"Unknown schedule_policy: {policy=}")这里有两个行为值得记住。其一,缓存感知策略在禁用前缀缓存时被静默降级为 FCFS。启动参数里写了 --schedule-policy lpm,但同时出现 --disable-radix-cache,最终生效的是 FCFS,日志里也不会给强提示。排障时若发现策略”没起作用”,先确认前缀缓存有没有被关掉或因为模型结构(含 positional_embed_overrides 的请求)而局部失效。其二,shortest-prefill-first 是唯一不允许静默降级的缓存感知策略——没有前缀缓存时,它计算未缓存工作量的依据就不存在了,索性报错,避免”看似生效、实则按全量算”的假象。
FCFS 与显式优先级
最朴素的路径在 calc_priority 开头(schedule_policy.py:205):
1policy = self._determine_active_policy(waiting_queue)2
3# Cache-agnostic policies sort without matching; match anyway so the load4# snapshot sees num_matched_prefix_tokens. Decode never prefills.5if (6 not isinstance(policy, CacheAwarePolicy)7 and self.tree_cache.supports_fast_match_prefix()8 and get_disagg().disaggregation_mode != "decode"9):10 for r in waiting_queue:11 match_kv_cache(r, self.tree_cache)12
13if self.policy == CacheAgnosticPolicy.FCFS:14 if self.enable_priority_scheduling:15 SchedulePolicy._sort_by_priority_and_fcfs(waiting_queue, self.priority_sign)16elif isinstance(policy, CacheAwarePolicy):17 ...无缓存策略本该不需要匹配前缀,但代码仍会做一次 match_kv_cache。原因写在注释里:让负载快照看见 num_matched_prefix_tokens。DP Attention 下负载均衡器会把每个 rank 的”待处理 token 数(扣掉命中部分)“当作负载指标上报,如果 FCFS 阶段不填这个字段,负载快照会把所有等待请求当成全量计算,导致路由把请求推向错误的 rank。这一段不改变排序,只补齐账本——和第十九篇 / GLM 系列讲的负载账本是一回事。
_determine_active_policy 是一个易被忽视的降级点:
1def _determine_active_policy(self, waiting_queue: List[Req]) -> Policy:2 if (3 self.policy in (CacheAwarePolicy.LPM, CacheAwarePolicy.HRRN)4 and len(waiting_queue) > WAITING_QUEUE_PREFIX_MATCH_MAX5 ):6 # Turn off the expensive prefix matching and sorting when the #queue is large.7 return CacheAgnosticPolicy.FCFS8 return self.policyWAITING_QUEUE_PREFIX_MATCH_MAX = 128(schedule_policy.py:129)。也就是说,LPM 和 HRRN 在队列超过 128 条时自动退化为 FCFS,其余缓存感知策略(dfs-weight、shortest-prefill-first)不降级。为什么只降这两个?因为它们对每条请求都要做一次前缀匹配,成本是 O(队列长度 × 匹配开销);dfs-weight 只需要拿到每条请求的 last_node 然后做一次 DFS,shortest-prefill-first 只用命中长度做算术。在高压场景里,每轮每请求一次前缀匹配会成为 CPU 侧瓶颈——解码请求本就等着 GPU 回包,若调度器把几十毫秒花在匹配上,GPU 会出现空槽。这个 128 是经验阈值,不是可配置项;若要调,只能改源码或重编译。
FCFS 叠加优先级的排序是(schedule_policy.py:528):
1@staticmethod2def _sort_by_priority_and_fcfs(waiting_queue: List[Req], priority_sign: int) -> None:3 waiting_queue.sort(4 key=lambda x: (5 x.priority * priority_sign,6 x.time_stats.wait_queue_entry_time,7 )8 )priority_sign 在构造时确定:默认 -1 表示数值大的先跑,--schedule-low-priority-values-first 时为 1 表示数值小的先跑。优先级调度的完整语义散落在 Scheduler._set_or_validate_priority(scheduler.py:3738):未传 priority 的请求在优先级调度开启时会被赋值为 sys.maxsize(低值优先模式)或 -sys.maxsize-1(高值优先模式),从而排在所有显式设置优先级的请求之后;若优先级调度关闭而请求仍传了 priority,--abort-on-priority-when-disabled 会直接以 503 拒绝,否则静默忽略。这一层与排序键是两套代码,配置时需要同时看:
1# 高值优先 + 默认未传优先级请求排在最后2python -m sglang.launch_server --model-path <model> --enable-priority-scheduling3
4# 低值优先(0 最优先),适合把 SLO 等级映射成小整数5python -m sglang.launch_server --model-path <model> \6 --enable-priority-scheduling --schedule-low-priority-values-firstLPM:最长匹配前缀优先
LPM 是最贴合 RadixAttention 的排序。它的排序键在 _sort_by_longest_prefix(schedule_policy.py:443):
1@staticmethod2def _sort_by_longest_prefix(3 waiting_queue: List[Req], temporary_deprioritized: Set[int]4) -> None:5 waiting_queue.sort(6 key=lambda r: (7 -r.num_matched_prefix_tokens8 if r.rid not in temporary_deprioritized9 else float("inf")10 )11 )命中长度降序,被”临时降级”的请求放到队尾(float("inf") 取负后是最小值,因此排在最后)。命中长度从哪来?由 _compute_prefix_matches 在排序前统一填充:
1def _compute_prefix_matches(2 self, waiting_queue: List[Req], policy: CacheAwarePolicy3) -> Set[int]:4 temporary_deprioritized: Set[int] = set()5 self.waiting_queue_radix_tree.reset()6
7 for r in waiting_queue:8 prefix_ids = r.origin_input_ids + r.output_ids9 extra_key = r.extra_key10 cache_salt = r.cache_salt11 match_result = match_kv_cache(r, self.tree_cache, prefix_ids)12
13 # NOTE(sang): This logic is for in-batch prefix caching;14 # If there are more than 1 request that have small matching prefix from15 # existing cache, but all those requests share the same prefix, we prefer16 # to schedule only one of them so that we can increase the cache hit rate.17 if r.prefix_len <= IN_BATCH_PREFIX_CACHING_CHECK_THRESHOLD:18 match_result = self.waiting_queue_radix_tree.match_prefix(19 MatchPrefixParams(20 key=RadixKey(21 token_ids=prefix_ids,22 extra_key=extra_key,23 cache_salt=cache_salt,24 )25 )26 )27 if envs.SGLANG_RADIX_FORCE_MISS.get():28 match_result = zero_match_result(29 self.waiting_queue_radix_tree, match_result, extra_key=extra_key30 )31 if match_result.device_prefix_len >= IN_BATCH_PREFIX_CACHING_DEPRIORITIZE_THRESHOLD:32 temporary_deprioritized.add(r.rid)33 else:34 # Insert with a dummy key35 self.waiting_queue_radix_tree.insert(36 InsertParams(37 key=RadixKey(token_ids=prefix_ids, extra_key=extra_key, cache_salt=cache_salt),38 value=torch.empty(len(prefix_ids), dtype=torch.bool),39 )40 )41 return temporary_deprioritized这段代码藏着一个在线上很常见、文档里几乎没写的行为:同批前缀去重。
为什么”同批前缀”需要去重
设想一个批处理场景:客户端一次提交 50 条请求,前缀都是相同的两万 token 的系统提示,尾部各不相同,而 缓存里一条都没有(比如服务刚重启,或系统提示换了版本)。此时对每条请求做 LPM,命中长度全是 0,排序完全不改变先后——它们的”冷前缀”一模一样。
按 FCFS 依次装批,前几条请求各自把自己那份两万 token 的系统提示算一遍写入 radix tree;第 2 条开始,它匹配到第 1 条刚写入的前缀了吗?没有——因为 calc_priority 拿到的是本轮开始时的树快照,第 1 条请求的 KV 要等这一轮前向跑完才会被 checkpoint 进树。于是同一轮装进来的所有请求 都在重复计算同一段系统提示。这在长系统提示的场景里是巨大的浪费:50 条请求 × 20K token 的 prefill 计算,其中 49×20K 是重复的。
_compute_prefix_matches 的解法是:对每一条命中长度很短的请求(prefix_len <= IN_BATCH_PREFIX_CACHING_CHECK_THRESHOLD,默认 32),除了查真实缓存树,还在一棵只存 token 键的临时 radix tree(waiting_queue_radix_tree,每次 calc_priority 开头 reset())里再查一次。
- 如果临时树里已经有一条更长的共同前缀(
device_prefix_len >= DEPRIORITIZE_THRESHOLD,默认也是 32),把这条请求的rid放进temporary_deprioritized——本轮不优先调度它。 - 否则把它的 token 键塞进临时树,作为后续请求的”已见过前缀”。
配合排序键里那句 float("inf"),效果是:同一组共享冷前缀的请求里,第一条保持原顺序、先被装进本轮 prefill;其余重复前缀的请求被推到队尾,等本轮前向把第一条的 KV 写进真实 radix tree 后,下一轮再被 calc_priority 匹配到——此时命中长度变成了 20K,它们会被 LPM 主动提到前面。总计算量从 50×20K 压到约 1×20K + 50×(尾部)。
这里的阈值 32 不是随手取的。系统提示的常见开头(如 "You are a helpful assistant..."、中文模板的 <|im_start|> 标签串)常常只有几十 token 的共同前缀。阈值取得太小,会把本来就没多少共享的请求错当成”同批共享”而延迟;取得太大,则错过长系统提示的聚合。IN_BATCH_PREFIX_CACHING_CHECK_THRESHOLD 只作用于”要不要再查临时树”,IN_BATCH_PREFIX_CACHING_DEPRIORITIZE_THRESHOLD 只作用于”查到了多长才降级”,二者都可用环境变量覆盖。

上图来自 SGLang 论文(arXiv:2312.07104)的 Figure 3。图中每一步都对应请求进入、命中、分叉、驱逐的完整生命周期:请求 1 写入前缀后,请求 2 的输入在前缀末尾分叉出新分支(新增节点 e/f),请求 3、4 沿已有分支命中,之后再为新请求腾空间时从叶子 i/k/l 开始驱逐。calc_priority 里的”临时树去重”借用的正是这套语义,只是临时树不带 KV、只带 token 键,所以去重成本几乎可以忽略——这正是 SGLang 能把去重放在每轮排序路径里而不拖慢调度的原因。
关于 LPM 与 DFS 在离线批处理下被证明等价,这是 SGLang 论文的 Theorem 3.1:DFS 顺序访问 radix tree 时,每条边上的 KV 只需在第一次计算;只要缓存容量不小于最长请求长度,同一条边上的前缀在其子树被计算期间会持续命中,不会被驱逐。证明用的是下界 ∑e∈edges(T)∣e∣。在线场景下 LPM 会因新请求不断插入而扰动 DFS 顺序,但仍近似在”不断增大的全量树”上做 DFS。
DFS 权重:按子树大小排序
dfs-weight 的排序不依赖排序键大小,而是整体置换(schedule_policy.py:498):
1@staticmethod2def _sort_by_dfs_weight(waiting_queue: List[Req], tree_cache: BasePrefixCache) -> None:3 order = tree_cache.dfs_weight_order([req.last_node for req in waiting_queue])4 waiting_queue[:] = [waiting_queue[index] for index in order]dfs_weight_order 在 base_prefix_cache.py:359:
1def _dfs_weight_order(root_node, node_handles, resolve_node_handle) -> list[int]:2 last_node_to_indices = {}3 for index, node_handle in enumerate(node_handles):4 node = resolve_node_handle(node_handle)5 last_node_to_indices.setdefault(node, []).append(index)6
7 node_to_weight = {node: len(indices) for node, indices in last_node_to_indices.items()}8
9 stack = [(root_node, False)]10 while stack:11 node, visited = stack.pop()12 if visited:13 weight = node_to_weight.get(node, 0)14 for child in node.children.values():15 weight += node_to_weight.get(child, 0)16 node_to_weight[node] = weight17 continue18 stack.append((node, True))19 for child in reversed(list(node.children.values())):20 stack.append((child, False))21
22 order = []23 stack = [(root_node, False)]24 while stack:25 node, visited = stack.pop()26 if visited:27 order.extend(last_node_to_indices.get(node, ()))28 continue29 children = list(node.children.values())30 children.sort(key=lambda child: -node_to_weight.get(child, 0))31 stack.append((node, True))32 for child in reversed(children):33 stack.append((child, False))34 return order分三步读。第一步,把每条请求挂在它的 last_node(这条请求匹配到的最深缓存节点)上,节点的初始权重是本节点匹配到的请求数。第二步,后序 DFS 把子节点的权重累加进父节点,得到”以该节点为根的子树内命中请求总数”。第三步,再从根开始做一次 DFS:每一步先按子树的命中请求数降序排列子节点,再压栈访问。于是命中同一分支的请求会连续出现在输出里,命中人数最多的分支最先走。
它与 LPM 的差别在于”衡量同一性的粒度”。LPM 只比较单条请求命中了多少 token,两条请求即使命中长度一样,只要分属树的完全不同的子树,也会被 LPM 平级处理;DFS 权重则把它们聚集到不同的块里,让一整组共享分支的请求成批被调度。在”很多租户各自用同一套模板、彼此之间没有共享”的场景里,DFS 权重的局部性会明显好于 LPM。
但 DFS 权重也有边界:它不降级。当 last_node 大量指向根节点或浅层节点(例如冷启动时缓存几乎为空),所有请求挂在少数节点上,DFS 权重就退化为”按节点分组”,排序意义有限;此时它在成本上仍优于 LPM(不需要逐条匹配),但命中率提升有限。
HRRN:按 token 计的老化
HRRN(Highest Response Ratio Next)是经典操作系统调度里的”防饥饿”方案:响应比 = 1 + 等待时间 / 预计服务时间,响应比最高的先跑。它能既照顾短作业、又不让长作业饿死。SGLang 针对 LLM 推理改造了两处(schedule_policy.py:462):
1@staticmethod2def _sort_by_hrrn(3 waiting_queue: List[Req],4 temporary_deprioritized: Set[int],5 processed_tokens: int,6) -> None:7 def _key(r: Req):8 rid = r.rid9 if rid in temporary_deprioritized:10 return (float("inf"), rid)11 uncached = SchedulePolicy._uncached_len(r)12 if uncached <= 0:13 # No prefill work; drain immediately.14 return (-float("inf"), rid)15 waited_tokens = max(0, processed_tokens - r.arrival_processed_tokens)16 ratio_delta = waited_tokens / uncached17 return (-ratio_delta, rid)18
19 waiting_queue.sort(key=_key)改造一:时间单位从秒改成了 token。processed_tokens 是调度器维护的单调递增计数器(self.processed_tokens_counter),每轮在前向结束后累加 batch.extend_num_tokens(scheduler.py:4783)。请求入队时(_add_request_to_queue,scheduler.py:3694)会把当时的计数器值记在 arrival_processed_tokens 上。用 token 而不是秒的原因很直接:秒是 wall clock,在一个共享 GPU 的容器里会被其他进程、页面缓存、NCCL 抖动干扰,同一批请求在不同机器上的等待比例不一致;而 token 计数只反映”服务已经实际处理了多少计算”,跨 TP rank 也不会有时钟偏移。注释里给了一个等式:
也就是说,在吞吐稳定的假设下,“等待的 token 数 / 未缓存 token 数” 与”等待秒数 / 预计服务秒数”是同构的。
改造二:命中长度用于算未缓存工作量。_uncached_len(schedule_policy.py:452)的定义:
1@staticmethod2def _uncached_len(r: Req) -> int:3 """Number of tokens that must actually be prefilled for this req4 (all cache levels — device + host via hicache — counted as cached)."""5 return max(0, len(r.origin_input_ids) - r.num_matched_prefix_tokens)它用 num_matched_prefix_tokens,这个字段在 HiCache 等分层缓存下也会把 host 层命中算进去——也就是说,一个”命中 100K host 层、只剩 1K 要计算”的请求,其 HRRN 分母是 1K,而”命中 0、要算 20K”的请求分母是 20K。这与 LPM 的降级排序在同一精神下:已经缓存的部分不占计算,也不该作为服务成本计入。
两个特殊值解释了边界行为:uncached <= 0 的请求返回 -inf,永远排在最前,因为它们不需要计算,只是把 KV 挂上后立刻进 decode;临时降级的请求返回 inf、放到最后。这比 LPM 的 float("inf") 语义更细致——它们都被”推到队尾”,但 LPM 仍然按命中长度排,HRRN 则按响应比排,二者不会产生同样的次序。
HRRN 也存在与 LPM 相同的 128 队列降级——它的排序成本同样是每请求一次前缀匹配加一次除法。在负载很高、队列塞满 1000 条的极端情况下,直接退化为 FCFS 反而是保证 GPU 不空转的正确做法。
shortest-prefill-first:把 LPM 反过来
shortest-prefill-first 的逻辑是”谁离出第一个 token 最近,谁先跑”(schedule_policy.py:384):
1@staticmethod2def _shortest_prefill_work(r: Req) -> int:3 return max(4 1,5 len(r.origin_input_ids) + len(r.output_ids) - r.num_matched_prefix_tokens,6 )7
8@staticmethod9def _sort_by_shortest_prefill(10 waiting_queue: List[Req], temporary_deprioritized: Set[int]11) -> None:12 waiting_queue.sort(13 key=lambda r: (14 r.rid in temporary_deprioritized,15 SchedulePolicy._shortest_prefill_work(r),16 r.time_stats.wait_queue_entry_time,17 )18 )注意这里 rid in temporary_deprioritized 是 bool,False < True,所以被降级的请求排在后面;再按未缓存工作量升序;最后按入队时间打平。三种策略(LPM / HRRN / shortest-prefill-first)共用同一套 _compute_prefix_matches 产出的 temporary_deprioritized,去重逻辑对所有缓存感知策略统一生效。
这策略最大的风险是长提示饥饿:一个 100K 的长提示,如果每轮都有新到的 1K 短提示入队,按工作量升序它永远到不了队头。源码里对这个问题有两道防线。
第一道:shortest_prefill_chunk_limit(schedule_policy.py:408),当已有一个 chunked request 在分块推进时,为后面的短请求保留一部分 chunk 预算:
1def shortest_prefill_chunk_limit(2 self, chunked_req: Req, waiting_queue: List[Req], budget: int, page_size: int3) -> Optional[int]:4 """Cap the active prefill chunk to reserve tokens for shorter waiting requests."""5 if (6 self.policy != CacheAwarePolicy.SHORTEST_PREFILL_FIRST7 or budget < 2 * page_size8 ):9 return None10 remaining = len(chunked_req.full_untruncated_fill_ids) - chunked_req.prefix_len11 reserved = 012 for req in waiting_queue:13 work = self._shortest_prefill_work(req)14 charge = _ceil_div(work, page_size) * page_size15 if work >= remaining or reserved + charge > budget - page_size:16 break17 reserved += charge18 if not reserved:19 return None20 # Page alignment keeps continuation boundaries allocator-compatible.21 return (budget - reserved) // page_size * page_size调用点在 _get_new_batch_prefill_raw 里、add_chunked_req 之前:adder.chunked_req_limit = self.policy.shortest_prefill_chunk_limit(...)。含义是:如果本轮 chunk 预算为 B,长提示续算还剩 R 个 token,那么先扣除短请求们各自需要的一整页,再让长提示吃掉剩余。由于 add_chunked_req 会把 _rem_tokens = min(_rem_tokens, self.chunked_req_limit) 截断,长提示会让出一条短请求的份额。这里几个细节都值得留意:charge 按页向上取整(分配器以页为单位);budget - page_size 是给 add_chunked_req 自身预留的一页对齐余量;返回值也按页向下取整,保证续算边界与分配器对齐;work >= remaining 时停止,意味着短请求不能再比长提示剩余量更大——没有意义。
第二道:_select_prefill_admission 里的一段特判:
1elif chunk_tokens_limit is not None and chunk_fit_tokens > chunk_tokens_limit:2 if (3 has_chunked_req4 and get_schedule().schedule_policy == "shortest-prefill-first"5 ):6 # Only one unfinished chunked request can be tracked.7 return AddReqResult.OTHERSGLang 同一时刻只跟踪一个 chunked_req。若本轮已经有一个长提示在分块推进,短请求即使排到前面,也不能被切成新 chunk,否则两个 chunk 谁都没法继续。于是 shortest-prefill-first 下,若存在 has_chunked_req 且当前候选请求本身的未缓存输入超过当前 chunk 上限,就直接拒绝、不再考察后续请求。代价是:长提示分块期间,后续的长请求会被卡住,直到当前 chunk 走完。这在短请求为主的服务中几乎不可见,在”大量长 PDF”为主的服务中会变成队头阻塞,选型时需要用具体负载试跑。
shortest-prefill-first 还有一段实验性日志,每 128 轮打一次:
1self._shortest_prefill_calls += 12if waiting_queue and self._shortest_prefill_calls % 128 == 1:3 logger.info(4 "Experimental shortest-prefill-first: queue=%d shortest_uncached=%d",5 len(waiting_queue),6 self._shortest_prefill_work(waiting_queue[0]),7 )这说明该策略目前仍带有明显”实验性”标记:不在默认推荐列表,没有像 LPM/DFS 那样被写进论文的 Theorem 体系,也没有对队列长度做专门降级(它的排序代价低,降级没有必要)。
routing-key:让同一租户的请求落在同一个 rank
routing-key 是缓存无关策略中唯一带”全局视野”的(schedule_policy.py:540):
1@staticmethod2def _sort_by_routing_key(waiting_queue: List[Req], running_batch: ScheduleBatch) -> None:3 routing_key_counts = Counter(4 r.routing_key for r in running_batch.reqs if r.routing_key5 )6 if not routing_key_counts:7 return8
9 def sort_key(req: Req):10 key = req.routing_key11 if key and key in routing_key_counts:12 return (0, -count, key)13 else:14 return (1, 0, key or "")15
16 waiting_queue.sort(key=sort_key)它把”在 running batch 里已经出现过 的 routing key”提到前面,按出现次数降序;没有出现过的排后面,按 key 字典序。语义上,这是在单 rank 内的粘性:当 running batch 里已经有一条租户 A 的请求在跑,同租户的新请求立即插到前面,于是它们共享的公共 prefix(租户固定系统提示、对话历史)更有可能在该 rank 的 Radix tree 里命中。
但是 routing key 的完整价值在rank 选择层:谁来决定请求进哪个 rank?那是前面第十九篇 / GLM 系列讲的 DataParallelController 与 Router 的职责,它们读 r.routing_key 作为一致性哈希(consistent hashing)的键,然后把同一 key 的请求推到同一个 rank。SchedulePolicy 这层只能看到本 rank 的队列,因此”粘性”效果是局部的——它让同 key 请求在本 rank 内部别被其他租户挤开,但无法把本该去另一个 rank 的请求拉过来。真正要让 routing-key 起作用,需要在路由侧也按同一算法选同一个 key。
在 P/D 分离下还有一层:当 disagg().disaggregation_mode == "decode" 时,calc_priority 不做前缀匹配(注释:Decode never prefills.),_touch_waiting_prefixes 也直接返回。这与 decode 实例不具备 Radix tree 写权限的结构相符。
前缀触达:让等待中的前缀不被 LRU 驱逐
calc_priority 末尾调用 _touch_waiting_prefixes:
1def _touch_waiting_prefixes(self, policy, waiting_queue, bootstrap_queue=[]):2 # Head last, so LRU evicts in reverse admission order; cache-aware policies3 # already touch while matching, and other eviction strategies ignore recency.4 bootstrap_max = envs.SGLANG_WAITING_PREFIX_REFRESH_BOOTSTRAP_MAX.get()5 if (6 isinstance(policy, CacheAwarePolicy)7 or not (waiting_queue or (bootstrap_queue and bootstrap_max > 0))8 or not envs.SGLANG_ENABLE_WAITING_PREFIX_REFRESH.get()9 or get_disagg().disaggregation_mode == "decode"10 or get_memory().radix_eviction_policy.lower() not in ("lru", "joint_lru")11 or self.tree_cache.supports_fast_match_prefix()12 or not self.tree_cache.supports_prefix_sharing()13 ):14 return15 refresh = (16 self._waiting_prefix_refresh_calls % WAITING_PREFIX_REFRESH_INTERVAL == 017 )18 self._waiting_prefix_refresh_calls += 119 if not refresh:20 return21 for r in reversed(bootstrap_queue[:bootstrap_max]):22 touch_waiting_prefix(r, self.tree_cache)23 for r in reversed(waiting_queue[:WAITING_QUEUE_PREFIX_MATCH_MAX]):24 touch_waiting_prefix(r, self.tree_cache)这段代码只对缓存无关策略有意义:缓存感知策略在做前缀匹配时已经触达了节点,无需再触达一遍。为什么缓存无关策略需要补这么一手?因为 FCFS 下的等待请求仍然依赖缓存——前端可能已经提交了大量同系统提示的请求,但它们没有被 LPM 优先调度,Radix tree 的节点在这段时间里没有任何”访问”,LRU 有可能先把它们驱逐,再轮到这些请求时又要从头预填。
WAITING_PREFIX_REFRESH_INTERVAL = 32(schedule_policy.py:131)——每 32 轮才触达一次,而不是每轮。原因同样写在注释里:“Bound the walk cost using scheduling passes, which agree across TP ranks.”pass 计数在所有 TP rank 上一致,所以触达动作在不同 rank 上同步发生,避免某个 rank 触达、另一个不触达造成 Radix tree 分裂成不同形态。每轮都触达会让调度器 CPU 成本翻倍,32 轮足够防止一个热前缀在几十毫秒窗口内被驱逐。
倒序触达也是一个精妙的细节:注释说 “Head last, so LRU evicts in reverse admission order”。LRU 里最近访问的不会先被驱逐,所以触达顺序决定了谁”更安全”。按 reversed(queue[:128]) 倒着触达,队头的请求最后被触达、成为最近访问,从而在下一次驱逐时最后才被考虑——这与”队头即将被调度”的预期一致。若顺序反过来,队头反而会先于队尾被驱逐,让最该保护的请求最危险。
DP Attention 下的 prefill 延迟器
单机 TP 模式下 calc_priority 的输出直接决定本轮 prefill 装谁。DP Attention 模式下还多一层来自 prefill_delayer.py 的”要不要在这一轮启动 prefill”的全局协商。
先看它的入口 PrefillDelayerSinglePassExecutor(prefill_delayer.py:387):
1class PrefillDelayerSinglePassExecutor:2 def __init__(self, prefill_delayer: PrefillDelayer, token_usage: float):3 self._prefill_delayer = prefill_delayer4 self._token_usage = token_usage5 self._result: Optional[_NegotiateOutput] = None6 self._attempted_prefill_bs = 07 ...8 def negotiate_should_allow_prefill(9 self,10 local_prefillable: bool,11 running_batch: int = 0,12 max_prefill_bs: int = 0,13 max_running_requests: int = 0,14 waiting_queue_len: int = 0,15 ) -> bool:16 if local_prefillable:17 self._attempted_prefill_bs = max(18 self._attempted_prefill_bs,19 self._estimate_attempted_prefill_bs(20 running_batch=running_batch,21 max_running_requests=max_running_requests,22 waiting_queue_len=waiting_queue_len,23 ),24 )25 if not self._called:26 self._result = self._prefill_delayer._negotiate_should_allow_prefill(27 local_prefillable=local_prefillable,28 token_usage=self._token_usage,29 running_batch=running_batch,30 max_prefill_bs=max_prefill_bs,31 max_running_requests=max_running_requests,32 waiting_queue_len=waiting_queue_len,33 )34 return self._result.output_allow它把一次”要不要 prefill”拆成两种视角:本 rank 的角度(local_prefillable)与 全组的角度(all-gather 出来的 global_prefillable)。核心决策在 _negotiate_should_allow_prefill_pure:
- 所有 rank 都能 prefill (
prefillable_status == "all"),且当前占用的池空间低于token_usage_low_watermark,则无条件放行。这是”GPU 闲着呢,别等”。 - 都不能 prefill (
"none"),直接放行(放不放无所谓);后续add_one_req自然会因预算拒绝。 - 部分能、部分不能 (
"mixed"),或者"all"但触发了 slot 条件 / 队列条件,则延迟。
slot 条件的判断是:
1slot_condition = (2 max_running_requests - global_running_batch_max3 < global_max_prefill_bs_max4)即”剩余请求槽位比最近装过的 prefill batch 还小”。此时若现在装 prefill,就会占掉本可用于 decode 的槽位,让下一次合并 decode batch 达不到最大 batch——在 DP 下会直接表现为某几个 rank 空跑、而另外几个 rank 的 decode 撑满。队列条件是一个可选的增量触发(prefill_delayer_queue_min_ratio),在等待队列小于 min(running_req × ratio, max_prefill_bs) 且还没超时的情况下继续延迟,把小碎片合并成更大的 prefill。
延迟并不是无限期。self._max_delay_passes 默认 30,来自 --prefill-delayer-max-delay-passes;队列触发另有一个 wall-clock 上限 _max_delay_ms,默认 5000ms(在配置里未设时的回退值)。这两个上限的必要性在饱和场景下很清楚:如果 slot_condition 长期为真(GPU 槽位一直满),prefill 会永远被推迟,TTFT 变成长尾,直到超时兜底强制放行。skip_first_delayer 是另一处防呆:首次调用的延迟不生效,保证引擎启动后至少有一次 prefill 能推进状态机。
延迟器在 add_one_req 里还有一处调用,目的是让”本轮已经有 mid-chunk 的 rank”仍然继续 prefill(prefillable=True 的本地标记),其返回值被忽略——因为 chunk 续算是”必须走完”的路径,不应让延迟器把它拦下来。
一张决策流
把上面几个层次串起来,一轮 prefill 的完整决策可以写成下面这张表。

上图是 SGLang 官方论文附录给出的整体架构图,前端进程与运行时进程之间只通过 ZMQ 传请求和数据,调度器在运行时进程内。本层 SchedulePolicy 位于图中运行时进程内部、调度器进入装批逻辑之前的位置。结合上图与本篇的源码路径,一轮 prefill 的顺序可以整理为:
1event_loop2 └─ get_next_batch_to_run3 ├─ 合并上一轮 prefill batch、过滤已完成请求4 ├─ chunked_req 暂摘5 ├─ _should_defer_prefill? → 是:这一轮只跑 decode6 └─ get_new_batch_prefill7 ├─ 预取 grammar 队列、HiCache 状态8 ├─ PrefillDelayerSinglePassExecutor 协商(DP 下跨 rank)9 ├─ SchedulePolicy.calc_priority10 │ ├─ _validate_and_adjust_policy(构造时已决定)11 │ ├─ _determine_active_policy(队列 > 128 → FCFS)12 │ ├─ 缓存无关策略补 match_kv_cache(负载快照用)13 │ ├─ _compute_prefix_matches(同批去重 + 临时树)14 │ ├─ 按策略排序:FCFS / LPM / DFS / HRRN / SPF / LOF / routing-key15 │ └─ _touch_waiting_prefixes(无缓存策略、每 32 轮)16 ├─ shortest_prefill_chunk_limit(仅 SPF)17 ├─ add_chunked_req(续算上一轮长提示)18 ├─ PrefillAdder(...)19 └─ for req in waiting_queue:20 ├─ add_one_req → NO_TOKEN/OTHER → break21 └─ CONTINUE选型:什么时候用哪个
没有”最优”策略,只有匹配负载的策略。按经验粗略归纳:
| 负载形态 | 推荐策略 | 理由 |
|---|---|---|
| 大量同模板短请求(客服、对话) | lpm | 前缀命中是主要收益,排序会主动把刚写入前缀的后续请求提到前面 |
| 长系统提示 + 批处理(批量摘要) | lpm(依赖同批去重) | 冷前缀只在首条上算一次,其余被推到下一轮再命中 |
| 多租户、共享不同模板 | dfs-weight 或 lpm | dfs-weight 在子树层面聚合同租户请求;lpm 在 token 层面更细 |
| 延迟敏感、短提示为主 | shortest-prefill-first | 最小化短请求 TTFT |
| 长输出、长尾 | lof | 让长输出尽早起步,减少尾部驻留 |
| 严格公平、逐请求 SLA | fcfs 或 routing-key | fcfs 无饥饿风险;routing-key 与路由层粘性配合 |
| 客户端显式 SLO 等级 | fcfs + --enable-priority-scheduling | 显式优先级 + FCFS 打破平局 |
选型时还要一并看三项配置:前缀缓存是否启用(否则缓存感知策略被降级)、队列长度(超过 128 的 LPM/HRRN 会自动退化为 FCFS)、是否 DP Attention(延迟器会额外改变本轮的 prefill 启动时机)。三者叠加会改变实际生效策略,必须放在一起考虑。

图中是 SGLang 论文的多子图消融结果,重点看 FCFS Schedule 与 Random Schedule 两列:论文在批量处理、Tree-of-Thought、多轮对话等任务上分别关闭缓存感知调度、改成先来先服务或随机,缓存命中率与吞吐会明显下滑。这说明 SchedulePolicy 并非仅在”锦上添花”的边角处起作用——它是 RadixAttention 能拿到 95%+ 前缀命中率的一半工程原因(另一半是缓存本身),没有排序的支撑,缓存只会在正确的请求恰好按顺序到达时才命中。
局限与后续
- 128 队列硬编码:
WAITING_QUEUE_PREFIX_MATCH_MAX没有暴露为启动参数,调整只能改源码。对超长等待队列(数百到上千条)的服务,LPM/HRRN 在高峰时会频繁退化为 FCFS,命中率出现锯齿式下降。 - 同批去重的临时树不带成本感知:
_compute_prefix_matches只看”是否共享前缀”,不看”请求大小”。一组 100K 长请求共享前缀,与一组 1K 短请求共享前缀,去重逻辑完全一样,但前者延迟一条的代价要高得多。对长短混杂的请求,可以在临时树里同时记录每条请求的 token 数,做加权去重;当前代码没有这么做。 - 无”长度感知的 LPM”:LPM 只看命中绝对长度,因此一个命中 20K 但只剩 100 token 可计算的请求,可能被排在一个命中 10K、还要算 100K 的请求后面——前者更快出首 token。这恰好是 HRRN 与 shortest-prefill-first 试图修正的点,但二者都放弃了”最精确的缓存命中账”,只在近似指标上权衡。
- routing-key 粘性依赖路由侧配合:单 rank 内的
_sort_by_routing_key只看到 running batch 中出现过的 key。若 Router 层不做一致性哈希,同 key 请求分散到不同 rank,粘性排序也就没什么用了。 - 延迟器的延迟上限默认值:
prefill_delayer_max_delay_passes=30与prefill_delayer_max_delay_ms=5000都是经验值,实际服务需要按队列进入速率与目标 TTFT 反推。论文没讨论这一层,代码里也只给出”兜底”语义,没有提供调参辅助。
下一篇会接着讲等待队列的另一面:当 PrefillAdder 因为预算拒绝候选请求、Scheduler 需要从 waiting queue 和 running batch 之间平衡”抢占”时,SGLang 如何计算 preemption 之后 KV 回滚的完整路径。schedule_policy 只负责排序,真正的抢占决策在 PrefillAdder.preempt_to_schedule 中,而它与本轮的 sorted_valid_running_reqs 排序共享同一套优先级符号约定——那部分是下一步要拆的。
参考资料
- SGLang: Efficient Execution of Structured Language Model Programs(arXiv:2312.07104)
- SGLang GitHub 仓库
- SGLang 调度策略源码
python/sglang/srt/managers/schedule_policy.py - SGLang Scheduler 源码
python/sglang/srt/managers/scheduler.py - SGLang Prefill Delayer 源码
python/sglang/srt/managers/prefill_delayer.py - SGLang Server Arguments 文档
文章分享
如果这篇文章对你有帮助,欢迎分享给更多人!
评论区
分享你的想法,与大家交流讨论
正在获取所在地区…
正在读取天气
音乐
暂未播放



