Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
f2fe3d0
[Scheduler] Keep waiting requests' cached prefixes resident under cac…
peilii Sep 28, 2026
3a79e2d
Document the 128 waiting-queue cap on the prefix refresh
peilii Sep 29, 2026
b788b80
Merge branch 'main' into peili/refresh-waiting-prefix-fcfs
nvpohanh Sep 30, 2026
a8d6d61
Merge branch 'main' into peili/refresh-waiting-prefix-fcfs
nvpohanh Sep 30, 2026
a2f2367
Refresh waiting prefixes on the device tree only and skip chunk caches
peilii Oct 1, 2026
0d92e71
Merge remote-tracking branch 'fork/peili/refresh-waiting-prefix-fcfs'…
peilii Oct 1, 2026
5708671
Give UnifiedRadixCache the device-only prefix refresh
peilii Oct 1, 2026
998f9e9
Refresh waiting prefixes head-last at any depth, also through Streami…
peilii Oct 1, 2026
80f7932
Build the request radix key in one place for the matcher and the refresh
peilii Oct 2, 2026
8c898f6
Merge branch 'main' into peili/refresh-waiting-prefix-fcfs
nvpohanh Oct 5, 2026
f910570
Merge branch 'main' into peili/refresh-waiting-prefix-fcfs
nvpohanh Oct 6, 2026
6767a7a
Merge branch 'main' into peili/refresh-waiting-prefix-fcfs
nvpohanh Oct 6, 2026
4a3d7f1
Merge branch 'main' into peili/refresh-waiting-prefix-fcfs
hnyls2002 Oct 8, 2026
60bdb8c
[Scheduler] Refresh waiting prefixes after sorting, throttled and LRU…
hnyls2002 Oct 8, 2026
7a52058
[Scheduler] Address review: rename refresh_prefix, trim tests and com…
hnyls2002 Oct 8, 2026
8c5f848
[Scheduler] Rename the prefix touch API and share the queue bound
hnyls2002 Oct 8, 2026
893db7d
[Scheduler] Trim the comments on the waiting-prefix touch
hnyls2002 Oct 8, 2026
e5253fe
Merge remote-tracking branch 'origin/main' into peili/refresh-waiting…
hnyls2002 Oct 8, 2026
c42da2b
[Scheduler] Read device_prefix_len in the waiting-prefix touch test
hnyls2002 Oct 8, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions python/sglang/srt/environ.py
Original file line number Diff line number Diff line change
Expand Up @@ -602,6 +602,7 @@ class Envs:
# ===================================================================
# Scheduler token budgeting and admission
# ===================================================================
SGLANG_ENABLE_WAITING_PREFIX_REFRESH = EnvBool(True)
SGLANG_INIT_NEW_TOKEN_RATIO = EnvFloat(0.7)
SGLANG_MIN_NEW_TOKEN_RATIO_FACTOR = EnvFloat(0.14)
SGLANG_NEW_TOKEN_RATIO_DECAY_STEPS = EnvInt(600)
Expand Down
38 changes: 33 additions & 5 deletions python/sglang/srt/managers/schedule_policy.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
from sglang.srt.runtime_context import (
get_disagg,
get_exec,
get_memory,
get_schedule,
)
from sglang.srt.utils import get_bool_env_var, is_gfx95_supported, is_hip
Expand All @@ -31,6 +32,7 @@

import os
import random
import time
from collections import Counter
from contextlib import contextmanager, nullcontext
from dataclasses import dataclass
Expand Down Expand Up @@ -66,7 +68,7 @@
MatchPrefixParams,
zero_match_result,
)
from sglang.srt.mem_cache.common import match_kv_cache
from sglang.srt.mem_cache.common import match_kv_cache, touch_waiting_prefix
from sglang.srt.mem_cache.radix_cache import RadixCache, RadixKey, TreeNode
from sglang.srt.mem_cache.unified_cache.components import (
CacheTransferPhase,
Expand Down Expand Up @@ -128,6 +130,11 @@ def _use_exact_chunk_fill() -> bool:
)
PREFILL_TILE_BUDGET_MODE = "compact"

