SGLang 源码完全拆解(二十七):调度策略——FCFS、LPM、DFS 权重与 HRRN,一张等待队列的排序账

8214 字
41 分钟
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),它大致做三件事:

  1. 合并与过滤:把上一轮 prefill batch 里的请求并进 running batch,剔除已完成的请求。若上一轮留了 chunked_req,先把它从 batch 里摘出来,以免它在合并时被当成已完成请求清掉。
  2. 决定这一轮跑谁:若 _should_defer_prefill(running_batch) 为真(比如 prefill 延迟器在起作用),new_batch 置空,这一轮只跑 decode;否则调用 get_new_batch_prefill 尝试装一个新的 prefill batch。只要有新 batch,这一轮就 prefill 优先;没有才去 update_running_batch 跑 decode。
  3. DP/MoE 适配:maybe_prepare_mlp_sync_batch、maybe_convert_decode_to_extend 等收尾。

排序发生在第 2 步的内部,_get_new_batch_prefill_raw(scheduler.py:4334)里:

if (running_batch.batch_is_full or len(self.waiting_queue) == 0) and self.chunked_req is None:
return None, running_batch
...
# Get priority queue
self.policy.calc_priority(
self.waiting_queue,
running_batch,
processed_tokens=self.processed_tokens_counter,
bootstrap_queue=(
self.disagg_prefill_bootstrap_queue.queue
if self.disagg_prefill_bootstrap_queue is not None
else []
),
)
adder = self.get_prefill_adder_class()(...)
if self.chunked_req is not None:
self.chunked_req.init_next_round_input()
adder.chunked_req_limit = self.policy.shortest_prefill_chunk_limit(...)
self.chunked_req = adder.add_chunked_req(self.chunked_req)
...
for req in self.waiting_queue:
...
req.init_next_round_input(self.tree_cache)
res = adder.add_one_req(...)
if res != AddReqResult.CONTINUE:
...
break

注意三件事。第一,排序发生在装批之前,只对 waiting queue 生效。running batch 里的请求这一轮一定会跑(除非被 retract),排序动不了它们。第二,排序之后才切 chunked request、才创建 PrefillAdder。这意味着 LPM、DFS 这类需要访问 tree_cache 的策略,此时读到的缓存树是这一轮前向之前的快照,不会被本轮即将写入的新 KV 污染。第三,排序只是一次置换,不保证排在前面的请求一定被接纳。add_one_req 仍可能因为 KV 预算或计算预算拒绝它,此时循环 break,后面的请求根本不会被考察——这是”排序决定命运”的真正原因。

策略表:缓存无关与缓存感知

SchedulePolicy 把策略分成两组(schedule_policy.py:164):

class CacheAwarePolicy(Enum):
LPM = "lpm" # longest prefix match
DFS_WEIGHT = "dfs-weight" # depth-first search weighting
HRRN = "hrrn" # highest response ratio next, token-based aging
SHORTEST_PREFILL_FIRST = "shortest-prefill-first"
class CacheAgnosticPolicy(Enum):
FCFS = "fcfs"
LOF = "lof" # longest output first
RANDOM = "random"
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
lpmnum_matched_prefix_tokens,降序是最大化前缀命中冷请求可能长期等待
dfs-weightRadix 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):

def _validate_and_adjust_policy(self, policy: str, tree_cache: BasePrefixCache) -> Policy:
try:
policy_enum = CacheAwarePolicy(policy)
if getattr(tree_cache, "disable", True):
if policy_enum == CacheAwarePolicy.SHORTEST_PREFILL_FIRST:
raise ValueError(
"Experimental shortest-prefill-first requires prefix caching"
)
# If tree_cache is disabled, using CacheAgnosticPolicy policy
return CacheAgnosticPolicy.FCFS
return policy_enum
except ValueError:
try:
return CacheAgnosticPolicy(policy)
except ValueError:
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):

policy = self._determine_active_policy(waiting_queue)
# Cache-agnostic policies sort without matching; match anyway so the load
# snapshot sees num_matched_prefix_tokens. Decode never prefills.
if (
not isinstance(policy, CacheAwarePolicy)
and self.tree_cache.supports_fast_match_prefix()
and get_disagg().disaggregation_mode != "decode"
):
for r in waiting_queue:
match_kv_cache(r, self.tree_cache)
if self.policy == CacheAgnosticPolicy.FCFS:
if self.enable_priority_scheduling:
SchedulePolicy._sort_by_priority_and_fcfs(waiting_queue, self.priority_sign)
elif isinstance(policy, CacheAwarePolicy):
...

无缓存策略本该不需要匹配前缀,但代码仍会做一次 match_kv_cache。原因写在注释里:让负载快照看见 num_matched_prefix_tokens。DP Attention 下负载均衡器会把每个 rank 的”待处理 token 数(扣掉命中部分)“当作负载指标上报,如果 FCFS 阶段不填这个字段,负载快照会把所有等待请求当成全量计算,导致路由把请求推向错误的 rank。这一段不改变排序,只补齐账本——和第十九篇 / GLM 系列讲的负载账本是一回事。

_determine_active_policy 是一个易被忽视的降级点:

def _determine_active_policy(self, waiting_queue: List[Req]) -> Policy:
if (
self.policy in (CacheAwarePolicy.LPM, CacheAwarePolicy.HRRN)
and len(waiting_queue) > WAITING_QUEUE_PREFIX_MATCH_MAX
):
# Turn off the expensive prefix matching and sorting when the #queue is large.
return CacheAgnosticPolicy.FCFS
return self.policy

WAITING_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):

@staticmethod
def _sort_by_priority_and_fcfs(waiting_queue: List[Req], priority_sign: int) -> None:
waiting_queue.sort(
key=lambda x: (
x.priority * priority_sign,
x.time_stats.wait_queue_entry_time,
)
)

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 拒绝,否则静默忽略。这一层与排序键是两套代码,配置时需要同时看:

Terminal window
# 高值优先 + 默认未传优先级请求排在最后
python -m sglang.launch_server --model-path <model> --enable-priority-scheduling
# 低值优先(0 最优先),适合把 SLO 等级映射成小整数
python -m sglang.launch_server --model-path <model> \
--enable-priority-scheduling --schedule-low-priority-values-first

LPM:最长匹配前缀优先

LPM 是最贴合 RadixAttention 的排序。它的排序键在 _sort_by_longest_prefix(schedule_policy.py:443):

@staticmethod
def _sort_by_longest_prefix(
waiting_queue: List[Req], temporary_deprioritized: Set[int]
) -> None:
waiting_queue.sort(
key=lambda r: (
-r.num_matched_prefix_tokens
if r.rid not in temporary_deprioritized
else float("inf")
)
)

命中长度降序,被”临时降级”的请求放到队尾(float("inf") 取负后是最小值,因此排在最后)。命中长度从哪来?由 _compute_prefix_matches 在排序前统一填充:

def _compute_prefix_matches(
self, waiting_queue: List[Req], policy: CacheAwarePolicy
) -> Set[int]:
temporary_deprioritized: Set[int] = set()
self.waiting_queue_radix_tree.reset()
for r in waiting_queue:
prefix_ids = r.origin_input_ids + r.output_ids
extra_key = r.extra_key
cache_salt = r.cache_salt
match_result = match_kv_cache(r, self.tree_cache, prefix_ids)
# NOTE(sang): This logic is for in-batch prefix caching;
# If there are more than 1 request that have small matching prefix from
# existing cache, but all those requests share the same prefix, we prefer
# to schedule only one of them so that we can increase the cache hit rate.
if r.prefix_len <= IN_BATCH_PREFIX_CACHING_CHECK_THRESHOLD:
match_result = self.waiting_queue_radix_tree.match_prefix(
MatchPrefixParams(
key=RadixKey(
token_ids=prefix_ids,
extra_key=extra_key,
cache_salt=cache_salt,
)
)
)
if envs.SGLANG_RADIX_FORCE_MISS.get():
match_result = zero_match_result(
self.waiting_queue_radix_tree, match_result, extra_key=extra_key
)
if match_result.device_prefix_len >= IN_BATCH_PREFIX_CACHING_DEPRIORITIZE_THRESHOLD:
temporary_deprioritized.add(r.rid)
else:
# Insert with a dummy key
self.waiting_queue_radix_tree.insert(
InsertParams(
key=RadixKey(token_ids=prefix_ids, extra_key=extra_key, cache_salt=cache_salt),
value=torch.empty(len(prefix_ids), dtype=torch.bool),
)
)
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 论文 Figure 3:RadixAttention 中 KV 缓存的匹配、插入、分裂与 LRU 驱逐
SGLang 论文 Figure 3:RadixAttention 中 KV 缓存的匹配、插入、分裂与 LRU 驱逐

上图来自 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∣\sum_{e \in \text{edges}(T)} |e|。在线场景下 LPM 会因新请求不断插入而扰动 DFS 顺序,但仍近似在”不断增大的全量树”上做 DFS。