# Arbitrary; bounds per-round prefix work on the waiting queue.
WAITING_QUEUE_PREFIX_MATCH_MAX = 128
# Arbitrary; bounds the walk cost of touching waiting prefixes.
WAITING_PREFIX_REFRESH_INTERVAL_S = 0.5


def _ceil_div(value: int, divisor: int) -> int:
return -(-value // divisor)
Expand Down Expand Up @@ -195,6 +202,7 @@ def __init__(
self.schedule_low_priority_values_first = schedule_low_priority_values_first
self.priority_sign = 1 if schedule_low_priority_values_first else -1
self._shortest_prefill_calls = 0
self._last_waiting_prefix_refresh = float("-inf")

# It is used to find the matching prefix for in-batch prefix caching.
self.waiting_queue_radix_tree = RadixCache.create_simulated()
Expand Down Expand Up @@ -222,9 +230,7 @@ def calc_priority(
SchedulePolicy._sort_by_priority_and_fcfs(
waiting_queue, self.priority_sign
)
return

if isinstance(policy, CacheAwarePolicy):
elif isinstance(policy, CacheAwarePolicy):
temporary_deprioritized = self._compute_prefix_matches(
waiting_queue, policy
)
Expand Down Expand Up @@ -268,14 +274,36 @@ def calc_priority(
else:
raise ValueError(f"Unknown CacheAgnostic Policy: {policy=}")

self._touch_waiting_prefixes(policy, waiting_queue)

def _touch_waiting_prefixes(self, policy: Policy, waiting_queue: List[Req]) -> None:
# Head last, so LRU evicts in reverse admission order; cache-aware policies
# already touch while matching, and other eviction strategies ignore recency.
if (
isinstance(policy, CacheAwarePolicy)
or not waiting_queue
or not envs.SGLANG_ENABLE_WAITING_PREFIX_REFRESH.get()
or get_disagg().disaggregation_mode == "decode"
or get_memory().radix_eviction_policy.lower() != "lru"
or self.tree_cache.supports_fast_match_prefix()
or not self.tree_cache.supports_prefix_sharing()
):
return
now = time.monotonic()
if now - self._last_waiting_prefix_refresh < WAITING_PREFIX_REFRESH_INTERVAL_S:
return
self._last_waiting_prefix_refresh = now
for r in reversed(waiting_queue[:WAITING_QUEUE_PREFIX_MATCH_MAX]):
touch_waiting_prefix(r, self.tree_cache)

def _determine_active_policy(self, waiting_queue: List[Req]) -> Policy:
if (
self.policy
in (
CacheAwarePolicy.LPM,
CacheAwarePolicy.HRRN,
)
and len(waiting_queue) > 128
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
Expand Down
4 changes: 4 additions & 0 deletions python/sglang/srt/mem_cache/base_prefix_cache.py
Original file line number Diff line number Diff line change
Expand Up @@ -440,6 +440,10 @@ def match_prefix(self, params: MatchPrefixParams) -> MatchResult:
def supports_fast_match_prefix(self) -> bool:
return False

def touch_prefix(self, key: RadixKey) -> None:
"""Bump LRU recency along ``key``'s cached path. Unlike ``match_prefix``,
never allocates, loads, or queries external tiers."""

def dfs_weight_order(self, node_handles: Sequence[Any]) -> list[int]:
"""Return request indices in depth-first, subtree-weight order."""
return _dfs_weight_order(self.root_node, node_handles, self.resolve_node_handle)
Expand Down
44 changes: 29 additions & 15 deletions python/sglang/srt/mem_cache/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,34 @@ def free_kv_row_segments(
allocator.free_segments(swa_alive)


def _req_radix_key(
tree_cache: BasePrefixCache,
req: Req,
token_ids: array[int],
max_prefix_len: Optional[int] = None,
) -> RadixKey:
# unified_kv SWA lives in a per-request ring the tree never stores, so a reused
# prefix carries stale SWA; cap the match so the window is re-prefilled.
reprefill_tail = tree_cache.swa_reprefill_tail_tokens()
key_limit = max(0, len(token_ids) - reprefill_tail) if reprefill_tail else None
if max_prefix_len is not None:
key_limit = (
max_prefix_len if key_limit is None else min(key_limit, max_prefix_len)
)
return RadixKey(
token_ids=token_ids,
extra_key=req.extra_key,
limit=key_limit,
cache_salt=req.cache_salt,
)


def touch_waiting_prefix(req: Req, tree_cache: BasePrefixCache) -> None:
tree_cache.touch_prefix(
_req_radix_key(tree_cache, req, req.origin_input_ids + req.output_ids)
)


def match_kv_cache(
req: Req,
tree_cache: BasePrefixCache,
Expand All @@ -184,23 +212,9 @@ def match_kv_cache(
if token_ids is None:
token_ids = req.origin_input_ids + req.output_ids

# unified_kv SWA lives in a per-request ring the tree never stores, so a reused
# prefix carries stale SWA; cap the match so the window is re-prefilled.
reprefill_tail = tree_cache.swa_reprefill_tail_tokens()
key_limit = max(0, len(token_ids) - reprefill_tail) if reprefill_tail else None
if max_prefix_len is not None:
key_limit = (
max_prefix_len if key_limit is None else min(key_limit, max_prefix_len)
)

match_result = tree_cache.match_prefix(
MatchPrefixParams(
key=RadixKey(
token_ids=token_ids,
extra_key=req.extra_key,
limit=key_limit,
cache_salt=req.cache_salt,
),
key=_req_radix_key(tree_cache, req, token_ids, max_prefix_len),
cow_mamba=cow_mamba,
req=req,
)
Expand Down
9 changes: 9 additions & 0 deletions python/sglang/srt/mem_cache/radix_cache.py
Original file line number Diff line number Diff line change
Expand Up @@ -406,6 +406,15 @@ def match_prefix(self, params: MatchPrefixParams) -> MatchResult:
best_match_node=last_node,
)

def touch_prefix(self, key: RadixKey) -> None:
key, _ = key.maybe_to_bigram_view(self.is_eagle)
if self.disable or len(key) == 0:
return
key = key.page_aligned(self.page_size)
if len(key) == 0:
return
self._match_prefix_helper(self.root_node, key)

def insert(self, params: InsertParams) -> InsertResult:
if self.disable:
return InsertResult(prefix_len=0)
Expand Down
7 changes: 7 additions & 0 deletions python/sglang/srt/mem_cache/unified_radix_cache.py
Original file line number Diff line number Diff line change
Expand Up @@ -599,6 +599,13 @@ def _match_tree(self, params: MatchPrefixParams) -> MatchResult:
def supports_fast_match_prefix(self) -> bool:
return self.tree_core.supports_fast_match_prefix()

def touch_prefix(self, key: RadixKey) -> None:
# Tree walk only; skips match_prefix's session shortcut, finalizers and linker.
if self.disable:
return
result = self.tree_core.match_prefix(MatchPrefixParams(key=key))
self._apply_cache_actions(result.cache_actions)

def supports_prefix_sharing(self) -> bool:
return not self.disable

Expand Down
5 changes: 5 additions & 0 deletions test/manual/test_schedule_policy.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,9 @@
SchedulePolicy,
)
from sglang.srt.mem_cache.radix_cache import RadixCache
from sglang.srt.runtime_context import publish, reset_context
from sglang.srt.sampling.sampling_params import SamplingParams
from sglang.srt.server_args import ServerArgs
from sglang.test.test_utils import CustomTestCase


Expand All @@ -26,6 +28,9 @@ def _make_req(rid, origin_input_text, origin_input_ids, sampling_params=None, **

class TestSchedulePolicy(CustomTestCase):
def setUp(self):
reset_context()
self.addCleanup(reset_context)
publish(ServerArgs(model_path="dummy"), role="test")
self.tree_cache = RadixCache.create_simulated()

def test_init_with_cache_aware_policy(self):
Expand Down
103 changes: 103 additions & 0 deletions test/registered/unit/mem_cache/test_fcfs_waiting_prefix_refresh.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import time
import unittest
from array import array

import torch

from sglang.srt.managers.schedule_batch import Req
from sglang.srt.managers.schedule_policy import SchedulePolicy
from sglang.srt.mem_cache.allocator import TokenToKVPoolAllocator
from sglang.srt.mem_cache.base_prefix_cache import (
EvictParams,
InsertParams,
MatchPrefixParams,
)
from sglang.srt.mem_cache.cache_init_params import CacheInitParams
from sglang.srt.mem_cache.memory_pool import MHATokenToKVPool, ReqToTokenPool
from sglang.srt.mem_cache.radix_cache import RadixCache, RadixKey
from sglang.srt.runtime_context import publish, reset_context
from sglang.srt.sampling.sampling_params import SamplingParams
from sglang.srt.server_args import ServerArgs
from sglang.test.ci.ci_register import register_cpu_ci
from sglang.test.test_utils import CustomTestCase

register_cpu_ci(est_time=5, suite="base-a-test-cpu")

OLDER_PREFIX = [1, 2, 3, 4]
NEWER_PREFIX = [5, 6, 7, 8]


class TestFcfsWaitingPrefixRefresh(CustomTestCase):
def setUp(self):
reset_context()
self.addCleanup(reset_context)
torch.set_default_device(None)

def _run_and_evict_one_prefix(self, eviction_policy):
kv_cache = MHATokenToKVPool(
size=16,
page_size=1,
dtype=torch.float16,
head_num=1,
head_dim=8,
layer_num=1,
device="cpu",
enable_memory_saver=False,
)
allocator = TokenToKVPoolAllocator(
size=16,
dtype=torch.float16,
device="cpu",
kvcache=kv_cache,
need_sort=False,
)
cache = RadixCache(
CacheInitParams(
disable=False,
req_to_token_pool=ReqToTokenPool(
size=8, max_context_len=64, device="cpu", enable_memory_saver=False
),
token_to_kv_pool_allocator=allocator,
page_size=1,
eviction_policy=eviction_policy,
enable_kv_cache_events=False,
)
)
for tokens, slots in (
(OLDER_PREFIX, [10, 11, 12, 13]),
(NEWER_PREFIX, [20, 21, 22, 23]),
):
cache.insert(
InsertParams(
key=RadixKey(array("q", tokens)),
value=torch.tensor(slots, dtype=torch.int64),
)
)
time.sleep(0.005)
queue = [
Req(rid, "", array("q", tokens + [9]), SamplingParams())
for rid, tokens in ((1, OLDER_PREFIX), (2, NEWER_PREFIX))
]
publish(
ServerArgs(model_path="dummy", radix_eviction_policy=eviction_policy),
role="test",
)
SchedulePolicy("fcfs", cache, False, False, False).calc_priority(queue)
cache.evict(EvictParams(num_tokens=len(OLDER_PREFIX)))
return [
cache.match_prefix(
MatchPrefixParams(key=RadixKey(array("q", tokens)))
).device_prefix_len
for tokens in (OLDER_PREFIX, NEWER_PREFIX)
]

def test_lru_evicts_the_prefix_furthest_from_admission(self):
self.assertEqual(self._run_and_evict_one_prefix("lru"), [4, 0])

def test_mru_keeps_plain_order(self):
# MRU evicts the most recent first, so a refresh would invert its intent.
self.assertEqual(self._run_and_evict_one_prefix("mru"), [4, 0])


if __name__ == "__main__":
unittest.main()
Loading