DFS 权重:按子树大小排序

dfs-weight 的排序不依赖排序键大小,而是整体置换(schedule_policy.py:498):

@staticmethod
def _sort_by_dfs_weight(waiting_queue: List[Req], tree_cache: BasePrefixCache) -> None:
order = tree_cache.dfs_weight_order([req.last_node for req in waiting_queue])
waiting_queue[:] = [waiting_queue[index] for index in order]

dfs_weight_order 在 base_prefix_cache.py:359:

def _dfs_weight_order(root_node, node_handles, resolve_node_handle) -> list[int]:
last_node_to_indices = {}
for index, node_handle in enumerate(node_handles):
node = resolve_node_handle(node_handle)
last_node_to_indices.setdefault(node, []).append(index)
node_to_weight = {node: len(indices) for node, indices in last_node_to_indices.items()}
stack = [(root_node, False)]
while stack:
node, visited = stack.pop()
if visited:
weight = node_to_weight.get(node, 0)
for child in node.children.values():
weight += node_to_weight.get(child, 0)
node_to_weight[node] = weight
continue
stack.append((node, True))
for child in reversed(list(node.children.values())):
stack.append((child, False))
order = []
stack = [(root_node, False)]
while stack:
node, visited = stack.pop()
if visited:
order.extend(last_node_to_indices.get(node, ()))
continue
children = list(node.children.values())
children.sort(key=lambda child: -node_to_weight.get(child, 0))
stack.append((node, True))
for child in reversed(children):
stack.append((child, False))
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):

@staticmethod
def _sort_by_hrrn(
waiting_queue: List[Req],
temporary_deprioritized: Set[int],
processed_tokens: int,
) -> None:
def _key(r: Req):
rid = r.rid
if rid in temporary_deprioritized:
return (float("inf"), rid)
uncached = SchedulePolicy._uncached_len(r)
if uncached <= 0:
# No prefill work; drain immediately.
return (-float("inf"), rid)
waited_tokens = max(0, processed_tokens - r.arrival_processed_tokens)
ratio_delta = waited_tokens / uncached
return (-ratio_delta, rid)
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 也不会有时钟偏移。注释里给了一个等式:

ratio=1+wait_secest_prefill_time≈1+processed_tokens−arrival_processed_tokensuncached\text{ratio} = 1 + \frac{\text{wait\_sec}}{\text{est\_prefill\_time}} \approx 1 + \frac{\text{processed\_tokens} - \text{arrival\_processed\_tokens}}{\text{uncached}}

也就是说,在吞吐稳定的假设下,“等待的 token 数 / 未缓存 token 数” 与”等待秒数 / 预计服务秒数”是同构的。

改造二:命中长度用于算未缓存工作量。_uncached_len(schedule_policy.py:452)的定义:

@staticmethod
def _uncached_len(r: Req) -> int:
"""Number of tokens that must actually be prefilled for this req
(all cache levels — device + host via hicache — counted as cached)."""
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):

@staticmethod
def _shortest_prefill_work(r: Req) -> int:
return max(
1,
len(r.origin_input_ids) + len(r.output_ids) - r.num_matched_prefix_tokens,
)
@staticmethod
def _sort_by_shortest_prefill(
waiting_queue: List[Req], temporary_deprioritized: Set[int]
) -> None:
waiting_queue.sort(
key=lambda r: (
r.rid in temporary_deprioritized,
SchedulePolicy._shortest_prefill_work(r),
r.time_stats.wait_queue_entry_time,
)
)

注意这里 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 预算:

def shortest_prefill_chunk_limit(
self, chunked_req: Req, waiting_queue: List[Req], budget: int, page_size: int
) -> Optional[int]:
"""Cap the active prefill chunk to reserve tokens for shorter waiting requests."""
if (
self.policy != CacheAwarePolicy.SHORTEST_PREFILL_FIRST
or budget < 2 * page_size
):
return None
remaining = len(chunked_req.full_untruncated_fill_ids) - chunked_req.prefix_len
reserved = 0
for req in waiting_queue:
work = self._shortest_prefill_work(req)
charge = _ceil_div(work, page_size) * page_size
if work >= remaining or reserved + charge > budget - page_size:
break
reserved += charge
if not reserved:
return None
# Page alignment keeps continuation boundaries allocator-compatible.
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 预算为 BB,长提示续算还剩 RR 个 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 里的一段特判:

elif chunk_tokens_limit is not None and chunk_fit_tokens > chunk_tokens_limit:
if (
has_chunked_req
and get_schedule().schedule_policy == "shortest-prefill-first"
):
# Only one unfinished chunked request can be tracked.
return AddReqResult.OTHER

SGLang 同一时刻只跟踪一个 chunked_req。若本轮已经有一个长提示在分块推进,短请求即使排到前面,也不能被切成新 chunk,否则两个 chunk 谁都没法继续。于是 shortest-prefill-first 下,若存在 has_chunked_req 且当前候选请求本身的未缓存输入超过当前 chunk 上限,就直接拒绝、不再考察后续请求。代价是:长提示分块期间,后续的长请求会被卡住,直到当前 chunk 走完。这在短请求为主的服务中几乎不可见,在”大量长 PDF”为主的服务中会变成队头阻塞,选型时需要用具体负载试跑。

shortest-prefill-first 还有一段实验性日志,每 128 轮打一次:

self._shortest_prefill_calls += 1
if waiting_queue and self._shortest_prefill_calls % 128 == 1:
logger.info(
"Experimental shortest-prefill-first: queue=%d shortest_uncached=%d",
len(waiting_queue),
self._shortest_prefill_work(waiting_queue[0]),
)

这说明该策略目前仍带有明显”实验性”标记:不在默认推荐列表,没有像 LPM/DFS 那样被写进论文的 Theorem 体系,也没有对队列长度做专门降级(它的排序代价低,降级没有必要)。

routing-key:让同一租户的请求落在同一个 rank

routing-key 是缓存无关策略中唯一带”全局视野”的(schedule_policy.py:540):

@staticmethod
def _sort_by_routing_key(waiting_queue: List[Req], running_batch: ScheduleBatch) -> None:
routing_key_counts = Counter(
r.routing_key for r in running_batch.reqs if r.routing_key
)
if not routing_key_counts:
return
def sort_key(req: Req):
key = req.routing_key
if key and key in routing_key_counts:
return (0, -count, key)
else:
return (1, 0, key or "")
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:

def _touch_waiting_prefixes(self, policy, waiting_queue, bootstrap_queue=[]):
# Head last, so LRU evicts in reverse admission order; cache-aware policies
# already touch while matching, and other eviction strategies ignore recency.
bootstrap_max = envs.SGLANG_WAITING_PREFIX_REFRESH_BOOTSTRAP_MAX.get()
if (
isinstance(policy, CacheAwarePolicy)
or not (waiting_queue or (bootstrap_queue and bootstrap_max > 0))
or not envs.SGLANG_ENABLE_WAITING_PREFIX_REFRESH.get()
or get_disagg().disaggregation_mode == "decode"
or get_memory().radix_eviction_policy.lower() not in ("lru", "joint_lru")
or self.tree_cache.supports_fast_match_prefix()
or not self.tree_cache.supports_prefix_sharing()
):
return
refresh = (
self._waiting_prefix_refresh_calls % WAITING_PREFIX_REFRESH_INTERVAL == 0
)
self._waiting_prefix_refresh_calls += 1
if not refresh:
return
for r in reversed(bootstrap_queue[:bootstrap_max]):
touch_waiting_prefix(r, self.tree_cache)
for r in reversed(waiting_queue[:WAITING_QUEUE_PREFIX_MATCH_MAX]):
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):

class PrefillDelayerSinglePassExecutor:
def __init__(self, prefill_delayer: PrefillDelayer, token_usage: float):
self._prefill_delayer = prefill_delayer
self._token_usage = token_usage
self._result: Optional[_NegotiateOutput] = None
self._attempted_prefill_bs = 0
...
def negotiate_should_allow_prefill(
self,
local_prefillable: bool,
running_batch: int = 0,
max_prefill_bs: int = 0,
max_running_requests: int = 0,
waiting_queue_len: int = 0,
) -> bool:
if local_prefillable:
self._attempted_prefill_bs = max(
self._attempted_prefill_bs,
self._estimate_attempted_prefill_bs(
running_batch=running_batch,
max_running_requests=max_running_requests,
waiting_queue_len=waiting_queue_len,
),
)
if not self._called:
self._result = self._prefill_delayer._negotiate_should_allow_prefill(
local_prefillable=local_prefillable,
token_usage=self._token_usage,
running_batch=running_batch,
max_prefill_bs=max_prefill_bs,
max_running_requests=max_running_requests,
waiting_queue_len=waiting_queue_len,
)
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 条件的判断是:

slot_condition = (
max_running_requests - global_running_batch_max
< global_max_prefill_bs_max
)

即”剩余请求槽位比最近装过的 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 整体架构与前端-运行时数据流
SGLang 整体架构与前端-运行时数据流

上图是 SGLang 官方论文附录给出的整体架构图,前端进程与运行时进程之间只通过 ZMQ 传请求和数据,调度器在运行时进程内。本层 SchedulePolicy 位于图中运行时进程内部、调度器进入装批逻辑之前的位置。结合上图与本篇的源码路径,一轮 prefill 的顺序可以整理为:

event_loop
└─ get_next_batch_to_run
├─ 合并上一轮 prefill batch、过滤已完成请求
├─ chunked_req 暂摘
├─ _should_defer_prefill? → 是:这一轮只跑 decode
└─ get_new_batch_prefill
├─ 预取 grammar 队列、HiCache 状态
├─ PrefillDelayerSinglePassExecutor 协商(DP 下跨 rank)
├─ SchedulePolicy.calc_priority
│ ├─ _validate_and_adjust_policy(构造时已决定)
│ ├─ _determine_active_policy(队列 > 128 → FCFS)
│ ├─ 缓存无关策略补 match_kv_cache(负载快照用)
│ ├─ _compute_prefix_matches(同批去重 + 临时树)
│ ├─ 按策略排序:FCFS / LPM / DFS / HRRN / SPF / LOF / routing-key
│ └─ _touch_waiting_prefixes(无缓存策略、每 32 轮)
├─ shortest_prefill_chunk_limit(仅 SPF)
├─ add_chunked_req(续算上一轮长提示)
├─ PrefillAdder(...)
└─ for req in waiting_queue:
├─ add_one_req → NO_TOKEN/OTHER → break
└─ CONTINUE

选型:什么时候用哪个

没有”最优”策略,只有匹配负载的策略。按经验粗略归纳:

负载形态推荐策略理由
大量同模板短请求(客服、对话)lpm前缀命中是主要收益,排序会主动把刚写入前缀的后续请求提到前面
长系统提示 + 批处理(批量摘要)lpm(依赖同批去重)冷前缀只在首条上算一次,其余被推到下一轮再命中
多租户、共享不同模板dfs-weight 或 lpmdfs-weight 在子树层面聚合同租户请求;lpm 在 token 层面更细
延迟敏感、短提示为主shortest-prefill-first最小化短请求 TTFT
长输出、长尾lof让长输出尽早起步,减少尾部驻留
严格公平、逐请求 SLAfcfs 或 routing-keyfcfs 无饥饿风险;routing-key 与路由层粘性配合
客户端显式 SLO 等级fcfs + --enable-priority-scheduling显式优先级 + FCFS 打破平局

选型时还要一并看三项配置:前缀缓存是否启用(否则缓存感知策略被降级)、队列长度(超过 128 的 LPM/HRRN 会自动退化为 FCFS)、是否 DP Attention(延迟器会额外改变本轮的 prefill 启动时机)。三者叠加会改变实际生效策略,必须放在一起考虑。

SGLang 论文:缓存命中率与消融实验结果
SGLang 论文:缓存命中率与消融实验结果

图中是 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 排序共享同一套优先级符号约定——那部分是下一步要拆的。

参考资料

  1. SGLang: Efficient Execution of Structured Language Model Programs(arXiv:2312.07104)
  2. SGLang GitHub 仓库
  3. SGLang 调度策略源码 python/sglang/srt/managers/schedule_policy.py
  4. SGLang Scheduler 源码 python/sglang/srt/managers/scheduler.py
  5. SGLang Prefill Delayer 源码 python/sglang/srt/managers/prefill_delayer.py
  6. SGLang Server Arguments 文档

文章分享

如果这篇文章对你有帮助,欢迎分享给更多人!

SGLang 源码完全拆解(二十七):调度策略——FCFS、LPM、DFS 权重与 HRRN,一张等待队列的排序账
https://pinghaoyang.com.cn/articles/insights/posts/sglang-schedule-policy-priority/
作者
平昊阳
发布于
2026-11-02
许可协议
CC BY-NC-SA 4.0

评论区

访客信息
--°天气加载中

正在获取所在地区…

💨 风力--
💧 湿度--

正在读取天气

Profile Image of the Author
平昊阳
乘长风,破巨浪, 展鸿图于未央!
公告
欢迎来到我的个人博客!欢迎关注交流吖!
更多相关公告,见
「社交-留言」。
音乐
封面

音乐

暂未播放

0:000:00
暂无歌词
站点统计
文章
448
分类
29
标签
451
总字数
6,178,652
运行时长
0 天
最后活动
0 天前

文章目录