From 1458c5dfdf7b8fff6124a1efe67beca46bffe2d4 Mon Sep 17 00:00:00 2001 From: David <12414531+DavidBellamy@users.noreply.github.com> Date: Mon, 25 May 2026 19:32:04 -0700 Subject: [PATCH 01/11] feat: foundation for --no-cache-thoughts with position-preserved radix entries Adds the data-model and helper pieces needed to skip reasoning tokens from the shared prefix cache while preserving thought-infused answer K/V across turns. * server_args: --no-cache-thoughts boolean flag * schedule_batch.Req: answer_start_position field, set by update_reasoning_tokens when the boundary is detected * base_prefix_cache: original_positions on InsertParams and MatchResult (backwards-compat default: None == today's contiguous-position behavior) * radix_cache (RadixCache only): round-trip per-token positions through insert, match_prefix, _split_node, and TreeNode * common.split_kv_for_no_cache_thoughts: pure helper that splits a finished request's KV into the radix-bound slice (prompt + post- answer with original positions) and the thought slice to free directly 11 unit tests cover the new behavior. Remaining work (not in this commit): wiring release_kv_cache to invoke the helper, non-contiguous positions in the scheduler's compute_position/clamp_position paths, schema propagation to the 6 non-RadixCache backends, and the e2e server-fixture test. --- python/sglang/srt/managers/schedule_batch.py | 9 ++ .../sglang/srt/mem_cache/base_prefix_cache.py | 8 + python/sglang/srt/mem_cache/common.py | 93 +++++++++++- python/sglang/srt/mem_cache/radix_cache.py | 45 +++++- python/sglang/srt/server_args.py | 12 ++ .../radix_cache/test_no_cache_thoughts_cli.py | 22 +++ .../test_no_cache_thoughts_split.py | 128 ++++++++++++++++ .../test_radix_position_preservation.py | 137 ++++++++++++++++++ .../test_req_answer_start_position.py | 31 ++++ 9 files changed, 481 insertions(+), 4 deletions(-) create mode 100644 test/registered/radix_cache/test_no_cache_thoughts_cli.py create mode 100644 test/registered/radix_cache/test_no_cache_thoughts_split.py create mode 100644 test/registered/radix_cache/test_radix_position_preservation.py create mode 100644 test/registered/radix_cache/test_req_answer_start_position.py diff --git a/python/sglang/srt/managers/schedule_batch.py b/python/sglang/srt/managers/schedule_batch.py index 09254f314b0c..b12b91ecf306 100644 --- a/python/sglang/srt/managers/schedule_batch.py +++ b/python/sglang/srt/managers/schedule_batch.py @@ -640,6 +640,11 @@ def __init__( # State indicating whether the reasoning phase has finished (only meaningful when require_reasoning is True) self._is_reasoning_over = False self.reasoning_tokens = 0 + # Absolute position (in the origin_input_ids + output_ids index space) of the + # first token after . Set by update_reasoning_tokens when the + # boundary is detected; consumed at request finish under --no-cache-thoughts to + # split the request's KV between freed-only thoughts and radix-inserted answer. + self.answer_start_position: Optional[int] = None # Sampling info if isinstance(sampling_params.custom_params, dict): @@ -1291,6 +1296,10 @@ def update_reasoning_tokens(self, token_id, think_end_id): end_pos = token_id.index(think_end_id) self.reasoning_tokens += end_pos + 1 self._is_reasoning_over = True + # The answer begins immediately after . Position is in the absolute + # token-index space (origin_input_ids + output_ids), which equals the RoPE + # position when the request was decoded with contiguous positions. + self.answer_start_position = len(self.origin_input_ids) + self.reasoning_tokens except ValueError: self.reasoning_tokens += len(token_id) diff --git a/python/sglang/srt/mem_cache/base_prefix_cache.py b/python/sglang/srt/mem_cache/base_prefix_cache.py index be219339cef6..8713fa841e57 100644 --- a/python/sglang/srt/mem_cache/base_prefix_cache.py +++ b/python/sglang/srt/mem_cache/base_prefix_cache.py @@ -61,6 +61,13 @@ class InsertParams: chunked: bool = False priority: int = 0 + # Per-token original RoPE positions for the inserted tokens. When None (default), + # the cache uses standard contiguous positions and behavior is unchanged. When + # provided, length must equal len(key.token_ids); positions are stored alongside + # the kv_indices so subsequent match_prefix calls can return them, letting the + # scheduler use non-contiguous positions in the ForwardBatch. + original_positions: Optional[torch.Tensor] = None + @dataclasses.dataclass class InsertResult: @@ -145,6 +152,7 @@ class MatchResult(NamedTuple): host_hit_length: int = 0 mamba_branching_seqlen: Optional[int] = None cache_protected_len: Optional[int] = None + original_positions: Optional[torch.Tensor] = None class BasePrefixCache(ABC, PrefixCacheTrait): diff --git a/python/sglang/srt/mem_cache/common.py b/python/sglang/srt/mem_cache/common.py index 5a759ed11bcd..f0920d54832e 100644 --- a/python/sglang/srt/mem_cache/common.py +++ b/python/sglang/srt/mem_cache/common.py @@ -1,7 +1,8 @@ from __future__ import annotations +import dataclasses import logging -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, List import torch import triton @@ -17,6 +18,96 @@ if TYPE_CHECKING: from sglang.srt.managers.schedule_batch import Req, ScheduleBatch + +@dataclasses.dataclass +class NoCacheThoughtsSplit: + """Result of splitting a finished reasoning request's KV into the radix-bound slice + and the freed-only thought slice. + + The cache_finished_req path consumes virtual_token_ids/positions/kv_indices to + register the answer in the radix tree with its original positions preserved, + then frees thought_kv_indices_to_free directly to the allocator. + """ + + virtual_token_ids: List[int] + virtual_kv_indices: torch.Tensor + virtual_positions: torch.Tensor + thought_kv_indices_to_free: torch.Tensor + + +def split_kv_for_no_cache_thoughts( + origin_input_ids: List[int], + output_ids: List[int], + req_to_token_slot: torch.Tensor, + answer_start_position: int, +) -> NoCacheThoughtsSplit: + """Compute the split-insertion tensors for a finished reasoning request. + + When --no-cache-thoughts is enabled and a request emits ``, the tokens + between input_end and the `` boundary are thoughts that must NOT be + registered in the cross-request radix cache; the post-`` answer must + be inserted with the original RoPE positions preserved. + + Args: + origin_input_ids: input token ids (positions 0..len(input)-1). + output_ids: generated token ids (positions len(input)..len(input)+len(output)-1). + req_to_token_slot: 1D tensor of kv_indices, one per token in the request's + sequence (length must be >= len(origin_input_ids) + len(output_ids)). + answer_start_position: absolute position (in the input+output index space) + of the first answer token, i.e. the token immediately after ``. + + Returns: + NoCacheThoughtsSplit with: + * virtual_token_ids: input + post-`` answer (thoughts removed). + * virtual_kv_indices: kv_indices for those tokens, gathered from req_to_token_slot. + * virtual_positions: matching original RoPE positions; non-contiguous between + input_len-1 and answer_start_position. + * thought_kv_indices_to_free: kv_indices for slots covering the thought slice. + + When answer_start_position >= len(input) + len(output), the answer hasn't been + generated yet (e.g. the request was cut off mid-thought) and the result contains + only the input prompt slice. + """ + input_len = len(origin_input_ids) + total_len = input_len + len(output_ids) + + # Clamp answer_start so we behave sanely if reasoning never finished. + answer_start = min(answer_start_position, total_len) + + # Slots / positions for input + post- answer. + answer_count = max(total_len - answer_start, 0) + answer_output_offset = answer_start - input_len # index into output_ids + + virtual_token_ids = list(origin_input_ids) + list( + output_ids[answer_output_offset:] if answer_count > 0 else [] + ) + + kept_slot_indices = list(range(input_len)) + list(range(answer_start, total_len)) + kept_positions = list(range(input_len)) + list(range(answer_start, total_len)) + thought_slot_indices = list(range(input_len, answer_start)) + + device = req_to_token_slot.device + if kept_slot_indices: + idx_tensor = torch.tensor(kept_slot_indices, dtype=torch.int64, device=device) + virtual_kv_indices = req_to_token_slot[idx_tensor].to(torch.int64).clone() + else: + virtual_kv_indices = torch.empty((0,), dtype=torch.int64, device=device) + + virtual_positions = torch.tensor(kept_positions, dtype=torch.int64, device=device) + + if thought_slot_indices: + t_idx = torch.tensor(thought_slot_indices, dtype=torch.int64, device=device) + thought_kv_indices_to_free = req_to_token_slot[t_idx].to(torch.int64).clone() + else: + thought_kv_indices_to_free = torch.empty((0,), dtype=torch.int64, device=device) + + return NoCacheThoughtsSplit( + virtual_token_ids=virtual_token_ids, + virtual_kv_indices=virtual_kv_indices, + virtual_positions=virtual_positions, + thought_kv_indices_to_free=thought_kv_indices_to_free, + ) + # Needs 2 + 1 slots for mamba request with prefix cache. 2 for ping pong cache, 1 for running mamba state. MAMBA_STATE_PER_REQ_PREFIX_CACHE = 3 MAMBA_STATE_PER_REQ_NO_CACHE = 1 diff --git a/python/sglang/srt/mem_cache/radix_cache.py b/python/sglang/srt/mem_cache/radix_cache.py index 7d1616037243..a3a127bced0c 100644 --- a/python/sglang/srt/mem_cache/radix_cache.py +++ b/python/sglang/srt/mem_cache/radix_cache.py @@ -127,6 +127,9 @@ def __init__(self, id: Optional[int] = None, priority: int = 0): self.parent: TreeNode = None self.key: RadixKey = None self.value: Optional[torch.Tensor] = None + # Per-token original RoPE positions, parallel to `value`. None when the node was + # inserted without positions (standard behavior). + self.positions: Optional[torch.Tensor] = None self.lock_ref = 0 self.last_access_time = time.monotonic() self.creation_time = time.monotonic() @@ -432,15 +435,30 @@ def empty_match_result(): if len(key) == 0: return empty_match_result() - value, last_node = self._match_prefix_helper(self.root_node, key) + value, positions, last_node = self._match_prefix_helper(self.root_node, key) if value: value = torch.cat(value) else: value = torch.empty((0,), dtype=torch.int64, device=self.device) + # If any matched node carried original positions, concatenate and return them. + # Otherwise return None for backwards compatibility. + if positions and any(p is not None for p in positions): + # Replace any None entries (mixed-mode tree) with contiguous fallback positions. + # This should be rare; tree builders typically use positions consistently. + concat_positions = torch.cat( + [ + p if p is not None else torch.empty((0,), dtype=torch.int64, device=self.device) + for p in positions + ] + ) + original_positions = concat_positions + else: + original_positions = None return MatchResult( device_indices=value, last_device_node=last_node, last_host_node=last_node, + original_positions=original_positions, ) def insert(self, params: InsertParams) -> InsertResult: @@ -451,13 +469,22 @@ def insert(self, params: InsertParams) -> InsertResult: value = params.value priority = params.priority chunked = params.chunked + positions = params.original_positions if value is None: value = torch.tensor(key.token_ids, dtype=torch.int64) + if positions is not None and len(positions) != len(key.token_ids): + raise ValueError( + f"original_positions length {len(positions)} does not match key " + f"token_ids length {len(key.token_ids)}" + ) + key, value = self.maybe_bigram_convert(key, value) - prefix_len = self._insert_helper(self.root_node, key, value, priority, chunked) + prefix_len = self._insert_helper( + self.root_node, key, value, priority, chunked, positions + ) return InsertResult(prefix_len=prefix_len) def cache_finished_req(self, req: Req, is_insert: bool = True): @@ -671,6 +698,7 @@ def _match_prefix_helper(self, node: TreeNode, key: RadixKey): child_key = self.get_child_key_fn(key) value = [] + positions = [] # Parallel list of per-node positions tensors (or Nones). while len(key) > 0 and child_key in node.children.keys(): child = node.children[child_key] child.last_access_time = access_time @@ -678,17 +706,19 @@ def _match_prefix_helper(self, node: TreeNode, key: RadixKey): if prefix_len < len(child.key): new_node = self._split_node(child.key, child, prefix_len) value.append(new_node.value) + positions.append(new_node.positions) node = new_node break else: value.append(child.value) + positions.append(child.positions) node = child key = key[prefix_len:] if len(key): child_key = self.get_child_key_fn(key) - return value, node + return value, positions, node def _split_node(self, key: RadixKey, child: TreeNode, split_len: int): # new_node -> child @@ -700,6 +730,10 @@ def _split_node(self, key: RadixKey, child: TreeNode, split_len: int): new_node.lock_ref = child.lock_ref new_node.key = child.key[:split_len] new_node.value = child.value[:split_len].clone() + # Split positions in lockstep with value. + if child.positions is not None: + new_node.positions = child.positions[:split_len].clone() + child.positions = child.positions[split_len:].clone() child.parent = new_node child.key = child.key[split_len:] child.value = child.value[split_len:].clone() @@ -727,6 +761,7 @@ def _insert_helper( value, priority: int = 0, chunked: bool = False, + positions: Optional[torch.Tensor] = None, ): # Convert None priority to 0 if priority is None: @@ -748,6 +783,8 @@ def _insert_helper( total_prefix_length += prefix_len key = key[prefix_len:] value = value[prefix_len:] + if positions is not None: + positions = positions[prefix_len:] if prefix_len < len(node.key): new_node = self._split_node(node.key, node, prefix_len) @@ -765,6 +802,8 @@ def _insert_helper( new_node.parent = node new_node.key = key new_node.value = value.clone() + if positions is not None: + new_node.positions = positions.clone() self._inc_hit_count(new_node, chunked) node.children[child_key] = new_node self.evictable_size_ += len(key) diff --git a/python/sglang/srt/server_args.py b/python/sglang/srt/server_args.py index 1bd07eec2992..2d63a93ac2d7 100644 --- a/python/sglang/srt/server_args.py +++ b/python/sglang/srt/server_args.py @@ -436,6 +436,7 @@ class ServerArgs: file_storage_path: str = "sglang_storage" enable_cache_report: bool = False reasoning_parser: Optional[str] = None + no_cache_thoughts: bool = False tool_call_parser: Optional[str] = None tool_server: Optional[str] = None sampling_defaults: str = "model" @@ -4508,6 +4509,17 @@ def add_cli_args(parser: argparse.ArgumentParser): default=ServerArgs.reasoning_parser, help=f"Specify the parser for reasoning models, supported parsers are: {list(ReasoningParser.DetectorMap.keys())}.", ) + parser.add_argument( + "--no-cache-thoughts", + action="store_true", + default=ServerArgs.no_cache_thoughts, + help=( + "Skip inserting reasoning (thought) tokens into the shared prefix cache. " + "Answer tokens after are inserted with their original RoPE positions " + "preserved so that thought-infused K/V representations remain reusable across " + "turns. Requires --reasoning-parser." + ), + ) tool_call_parser_choices = list(FunctionCallParser.ToolCallParserEnum.keys()) parser.add_argument( "--tool-call-parser", diff --git a/test/registered/radix_cache/test_no_cache_thoughts_cli.py b/test/registered/radix_cache/test_no_cache_thoughts_cli.py new file mode 100644 index 000000000000..60cbd86887b5 --- /dev/null +++ b/test/registered/radix_cache/test_no_cache_thoughts_cli.py @@ -0,0 +1,22 @@ +"""Tests for the --no-cache-thoughts CLI flag on ServerArgs.""" + +import argparse +import unittest + +from sglang.srt.server_args import ServerArgs + + +class TestNoCacheThoughtsCliFlag(unittest.TestCase): + def test_default_is_false(self): + s = ServerArgs(model_path="dummy") + self.assertFalse(s.no_cache_thoughts) + + def test_argparse_sets_to_true(self): + parser = argparse.ArgumentParser() + ServerArgs.add_cli_args(parser) + ns = parser.parse_args(["--model-path", "dummy", "--no-cache-thoughts"]) + self.assertTrue(ns.no_cache_thoughts) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/registered/radix_cache/test_no_cache_thoughts_split.py b/test/registered/radix_cache/test_no_cache_thoughts_split.py new file mode 100644 index 000000000000..90d84f28a22f --- /dev/null +++ b/test/registered/radix_cache/test_no_cache_thoughts_split.py @@ -0,0 +1,128 @@ +"""Tests for the --no-cache-thoughts split-insertion helper. + +When a reasoning request finishes with --no-cache-thoughts enabled, the request's +KV must be split: the input + post- answer is inserted into the radix tree +with original positions preserved; the thought-slice KV pages are freed directly. + +This test pins down the helper function's contract: given a finished Req's metadata +and its full per-request KV slot, produce the virtual token list / kv_indices / +positions that should be inserted, plus the kv_indices that should be freed. + +Tests run without a GPU or model. +""" + +import unittest + +import torch + +from sglang.srt.mem_cache.common import split_kv_for_no_cache_thoughts +from sglang.test.test_utils import CustomTestCase + + +class TestNoCacheThoughtsSplit(CustomTestCase): + """Validate the split-insertion helper for --no-cache-thoughts.""" + + def test_split_basic_case(self): + """ + Setup mirrors a typical reasoning request: + positions: 0 1 2 3 4 5 6 7 8 + tokens: A B T1 T2 X Y Z + └─prompt─┘ └────thoughts────┘ └─answer─┘ + answer_start_position = 6 (position right after ) + kv_indices in the per-request slot: [100..108], one per token. + """ + origin_input_ids = [101, 102] # A, B + output_ids = [201, 202, 203, 204, 301, 302, 303] # think, T1, T2, end, X, Y, Z + req_to_token_slot = torch.tensor( + [1000, 1001, 1002, 1003, 1004, 1005, 1006, 1007, 1008], dtype=torch.int64 + ) + answer_start_position = 6 + + result = split_kv_for_no_cache_thoughts( + origin_input_ids=origin_input_ids, + output_ids=output_ids, + req_to_token_slot=req_to_token_slot, + answer_start_position=answer_start_position, + ) + + # Virtual token list: [A, B, X, Y, Z] + self.assertEqual(result.virtual_token_ids, [101, 102, 301, 302, 303]) + # Virtual kv_indices: pointers for slots [0, 1, 6, 7, 8] + self.assertEqual( + result.virtual_kv_indices.tolist(), [1000, 1001, 1006, 1007, 1008] + ) + # Virtual positions: prompt positions + answer original positions + self.assertEqual( + result.virtual_positions.tolist(), [0, 1, 6, 7, 8] + ) + # Thought kv_indices to free: slots [2, 3, 4, 5] + self.assertEqual( + result.thought_kv_indices_to_free.tolist(), [1002, 1003, 1004, 1005] + ) + + def test_split_no_answer_yet(self): + """If answer_start_position == total_len (i.e. emitted last, no answer + tokens generated yet), virtual sequence is just the prompt and there's no answer + slice.""" + origin_input_ids = [101, 102] + output_ids = [201, 202, 203, 204] # think, T1, T2, end_think — no answer yet + req_to_token_slot = torch.tensor( + [1000, 1001, 1002, 1003, 1004, 1005], dtype=torch.int64 + ) + # at position 5; answer starts at 6, but seq ends at 5. + answer_start_position = 6 + + result = split_kv_for_no_cache_thoughts( + origin_input_ids=origin_input_ids, + output_ids=output_ids, + req_to_token_slot=req_to_token_slot, + answer_start_position=answer_start_position, + ) + + # Virtual list contains only the input. + self.assertEqual(result.virtual_token_ids, [101, 102]) + self.assertEqual(result.virtual_kv_indices.tolist(), [1000, 1001]) + self.assertEqual(result.virtual_positions.tolist(), [0, 1]) + # All output tokens are thoughts to free. + self.assertEqual( + result.thought_kv_indices_to_free.tolist(), [1002, 1003, 1004, 1005] + ) + + def test_split_long_answer(self): + """Multi-token answer with a longer thought slice.""" + origin_input_ids = [10, 11, 12] # 3-token prompt at positions 0-2 + # 5-token thoughts at positions 3-7, then 4-token answer at positions 8-11 + output_ids = [20, 21, 22, 23, 24, 30, 31, 32, 33] + req_to_token_slot = torch.tensor( + [500, 501, 502, 503, 504, 505, 506, 507, 508, 509, 510, 511], + dtype=torch.int64, + ) + answer_start_position = 8 + + result = split_kv_for_no_cache_thoughts( + origin_input_ids=origin_input_ids, + output_ids=output_ids, + req_to_token_slot=req_to_token_slot, + answer_start_position=answer_start_position, + ) + + # Virtual list: [10, 11, 12, 30, 31, 32, 33] + self.assertEqual(result.virtual_token_ids, [10, 11, 12, 30, 31, 32, 33]) + # Virtual kv_indices: slots [0, 1, 2, 8, 9, 10, 11] + self.assertEqual( + result.virtual_kv_indices.tolist(), + [500, 501, 502, 508, 509, 510, 511], + ) + # Virtual positions: prompt [0, 1, 2] + answer [8, 9, 10, 11] + self.assertEqual( + result.virtual_positions.tolist(), [0, 1, 2, 8, 9, 10, 11] + ) + # Thoughts: slots [3, 4, 5, 6, 7] + self.assertEqual( + result.thought_kv_indices_to_free.tolist(), + [503, 504, 505, 506, 507], + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/registered/radix_cache/test_radix_position_preservation.py b/test/registered/radix_cache/test_radix_position_preservation.py new file mode 100644 index 000000000000..a318f1051ff0 --- /dev/null +++ b/test/registered/radix_cache/test_radix_position_preservation.py @@ -0,0 +1,137 @@ +"""Tests for radix-tree round-tripping of per-token original RoPE positions. + +The radix tree must accept original_positions on insert and return them on +match_prefix, so callers can preserve non-contiguous positions (e.g. when a +generated thought slice was excluded from the cached entry) across cache hits. + +These tests run without a GPU or model — they use RadixCache.create_simulated(). +""" + +import unittest + +import torch + +from sglang.srt.mem_cache.base_prefix_cache import InsertParams, MatchPrefixParams +from sglang.srt.mem_cache.radix_cache import RadixCache, RadixKey +from sglang.test.ci.ci_register import register_cuda_ci +from sglang.test.test_utils import CustomTestCase + +register_cuda_ci(est_time=5, suite="stage-b-test-1-gpu-small") + + +class TestRadixPositionPreservation(CustomTestCase): + """Radix tree must carry per-token original_positions through insert and match.""" + + def setUp(self): + self.tree = RadixCache.create_simulated() + + def test_insert_accepts_original_positions(self): + """InsertParams must accept an original_positions tensor matching key length.""" + token_ids = [10, 11, 12, 13, 14] + positions = torch.tensor([0, 1, 6, 7, 8], dtype=torch.int64) + kv_indices = torch.tensor([100, 101, 102, 103, 104], dtype=torch.int64) + + result = self.tree.insert( + InsertParams( + key=RadixKey(token_ids=token_ids, extra_key=None), + value=kv_indices, + original_positions=positions, + ) + ) + # Insert is expected to succeed; prefix_len reflects pre-existing tree overlap (here, 0). + self.assertEqual(result.prefix_len, 0) + + def test_match_returns_non_contiguous_positions(self): + """After inserting with non-contiguous positions, match_prefix must return them.""" + token_ids = [10, 11, 12, 13, 14] + positions = torch.tensor([0, 1, 6, 7, 8], dtype=torch.int64) + kv_indices = torch.tensor([100, 101, 102, 103, 104], dtype=torch.int64) + + self.tree.insert( + InsertParams( + key=RadixKey(token_ids=token_ids, extra_key=None), + value=kv_indices, + original_positions=positions, + ) + ) + + match = self.tree.match_prefix( + MatchPrefixParams(key=RadixKey(token_ids=token_ids, extra_key=None)) + ) + + self.assertIsNotNone(match.original_positions) + self.assertEqual(match.original_positions.tolist(), [0, 1, 6, 7, 8]) + # device_indices must still match the inserted kv_indices. + self.assertEqual(match.device_indices.tolist(), [100, 101, 102, 103, 104]) + + def test_match_returns_none_positions_for_legacy_insert(self): + """Backwards-compat: insert without original_positions returns None on match.""" + token_ids = [20, 21, 22] + kv_indices = torch.tensor([200, 201, 202], dtype=torch.int64) + + self.tree.insert( + InsertParams( + key=RadixKey(token_ids=token_ids, extra_key=None), + value=kv_indices, + ) + ) + + match = self.tree.match_prefix( + MatchPrefixParams(key=RadixKey(token_ids=token_ids, extra_key=None)) + ) + + self.assertIsNone(match.original_positions) + self.assertEqual(match.device_indices.tolist(), [200, 201, 202]) + + def test_partial_match_returns_position_prefix(self): + """If only a prefix of the cached entry matches, returned positions cover that prefix.""" + token_ids = [10, 11, 12, 13, 14] + positions = torch.tensor([0, 1, 6, 7, 8], dtype=torch.int64) + kv_indices = torch.tensor([100, 101, 102, 103, 104], dtype=torch.int64) + + self.tree.insert( + InsertParams( + key=RadixKey(token_ids=token_ids, extra_key=None), + value=kv_indices, + original_positions=positions, + ) + ) + + # Query with only the first 3 tokens; expect positions [0, 1, 6] + match = self.tree.match_prefix( + MatchPrefixParams(key=RadixKey(token_ids=[10, 11, 12], extra_key=None)) + ) + + self.assertIsNotNone(match.original_positions) + self.assertEqual(match.original_positions.tolist(), [0, 1, 6]) + self.assertEqual(match.device_indices.tolist(), [100, 101, 102]) + + def test_extend_existing_path_with_positions(self): + """Inserting a longer sequence with positions extends an existing prefix path.""" + # First, insert the contiguous prompt [A, B] at positions [0, 1]. + self.tree.insert( + InsertParams( + key=RadixKey(token_ids=[1, 2], extra_key=None), + value=torch.tensor([100, 101], dtype=torch.int64), + original_positions=torch.tensor([0, 1], dtype=torch.int64), + ) + ) + + # Then, insert [A, B, X, Y, Z] at positions [0, 1, 6, 7, 8] (gap where thoughts were). + self.tree.insert( + InsertParams( + key=RadixKey(token_ids=[1, 2, 3, 4, 5], extra_key=None), + value=torch.tensor([100, 101, 102, 103, 104], dtype=torch.int64), + original_positions=torch.tensor([0, 1, 6, 7, 8], dtype=torch.int64), + ) + ) + + match = self.tree.match_prefix( + MatchPrefixParams(key=RadixKey(token_ids=[1, 2, 3, 4, 5], extra_key=None)) + ) + self.assertIsNotNone(match.original_positions) + self.assertEqual(match.original_positions.tolist(), [0, 1, 6, 7, 8]) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/registered/radix_cache/test_req_answer_start_position.py b/test/registered/radix_cache/test_req_answer_start_position.py new file mode 100644 index 000000000000..30bd40cf25d3 --- /dev/null +++ b/test/registered/radix_cache/test_req_answer_start_position.py @@ -0,0 +1,31 @@ +"""Tests for Req.answer_start_position tracking via update_reasoning_tokens.""" + +import unittest + +from sglang.srt.managers.schedule_batch import Req +from sglang.srt.sampling.sampling_params import SamplingParams + + +class TestReqAnswerStartPosition(unittest.TestCase): + def test_set_when_think_end_detected(self): + """When update_reasoning_tokens sees the token, answer_start_position + is set to len(input) + reasoning_tokens, i.e. the position right after .""" + # Prompt is 2 tokens [10, 11] at positions 0, 1. + # Thoughts are 4 tokens [20, 21, 22, 99] at positions 2, 3, 4, 5 — 99 is . + # Answer should start at position 6. + req = Req(rid="r1", origin_input_text="hi", origin_input_ids=[10, 11], + sampling_params=SamplingParams(), require_reasoning=True) + think_end_id = 99 + # Feed thought tokens one at a time; not yet the id. + req.update_reasoning_tokens(20, think_end_id) + req.update_reasoning_tokens(21, think_end_id) + req.update_reasoning_tokens(22, think_end_id) + self.assertIsNone(req.answer_start_position) + # Feed the token. + req.update_reasoning_tokens(99, think_end_id) + self.assertTrue(req._is_reasoning_over) + self.assertEqual(req.answer_start_position, 6) + + +if __name__ == "__main__": + unittest.main() From 56beaaf232b0a57295c526ea48c428427c85f1ff Mon Sep 17 00:00:00 2001 From: David <12414531+DavidBellamy@users.noreply.github.com> Date: Mon, 25 May 2026 19:59:19 -0700 Subject: [PATCH 02/11] feat: split-insertion routing + non-contiguous position support Extends the foundation toward --no-cache-thoughts being end-to-end functional: * RadixCache.cache_finished_req(split=...): when a NoCacheThoughtsSplit is passed, use its virtual token ids / kv_indices / positions for the radix insert instead of looking them up from the per-request KV slot; free the thought slice directly. * common.release_kv_cache: when --no-cache-thoughts is on and the request has a recorded answer_start_position, build the split via split_kv_for_no_cache_thoughts and route it into cache_finished_req. * common.derive_extend_position_start: helper that turns per-request cached RoPE positions (returned by match_prefix on cache hits) into the per-request starting position for extend tokens. Returns None when no request has cached positions (signals legacy contiguous behavior). * forward_batch_info.compute_position(_torch): new extend_position_start kwarg overrides the implicit "positions start at extend_prefix_lens" assumption, enabling non-contiguous positions on cache hits. Triton path falls through to the torch path when the override is set; native triton support deferred. 8 new unit tests (19 total, all green). Not yet wired: ForwardBatch.init_new call site that would actually pass extend_position_start, Req-side cached positions tracking, and the decode-path clamp_position changes. --- python/sglang/srt/mem_cache/common.py | 51 +++++++++- python/sglang/srt/mem_cache/radix_cache.py | 58 +++++++++++- .../srt/model_executor/forward_batch_info.py | 40 +++++++- .../test_cache_finished_req_split.py | 75 +++++++++++++++ .../test_compute_position_noncontig.py | 65 +++++++++++++ .../test_derive_extend_position_start.py | 41 +++++++++ .../test_release_kv_cache_routing.py | 92 +++++++++++++++++++ 7 files changed, 413 insertions(+), 9 deletions(-) create mode 100644 test/registered/radix_cache/test_cache_finished_req_split.py create mode 100644 test/registered/radix_cache/test_compute_position_noncontig.py create mode 100644 test/registered/radix_cache/test_derive_extend_position_start.py create mode 100644 test/registered/radix_cache/test_release_kv_cache_routing.py diff --git a/python/sglang/srt/mem_cache/common.py b/python/sglang/srt/mem_cache/common.py index f0920d54832e..2e6e80f53231 100644 --- a/python/sglang/srt/mem_cache/common.py +++ b/python/sglang/srt/mem_cache/common.py @@ -2,7 +2,7 @@ import dataclasses import logging -from typing import TYPE_CHECKING, List +from typing import TYPE_CHECKING, List, Optional import torch import triton @@ -19,6 +19,35 @@ from sglang.srt.managers.schedule_batch import Req, ScheduleBatch +def derive_extend_position_start( + extend_prefix_lens: List[int], + cached_positions_per_req: List[Optional["torch.Tensor"]], +) -> Optional[List[int]]: + """Given per-request cached RoPE positions, return the starting RoPE position for + each request's extend (prefill) tokens. + + Args: + extend_prefix_lens: per-request count of cached prefix tokens. + cached_positions_per_req: per-request tensor of cached original positions, + or None if no positions are cached for that request. + + Returns: + None if every entry is None (i.e. no request has non-contiguous cached + positions, so the legacy contiguous-positions path applies). Otherwise a + list of per-request integer starts: max(cached_positions) + 1 when cached + positions exist, else extend_prefix_lens[i] (legacy). + """ + if all(p is None for p in cached_positions_per_req): + return None + starts: List[int] = [] + for prefix_len, positions in zip(extend_prefix_lens, cached_positions_per_req): + if positions is None or len(positions) == 0: + starts.append(int(prefix_len)) + else: + starts.append(int(positions.max().item()) + 1) + return starts + + @dataclasses.dataclass class NoCacheThoughtsSplit: """Result of splitting a finished reasoning request's KV into the radix-bound slice @@ -567,7 +596,25 @@ def release_kv_cache(req: Req, tree_cache: BasePrefixCache, is_insert: bool = Tr req.mamba_pool_idx = None return - tree_cache.cache_finished_req(req, is_insert=is_insert) + server_args = get_global_server_args() + if ( + is_insert + and getattr(server_args, "no_cache_thoughts", False) + and getattr(req, "require_reasoning", False) + and getattr(req, "answer_start_position", None) is not None + ): + # Skip the thought tokens from the shared prefix cache; insert only the + # input + post- answer slice, preserving original RoPE positions. + req_to_token_slot = tree_cache.req_to_token_pool.req_to_token[req.req_pool_idx] + split = split_kv_for_no_cache_thoughts( + origin_input_ids=req.origin_input_ids, + output_ids=req.output_ids, + req_to_token_slot=req_to_token_slot, + answer_start_position=req.answer_start_position, + ) + tree_cache.cache_finished_req(req, is_insert=is_insert, split=split) + else: + tree_cache.cache_finished_req(req, is_insert=is_insert) # FIXME: SessionAwareCache.cache_finished_req sets req_pool_idx = None to # transfer KV ownership to the SessionSlot, so we skip the remaining diff --git a/python/sglang/srt/mem_cache/radix_cache.py b/python/sglang/srt/mem_cache/radix_cache.py index a3a127bced0c..8a0646e11b8e 100644 --- a/python/sglang/srt/mem_cache/radix_cache.py +++ b/python/sglang/srt/mem_cache/radix_cache.py @@ -487,13 +487,67 @@ def insert(self, params: InsertParams) -> InsertResult: ) return InsertResult(prefix_len=prefix_len) - def cache_finished_req(self, req: Req, is_insert: bool = True): - """Cache request when it finishes.""" + def cache_finished_req(self, req: Req, is_insert: bool = True, split=None): + """Cache request when it finishes. + + Args: + req: the finished request whose KV is being committed. + is_insert: when False, free the request's kv_indices without inserting them + into the radix tree (e.g. abort / retract paths). + split: when provided (a NoCacheThoughtsSplit from + ``sglang.srt.mem_cache.common.split_kv_for_no_cache_thoughts``), use the + split's virtual token ids / kv_indices / positions for the radix insert + instead of looking them up from the per-request KV slot. The thought + slice in ``split.thought_kv_indices_to_free`` is freed immediately. This + allows skipping thought tokens while preserving original RoPE positions + on the cached answer slice. + """ # In deterministic mode, disable finished request insertion to radix cache if self.disable_finished_insert: is_insert = False kv_committed_len = req.pop_committed_kv_cache() + + if split is not None: + # Skip the per-req KV-pool lookup; use the pre-computed virtual slice. + if self.disable or not is_insert: + self.token_to_kv_pool_allocator.free(split.virtual_kv_indices) + self.token_to_kv_pool_allocator.free(split.thought_kv_indices_to_free) + self.dec_lock_ref(req.last_node) + return + # Free the thought slice; it never enters the shared cache. + self.token_to_kv_pool_allocator.free(split.thought_kv_indices_to_free) + keys = ( + convert_to_bigram_key(split.virtual_token_ids) + if self.is_eagle + else split.virtual_token_ids + ) + keys = page_align_keys(keys, self.page_size) + values = split.virtual_kv_indices[: len(keys)].to( + dtype=torch.int64, copy=True + ) + positions = split.virtual_positions[: len(keys)].to( + dtype=torch.int64, copy=True + ) + radix_key = RadixKey(keys, req.extra_key, is_bigram=self.is_eagle) + priority = getattr(req, "priority", 0) or 0 + result = self.insert( + InsertParams( + key=radix_key, + value=values, + priority=priority, + original_positions=positions, + ) + ) + new_prefix_len = result.prefix_len + self.token_to_kv_pool_allocator.free( + split.virtual_kv_indices[req.cache_protected_len : new_prefix_len] + ) + # Free any unaligned tail from the page_align trim. + self.token_to_kv_pool_allocator.free(split.virtual_kv_indices[len(keys) :]) + self.dec_lock_ref(req.last_node) + return + if self.disable: kv_indices = self.req_to_token_pool.req_to_token[ req.req_pool_idx, :kv_committed_len diff --git a/python/sglang/srt/model_executor/forward_batch_info.py b/python/sglang/srt/model_executor/forward_batch_info.py index eaecdc54bcf4..f9d0f5478b86 100644 --- a/python/sglang/srt/model_executor/forward_batch_info.py +++ b/python/sglang/srt/model_executor/forward_batch_info.py @@ -1105,8 +1105,20 @@ def compute_position( extend_prefix_lens: torch.Tensor, extend_seq_lens: torch.Tensor, extend_seq_lens_sum: int, + extend_position_start: Optional[torch.Tensor] = None, ): - if support_triton(attn_backend): + """Compute positions for the extend (prefill) tokens. + + extend_position_start (optional): per-request override for the starting RoPE + position of the extend tokens. When None, positions are contiguous and start + at extend_prefix_lens[i]. When provided, positions for request i are + [extend_position_start[i], extend_position_start[i] + 1, ...]; used by cache + hits whose cached entries carry non-contiguous original positions. + """ + if support_triton(attn_backend) and extend_position_start is None: + # The fused triton kernel uses extend_prefix_lens as the position start. + # The override path is not yet implemented there; fall through to the + # torch path when an override is requested. positions, extend_start_loc = compute_position_triton( extend_prefix_lens, extend_seq_lens, @@ -1114,7 +1126,7 @@ def compute_position( ) else: positions, extend_start_loc = compute_position_torch( - extend_prefix_lens, extend_seq_lens + extend_prefix_lens, extend_seq_lens, extend_position_start ) return positions, extend_start_loc @@ -1176,14 +1188,32 @@ def compute_position_kernel( def compute_position_torch( - extend_prefix_lens: torch.Tensor, extend_seq_lens: torch.Tensor + extend_prefix_lens: torch.Tensor, + extend_seq_lens: torch.Tensor, + extend_position_start: Optional[torch.Tensor] = None, ): + """Compute per-token positions for the extend (prefill) tokens of a batch. + + Args: + extend_prefix_lens: per-request count of cached prefix tokens (KV slot count). + extend_seq_lens: per-request count of new tokens being prefilled. + extend_position_start: optional per-request override for the first RoPE + position of the extend tokens. When None, positions start at + extend_prefix_lens[i] (contiguous: token i sits at RoPE position i). + When provided, positions for request i are + [extend_position_start[i], extend_position_start[i] + 1, ...]. This + supports cache hits whose stored kv has non-contiguous positions + (e.g. with gaps where thoughts were skipped). + """ + starts = ( + extend_position_start if extend_position_start is not None else extend_prefix_lens + ) positions = torch.cat( [ torch.arange( - prefix_len, prefix_len + extend_len, device=extend_prefix_lens.device + start, start + extend_len, device=extend_prefix_lens.device ) - for prefix_len, extend_len in zip(extend_prefix_lens, extend_seq_lens) + for start, extend_len in zip(starts, extend_seq_lens) ], axis=0, ) diff --git a/test/registered/radix_cache/test_cache_finished_req_split.py b/test/registered/radix_cache/test_cache_finished_req_split.py new file mode 100644 index 000000000000..5e620490f789 --- /dev/null +++ b/test/registered/radix_cache/test_cache_finished_req_split.py @@ -0,0 +1,75 @@ +"""Tests that RadixCache.cache_finished_req accepts a pre-computed split, bypassing +the per-req KV-pool lookup and inserting [prompt + post- answer] with original +positions preserved. + +The split is constructed by split_kv_for_no_cache_thoughts in the caller; this test +pins down the cache-side contract that consumes it. +""" + +import unittest +from unittest.mock import MagicMock + +import torch + +from sglang.srt.mem_cache.base_prefix_cache import MatchPrefixParams +from sglang.srt.mem_cache.common import split_kv_for_no_cache_thoughts +from sglang.srt.mem_cache.radix_cache import RadixCache, RadixKey + + +class TestCacheFinishedReqSplit(unittest.TestCase): + def test_split_inserts_virtual_slice_with_positions(self): + # Mock allocator records free() calls so we can assert the thought slice was freed. + mock_allocator = MagicMock() + tree = RadixCache.create_simulated(mock_allocator=mock_allocator) + + # Synthetic finished reasoning request: + # positions: 0 1 2 3 4 5 6 7 8 + # tokens: A B T1 T2 X Y Z + # prompt=[A,B] thoughts=[,T1,T2,] answer=[X,Y,Z]; answer_start_position=6 + split = split_kv_for_no_cache_thoughts( + origin_input_ids=[101, 102], + output_ids=[201, 202, 203, 204, 301, 302, 303], + req_to_token_slot=torch.tensor( + [1000, 1001, 1002, 1003, 1004, 1005, 1006, 1007, 1008], + dtype=torch.int64, + ), + answer_start_position=6, + ) + + # Build a minimal Req-like stub: cache_finished_req(req, ..., split=split) should + # use split.virtual_* directly and ignore req.req_to_token_pool/req_pool_idx. + req_stub = MagicMock() + req_stub.extra_key = None + req_stub.priority = 0 + req_stub.pop_committed_kv_cache.return_value = 0 # bookkeeping no-op + req_stub.last_node = tree.root_node + req_stub.cache_protected_len = 0 + + tree.cache_finished_req(req_stub, is_insert=True, split=split) + + # The radix tree should now contain a path matching the virtual token ids + # (skipping the thought slice). + match = tree.match_prefix( + MatchPrefixParams( + key=RadixKey(token_ids=[101, 102, 301, 302, 303], extra_key=None) + ) + ) + self.assertEqual(match.device_indices.tolist(), [1000, 1001, 1006, 1007, 1008]) + self.assertIsNotNone(match.original_positions) + self.assertEqual(match.original_positions.tolist(), [0, 1, 6, 7, 8]) + + # The thought-slice kv_indices ([1002, 1003, 1004, 1005]) should have been freed. + freed_calls = mock_allocator.free.call_args_list + freed_indices = [] + for call in freed_calls: + arg = call.args[0] if call.args else call.kwargs.get("indices") + if isinstance(arg, torch.Tensor): + freed_indices.extend(arg.tolist()) + self.assertEqual( + sorted(set(freed_indices) & {1002, 1003, 1004, 1005}), + [1002, 1003, 1004, 1005], + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/registered/radix_cache/test_compute_position_noncontig.py b/test/registered/radix_cache/test_compute_position_noncontig.py new file mode 100644 index 000000000000..dfac073d21ef --- /dev/null +++ b/test/registered/radix_cache/test_compute_position_noncontig.py @@ -0,0 +1,65 @@ +"""Tests for non-contiguous extend-positions in forward_batch_info.compute_position_torch. + +When a request hits a cached entry with non-contiguous original positions (e.g. +[0, 1, 6, 7, 8] — gap where thoughts used to live), the new extend tokens must +continue from max(cached_positions) + 1, not from len(cached). This test pins +down the extended API that supports that case. +""" + +import unittest + +import torch + +from sglang.srt.model_executor.forward_batch_info import ( + compute_position, + compute_position_torch, +) + + +class TestComputePositionNonContiguous(unittest.TestCase): + def test_extend_position_start_overrides_prefix_len(self): + """When extend_position_start is provided, positions for each request's + extend tokens start at extend_position_start[i] rather than extend_prefix_lens[i].""" + # Single request: cached 5 tokens at positions [0, 1, 6, 7, 8], now extending by 3. + # Standard behavior would put extend positions at [5, 6, 7]; with the override, + # they should be at [9, 10, 11]. + extend_prefix_lens = torch.tensor([5], dtype=torch.int64) + extend_seq_lens = torch.tensor([3], dtype=torch.int64) + extend_position_start = torch.tensor([9], dtype=torch.int64) + + positions, _ = compute_position_torch( + extend_prefix_lens, extend_seq_lens, extend_position_start + ) + self.assertEqual(positions.tolist(), [9, 10, 11]) + + def test_none_override_preserves_legacy_behavior(self): + """When extend_position_start is None, positions start at extend_prefix_lens (unchanged).""" + extend_prefix_lens = torch.tensor([2, 4], dtype=torch.int64) + extend_seq_lens = torch.tensor([3, 1], dtype=torch.int64) + + positions, _ = compute_position_torch(extend_prefix_lens, extend_seq_lens) + # Request 0: starts at 2 -> [2, 3, 4]. Request 1: starts at 4 -> [4]. + self.assertEqual(positions.tolist(), [2, 3, 4, 4]) + + def test_compute_position_wrapper_forwards_override(self): + """compute_position(...) (the wrapper) must forward extend_position_start to the + underlying torch / triton implementation.""" + extend_prefix_lens = torch.tensor([5], dtype=torch.int64) + extend_seq_lens = torch.tensor([3], dtype=torch.int64) + extend_position_start = torch.tensor([9], dtype=torch.int64) + + # Use the non-triton backend name to force the torch path; the wrapper still + # routes to compute_position_triton when support_triton(attn_backend) is True + # on CUDA hosts. The torch path is the unambiguous behavioral test. + positions, _ = compute_position( + attn_backend="aiter", # not triton-supported -> takes torch path + extend_prefix_lens=extend_prefix_lens, + extend_seq_lens=extend_seq_lens, + extend_seq_lens_sum=3, + extend_position_start=extend_position_start, + ) + self.assertEqual(positions.tolist(), [9, 10, 11]) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/registered/radix_cache/test_derive_extend_position_start.py b/test/registered/radix_cache/test_derive_extend_position_start.py new file mode 100644 index 000000000000..5f9161dab638 --- /dev/null +++ b/test/registered/radix_cache/test_derive_extend_position_start.py @@ -0,0 +1,41 @@ +"""Tests for the helper that derives per-request extend_position_start from cached +positions returned by cache hits. + +This helper is what the scheduler / ForwardBatch call site uses to bridge between +the radix tree (which returns non-contiguous original_positions on cache hits) and +compute_position's extend_position_start parameter. +""" + +import unittest + +import torch + +from sglang.srt.mem_cache.common import derive_extend_position_start + + +class TestDeriveExtendPositionStart(unittest.TestCase): + def test_returns_none_when_all_requests_lack_cached_positions(self): + """When no request has cached positions (e.g. flag off, or no cache hit), the + helper returns None — signaling that compute_position should use the legacy + contiguous behavior.""" + out = derive_extend_position_start( + extend_prefix_lens=[3, 5], + cached_positions_per_req=[None, None], + ) + self.assertIsNone(out) + + def test_uses_max_plus_one_for_cached_request(self): + """A request with cached non-contiguous positions returns max(positions) + 1; + a request without cached positions falls back to extend_prefix_lens (legacy).""" + out = derive_extend_position_start( + extend_prefix_lens=[5, 3], + cached_positions_per_req=[ + torch.tensor([0, 1, 6, 7, 8], dtype=torch.int64), # max 8 -> start 9 + None, # legacy fallback -> start 3 + ], + ) + self.assertEqual(out, [9, 3]) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/registered/radix_cache/test_release_kv_cache_routing.py b/test/registered/radix_cache/test_release_kv_cache_routing.py new file mode 100644 index 000000000000..4d8edfab1f39 --- /dev/null +++ b/test/registered/radix_cache/test_release_kv_cache_routing.py @@ -0,0 +1,92 @@ +"""Tests that release_kv_cache routes through the split helper when --no-cache-thoughts +is enabled and the request has a recorded answer_start_position. + +The test mocks the tree_cache and req objects narrowly enough to observe the routing +decision without needing a real KV pool. The next-cycle test will exercise the +non-flag path to ensure no regression. +""" + +import unittest +from unittest.mock import MagicMock, patch + +import torch + +from sglang.srt.mem_cache.common import ( + NoCacheThoughtsSplit, + release_kv_cache, +) + + +class TestReleaseKvCacheRouting(unittest.TestCase): + def _make_tree_cache_mock(self): + tree = MagicMock() + tree.supports_mamba.return_value = False + # Per-req KV slot used by the split helper. + tree.req_to_token_pool.req_to_token = torch.tensor( + [[1000, 1001, 1002, 1003, 1004, 1005, 1006, 1007, 1008]], + dtype=torch.int64, + ) + # The split helper builds its slot indexing from this tensor. + return tree + + def _make_req_mock(self): + req = MagicMock() + req.req_pool_idx = 0 + req.require_reasoning = True + req.answer_start_position = 6 + req.origin_input_ids = [101, 102] + req.output_ids = [201, 202, 203, 204, 301, 302, 303] + req.pop_overallocated_kv_cache.return_value = (0, 0) + req.mamba_pool_idx = None + return req + + def test_routes_through_split_when_flag_on(self): + tree = self._make_tree_cache_mock() + req = self._make_req_mock() + + fake_server_args = MagicMock() + fake_server_args.no_cache_thoughts = True + fake_server_args.page_size = 1 + fake_server_args.speculative_algorithm = None + + with patch( + "sglang.srt.mem_cache.common.get_global_server_args", + return_value=fake_server_args, + ): + release_kv_cache(req, tree, is_insert=True) + + # cache_finished_req must have been called with a split kwarg. + call = tree.cache_finished_req.call_args + self.assertIsNotNone(call, "cache_finished_req was not called") + split = call.kwargs.get("split") + self.assertIsNotNone(split, "split kwarg was not passed") + self.assertIsInstance(split, NoCacheThoughtsSplit) + # Sanity-check the split contents match the synthetic Req. + self.assertEqual(split.virtual_token_ids, [101, 102, 301, 302, 303]) + self.assertEqual(split.virtual_positions.tolist(), [0, 1, 6, 7, 8]) + + def test_no_split_when_flag_off(self): + tree = self._make_tree_cache_mock() + req = self._make_req_mock() # has require_reasoning + answer_start_position set + + fake_server_args = MagicMock() + fake_server_args.no_cache_thoughts = False # flag off + fake_server_args.page_size = 1 + fake_server_args.speculative_algorithm = None + + with patch( + "sglang.srt.mem_cache.common.get_global_server_args", + return_value=fake_server_args, + ): + release_kv_cache(req, tree, is_insert=True) + + call = tree.cache_finished_req.call_args + self.assertIsNotNone(call) + self.assertIsNone( + call.kwargs.get("split"), + "split kwarg should not be passed when --no-cache-thoughts is off", + ) + + +if __name__ == "__main__": + unittest.main() From b16d74996a93a8807dc4dc00e4fe97f76a148c11 Mon Sep 17 00:00:00 2001 From: David <12414531+DavidBellamy@users.noreply.github.com> Date: Mon, 25 May 2026 20:06:14 -0700 Subject: [PATCH 03/11] feat: ForwardBatch.init_new uses build_extend_positions helper Wires the call site so per-request cached non-contiguous positions feed into the positions tensor handed to the model. Behavior is identical to today when batch.cached_positions_per_req is None (the default), since build_extend_positions falls through to compute_position in that case. * build_extend_positions: new helper in forward_batch_info that bridges scheduler state (per-req cached positions, prefix lens) to compute_position's extend_position_start tensor. Lives next to compute_position so the call site only needs to swap which function it calls. * ForwardBatch.init_new: extend-mode path now calls build_extend_positions, reading getattr(batch, "cached_positions_per_req", None). Uses getattr so pre-existing ScheduleBatch instances without the field continue to work. The matching field on ScheduleBatch (cached_positions_per_req) and its population from match_prefix results is the next integration step; this commit is a no-op until that field is wired. 2 new unit tests on build_extend_positions (21 total, all green). The test_compute_position_noncontig hard-coded backend name moved from "aiter" to "torch_native" because support_triton() returns True for everything except {torch_native, intel_amx, ascend} and the test must force the torch path. --- .../srt/model_executor/forward_batch_info.py | 55 +++++++++++++++++-- .../test_build_extend_positions.py | 53 ++++++++++++++++++ .../test_compute_position_noncontig.py | 2 +- 3 files changed, 104 insertions(+), 6 deletions(-) create mode 100644 test/registered/radix_cache/test_build_extend_positions.py diff --git a/python/sglang/srt/model_executor/forward_batch_info.py b/python/sglang/srt/model_executor/forward_batch_info.py index f9d0f5478b86..7e15f689f8ca 100644 --- a/python/sglang/srt/model_executor/forward_batch_info.py +++ b/python/sglang/srt/model_executor/forward_batch_info.py @@ -555,11 +555,18 @@ def init_new( batch.extend_prefix_lens, dtype=torch.int32 ).to(device, non_blocking=True) ret.extend_num_tokens = batch.extend_num_tokens - positions, ret.extend_start_loc = compute_position( - model_runner.server_args.attention_backend, - ret.extend_prefix_lens, - ret.extend_seq_lens, - ret.extend_num_tokens, + # Honor per-request cached non-contiguous positions from cache hits when + # available; otherwise behaves identically to compute_position. + positions, ret.extend_start_loc = build_extend_positions( + attn_backend=model_runner.server_args.attention_backend, + extend_prefix_lens=ret.extend_prefix_lens, + extend_seq_lens=ret.extend_seq_lens, + extend_num_tokens=ret.extend_num_tokens, + extend_prefix_lens_cpu=batch.extend_prefix_lens, + cached_positions_per_req=getattr( + batch, "cached_positions_per_req", None + ), + device=device, ) if ret.positions is None: ret.positions = positions @@ -1100,6 +1107,44 @@ def __repr__(self) -> str: return f"PPProxyTensors(tensors={self.tensors})" +def build_extend_positions( + attn_backend: str, + extend_prefix_lens: torch.Tensor, + extend_seq_lens: torch.Tensor, + extend_num_tokens: int, + extend_prefix_lens_cpu: List[int], + cached_positions_per_req: Optional[List[Optional[torch.Tensor]]], + device, +): + """Build extend-token positions, honoring per-request cached non-contiguous positions. + + Returns (positions, extend_start_loc) matching compute_position's signature. When + cached_positions_per_req is None or contains only None entries, positions are + contiguous (legacy behavior). Otherwise the per-request start position is + max(cached_positions[i]) + 1 for cached requests, and extend_prefix_lens_cpu[i] + for non-cached requests. + """ + from sglang.srt.mem_cache.common import derive_extend_position_start + + extend_position_start_tensor = None + if cached_positions_per_req is not None: + starts = derive_extend_position_start( + extend_prefix_lens_cpu, cached_positions_per_req + ) + if starts is not None: + extend_position_start_tensor = torch.tensor( + starts, dtype=torch.int64 + ).to(device, non_blocking=True) + + return compute_position( + attn_backend, + extend_prefix_lens, + extend_seq_lens, + extend_num_tokens, + extend_position_start=extend_position_start_tensor, + ) + + def compute_position( attn_backend: str, extend_prefix_lens: torch.Tensor, diff --git a/test/registered/radix_cache/test_build_extend_positions.py b/test/registered/radix_cache/test_build_extend_positions.py new file mode 100644 index 000000000000..29fb883a9993 --- /dev/null +++ b/test/registered/radix_cache/test_build_extend_positions.py @@ -0,0 +1,53 @@ +"""Tests for the call-site helper that builds extend-token positions while honoring +per-request cached non-contiguous positions. + +This is the bridge between ScheduleBatch state (per-request cached_positions on +cache hits) and ForwardBatch's positions tensor. +""" + +import unittest + +import torch + +from sglang.srt.model_executor.forward_batch_info import build_extend_positions + + +class TestBuildExtendPositions(unittest.TestCase): + def test_legacy_path_when_no_cached_positions(self): + """When cached_positions_per_req is None (or all None entries), positions are + contiguous starting from extend_prefix_lens[i] — i.e. unchanged from today.""" + positions, _ = build_extend_positions( + attn_backend="torch_native", # forces torch path (not triton-supported) + extend_prefix_lens=torch.tensor([2, 4], dtype=torch.int64), + extend_seq_lens=torch.tensor([3, 1], dtype=torch.int64), + extend_num_tokens=4, + extend_prefix_lens_cpu=[2, 4], + cached_positions_per_req=None, + device="cpu", + ) + # Req 0: arange(2, 5) = [2,3,4]. Req 1: arange(4, 5) = [4]. + self.assertEqual(positions.tolist(), [2, 3, 4, 4]) + + def test_cached_positions_override_extends_from_max_plus_one(self): + """When a request has cached non-contiguous positions ending at p, its extend + tokens start at p+1, not at extend_prefix_lens. Other requests fall back to + extend_prefix_lens (legacy).""" + cached_positions_per_req = [ + torch.tensor([0, 1, 6, 7, 8], dtype=torch.int64), # max 8 -> extend starts at 9 + None, # legacy fallback -> extend starts at 4 + ] + positions, _ = build_extend_positions( + attn_backend="torch_native", + extend_prefix_lens=torch.tensor([5, 4], dtype=torch.int64), + extend_seq_lens=torch.tensor([3, 1], dtype=torch.int64), + extend_num_tokens=4, + extend_prefix_lens_cpu=[5, 4], + cached_positions_per_req=cached_positions_per_req, + device="cpu", + ) + # Req 0: arange(9, 12) = [9,10,11]. Req 1: arange(4, 5) = [4]. + self.assertEqual(positions.tolist(), [9, 10, 11, 4]) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/registered/radix_cache/test_compute_position_noncontig.py b/test/registered/radix_cache/test_compute_position_noncontig.py index dfac073d21ef..f33b80f2bbe1 100644 --- a/test/registered/radix_cache/test_compute_position_noncontig.py +++ b/test/registered/radix_cache/test_compute_position_noncontig.py @@ -52,7 +52,7 @@ def test_compute_position_wrapper_forwards_override(self): # routes to compute_position_triton when support_triton(attn_backend) is True # on CUDA hosts. The torch path is the unambiguous behavioral test. positions, _ = compute_position( - attn_backend="aiter", # not triton-supported -> takes torch path + attn_backend="torch_native", # not triton-supported -> takes torch path extend_prefix_lens=extend_prefix_lens, extend_seq_lens=extend_seq_lens, extend_seq_lens_sum=3, From 74a2fb29d0b5ce6424c8f7472994929ba9080518 Mon Sep 17 00:00:00 2001 From: David <12414531+DavidBellamy@users.noreply.github.com> Date: Mon, 25 May 2026 20:14:36 -0700 Subject: [PATCH 04/11] feat: plumb cached_positions from match_prefix through to ForwardBatch Closes the gap between the radix tree (which returns non-contiguous positions on cache hits) and ForwardBatch.init_new (which consumes them via the previously wired build_extend_positions helper). * Req.cached_positions: new field. init_next_round_input now stores match_result.original_positions on the Req alongside the existing prefix_indices unpacking. None when the cache hit was on a legacy entry without positions. * ScheduleBatch.cached_positions_per_req: new field. prepare_for_extend populates it via collect_cached_positions(reqs), which aggregates per-req cached_positions or returns None if no req has any (signaling that ForwardBatch should take the legacy contiguous-positions path). * collect_cached_positions: module-level helper, isolated so it can be unit- tested without standing up a real ScheduleBatch. 3 new unit tests (24 total, all green). The prefill cache-hit path is now wired end-to-end. Remaining: decode-path clamp_position must use max_position + 1 (not seq_len - 1) so per-token positions during decode continue from where the cached prefix left off. --- python/sglang/srt/managers/schedule_batch.py | 26 ++++++++++ .../test_collect_cached_positions.py | 28 +++++++++++ .../radix_cache/test_req_cached_positions.py | 48 +++++++++++++++++++ 3 files changed, 102 insertions(+) create mode 100644 test/registered/radix_cache/test_collect_cached_positions.py create mode 100644 test/registered/radix_cache/test_req_cached_positions.py diff --git a/python/sglang/srt/managers/schedule_batch.py b/python/sglang/srt/managers/schedule_batch.py index b12b91ecf306..120316df7db8 100644 --- a/python/sglang/srt/managers/schedule_batch.py +++ b/python/sglang/srt/managers/schedule_batch.py @@ -554,6 +554,19 @@ def merge(self, other: MultimodalInputs): # other args would be kept intact +def collect_cached_positions(reqs): + """Aggregate per-request cached non-contiguous RoPE positions across a batch. + + Returns a per-request list of Optional[torch.Tensor] when at least one request + has cached positions; None otherwise (signal to ForwardBatch.init_new that the + legacy contiguous-positions path applies). + """ + positions = [getattr(r, "cached_positions", None) for r in reqs] + if all(p is None for p in positions): + return None + return positions + + class Req(ReqDllmMixin): """The input and output status of a request.""" @@ -645,6 +658,13 @@ def __init__( # boundary is detected; consumed at request finish under --no-cache-thoughts to # split the request's KV between freed-only thoughts and radix-inserted answer. self.answer_start_position: Optional[int] = None + # Per-token original RoPE positions for the prefix matched in the radix cache. + # Set by init_next_round_input when match_prefix returns non-None positions + # (i.e. the cached entry was inserted with non-contiguous positions, e.g. via + # the --no-cache-thoughts split path). Consumed at batch construction so the + # ForwardBatch's positions tensor lines up with the rotation baked into the + # cached K vectors. None means "use legacy contiguous positions". + self.cached_positions: Optional[torch.Tensor] = None # Sampling info if isinstance(sampling_params.custom_params, dict): @@ -1001,6 +1021,7 @@ def init_next_round_input( match_result.host_hit_length, match_result.mamba_branching_seqlen, ) + self.cached_positions = match_result.original_positions if match_result.cache_protected_len is not None: self.cache_protected_len = match_result.cache_protected_len else: @@ -1384,6 +1405,10 @@ class ScheduleBatch(ScheduleBatchDisaggregationDecodeMixin): # For extend and mixed chunekd prefill prefix_lens: List[int] = None + # Per-request original RoPE positions for the matched prefix when the cache hit + # carried non-contiguous positions (e.g. from --no-cache-thoughts). None means no + # request in the batch has such positions; the contiguous-positions path applies. + cached_positions_per_req: Optional[List[Optional[torch.Tensor]]] = None extend_lens: List[int] = None extend_num_tokens: Optional[int] = None decoding_reqs: List[Req] = None @@ -1615,6 +1640,7 @@ def prepare_for_extend(self): # Set batch fields needed by alloc_for_extend self.prefix_lens = prefix_lens + self.cached_positions_per_req = collect_cached_positions(reqs) self.extend_lens = extend_lens self.seq_lens = seq_lens_tensor self.seq_lens_cpu = seq_lens_cpu diff --git a/test/registered/radix_cache/test_collect_cached_positions.py b/test/registered/radix_cache/test_collect_cached_positions.py new file mode 100644 index 000000000000..f5af57a420d5 --- /dev/null +++ b/test/registered/radix_cache/test_collect_cached_positions.py @@ -0,0 +1,28 @@ +"""Tests for the helper that aggregates Req.cached_positions into a per-request list +suitable for ForwardBatch.init_new to consume via build_extend_positions. +""" + +import unittest +from unittest.mock import MagicMock + +import torch + +from sglang.srt.managers.schedule_batch import collect_cached_positions + + +class TestCollectCachedPositions(unittest.TestCase): + def test_returns_none_when_no_req_has_cached_positions(self): + reqs = [MagicMock(cached_positions=None), MagicMock(cached_positions=None)] + self.assertIsNone(collect_cached_positions(reqs)) + + def test_returns_list_when_any_req_has_cached_positions(self): + r1 = MagicMock(cached_positions=torch.tensor([0, 1, 6, 7, 8], dtype=torch.int64)) + r2 = MagicMock(cached_positions=None) + out = collect_cached_positions([r1, r2]) + self.assertIsNotNone(out) + self.assertEqual(out[0].tolist(), [0, 1, 6, 7, 8]) + self.assertIsNone(out[1]) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/registered/radix_cache/test_req_cached_positions.py b/test/registered/radix_cache/test_req_cached_positions.py new file mode 100644 index 000000000000..a6f9eca18017 --- /dev/null +++ b/test/registered/radix_cache/test_req_cached_positions.py @@ -0,0 +1,48 @@ +"""Tests that Req captures the cached non-contiguous positions returned by match_prefix. + +When the prefix cache has an entry with original_positions set (e.g. because a prior +turn was inserted via the split path), a future request that hits that entry must +record those positions on the Req so the scheduler can build the right +extend_position_start for compute_position. +""" + +import unittest + +import torch + +from sglang.srt.managers.schedule_batch import Req +from sglang.srt.mem_cache.base_prefix_cache import InsertParams +from sglang.srt.mem_cache.radix_cache import RadixCache, RadixKey +from sglang.srt.sampling.sampling_params import SamplingParams + + +class TestReqCachedPositions(unittest.TestCase): + def test_match_with_non_contiguous_positions_stored_on_req(self): + # Seed the radix tree with [A, B, X, Y, Z] at positions [0, 1, 6, 7, 8] — + # simulating a prior turn that was inserted via the split path. + tree = RadixCache.create_simulated() + tree.insert( + InsertParams( + key=RadixKey(token_ids=[10, 11, 30, 31, 32], extra_key=None), + value=torch.tensor([100, 101, 102, 103, 104], dtype=torch.int64), + original_positions=torch.tensor([0, 1, 6, 7, 8], dtype=torch.int64), + ) + ) + + # New request whose input matches the cached entry. + req = Req( + rid="r1", + origin_input_text="...", + origin_input_ids=[10, 11, 30, 31, 32, 99], # +1 trailing token so we don't truncate + sampling_params=SamplingParams(), + ) + req.init_next_round_input(tree_cache=tree) + + self.assertIsNotNone(req.cached_positions) + # The first 5 tokens should hit the cached prefix; positions reflect the original + # non-contiguous layout. (Match may stop one token short to enable logprob compute.) + self.assertEqual(req.cached_positions.tolist()[:5], [0, 1, 6, 7, 8]) + + +if __name__ == "__main__": + unittest.main() From 51ae6066826edc06cb50cfc7cdf852dff5ca5b67 Mon Sep 17 00:00:00 2001 From: David <12414531+DavidBellamy@users.noreply.github.com> Date: Mon, 25 May 2026 20:24:40 -0700 Subject: [PATCH 05/11] feat: decode-path position offsets for non-contiguous cache hits After a prefill cache hit whose cached entry carried non-contiguous RoPE positions, the request's decode tokens must continue from max(cached) + extend + 1, not from seq_len - 1. This commit closes that gap. * clamp_position(seq_lens, position_offsets=None): new optional offsets arg. When provided, output is clamp(seq_lens - 1) + offsets. Behavior unchanged when offsets is None. The CUDA-path wrapper is a thin Python add on top of the existing jit_kernel; the CUDA kernel itself is untouched. * common.derive_position_offsets: helper computing per-request offset as max(cached_positions) - (prefix_len - 1), or 0 for requests without cached positions. Returns None if no req in the batch needs an offset. * ScheduleBatch.position_offsets: new tensor field, populated in prepare_for_extend alongside cached_positions_per_req. * ForwardBatch.init_new (decode path): passes batch.position_offsets through to clamp_position via getattr (legacy callers still work). 4 new unit tests covering clamp_position offsets and the offsets helper (28 total, all green). --- python/sglang/srt/managers/schedule_batch.py | 14 +++++++ python/sglang/srt/mem_cache/common.py | 27 ++++++++++++++ .../srt/model_executor/forward_batch_info.py | 32 ++++++++++++++-- .../test_clamp_position_with_offsets.py | 35 ++++++++++++++++++ .../test_derive_position_offsets.py | 37 +++++++++++++++++++ 5 files changed, 141 insertions(+), 4 deletions(-) create mode 100644 test/registered/radix_cache/test_clamp_position_with_offsets.py create mode 100644 test/registered/radix_cache/test_derive_position_offsets.py diff --git a/python/sglang/srt/managers/schedule_batch.py b/python/sglang/srt/managers/schedule_batch.py index 120316df7db8..fc0c7c995c49 100644 --- a/python/sglang/srt/managers/schedule_batch.py +++ b/python/sglang/srt/managers/schedule_batch.py @@ -1409,6 +1409,10 @@ class ScheduleBatch(ScheduleBatchDisaggregationDecodeMixin): # carried non-contiguous positions (e.g. from --no-cache-thoughts). None means no # request in the batch has such positions; the contiguous-positions path applies. cached_positions_per_req: Optional[List[Optional[torch.Tensor]]] = None + # Per-request RoPE offset (one int per req, device tensor) added to (seq_len - 1) + # at decode time so decode positions continue from where a non-contiguous prefill + # cache hit left off. None when no req in the batch needs an offset. + position_offsets: Optional[torch.Tensor] = None extend_lens: List[int] = None extend_num_tokens: Optional[int] = None decoding_reqs: List[Req] = None @@ -1641,6 +1645,16 @@ def prepare_for_extend(self): # Set batch fields needed by alloc_for_extend self.prefix_lens = prefix_lens self.cached_positions_per_req = collect_cached_positions(reqs) + if self.cached_positions_per_req is not None: + from sglang.srt.mem_cache.common import derive_position_offsets + + offsets_list = derive_position_offsets( + prefix_lens, self.cached_positions_per_req + ) + if offsets_list is not None: + self.position_offsets = torch.tensor( + offsets_list, dtype=torch.int64, pin_memory=_pin + ).to(self.device, non_blocking=True) self.extend_lens = extend_lens self.seq_lens = seq_lens_tensor self.seq_lens_cpu = seq_lens_cpu diff --git a/python/sglang/srt/mem_cache/common.py b/python/sglang/srt/mem_cache/common.py index 2e6e80f53231..8201c95df0ed 100644 --- a/python/sglang/srt/mem_cache/common.py +++ b/python/sglang/srt/mem_cache/common.py @@ -19,6 +19,33 @@ from sglang.srt.managers.schedule_batch import Req, ScheduleBatch +def derive_position_offsets( + extend_prefix_lens: List[int], + cached_positions_per_req: List[Optional["torch.Tensor"]], +) -> Optional[List[int]]: + """Given per-request cached RoPE positions, return a per-request offset to add + to (seq_len - 1) at decode time so decode positions continue from where the + non-contiguous prefill cache hit left off. + + The offset captures the gap in RoPE space caused by tokens that exist in the + cached entry's position layout but not in the contiguous-token-count layout: + offset[i] = max(cached_positions[i]) - (extend_prefix_lens[i] - 1) + A request without cached positions contributes 0 (no offset). + + Returns None if every entry is None (i.e. no request in the batch needs an + offset; the legacy clamp(seq_lens - 1) path applies). + """ + if all(p is None for p in cached_positions_per_req): + return None + offsets: List[int] = [] + for prefix_len, positions in zip(extend_prefix_lens, cached_positions_per_req): + if positions is None or len(positions) == 0: + offsets.append(0) + else: + offsets.append(int(positions.max().item()) - (int(prefix_len) - 1)) + return offsets + + def derive_extend_position_start( extend_prefix_lens: List[int], cached_positions_per_req: List[Optional["torch.Tensor"]], diff --git a/python/sglang/srt/model_executor/forward_batch_info.py b/python/sglang/srt/model_executor/forward_batch_info.py index 7e15f689f8ca..ce4716efb2b9 100644 --- a/python/sglang/srt/model_executor/forward_batch_info.py +++ b/python/sglang/srt/model_executor/forward_batch_info.py @@ -544,7 +544,10 @@ def init_new( # Init position information if ret.forward_mode.is_decode() or ret.forward_mode.is_target_verify(): if ret.positions is None: - ret.positions = clamp_position(batch.seq_lens) + ret.positions = clamp_position( + batch.seq_lens, + position_offsets=getattr(batch, "position_offsets", None), + ) else: assert isinstance(batch.extend_seq_lens, list) assert isinstance(batch.extend_prefix_lens, list) @@ -1267,13 +1270,34 @@ def compute_position_torch( return positions.to(torch.int64), extend_start_loc -def _clamp_position_native(seq_lens): - return torch.clamp((seq_lens - 1), min=0).to(torch.int64) +def _clamp_position_native(seq_lens, position_offsets: Optional[torch.Tensor] = None): + """Per-token decode position = clamp(seq_lens - 1, 0). + + Args: + seq_lens: per-request sequence length (token count). + position_offsets: optional per-request RoPE offset that shifts each + position. Used after a cache hit whose cached entry carried + non-contiguous RoPE positions: the offset is + max(cached_positions) - (prefix_token_count - 1), capturing the + gap in RoPE space caused by skipped thought tokens. When None, + behavior matches the legacy contiguous-positions path. + """ + base = torch.clamp((seq_lens - 1), min=0).to(torch.int64) + if position_offsets is not None: + base = base + position_offsets.to(torch.int64) + return base if is_cuda() or is_hip(): from sglang.jit_kernel.clamp_position import clamp_position_cuda - clamp_position = clamp_position_cuda + def clamp_position( + seq_lens: torch.Tensor, + position_offsets: Optional[torch.Tensor] = None, + ) -> torch.Tensor: + base = clamp_position_cuda(seq_lens) + if position_offsets is not None: + base = base + position_offsets.to(torch.int64) + return base else: clamp_position = _clamp_position_native diff --git a/test/registered/radix_cache/test_clamp_position_with_offsets.py b/test/registered/radix_cache/test_clamp_position_with_offsets.py new file mode 100644 index 000000000000..61d86fbfc1d7 --- /dev/null +++ b/test/registered/radix_cache/test_clamp_position_with_offsets.py @@ -0,0 +1,35 @@ +"""Tests for clamp_position honoring per-request position offsets so decode tokens +after a non-contiguous prefill cache hit get the right RoPE positions. + +At decode step N the next token's RoPE position should be: + (seq_lens[i] - 1) + position_offsets[i] +where position_offsets[i] accounts for the gap in RoPE-space caused by thought +tokens that were skipped from the cached entry. +""" + +import unittest + +import torch + +from sglang.srt.model_executor.forward_batch_info import _clamp_position_native + + +class TestClampPositionWithOffsets(unittest.TestCase): + def test_offset_shifts_position(self): + # Single req, seq_len=6 (so legacy position is 5), with a 4-position gap + # in RoPE space from skipped thoughts -> next decode position should be 9. + seq_lens = torch.tensor([6], dtype=torch.int64) + offsets = torch.tensor([4], dtype=torch.int64) + + positions = _clamp_position_native(seq_lens, position_offsets=offsets) + self.assertEqual(positions.tolist(), [9]) + + def test_legacy_behavior_when_offsets_none(self): + # Without offsets, behavior must match today's clamp(seq_lens - 1, min=0). + seq_lens = torch.tensor([5, 0, 3], dtype=torch.int64) + positions = _clamp_position_native(seq_lens) + self.assertEqual(positions.tolist(), [4, 0, 2]) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/registered/radix_cache/test_derive_position_offsets.py b/test/registered/radix_cache/test_derive_position_offsets.py new file mode 100644 index 000000000000..36842e25cba3 --- /dev/null +++ b/test/registered/radix_cache/test_derive_position_offsets.py @@ -0,0 +1,37 @@ +"""Tests for the helper that computes per-request RoPE position offsets so decode +positions continue from where the non-contiguous prefill cache hit left off. +""" + +import unittest + +import torch + +from sglang.srt.mem_cache.common import derive_position_offsets + + +class TestDerivePositionOffsets(unittest.TestCase): + def test_returns_none_when_no_cached_positions(self): + out = derive_position_offsets( + extend_prefix_lens=[3, 5], + cached_positions_per_req=[None, None], + ) + self.assertIsNone(out) + + def test_offset_equals_max_minus_prefix_minus_one_plus_one(self): + """Per-req offset = max(cached_positions) - (prefix_len - 1). + + Example: prefix_len=5 (cached 5 tokens), cached_positions=[0,1,6,7,8] + -> last cached position is 8, legacy max for 5 tokens is 4, offset is 4. + """ + out = derive_position_offsets( + extend_prefix_lens=[5, 3], + cached_positions_per_req=[ + torch.tensor([0, 1, 6, 7, 8], dtype=torch.int64), # max 8 -> offset 4 + None, # no cache positions -> offset 0 + ], + ) + self.assertEqual(out, [4, 0]) + + +if __name__ == "__main__": + unittest.main() From d88101df63899149ce141b9aad1334b2c91050a1 Mon Sep 17 00:00:00 2001 From: David <12414531+DavidBellamy@users.noreply.github.com> Date: Mon, 25 May 2026 20:38:33 -0700 Subject: [PATCH 06/11] feat: stub split kwarg in non-RadixCache backends (graceful no-op) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every prefix-cache backend's cache_finished_req now accepts the split kwarg so release_kv_cache won't raise TypeError when --no-cache-thoughts fires on a non-RadixCache backend. The behavior on those backends is a one-time warning plus fall-through to the default cache_finished_req (thoughts get cached as usual). Full split-insertion support per backend is deferred until empirical results justify the engineering. * chunk_cache.ChunkCache, swa_radix_cache.SWARadixCache, mamba_radix_cache.MambaRadixCache, radix_cache_cpp.RadixCacheCpp: accept split=None; warn-and-ignore if non-None. * lmc_radix_cache.LMCRadixCache: accept split=None, forward to super (which is RadixCache, fully implemented); warn that the LMCache offload that follows reads req.fill_ids directly and may offload more tokens than the radix retained. * session_aware_cache.SessionAwareCache: already accepted **kwargs and forwards to inner, so it works transparently once the inner backend handles split. * common.warn_split_unsupported_once: module-level one-time warning helper so the noise per backend is bounded. 6 new tests across 2 files (34 total; 2 skipped because C++ extensions can't JIT-compile in the test env — source verified by py_compile and reads). --- python/sglang/srt/mem_cache/chunk_cache.py | 9 ++- python/sglang/srt/mem_cache/common.py | 19 ++++++ .../sglang/srt/mem_cache/mamba_radix_cache.py | 14 ++++- .../sglang/srt/mem_cache/radix_cache_cpp.py | 12 +++- .../storage/lmcache/lmc_radix_cache.py | 17 +++++- .../sglang/srt/mem_cache/swa_radix_cache.py | 14 ++++- .../test_chunk_cache_split_kwarg.py | 40 ++++++++++++ .../test_other_backends_split_kwarg.py | 61 +++++++++++++++++++ 8 files changed, 176 insertions(+), 10 deletions(-) create mode 100644 test/registered/radix_cache/test_chunk_cache_split_kwarg.py create mode 100644 test/registered/radix_cache/test_other_backends_split_kwarg.py diff --git a/python/sglang/srt/mem_cache/chunk_cache.py b/python/sglang/srt/mem_cache/chunk_cache.py index 5d58bcde530c..2afd5bfa4696 100644 --- a/python/sglang/srt/mem_cache/chunk_cache.py +++ b/python/sglang/srt/mem_cache/chunk_cache.py @@ -65,7 +65,14 @@ def insert(self, params: InsertParams) -> InsertResult: # ChunkCache does not support prefix caching, so insert is a no-op return InsertResult(prefix_len=0) - def cache_finished_req(self, req: Req, is_insert: bool = True): + def cache_finished_req(self, req: Req, is_insert: bool = True, split=None): + # ChunkCache does not implement prefix caching, so it cannot honor a + # split-insertion request from --no-cache-thoughts. Fall back to the + # default behavior; the caller's split is dropped. + if split is not None: + from sglang.srt.mem_cache.common import warn_split_unsupported_once + + warn_split_unsupported_once("ChunkCache") kv_committed_len = req.pop_committed_kv_cache() # For decode server: if req.output_ids is empty, we want to free all req.origin_input_ids kv_indices = self.req_to_token_pool.req_to_token[ diff --git a/python/sglang/srt/mem_cache/common.py b/python/sglang/srt/mem_cache/common.py index 8201c95df0ed..4ad0d104ea0b 100644 --- a/python/sglang/srt/mem_cache/common.py +++ b/python/sglang/srt/mem_cache/common.py @@ -19,6 +19,25 @@ from sglang.srt.managers.schedule_batch import Req, ScheduleBatch +_split_unsupported_warned = set() + + +def warn_split_unsupported_once(backend_name: str) -> None: + """Emit a one-time warning when a prefix-cache backend that does not implement + --no-cache-thoughts split insertion receives a split kwarg. The split is dropped + and the backend falls back to its default cache_finished_req behavior, so the + feature gracefully no-ops on that backend. + """ + if backend_name in _split_unsupported_warned: + return + _split_unsupported_warned.add(backend_name) + logger.warning( + "%s does not implement --no-cache-thoughts split insertion; " + "thoughts will be cached normally on this backend (no-op).", + backend_name, + ) + + def derive_position_offsets( extend_prefix_lens: List[int], cached_positions_per_req: List[Optional["torch.Tensor"]], diff --git a/python/sglang/srt/mem_cache/mamba_radix_cache.py b/python/sglang/srt/mem_cache/mamba_radix_cache.py index d07702cf1efd..8fae60d0a982 100644 --- a/python/sglang/srt/mem_cache/mamba_radix_cache.py +++ b/python/sglang/srt/mem_cache/mamba_radix_cache.py @@ -514,8 +514,18 @@ def insert(self, params: InsertParams) -> InsertResult: ) return InsertResult(prefix_len=prefix_len, mamba_exist=mamba_exist) - def cache_finished_req(self, req: Req, is_insert: bool = True) -> None: - """Cache request when it finishes.""" + def cache_finished_req( + self, req: Req, is_insert: bool = True, split=None + ) -> None: + """Cache request when it finishes. + + MambaRadixCache does not yet implement split insertion; when --no-cache-thoughts + passes a split, fall back to the default behavior (thoughts cached normally). + """ + if split is not None: + from sglang.srt.mem_cache.common import warn_split_unsupported_once + + warn_split_unsupported_once("MambaRadixCache") kv_committed_len = req.pop_committed_kv_cache() if self.disable: kv_indices = self.req_to_token_pool.req_to_token[ diff --git a/python/sglang/srt/mem_cache/radix_cache_cpp.py b/python/sglang/srt/mem_cache/radix_cache_cpp.py index 66f9fad96ad7..62eabdf19c21 100644 --- a/python/sglang/srt/mem_cache/radix_cache_cpp.py +++ b/python/sglang/srt/mem_cache/radix_cache_cpp.py @@ -168,8 +168,16 @@ def protected_size(self): def total_size(self): return self.tree.total_size() - def cache_finished_req(self, req: Req, is_insert: bool = True): - """Cache request when it finishes.""" + def cache_finished_req(self, req: Req, is_insert: bool = True, split=None): + """Cache request when it finishes. + + RadixCacheCpp does not yet implement split insertion; when --no-cache-thoughts + passes a split, fall back to the default behavior (thoughts cached normally). + """ + if split is not None: + from sglang.srt.mem_cache.common import warn_split_unsupported_once + + warn_split_unsupported_once("RadixCacheCpp") assert req.req_pool_idx is not None kv_committed_len = req.pop_committed_kv_cache() token_ids = (req.origin_input_ids + req.output_ids)[:kv_committed_len] diff --git a/python/sglang/srt/mem_cache/storage/lmcache/lmc_radix_cache.py b/python/sglang/srt/mem_cache/storage/lmcache/lmc_radix_cache.py index 9a82aa31f4ef..31f4613dca7c 100644 --- a/python/sglang/srt/mem_cache/storage/lmcache/lmc_radix_cache.py +++ b/python/sglang/srt/mem_cache/storage/lmcache/lmc_radix_cache.py @@ -211,10 +211,21 @@ def match_prefix(self, params: MatchPrefixParams) -> MatchResult: # type: ignor return base_res - def cache_finished_req(self, req: Req, is_insert: bool = True) -> None: # type: ignore[override] - """On request completion, insert device KV into radix and store to LMCache.""" + def cache_finished_req( + self, req: Req, is_insert: bool = True, split=None + ) -> None: # type: ignore[override] + """On request completion, insert device KV into radix and store to LMCache. + + When --no-cache-thoughts passes a split, the inner radix accepts it (and stores + only the answer slice). The LMCache offload that follows reads req.fill_ids + directly and may offload more tokens than the radix retained; treat this as a + soft-no-op for the offload portion until proper split-aware offload lands. + """ + if split is not None: + from sglang.srt.mem_cache.common import warn_split_unsupported_once - super().cache_finished_req(req, is_insert=is_insert) + warn_split_unsupported_once("LMCRadixCache") + super().cache_finished_req(req, is_insert=is_insert, split=split) if not is_insert: return diff --git a/python/sglang/srt/mem_cache/swa_radix_cache.py b/python/sglang/srt/mem_cache/swa_radix_cache.py index 34df1617dd61..c6cb5f19aa65 100644 --- a/python/sglang/srt/mem_cache/swa_radix_cache.py +++ b/python/sglang/srt/mem_cache/swa_radix_cache.py @@ -441,8 +441,18 @@ def insert(self, params: InsertParams) -> InsertResult: ) return InsertResult(prefix_len=prefix_len) - def cache_finished_req(self, req: Req, is_insert: bool = True) -> None: - """Cache request when it finishes.""" + def cache_finished_req( + self, req: Req, is_insert: bool = True, split=None + ) -> None: + """Cache request when it finishes. + + SWARadixCache does not yet implement split insertion; when --no-cache-thoughts + passes a split, fall back to the default behavior (thoughts cached normally). + """ + if split is not None: + from sglang.srt.mem_cache.common import warn_split_unsupported_once + + warn_split_unsupported_once("SWARadixCache") kv_committed_len = req.pop_committed_kv_cache() if self.disable: kv_indices = self.req_to_token_pool.req_to_token[ diff --git a/test/registered/radix_cache/test_chunk_cache_split_kwarg.py b/test/registered/radix_cache/test_chunk_cache_split_kwarg.py new file mode 100644 index 000000000000..e6d57cf54fee --- /dev/null +++ b/test/registered/radix_cache/test_chunk_cache_split_kwarg.py @@ -0,0 +1,40 @@ +"""ChunkCache.cache_finished_req must accept the split kwarg used by the +--no-cache-thoughts code path. ChunkCache doesn't do prefix caching, so the +behavior is to ignore split entirely and fall back to its default cleanup. +""" + +import unittest +from unittest.mock import MagicMock + +import torch + +from sglang.srt.mem_cache.chunk_cache import ChunkCache +from sglang.srt.mem_cache.common import NoCacheThoughtsSplit + + +class TestChunkCacheSplitKwarg(unittest.TestCase): + def test_cache_finished_req_accepts_split(self): + cache = ChunkCache.__new__(ChunkCache) + cache.req_to_token_pool = MagicMock() + cache.req_to_token_pool.req_to_token = torch.tensor( + [[10, 11, 12]], dtype=torch.int64 + ) + cache.token_to_kv_pool_allocator = MagicMock() + + req = MagicMock() + req.pop_committed_kv_cache.return_value = 3 + req.req_pool_idx = 0 + + split = NoCacheThoughtsSplit( + virtual_token_ids=[1, 2], + virtual_kv_indices=torch.tensor([10, 12], dtype=torch.int64), + virtual_positions=torch.tensor([0, 5], dtype=torch.int64), + thought_kv_indices_to_free=torch.tensor([11], dtype=torch.int64), + ) + + # Must not raise TypeError on the new kwarg. + cache.cache_finished_req(req, is_insert=True, split=split) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/registered/radix_cache/test_other_backends_split_kwarg.py b/test/registered/radix_cache/test_other_backends_split_kwarg.py new file mode 100644 index 000000000000..2d5df6b779b3 --- /dev/null +++ b/test/registered/radix_cache/test_other_backends_split_kwarg.py @@ -0,0 +1,61 @@ +"""Signature-level test: every non-RadixCache prefix-cache backend's cache_finished_req +must accept the split kwarg (either explicitly or via **kwargs) so the --no-cache-thoughts +routing in release_kv_cache doesn't raise TypeError on these backends. +""" + +import inspect +import unittest + + +def _accepts_split(cls) -> bool: + sig = inspect.signature(cls.cache_finished_req) + has_split = "split" in sig.parameters + has_kwargs = any( + p.kind == inspect.Parameter.VAR_KEYWORD for p in sig.parameters.values() + ) + return has_split or has_kwargs + + +class TestOtherBackendsAcceptSplit(unittest.TestCase): + def test_swa_radix_cache(self): + from sglang.srt.mem_cache.swa_radix_cache import SWARadixCache + + self.assertTrue(_accepts_split(SWARadixCache), "SWARadixCache rejects split kwarg") + + def test_mamba_radix_cache(self): + from sglang.srt.mem_cache.mamba_radix_cache import MambaRadixCache + + self.assertTrue( + _accepts_split(MambaRadixCache), "MambaRadixCache rejects split kwarg" + ) + + def test_session_aware_cache(self): + from sglang.srt.mem_cache.session_aware_cache import SessionAwareCache + + self.assertTrue( + _accepts_split(SessionAwareCache), "SessionAwareCache rejects split kwarg" + ) + + def test_radix_cache_cpp(self): + try: + from sglang.srt.mem_cache.radix_cache_cpp import RadixCacheCpp + except Exception as e: + self.skipTest(f"RadixCacheCpp not importable in this env: {e}") + self.assertTrue( + _accepts_split(RadixCacheCpp), "RadixCacheCpp rejects split kwarg" + ) + + def test_lmc_radix_cache(self): + try: + from sglang.srt.mem_cache.storage.lmcache.lmc_radix_cache import ( + LMCRadixCache, + ) + except Exception as e: + self.skipTest(f"LMCRadixCache not importable in this env: {e}") + self.assertTrue( + _accepts_split(LMCRadixCache), "LMCRadixCache rejects split kwarg" + ) + + +if __name__ == "__main__": + unittest.main() From e2fe21fc36f79969ef6612eaf03d1d4fdf37cda1 Mon Sep 17 00:00:00 2001 From: David <12414531+DavidBellamy@users.noreply.github.com> Date: Mon, 25 May 2026 21:31:08 -0700 Subject: [PATCH 07/11] test: BBQ smoke + --no-cache-thoughts E2E (uncovers chat-template gap) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * test/manual/test_bbq_smoke.py: confirms BBQ-8B-Mid3 loads in the agentic-rl container image, the K2-v3 reasoning parser separates ... from the answer (290 reasoning tokens, 222 answer tokens on a sample prompt), and the /v1/chat/completions endpoint applies the chat template (which primes the assistant turn with \n). * test/manual/test_no_cache_thoughts_e2e.py: two-server cached_tokens-delta test. Confirms the --no-cache-thoughts split-insert path fires correctly (logged "split fired rid=... input_len=18 output_len=128 answer_start=144 virtual_tokens=20 thoughts_freed=126" during diagnostic runs). E2E test currently fails the assertion (cached_tokens=22 on both servers). Root cause: the BBQ chat template renders a prior assistant message with an EMPTY \n\n block even when reasoning_content is empty, instead of omitting the block. So turn 2's tokens after the user prompt are [, \n, , \n, answer...], while the cached path is [prompt..., answer...] with no tokens between them. Both servers' prefix match diverges at the same slot — at the empty marker — so neither sees the answer slice as cached. The feature implementation is correct; the chat template doesn't align with the no-cache-thoughts cached path for multi-turn rendering. Resolution: either (a) modify the chat template to drop the empty block when thinking content is empty, or (b) extend the split path to cover the ... boundary tokens with synthetic positions. Tracked as follow-up. --- test/manual/test_bbq_smoke.py | 102 +++++++++++ test/manual/test_no_cache_thoughts_e2e.py | 198 ++++++++++++++++++++++ 2 files changed, 300 insertions(+) create mode 100644 test/manual/test_bbq_smoke.py create mode 100644 test/manual/test_no_cache_thoughts_e2e.py diff --git a/test/manual/test_bbq_smoke.py b/test/manual/test_bbq_smoke.py new file mode 100644 index 000000000000..fbd62602a5e7 --- /dev/null +++ b/test/manual/test_bbq_smoke.py @@ -0,0 +1,102 @@ +"""Smoke-load BBQ-8B-Mid1 in SGLang with --reasoning-parser k2_v3 to confirm the +model architecture and reasoning parser are wired before designing the +--no-cache-thoughts E2E test around BBQ. + +Run manually on a GPU host: + python -m sglang.launch_server ... (see below) + +Or just invoke this module: + python test/manual/test_bbq_smoke.py +""" + +import json +import os +import subprocess +import sys +import time + +import requests + +BBQ_PATH = "/mnt/weka/shrd/k2m/suqi.sun/bbq_image/bbq-8b-mid3-final" +PORT = 30000 +BASE_URL = f"http://127.0.0.1:{PORT}" + + +def main() -> int: + assert os.path.isdir(BBQ_PATH), f"missing model dir: {BBQ_PATH}" + + cmd = [ + sys.executable, + "-m", + "sglang.launch_server", + "--model-path", + BBQ_PATH, + "--reasoning-parser", + "k2_v3", + "--enable-cache-report", + "--host", + "127.0.0.1", + "--port", + str(PORT), + "--mem-fraction-static", + "0.85", + "--trust-remote-code", + ] + print("Launching:", " ".join(cmd), flush=True) + proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT) + try: + # Wait for /health (up to 5 minutes). + deadline = time.time() + 300 + while time.time() < deadline: + try: + r = requests.get(f"{BASE_URL}/health", timeout=2) + if r.status_code == 200: + break + except Exception: + pass + if proc.poll() is not None: + # Server died. + out = proc.stdout.read().decode("utf-8", "replace") if proc.stdout else "" + print("SERVER EXITED EARLY:\n" + out[-4000:], flush=True) + return 1 + time.sleep(2) + else: + print("HEALTH POLL TIMED OUT", flush=True) + return 1 + print("Server is up.", flush=True) + + # Use the OpenAI-compatible chat completions endpoint so the chat template + # applies (which for BBQ-Mid3 primes the assistant turn with \n). + payload = { + "model": BBQ_PATH, + "messages": [ + { + "role": "user", + "content": "What is 12 * 7? Reason step by step.", + } + ], + "temperature": 0.0, + "max_tokens": 512, + } + r = requests.post(f"{BASE_URL}/v1/chat/completions", json=payload, timeout=120) + r.raise_for_status() + body = r.json() + choice = body["choices"][0]["message"] + content = choice.get("content", "") + reasoning = choice.get("reasoning_content", "") + print("=== reasoning_content ===\n", reasoning[:2000], flush=True) + print("=== content ===\n", content[:2000], flush=True) + print("=== usage ===\n", json.dumps(body.get("usage"), indent=2), flush=True) + # Hard check: the model emitted , so the K2V3 parser separated reasoning. + assert reasoning, "No reasoning_content — model didn't emit ..." + return 0 + finally: + proc.terminate() + try: + proc.wait(timeout=10) + except subprocess.TimeoutExpired: + proc.kill() + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/test/manual/test_no_cache_thoughts_e2e.py b/test/manual/test_no_cache_thoughts_e2e.py new file mode 100644 index 000000000000..f9ad128a7e20 --- /dev/null +++ b/test/manual/test_no_cache_thoughts_e2e.py @@ -0,0 +1,198 @@ +"""End-to-end test for --no-cache-thoughts on BBQ-8B-Mid3. + +What this test verifies +----------------------- +Two servers launched on the same BBQ checkpoint, one with --no-cache-thoughts and +one without (baseline). Both serve the same turn-1 reasoning request. Turn 2 +sends a chat history that contains the prior assistant answer (with ... + stripped by the chat template, as normal multi-turn rendering does). + +Expected cache behavior on turn 2: + * Baseline: only the original user prompt remains in the radix as a matchable + prefix, because turn-1's cached path appended the thought tokens between + the user prompt and the answer, so turn-2's thoughts-free input diverges + immediately after the user message. cached_tokens ~ len(user_prompt). + * --no-cache-thoughts: the answer was inserted with non-contiguous positions + (thoughts skipped), so turn-2's [user_prompt + answer] matches the cached + path. cached_tokens ~ len(user_prompt + answer). + +The hard assertion is therefore: on turn 2, + cached_tokens(--no-cache-thoughts server) > cached_tokens(baseline server) +with the delta being at least most of the answer's token count. + +How to run +---------- +This test runs inside the agentic-rl container image with an overlay of THIS +branch's SGLang. Example invocation from the m2 login node: + + srun --partition=main --time=00:30:00 -N 1 --gres=gpu:2 \ + --container-image=/mnt/weka/shrd/k2pta/agentic_rl_images/agentic-rl-2eff86d1.sqsh \ + --container-mounts=/mnt/weka:/mnt/weka,$PWD:/sglang \ + bash -c "pip install --no-deps --force-reinstall /sglang/python && \ + cd /sglang && python3 -m unittest test.manual.test_no_cache_thoughts_e2e -v" + +Two GPUs are requested so the two servers can run side-by-side (each on its +own GPU). The script picks GPU 0 / GPU 1 via CUDA_VISIBLE_DEVICES per server. +""" + +from __future__ import annotations + +import os +import subprocess +import sys +import time +import unittest + +import requests + +BBQ_PATH = "/mnt/weka/shrd/k2m/suqi.sun/bbq_image/bbq-8b-mid3-final" +PORT_NO_CACHE = 30000 +PORT_BASELINE = 30001 +BASE_URL_NO_CACHE = f"http://127.0.0.1:{PORT_NO_CACHE}" +BASE_URL_BASELINE = f"http://127.0.0.1:{PORT_BASELINE}" +HEALTH_TIMEOUT = 600 # bbq-mid3 takes 1-3 min to load +REQUEST_TIMEOUT = 300 + + +def _launch(port: int, extra_args: list[str], gpu: str) -> subprocess.Popen: + env = os.environ.copy() + env["CUDA_VISIBLE_DEVICES"] = gpu + cmd = [ + sys.executable, + "-m", + "sglang.launch_server", + "--model-path", + BBQ_PATH, + "--reasoning-parser", + "k2_v3", + "--enable-cache-report", + "--trust-remote-code", + "--host", + "127.0.0.1", + "--port", + str(port), + "--mem-fraction-static", + "0.80", + ] + list(extra_args) + return subprocess.Popen(cmd, env=env, stdout=subprocess.PIPE, stderr=subprocess.STDOUT) + + +def _wait_healthy(base_url: str, proc: subprocess.Popen) -> None: + deadline = time.time() + HEALTH_TIMEOUT + while time.time() < deadline: + try: + r = requests.get(f"{base_url}/health", timeout=2) + if r.status_code == 200: + return + except Exception: + pass + if proc.poll() is not None: + tail = ( + proc.stdout.read().decode("utf-8", "replace")[-4000:] + if proc.stdout + else "" + ) + raise RuntimeError(f"server at {base_url} exited early:\n{tail}") + time.sleep(3) + raise TimeoutError(f"server at {base_url} never became healthy") + + +def _kill(proc: subprocess.Popen) -> None: + try: + proc.terminate() + try: + proc.wait(timeout=15) + except subprocess.TimeoutExpired: + proc.kill() + except Exception: + pass + + +def _chat(base_url: str, messages: list[dict], max_tokens: int = 512) -> dict: + payload = { + "model": BBQ_PATH, + "messages": messages, + "temperature": 0.0, + "max_tokens": max_tokens, + } + r = requests.post( + f"{base_url}/v1/chat/completions", json=payload, timeout=REQUEST_TIMEOUT + ) + r.raise_for_status() + return r.json() + + +class TestNoCacheThoughtsE2E(unittest.TestCase): + + @classmethod + def setUpClass(cls): + assert os.path.isdir(BBQ_PATH), f"missing model dir: {BBQ_PATH}" + cls.proc_no_cache = _launch( + PORT_NO_CACHE, ["--no-cache-thoughts"], gpu="0" + ) + cls.proc_baseline = _launch(PORT_BASELINE, [], gpu="1") + try: + _wait_healthy(BASE_URL_NO_CACHE, cls.proc_no_cache) + _wait_healthy(BASE_URL_BASELINE, cls.proc_baseline) + except Exception: + _kill(cls.proc_no_cache) + _kill(cls.proc_baseline) + raise + + @classmethod + def tearDownClass(cls): + _kill(cls.proc_no_cache) + _kill(cls.proc_baseline) + + def test_cached_tokens_delta_on_turn2(self): + user_turn1 = { + "role": "user", + "content": "What is 12 * 7? Reason carefully.", + } + # Turn 1 on both servers. + resp_nc = _chat(BASE_URL_NO_CACHE, [user_turn1], max_tokens=512) + resp_bl = _chat(BASE_URL_BASELINE, [user_turn1], max_tokens=512) + + ans_nc = resp_nc["choices"][0]["message"]["content"] + ans_bl = resp_bl["choices"][0]["message"]["content"] + # With temperature=0 + same prompt, the answer portion should be identical. + # If they diverge it's still OK — we only need a valid prior assistant + # message for turn 2; we use each server's own turn-1 answer. + + user_turn2 = { + "role": "user", + "content": "Now multiply that result by 3.", + } + # Turn 2 history: chat template strips from prior assistant messages. + resp_nc_t2 = _chat( + BASE_URL_NO_CACHE, + [user_turn1, {"role": "assistant", "content": ans_nc}, user_turn2], + max_tokens=256, + ) + resp_bl_t2 = _chat( + BASE_URL_BASELINE, + [user_turn1, {"role": "assistant", "content": ans_bl}, user_turn2], + max_tokens=256, + ) + + cached_nc = resp_nc_t2["usage"]["prompt_tokens_details"]["cached_tokens"] + cached_bl = resp_bl_t2["usage"]["prompt_tokens_details"]["cached_tokens"] + prompt_nc = resp_nc_t2["usage"]["prompt_tokens"] + prompt_bl = resp_bl_t2["usage"]["prompt_tokens"] + + print(f"baseline: prompt_tokens={prompt_bl} cached_tokens={cached_bl}") + print(f"no-cache-thoughts: prompt_tokens={prompt_nc} cached_tokens={cached_nc}") + + # Core assertion: --no-cache-thoughts caches MORE of turn-2's input than baseline. + # The delta reflects the answer slice that was inserted with non-contiguous + # positions on the with-flag server but not on the baseline. + self.assertGreater( + cached_nc, + cached_bl, + f"--no-cache-thoughts cached_tokens ({cached_nc}) should exceed baseline " + f"({cached_bl}) by approximately len(turn-1 answer)", + ) + + +if __name__ == "__main__": + unittest.main() From 28726fa2b7b0f2363ff1b3f5bc3d09bef2cc3662 Mon Sep 17 00:00:00 2001 From: David <12414531+DavidBellamy@users.noreply.github.com> Date: Mon, 25 May 2026 23:24:56 -0700 Subject: [PATCH 08/11] feat: strip priming tail from --no-cache-thoughts cached prompt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the missing piece for multi-turn cache alignment: the chat template's priming tail (typically \n added by add_generation_prompt) is part of turn 1's input but NOT of turn 2's chat-template-rendered history. Caching it breaks the cache match at the assistant header. The fix scans the tail of origin_input_ids for the last token and excludes everything from there onward from the virtual cached prompt. * split_kv_for_no_cache_thoughts: new think_start_id kwarg. When provided scans backward through origin_input_ids for the last occurrence and uses that index as prompt_keep_len; tokens beyond are dropped from the cached entry. thought_kv_indices_to_free is also restricted to positions [input_len .. answer_start) so the priming slots (which are still owned by the radix entry that cache_unfinished_req inserted at prefill time) are not double-freed — that would trigger "token_to_kv_pool_allocator memory leak detected" on the next free check. * scheduler.Scheduler: encode alongside at init and stash self._think_start_id; copy onto each Req at construction so release_kv_cache (which has no scheduler reference) can read it. * release_kv_cache: pass req._think_start_id to split_kv_for_no_cache_thoughts. * Tests: new TDD cycle covering the priming-strip path on both the helper (test_no_cache_thoughts_split.py) and the routing wrapper (test_release_kv_cache_routing.py). All 35 unit tests green; the 14 new-style unit tests under test/registered/radix_cache/ now carry register_cuda_ci entries so they're picked up by test/run_suite.py. E2E test (test_no_cache_thoughts_e2e.py): now runs cleanly end-to-end (no crash, no leak) inside the agentic-rl image with the upstream BBQ chat template (test/manual/chat_templates/bbq_upstream.jinja) overriding Mid3's stale bundled copy. The assertion still fails because of a remaining tokenization-alignment issue between turn-1 model-generated answer tokens (possibly starting with leading whitespace/newline after ) and turn-2 chat-template-rendered answer tokens — separate concern from the implementation completed here. --- python/sglang/srt/managers/scheduler.py | 13 +++ python/sglang/srt/mem_cache/common.py | 42 ++++++++- test/manual/test_no_cache_thoughts_e2e.py | 92 +++++++++++++++---- .../test_build_extend_positions.py | 4 + .../test_cache_finished_req_split.py | 4 + .../test_chunk_cache_split_kwarg.py | 4 + .../test_clamp_position_with_offsets.py | 4 + .../test_collect_cached_positions.py | 4 + .../test_compute_position_noncontig.py | 3 + .../test_derive_extend_position_start.py | 4 + .../test_derive_position_offsets.py | 4 + .../radix_cache/test_no_cache_thoughts_cli.py | 4 + .../test_no_cache_thoughts_split.py | 56 +++++++++++ .../test_other_backends_split_kwarg.py | 4 + .../test_release_kv_cache_routing.py | 45 +++++++++ .../test_req_answer_start_position.py | 4 + .../radix_cache/test_req_cached_positions.py | 4 + 17 files changed, 272 insertions(+), 23 deletions(-) diff --git a/python/sglang/srt/managers/scheduler.py b/python/sglang/srt/managers/scheduler.py index 122ddb387486..64919a10e7e0 100644 --- a/python/sglang/srt/managers/scheduler.py +++ b/python/sglang/srt/managers/scheduler.py @@ -554,8 +554,15 @@ def init_tokenizer(self): reasoning_parser.detector.think_end_token, add_special_tokens=False )[0] self._think_end_id = self.tokenizer.think_end_id + # think_start_id is consumed by --no-cache-thoughts at request finish to + # strip the chat-template's ... priming tail from origin_input_ids + # before inserting the cached prompt into the radix tree. + self._think_start_id = self.tokenizer.encode( + reasoning_parser.detector.think_start_token, add_special_tokens=False + )[0] else: self._think_end_id = None + self._think_start_id = None def init_mamba_backend(self) -> None: initialize_mamba_selective_state_update_backend(self.server_args) @@ -1778,6 +1785,10 @@ def handle_generate_request( time_stats=recv_req.time_stats, ) req.tokenizer = self.tokenizer + # Per-request copy so release_kv_cache (which doesn't have a scheduler + # reference) can find the token id for the --no-cache-thoughts + # priming-tail stripping at request finish. + req._think_start_id = self._think_start_id if self.disaggregation_mode != DisaggregationMode.NULL: # Invalid request for disaggregated mode @@ -1824,6 +1835,7 @@ def handle_generate_request( vocab_size=self.model_config.vocab_size, ) req.tokenizer = self.tokenizer + req._think_start_id = self._think_start_id req.set_finish_with_abort( f"Invalid request: session id {session_id} does not exist" ) @@ -2081,6 +2093,7 @@ def handle_embedding_request( time_stats=recv_req.time_stats, ) req.tokenizer = self.tokenizer + req._think_start_id = self._think_start_id # Handle multimodal inputs if recv_req.image_inputs is not None: diff --git a/python/sglang/srt/mem_cache/common.py b/python/sglang/srt/mem_cache/common.py index 4ad0d104ea0b..92d89bb41e83 100644 --- a/python/sglang/srt/mem_cache/common.py +++ b/python/sglang/srt/mem_cache/common.py @@ -115,6 +115,7 @@ def split_kv_for_no_cache_thoughts( output_ids: List[int], req_to_token_slot: torch.Tensor, answer_start_position: int, + think_start_id: Optional[int] = None, ) -> NoCacheThoughtsSplit: """Compute the split-insertion tensors for a finished reasoning request. @@ -130,6 +131,14 @@ def split_kv_for_no_cache_thoughts( sequence (length must be >= len(origin_input_ids) + len(output_ids)). answer_start_position: absolute position (in the input+output index space) of the first answer token, i.e. the token immediately after ``. + think_start_id: when provided, the trailing run of `origin_input_ids` from + the last occurrence of this token id onward is treated as the chat + template's ... priming tail. Those tokens live only in this + turn's input (added by add_generation_prompt before decode began); + future turns' chat-template rendering of the historical assistant + message does NOT include them. They are excluded from the virtual + cached prompt and added to the freed slice, so the cached entry + aligns with what subsequent turns' inputs will actually contain. Returns: NoCacheThoughtsSplit with: @@ -149,16 +158,36 @@ def split_kv_for_no_cache_thoughts( # Clamp answer_start so we behave sanely if reasoning never finished. answer_start = min(answer_start_position, total_len) - # Slots / positions for input + post- answer. + # Identify the chat-template priming tail at the end of origin_input_ids + # (typically a `\n` block added by add_generation_prompt). Excluded + # from the cached prompt so the cached entry aligns with the way future + # turns render the prior assistant turn (without the priming tokens). + prompt_keep_len = input_len + if think_start_id is not None: + for i in range(input_len - 1, -1, -1): + if origin_input_ids[i] == think_start_id: + prompt_keep_len = i + break + + # Slots / positions for input prompt (sans priming) + post- answer. answer_count = max(total_len - answer_start, 0) answer_output_offset = answer_start - input_len # index into output_ids - virtual_token_ids = list(origin_input_ids) + list( + virtual_token_ids = list(origin_input_ids[:prompt_keep_len]) + list( output_ids[answer_output_offset:] if answer_count > 0 else [] ) - kept_slot_indices = list(range(input_len)) + list(range(answer_start, total_len)) - kept_positions = list(range(input_len)) + list(range(answer_start, total_len)) + kept_slot_indices = list(range(prompt_keep_len)) + list( + range(answer_start, total_len) + ) + kept_positions = list(range(prompt_keep_len)) + list( + range(answer_start, total_len) + ) + # Only the actual decoded thoughts (between the input prompt and ) + # are freed here. The priming tail (between prompt_keep_len and input_len) + # is still owned by the radix entry that cache_unfinished_req inserted at + # prefill time — freeing it produces a double-count against the allocator + # ("memory leak detected") because the tree node still claims those slots. thought_slot_indices = list(range(input_len, answer_start)) device = req_to_token_slot.device @@ -651,12 +680,17 @@ def release_kv_cache(req: Req, tree_cache: BasePrefixCache, is_insert: bool = Tr ): # Skip the thought tokens from the shared prefix cache; insert only the # input + post- answer slice, preserving original RoPE positions. + # think_start_id (when populated by the scheduler) lets the split helper + # also strip the chat-template's \n priming tail from origin_input_ids + # so the cached prompt aligns with how future turns render this assistant + # message (without the priming). req_to_token_slot = tree_cache.req_to_token_pool.req_to_token[req.req_pool_idx] split = split_kv_for_no_cache_thoughts( origin_input_ids=req.origin_input_ids, output_ids=req.output_ids, req_to_token_slot=req_to_token_slot, answer_start_position=req.answer_start_position, + think_start_id=getattr(req, "_think_start_id", None), ) tree_cache.cache_finished_req(req, is_insert=is_insert, split=split) else: diff --git a/test/manual/test_no_cache_thoughts_e2e.py b/test/manual/test_no_cache_thoughts_e2e.py index f9ad128a7e20..b2a78bf0c96e 100644 --- a/test/manual/test_no_cache_thoughts_e2e.py +++ b/test/manual/test_no_cache_thoughts_e2e.py @@ -28,7 +28,7 @@ srun --partition=main --time=00:30:00 -N 1 --gres=gpu:2 \ --container-image=/mnt/weka/shrd/k2pta/agentic_rl_images/agentic-rl-2eff86d1.sqsh \ --container-mounts=/mnt/weka:/mnt/weka,$PWD:/sglang \ - bash -c "pip install --no-deps --force-reinstall /sglang/python && \ + bash -c "pip install --no-deps -e /sglang/python && \ cd /sglang && python3 -m unittest test.manual.test_no_cache_thoughts_e2e -v" Two GPUs are requested so the two servers can run side-by-side (each on its @@ -46,6 +46,15 @@ import requests BBQ_PATH = "/mnt/weka/shrd/k2m/suqi.sun/bbq_image/bbq-8b-mid3-final" +# Upstream BBQ chat template from LLM360/bbq-chat-template:main, which drops the +# empty block in assistant rendering when message.think is not +# explicitly set. Required for --no-cache-thoughts to align with multi-turn +# input on turn 2. Mid3's bundled chat_template.jinja is stale. +CHAT_TEMPLATE = os.path.join( + os.path.dirname(os.path.abspath(__file__)), + "chat_templates", + "bbq_upstream.jinja", +) PORT_NO_CACHE = 30000 PORT_BASELINE = 30001 BASE_URL_NO_CACHE = f"http://127.0.0.1:{PORT_NO_CACHE}" @@ -65,6 +74,8 @@ def _launch(port: int, extra_args: list[str], gpu: str) -> subprocess.Popen: BBQ_PATH, "--reasoning-parser", "k2_v3", + "--chat-template", + CHAT_TEMPLATE, "--enable-cache-report", "--trust-remote-code", "--host", @@ -74,7 +85,15 @@ def _launch(port: int, extra_args: list[str], gpu: str) -> subprocess.Popen: "--mem-fraction-static", "0.80", ] + list(extra_args) - return subprocess.Popen(cmd, env=env, stdout=subprocess.PIPE, stderr=subprocess.STDOUT) + # Write server logs alongside the test source (bind-mounted) so the file + # survives the container shutdown and can be inspected from the host. + log_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), ".e2e_logs") + os.makedirs(log_dir, exist_ok=True) + log_path = os.path.join(log_dir, f"sglang_port{port}.log") + log_fd = open(log_path, "w") + proc = subprocess.Popen(cmd, env=env, stdout=log_fd, stderr=subprocess.STDOUT) + proc._log_path = log_path # type: ignore[attr-defined] + return proc def _wait_healthy(base_url: str, proc: subprocess.Popen) -> None: @@ -87,11 +106,14 @@ def _wait_healthy(base_url: str, proc: subprocess.Popen) -> None: except Exception: pass if proc.poll() is not None: - tail = ( - proc.stdout.read().decode("utf-8", "replace")[-4000:] - if proc.stdout - else "" - ) + log_path = getattr(proc, "_log_path", None) + tail = "" + if log_path: + try: + with open(log_path) as f: + tail = f.read()[-4000:] + except Exception: + pass raise RuntimeError(f"server at {base_url} exited early:\n{tail}") time.sleep(3) raise TimeoutError(f"server at {base_url} never became healthy") @@ -144,14 +166,38 @@ def tearDownClass(cls): _kill(cls.proc_no_cache) _kill(cls.proc_baseline) + def _dump_logs_on_failure(self, label: str, proc: subprocess.Popen) -> None: + """Always dump the server log on any chat failure — works whether the + server is dead or just unreachable.""" + log_path = getattr(proc, "_log_path", None) + if log_path is None: + return + try: + with open(log_path) as f: + out = f.read() + print( + f"=== {label} server log (last 6KB) from {log_path} ===\n{out[-6000:]}", + flush=True, + ) + except Exception as e: + print(f"(failed to read {label} log: {e})", flush=True) + def test_cached_tokens_delta_on_turn2(self): user_turn1 = { "role": "user", "content": "What is 12 * 7? Reason carefully.", } # Turn 1 on both servers. - resp_nc = _chat(BASE_URL_NO_CACHE, [user_turn1], max_tokens=512) - resp_bl = _chat(BASE_URL_BASELINE, [user_turn1], max_tokens=512) + try: + resp_nc = _chat(BASE_URL_NO_CACHE, [user_turn1], max_tokens=512) + except Exception: + self._dump_logs_on_failure("no_cache (turn 1)", self.proc_no_cache) + raise + try: + resp_bl = _chat(BASE_URL_BASELINE, [user_turn1], max_tokens=512) + except Exception: + self._dump_logs_on_failure("baseline (turn 1)", self.proc_baseline) + raise ans_nc = resp_nc["choices"][0]["message"]["content"] ans_bl = resp_bl["choices"][0]["message"]["content"] @@ -164,16 +210,24 @@ def test_cached_tokens_delta_on_turn2(self): "content": "Now multiply that result by 3.", } # Turn 2 history: chat template strips from prior assistant messages. - resp_nc_t2 = _chat( - BASE_URL_NO_CACHE, - [user_turn1, {"role": "assistant", "content": ans_nc}, user_turn2], - max_tokens=256, - ) - resp_bl_t2 = _chat( - BASE_URL_BASELINE, - [user_turn1, {"role": "assistant", "content": ans_bl}, user_turn2], - max_tokens=256, - ) + try: + resp_nc_t2 = _chat( + BASE_URL_NO_CACHE, + [user_turn1, {"role": "assistant", "content": ans_nc}, user_turn2], + max_tokens=256, + ) + except Exception: + self._dump_logs_on_failure("no_cache (turn 2)", self.proc_no_cache) + raise + try: + resp_bl_t2 = _chat( + BASE_URL_BASELINE, + [user_turn1, {"role": "assistant", "content": ans_bl}, user_turn2], + max_tokens=256, + ) + except Exception: + self._dump_logs_on_failure("baseline (turn 2)", self.proc_baseline) + raise cached_nc = resp_nc_t2["usage"]["prompt_tokens_details"]["cached_tokens"] cached_bl = resp_bl_t2["usage"]["prompt_tokens_details"]["cached_tokens"] diff --git a/test/registered/radix_cache/test_build_extend_positions.py b/test/registered/radix_cache/test_build_extend_positions.py index 29fb883a9993..f20e23d14261 100644 --- a/test/registered/radix_cache/test_build_extend_positions.py +++ b/test/registered/radix_cache/test_build_extend_positions.py @@ -11,6 +11,10 @@ from sglang.srt.model_executor.forward_batch_info import build_extend_positions +from sglang.test.ci.ci_register import register_cuda_ci + +register_cuda_ci(est_time=5, suite="stage-b-test-1-gpu-small") + class TestBuildExtendPositions(unittest.TestCase): def test_legacy_path_when_no_cached_positions(self): diff --git a/test/registered/radix_cache/test_cache_finished_req_split.py b/test/registered/radix_cache/test_cache_finished_req_split.py index 5e620490f789..f34921eac3cd 100644 --- a/test/registered/radix_cache/test_cache_finished_req_split.py +++ b/test/registered/radix_cache/test_cache_finished_req_split.py @@ -15,6 +15,10 @@ from sglang.srt.mem_cache.common import split_kv_for_no_cache_thoughts from sglang.srt.mem_cache.radix_cache import RadixCache, RadixKey +from sglang.test.ci.ci_register import register_cuda_ci + +register_cuda_ci(est_time=5, suite="stage-b-test-1-gpu-small") + class TestCacheFinishedReqSplit(unittest.TestCase): def test_split_inserts_virtual_slice_with_positions(self): diff --git a/test/registered/radix_cache/test_chunk_cache_split_kwarg.py b/test/registered/radix_cache/test_chunk_cache_split_kwarg.py index e6d57cf54fee..a0719c0bfbbb 100644 --- a/test/registered/radix_cache/test_chunk_cache_split_kwarg.py +++ b/test/registered/radix_cache/test_chunk_cache_split_kwarg.py @@ -11,6 +11,10 @@ from sglang.srt.mem_cache.chunk_cache import ChunkCache from sglang.srt.mem_cache.common import NoCacheThoughtsSplit +from sglang.test.ci.ci_register import register_cuda_ci + +register_cuda_ci(est_time=5, suite="stage-b-test-1-gpu-small") + class TestChunkCacheSplitKwarg(unittest.TestCase): def test_cache_finished_req_accepts_split(self): diff --git a/test/registered/radix_cache/test_clamp_position_with_offsets.py b/test/registered/radix_cache/test_clamp_position_with_offsets.py index 61d86fbfc1d7..bc7b1feeccb8 100644 --- a/test/registered/radix_cache/test_clamp_position_with_offsets.py +++ b/test/registered/radix_cache/test_clamp_position_with_offsets.py @@ -13,6 +13,10 @@ from sglang.srt.model_executor.forward_batch_info import _clamp_position_native +from sglang.test.ci.ci_register import register_cuda_ci + +register_cuda_ci(est_time=5, suite="stage-b-test-1-gpu-small") + class TestClampPositionWithOffsets(unittest.TestCase): def test_offset_shifts_position(self): diff --git a/test/registered/radix_cache/test_collect_cached_positions.py b/test/registered/radix_cache/test_collect_cached_positions.py index f5af57a420d5..24fb26b175b0 100644 --- a/test/registered/radix_cache/test_collect_cached_positions.py +++ b/test/registered/radix_cache/test_collect_cached_positions.py @@ -9,6 +9,10 @@ from sglang.srt.managers.schedule_batch import collect_cached_positions +from sglang.test.ci.ci_register import register_cuda_ci + +register_cuda_ci(est_time=5, suite="stage-b-test-1-gpu-small") + class TestCollectCachedPositions(unittest.TestCase): def test_returns_none_when_no_req_has_cached_positions(self): diff --git a/test/registered/radix_cache/test_compute_position_noncontig.py b/test/registered/radix_cache/test_compute_position_noncontig.py index f33b80f2bbe1..790660994cf0 100644 --- a/test/registered/radix_cache/test_compute_position_noncontig.py +++ b/test/registered/radix_cache/test_compute_position_noncontig.py @@ -14,6 +14,9 @@ compute_position, compute_position_torch, ) +from sglang.test.ci.ci_register import register_cuda_ci + +register_cuda_ci(est_time=5, suite="stage-b-test-1-gpu-small") class TestComputePositionNonContiguous(unittest.TestCase): diff --git a/test/registered/radix_cache/test_derive_extend_position_start.py b/test/registered/radix_cache/test_derive_extend_position_start.py index 5f9161dab638..6286615706b9 100644 --- a/test/registered/radix_cache/test_derive_extend_position_start.py +++ b/test/registered/radix_cache/test_derive_extend_position_start.py @@ -12,6 +12,10 @@ from sglang.srt.mem_cache.common import derive_extend_position_start +from sglang.test.ci.ci_register import register_cuda_ci + +register_cuda_ci(est_time=5, suite="stage-b-test-1-gpu-small") + class TestDeriveExtendPositionStart(unittest.TestCase): def test_returns_none_when_all_requests_lack_cached_positions(self): diff --git a/test/registered/radix_cache/test_derive_position_offsets.py b/test/registered/radix_cache/test_derive_position_offsets.py index 36842e25cba3..1e8680911338 100644 --- a/test/registered/radix_cache/test_derive_position_offsets.py +++ b/test/registered/radix_cache/test_derive_position_offsets.py @@ -8,6 +8,10 @@ from sglang.srt.mem_cache.common import derive_position_offsets +from sglang.test.ci.ci_register import register_cuda_ci + +register_cuda_ci(est_time=5, suite="stage-b-test-1-gpu-small") + class TestDerivePositionOffsets(unittest.TestCase): def test_returns_none_when_no_cached_positions(self): diff --git a/test/registered/radix_cache/test_no_cache_thoughts_cli.py b/test/registered/radix_cache/test_no_cache_thoughts_cli.py index 60cbd86887b5..b20910ca1537 100644 --- a/test/registered/radix_cache/test_no_cache_thoughts_cli.py +++ b/test/registered/radix_cache/test_no_cache_thoughts_cli.py @@ -5,6 +5,10 @@ from sglang.srt.server_args import ServerArgs +from sglang.test.ci.ci_register import register_cuda_ci + +register_cuda_ci(est_time=5, suite="stage-b-test-1-gpu-small") + class TestNoCacheThoughtsCliFlag(unittest.TestCase): def test_default_is_false(self): diff --git a/test/registered/radix_cache/test_no_cache_thoughts_split.py b/test/registered/radix_cache/test_no_cache_thoughts_split.py index 90d84f28a22f..47fe667d601e 100644 --- a/test/registered/radix_cache/test_no_cache_thoughts_split.py +++ b/test/registered/radix_cache/test_no_cache_thoughts_split.py @@ -18,6 +18,10 @@ from sglang.srt.mem_cache.common import split_kv_for_no_cache_thoughts from sglang.test.test_utils import CustomTestCase +from sglang.test.ci.ci_register import register_cuda_ci + +register_cuda_ci(est_time=5, suite="stage-b-test-1-gpu-small") + class TestNoCacheThoughtsSplit(CustomTestCase): """Validate the split-insertion helper for --no-cache-thoughts.""" @@ -88,6 +92,58 @@ def test_split_no_answer_yet(self): result.thought_kv_indices_to_free.tolist(), [1002, 1003, 1004, 1005] ) + def test_priming_tail_excluded_from_cached_prompt(self): + """When think_start_id is provided and origin_input_ids ends with a + \\n priming tail (i.e. add_generation_prompt added \\n + before decode began), those priming tokens must NOT be part of the + virtual cached prompt. They live only in turn 1's input; future turns' + chat-template rendering of the historical assistant message doesn't + include them. Including them in the cached path causes a token + mismatch on turn 2 at the position right after the assistant header. + + Setup mirrors a typical K2-v3 reasoning turn: + positions: 0 1 2 3 4 5 6 7 8 9 + tokens: A B C \\n t1 t2 X Y + └─prompt──┘ └─priming─┘ └──thoughts────┘ └answer┘ + think_start_id == 500 == the token id + answer_start_position = 8 + + Expected: + virtual_token_ids = [A, B, C, X, Y] + virtual_kv_indices = slots [0, 1, 2, 8, 9] + virtual_positions = [0, 1, 2, 8, 9] + thought_kv_indices_to_free = slots [3, 4, 5, 6, 7] + """ + origin_input_ids = [101, 102, 103, 500, 599] # last 2 are priming \\n + output_ids = [201, 202, 502, 301, 302] # think tokens, , answer + req_to_token_slot = torch.tensor( + [1000, 1001, 1002, 1003, 1004, 1005, 1006, 1007, 1008, 1009], + dtype=torch.int64, + ) + answer_start_position = 8 + + result = split_kv_for_no_cache_thoughts( + origin_input_ids=origin_input_ids, + output_ids=output_ids, + req_to_token_slot=req_to_token_slot, + answer_start_position=answer_start_position, + think_start_id=500, + ) + + self.assertEqual(result.virtual_token_ids, [101, 102, 103, 301, 302]) + self.assertEqual( + result.virtual_kv_indices.tolist(), [1000, 1001, 1002, 1008, 1009] + ) + self.assertEqual(result.virtual_positions.tolist(), [0, 1, 2, 8, 9]) + # Only the actual decoded thought slots (input_len..answer_start-1) get + # freed. The priming slots (prompt_keep_len..input_len-1) stay owned by + # the radix entry that cache_unfinished_req inserted during prefill; + # freeing them here would double-count and trigger leak detection. + self.assertEqual( + result.thought_kv_indices_to_free.tolist(), + [1005, 1006, 1007], + ) + def test_split_long_answer(self): """Multi-token answer with a longer thought slice.""" origin_input_ids = [10, 11, 12] # 3-token prompt at positions 0-2 diff --git a/test/registered/radix_cache/test_other_backends_split_kwarg.py b/test/registered/radix_cache/test_other_backends_split_kwarg.py index 2d5df6b779b3..4546b729ffab 100644 --- a/test/registered/radix_cache/test_other_backends_split_kwarg.py +++ b/test/registered/radix_cache/test_other_backends_split_kwarg.py @@ -6,6 +6,10 @@ import inspect import unittest +from sglang.test.ci.ci_register import register_cuda_ci + +register_cuda_ci(est_time=5, suite="stage-b-test-1-gpu-small") + def _accepts_split(cls) -> bool: sig = inspect.signature(cls.cache_finished_req) diff --git a/test/registered/radix_cache/test_release_kv_cache_routing.py b/test/registered/radix_cache/test_release_kv_cache_routing.py index 4d8edfab1f39..351c6e33dcb4 100644 --- a/test/registered/radix_cache/test_release_kv_cache_routing.py +++ b/test/registered/radix_cache/test_release_kv_cache_routing.py @@ -15,6 +15,9 @@ NoCacheThoughtsSplit, release_kv_cache, ) +from sglang.test.ci.ci_register import register_cuda_ci + +register_cuda_ci(est_time=5, suite="stage-b-test-1-gpu-small") class TestReleaseKvCacheRouting(unittest.TestCase): @@ -65,6 +68,48 @@ def test_routes_through_split_when_flag_on(self): self.assertEqual(split.virtual_token_ids, [101, 102, 301, 302, 303]) self.assertEqual(split.virtual_positions.tolist(), [0, 1, 6, 7, 8]) + def test_propagates_think_start_id_from_req(self): + """When req carries _think_start_id, release_kv_cache must pass it through + to split_kv_for_no_cache_thoughts so the priming tail is stripped from the + cached prompt.""" + tree = self._make_tree_cache_mock() + req = self._make_req_mock() + # Simulate add_generation_prompt having added \\n (token id 500) as the + # last 2 tokens of origin_input_ids. Use a different prompt that ends with priming. + req.origin_input_ids = [101, 102, 500, 599] + req.output_ids = [201, 202, 203, 204, 301, 302, 303] + # Total len = 4 + 7 = 11; answer_start is at the same offset within output_ids. + # output_ids: [thought_1, thought_2, thought_3, , X, Y, Z] + # answer_start_position is the absolute position right after : + # 4 (input) + 4 (think+thoughts+/think) = 8 + req.answer_start_position = 8 + # think_start_id == 500 (the opener) + req._think_start_id = 500 + # req_to_token_slot covers 11 tokens. + tree.req_to_token_pool.req_to_token = torch.tensor( + [[100, 101, 102, 103, 104, 105, 106, 107, 108, 109, 110]], + dtype=torch.int64, + ) + + fake_server_args = MagicMock() + fake_server_args.no_cache_thoughts = True + fake_server_args.page_size = 1 + fake_server_args.speculative_algorithm = None + + with patch( + "sglang.srt.mem_cache.common.get_global_server_args", + return_value=fake_server_args, + ): + release_kv_cache(req, tree, is_insert=True) + + split = tree.cache_finished_req.call_args.kwargs.get("split") + self.assertIsNotNone(split) + # Priming tail (slots 2, 3 — the \\n tokens) excluded from cached prompt. + # Cached prompt: [101, 102] from positions [0, 1]. + # Answer: [301, 302, 303] from positions [8, 9, 10]. + self.assertEqual(split.virtual_token_ids, [101, 102, 301, 302, 303]) + self.assertEqual(split.virtual_positions.tolist(), [0, 1, 8, 9, 10]) + def test_no_split_when_flag_off(self): tree = self._make_tree_cache_mock() req = self._make_req_mock() # has require_reasoning + answer_start_position set diff --git a/test/registered/radix_cache/test_req_answer_start_position.py b/test/registered/radix_cache/test_req_answer_start_position.py index 30bd40cf25d3..6d82be3410bb 100644 --- a/test/registered/radix_cache/test_req_answer_start_position.py +++ b/test/registered/radix_cache/test_req_answer_start_position.py @@ -5,6 +5,10 @@ from sglang.srt.managers.schedule_batch import Req from sglang.srt.sampling.sampling_params import SamplingParams +from sglang.test.ci.ci_register import register_cuda_ci + +register_cuda_ci(est_time=5, suite="stage-b-test-1-gpu-small") + class TestReqAnswerStartPosition(unittest.TestCase): def test_set_when_think_end_detected(self): diff --git a/test/registered/radix_cache/test_req_cached_positions.py b/test/registered/radix_cache/test_req_cached_positions.py index a6f9eca18017..51c54056c864 100644 --- a/test/registered/radix_cache/test_req_cached_positions.py +++ b/test/registered/radix_cache/test_req_cached_positions.py @@ -15,6 +15,10 @@ from sglang.srt.mem_cache.radix_cache import RadixCache, RadixKey from sglang.srt.sampling.sampling_params import SamplingParams +from sglang.test.ci.ci_register import register_cuda_ci + +register_cuda_ci(est_time=5, suite="stage-b-test-1-gpu-small") + class TestReqCachedPositions(unittest.TestCase): def test_match_with_non_contiguous_positions_stored_on_req(self): From c7a329fd2313bc267f35778fb055f003b1dc9ab9 Mon Sep 17 00:00:00 2001 From: David <12414531+DavidBellamy@users.noreply.github.com> Date: Tue, 26 May 2026 09:28:01 -0700 Subject: [PATCH 09/11] feat: revert priming-strip; align cached entry with TITO buffer + add TITO E2E MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The earlier priming-strip logic (stash think_start_id on Req, scan origin_input_ids for the last , drop the tail from the virtual cached prompt) was the wrong call for the production rollout path. In TITO ("Token-In, Token-Out") multi-turn rollouts, turn N+1's input is constructed from turn N's prompt_token_ids verbatim — the priming \n at the end of the input is carried over as-is. The cached entry from no-cache-thoughts must therefore also include the priming tail; otherwise turn N+1's input has [...prompt, , \n, answer...] while the cached path has [...prompt, answer...] — match dies right where we want it to extend through. Stripping the priming was an attempt to align with the OpenAI chat- completions text path, where the chat template strips reasoning from historical assistant messages. But that path also has a BPE retokenize mismatch that prevents the answer slice from aligning anyway (see TITO doc). So the chat-template path can't deliver multi-turn answer reuse under any priming-strip variant. The correct integration path is TITO, where keeping the priming in the cache is the right answer. Net change: * split_kv_for_no_cache_thoughts: removes think_start_id kwarg and the scan-for-priming logic. prompt_keep_len is always input_len. * common.release_kv_cache: drops the think_start_id propagation. * scheduler.Scheduler: drops self._think_start_id init and the per-Req _think_start_id assignments at the three Req-construction sites. * Test removals: the priming-strip-specific cases in test_no_cache_thoughts_split.py and test_release_kv_cache_routing.py. * New TITO E2E: test/manual/test_no_cache_thoughts_e2e.py now sends turn 2 with input_ids in the chat-completions body (bypassing chat template), strips output_token_ids[:reasoning_tokens] from the prior turn before appending to the buffer, and tokenizes only the env-delta (new user msg + assistant generation prompt). Assertion now passes: baseline cached_tokens=22, --no-cache-thoughts cached_tokens=191 (delta = answer length, exactly the multi-turn cache reuse the feature is supposed to deliver). All 34 unit tests still green (1 env-skipped for the C++ extension). --- python/sglang/srt/managers/scheduler.py | 14 -- python/sglang/srt/mem_cache/common.py | 53 ++-- test/manual/test_no_cache_thoughts_e2e.py | 232 +++++++++++------- .../test_no_cache_thoughts_split.py | 52 ---- .../test_release_kv_cache_routing.py | 42 ---- 5 files changed, 157 insertions(+), 236 deletions(-) diff --git a/python/sglang/srt/managers/scheduler.py b/python/sglang/srt/managers/scheduler.py index 64919a10e7e0..e3b07b510328 100644 --- a/python/sglang/srt/managers/scheduler.py +++ b/python/sglang/srt/managers/scheduler.py @@ -554,15 +554,8 @@ def init_tokenizer(self): reasoning_parser.detector.think_end_token, add_special_tokens=False )[0] self._think_end_id = self.tokenizer.think_end_id - # think_start_id is consumed by --no-cache-thoughts at request finish to - # strip the chat-template's ... priming tail from origin_input_ids - # before inserting the cached prompt into the radix tree. - self._think_start_id = self.tokenizer.encode( - reasoning_parser.detector.think_start_token, add_special_tokens=False - )[0] else: self._think_end_id = None - self._think_start_id = None def init_mamba_backend(self) -> None: initialize_mamba_selective_state_update_backend(self.server_args) @@ -1785,11 +1778,6 @@ def handle_generate_request( time_stats=recv_req.time_stats, ) req.tokenizer = self.tokenizer - # Per-request copy so release_kv_cache (which doesn't have a scheduler - # reference) can find the token id for the --no-cache-thoughts - # priming-tail stripping at request finish. - req._think_start_id = self._think_start_id - if self.disaggregation_mode != DisaggregationMode.NULL: # Invalid request for disaggregated mode if ( @@ -1835,7 +1823,6 @@ def handle_generate_request( vocab_size=self.model_config.vocab_size, ) req.tokenizer = self.tokenizer - req._think_start_id = self._think_start_id req.set_finish_with_abort( f"Invalid request: session id {session_id} does not exist" ) @@ -2093,7 +2080,6 @@ def handle_embedding_request( time_stats=recv_req.time_stats, ) req.tokenizer = self.tokenizer - req._think_start_id = self._think_start_id # Handle multimodal inputs if recv_req.image_inputs is not None: diff --git a/python/sglang/srt/mem_cache/common.py b/python/sglang/srt/mem_cache/common.py index 92d89bb41e83..a53aa58962df 100644 --- a/python/sglang/srt/mem_cache/common.py +++ b/python/sglang/srt/mem_cache/common.py @@ -115,7 +115,6 @@ def split_kv_for_no_cache_thoughts( output_ids: List[int], req_to_token_slot: torch.Tensor, answer_start_position: int, - think_start_id: Optional[int] = None, ) -> NoCacheThoughtsSplit: """Compute the split-insertion tensors for a finished reasoning request. @@ -131,14 +130,6 @@ def split_kv_for_no_cache_thoughts( sequence (length must be >= len(origin_input_ids) + len(output_ids)). answer_start_position: absolute position (in the input+output index space) of the first answer token, i.e. the token immediately after ``. - think_start_id: when provided, the trailing run of `origin_input_ids` from - the last occurrence of this token id onward is treated as the chat - template's ... priming tail. Those tokens live only in this - turn's input (added by add_generation_prompt before decode began); - future turns' chat-template rendering of the historical assistant - message does NOT include them. They are excluded from the virtual - cached prompt and added to the freed slice, so the cached entry - aligns with what subsequent turns' inputs will actually contain. Returns: NoCacheThoughtsSplit with: @@ -158,36 +149,24 @@ def split_kv_for_no_cache_thoughts( # Clamp answer_start so we behave sanely if reasoning never finished. answer_start = min(answer_start_position, total_len) - # Identify the chat-template priming tail at the end of origin_input_ids - # (typically a `\n` block added by add_generation_prompt). Excluded - # from the cached prompt so the cached entry aligns with the way future - # turns render the prior assistant turn (without the priming tokens). - prompt_keep_len = input_len - if think_start_id is not None: - for i in range(input_len - 1, -1, -1): - if origin_input_ids[i] == think_start_id: - prompt_keep_len = i - break - - # Slots / positions for input prompt (sans priming) + post- answer. + # Slots / positions for input prompt + post- answer. + # The full input prompt (including any \n priming tail from + # add_generation_prompt) stays in the cached entry — TITO rollouts feed + # turn N+1's input as raw token IDs that include turn N's prompt verbatim, + # so keeping the priming preserves cache alignment in that flow. answer_count = max(total_len - answer_start, 0) answer_output_offset = answer_start - input_len # index into output_ids - virtual_token_ids = list(origin_input_ids[:prompt_keep_len]) + list( + virtual_token_ids = list(origin_input_ids) + list( output_ids[answer_output_offset:] if answer_count > 0 else [] ) - kept_slot_indices = list(range(prompt_keep_len)) + list( - range(answer_start, total_len) - ) - kept_positions = list(range(prompt_keep_len)) + list( - range(answer_start, total_len) - ) - # Only the actual decoded thoughts (between the input prompt and ) - # are freed here. The priming tail (between prompt_keep_len and input_len) - # is still owned by the radix entry that cache_unfinished_req inserted at - # prefill time — freeing it produces a double-count against the allocator - # ("memory leak detected") because the tree node still claims those slots. + kept_slot_indices = list(range(input_len)) + list(range(answer_start, total_len)) + kept_positions = list(range(input_len)) + list(range(answer_start, total_len)) + # Free the decoded thought slots (between the input prompt and ). + # These were allocated during decode and are owned by the request — safe + # to free without affecting the radix entry that cache_unfinished_req + # inserted at prefill time (which only owns the input-prompt slots). thought_slot_indices = list(range(input_len, answer_start)) device = req_to_token_slot.device @@ -679,18 +658,14 @@ def release_kv_cache(req: Req, tree_cache: BasePrefixCache, is_insert: bool = Tr and getattr(req, "answer_start_position", None) is not None ): # Skip the thought tokens from the shared prefix cache; insert only the - # input + post- answer slice, preserving original RoPE positions. - # think_start_id (when populated by the scheduler) lets the split helper - # also strip the chat-template's \n priming tail from origin_input_ids - # so the cached prompt aligns with how future turns render this assistant - # message (without the priming). + # input + post- answer slice, preserving original RoPE positions + # for the answer (the input prompt keeps its contiguous positions). req_to_token_slot = tree_cache.req_to_token_pool.req_to_token[req.req_pool_idx] split = split_kv_for_no_cache_thoughts( origin_input_ids=req.origin_input_ids, output_ids=req.output_ids, req_to_token_slot=req_to_token_slot, answer_start_position=req.answer_start_position, - think_start_id=getattr(req, "_think_start_id", None), ) tree_cache.cache_finished_req(req, is_insert=is_insert, split=split) else: diff --git a/test/manual/test_no_cache_thoughts_e2e.py b/test/manual/test_no_cache_thoughts_e2e.py index b2a78bf0c96e..d0318139f2ab 100644 --- a/test/manual/test_no_cache_thoughts_e2e.py +++ b/test/manual/test_no_cache_thoughts_e2e.py @@ -1,38 +1,45 @@ -"""End-to-end test for --no-cache-thoughts on BBQ-8B-Mid3. - -What this test verifies ------------------------ -Two servers launched on the same BBQ checkpoint, one with --no-cache-thoughts and -one without (baseline). Both serve the same turn-1 reasoning request. Turn 2 -sends a chat history that contains the prior assistant answer (with ... - stripped by the chat template, as normal multi-turn rendering does). - -Expected cache behavior on turn 2: - * Baseline: only the original user prompt remains in the radix as a matchable - prefix, because turn-1's cached path appended the thought tokens between - the user prompt and the answer, so turn-2's thoughts-free input diverges - immediately after the user message. cached_tokens ~ len(user_prompt). - * --no-cache-thoughts: the answer was inserted with non-contiguous positions - (thoughts skipped), so turn-2's [user_prompt + answer] matches the cached - path. cached_tokens ~ len(user_prompt + answer). - -The hard assertion is therefore: on turn 2, - cached_tokens(--no-cache-thoughts server) > cached_tokens(baseline server) -with the delta being at least most of the answer's token count. - -How to run ----------- -This test runs inside the agentic-rl container image with an overlay of THIS -branch's SGLang. Example invocation from the m2 login node: - - srun --partition=main --time=00:30:00 -N 1 --gres=gpu:2 \ - --container-image=/mnt/weka/shrd/k2pta/agentic_rl_images/agentic-rl-2eff86d1.sqsh \ - --container-mounts=/mnt/weka:/mnt/weka,$PWD:/sglang \ - bash -c "pip install --no-deps -e /sglang/python && \ - cd /sglang && python3 -m unittest test.manual.test_no_cache_thoughts_e2e -v" - -Two GPUs are requested so the two servers can run side-by-side (each on its -own GPU). The script picks GPU 0 / GPU 1 via CUDA_VISIBLE_DEVICES per server. +"""End-to-end test for --no-cache-thoughts on BBQ-8B-Mid3, TITO-style. + +What this verifies +------------------ +Two SGLang servers on the same BBQ checkpoint, one with --no-cache-thoughts +and one without. Both serve the same turn-1 reasoning request. Turn 2 builds +its input via the TITO protocol — raw token IDs, never re-tokenizing prior +content through the chat template — and excludes turn 1's thought slice +(output_token_ids[:reasoning_tokens]) from the running buffer. + +Expected behavior on turn 2: + * Baseline server (no flag): cached_tokens covers the original user prompt + only. The cached path from turn 1's finish includes the priming + + thoughts, but turn 2's buffer has the priming followed by the answer + (no thoughts), so the match dies at the first thought slot. + * --no-cache-thoughts server: the cached path was inserted with non- + contiguous positions and excludes both thoughts AND the priming tail. + Turn 2's buffer aligns: prompt-without-priming + answer-only matches + the cached entry up through the entire answer. cached_tokens covers + roughly len(prompt - priming) + len(answer). + +Hard assertion: cached_tokens(--no-cache-thoughts) > cached_tokens(baseline). + +Why TITO-style and not OpenAI chat-completions text passthrough +--------------------------------------------------------------- +Round-tripping the model's answer through text and back through the chat +template's tokenizer drifts (BPE merges differ across string-concat +boundaries), so the answer's first token ID in turn 2's input does not +equal the first token ID stored in the cached path. The radix match dies +right after the assistant header — observable as cached_tokens stuck at +the prompt-prefix length regardless of whether the flag is set. The TITO +protocol prevents this by passing input_ids directly and never letting the +chat template re-tokenize prior assistant content. See reference_tito.md. + +How to run (inside the agentic-rl image; needs 2 GPUs) +------------------------------------------------------ + srun --partition=main --time=00:30:00 -N 1 --gres=gpu:2 \\ + --container-image=/mnt/weka/shrd/k2pta/agentic_rl_images/agentic-rl-2eff86d1.sqsh \\ + --container-mounts=/mnt/weka:/mnt/weka,$PWD:/sglang \\ + bash -c "pip install --no-deps --break-system-packages -e /sglang/python && \\ + cd /sglang/test/manual && \\ + python3 -m unittest test_no_cache_thoughts_e2e -v" """ from __future__ import annotations @@ -46,10 +53,6 @@ import requests BBQ_PATH = "/mnt/weka/shrd/k2m/suqi.sun/bbq_image/bbq-8b-mid3-final" -# Upstream BBQ chat template from LLM360/bbq-chat-template:main, which drops the -# empty block in assistant rendering when message.think is not -# explicitly set. Required for --no-cache-thoughts to align with multi-turn -# input on turn 2. Mid3's bundled chat_template.jinja is stale. CHAT_TEMPLATE = os.path.join( os.path.dirname(os.path.abspath(__file__)), "chat_templates", @@ -85,8 +88,6 @@ def _launch(port: int, extra_args: list[str], gpu: str) -> subprocess.Popen: "--mem-fraction-static", "0.80", ] + list(extra_args) - # Write server logs alongside the test source (bind-mounted) so the file - # survives the container shutdown and can be inspected from the host. log_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), ".e2e_logs") os.makedirs(log_dir, exist_ok=True) log_path = os.path.join(log_dir, f"sglang_port{port}.log") @@ -130,12 +131,37 @@ def _kill(proc: subprocess.Popen) -> None: pass -def _chat(base_url: str, messages: list[dict], max_tokens: int = 512) -> dict: +def _chat_text(base_url: str, messages: list[dict], max_tokens: int) -> dict: + """Turn 1 — chat completions with text, asking the server to return raw token IDs.""" payload = { "model": BBQ_PATH, "messages": messages, "temperature": 0.0, "max_tokens": max_tokens, + "return_prompt_token_ids": True, + "return_completion_token_ids": True, + "return_meta_info": True, + } + r = requests.post( + f"{base_url}/v1/chat/completions", json=payload, timeout=REQUEST_TIMEOUT + ) + r.raise_for_status() + return r.json() + + +def _chat_token_ids(base_url: str, input_ids: list[int], max_tokens: int) -> dict: + """Turn 2+ — chat completions with input_ids, bypassing the chat template.""" + payload = { + "model": BBQ_PATH, + # messages is required by the OpenAI shape but is ignored for tokenization + # when input_ids is set; SGLang still uses it to derive stop tokens. + "messages": [{"role": "user", "content": "ignored when input_ids is set"}], + "input_ids": input_ids, + "temperature": 0.0, + "max_tokens": max_tokens, + "return_prompt_token_ids": True, + "return_completion_token_ids": True, + "return_meta_info": True, } r = requests.post( f"{base_url}/v1/chat/completions", json=payload, timeout=REQUEST_TIMEOUT @@ -144,7 +170,44 @@ def _chat(base_url: str, messages: list[dict], max_tokens: int = 512) -> dict: return r.json() -class TestNoCacheThoughtsE2E(unittest.TestCase): +def _tokenize(text: str) -> list[int]: + """Tokenize a string fragment via HF AutoTokenizer with add_special_tokens=False.""" + from transformers import AutoTokenizer + + tok = AutoTokenizer.from_pretrained(BBQ_PATH, trust_remote_code=True) + return tok.encode(text, add_special_tokens=False) + + +def _build_turn2_input_ids(turn1_resp: dict, new_user_text: str) -> list[int]: + """Construct turn 2's input_ids by: + 1. Taking turn 1's prompt_token_ids verbatim (no re-tokenization). + 2. Appending turn 1's answer slice — output_token_ids[reasoning_tokens:] — + so the thought tokens are stripped before they enter turn 2's buffer. + 3. Appending the env-delta tokens for the new user turn + assistant + generation prompt. The K2V3 boundary patch (append \\n after + <|im_end|>) is included because the model stops on <|im_end|> + without emitting the trailing \\n that the chat template would have. + """ + prompt_ids = turn1_resp["choices"][0]["prompt_token_ids"] + completion_ids = turn1_resp["choices"][0]["completion_token_ids"] + reasoning_tokens = turn1_resp["usage"]["reasoning_tokens"] + + # Strip the thought slice — keep only what follows . + answer_ids = completion_ids[reasoning_tokens:] + + # K2V3 / Qwen3 boundary patch: model stops on <|im_end|>; template emits + # <|im_end|>\n. The missing \n is part of the env-delta tokenization below + # (it's the leading \n of the env-delta string). + env_delta = ( + f"\n<|im_start|>user\n{new_user_text}<|im_end|>\n" + f"<|im_start|>assistant\n\n" + ) + env_delta_ids = _tokenize(env_delta) + + return list(prompt_ids) + list(answer_ids) + list(env_delta_ids) + + +class TestNoCacheThoughtsE2ETito(unittest.TestCase): @classmethod def setUpClass(cls): @@ -166,9 +229,7 @@ def tearDownClass(cls): _kill(cls.proc_no_cache) _kill(cls.proc_baseline) - def _dump_logs_on_failure(self, label: str, proc: subprocess.Popen) -> None: - """Always dump the server log on any chat failure — works whether the - server is dead or just unreachable.""" + def _dump_logs(self, label: str, proc: subprocess.Popen) -> None: log_path = getattr(proc, "_log_path", None) if log_path is None: return @@ -182,51 +243,33 @@ def _dump_logs_on_failure(self, label: str, proc: subprocess.Popen) -> None: except Exception as e: print(f"(failed to read {label} log: {e})", flush=True) - def test_cached_tokens_delta_on_turn2(self): - user_turn1 = { - "role": "user", - "content": "What is 12 * 7? Reason carefully.", - } - # Turn 1 on both servers. - try: - resp_nc = _chat(BASE_URL_NO_CACHE, [user_turn1], max_tokens=512) - except Exception: - self._dump_logs_on_failure("no_cache (turn 1)", self.proc_no_cache) - raise + def test_tito_cached_tokens_delta_on_turn2(self): + user_turn1 = {"role": "user", "content": "What is 12 * 7? Reason carefully."} + new_user_text = "Now multiply that result by 3." + + # Turn 1: chat completions over text, with Tito flags so we get raw IDs back. try: - resp_bl = _chat(BASE_URL_BASELINE, [user_turn1], max_tokens=512) + resp_nc = _chat_text(BASE_URL_NO_CACHE, [user_turn1], max_tokens=512) + resp_bl = _chat_text(BASE_URL_BASELINE, [user_turn1], max_tokens=512) except Exception: - self._dump_logs_on_failure("baseline (turn 1)", self.proc_baseline) + self._dump_logs("no_cache (turn 1)", self.proc_no_cache) + self._dump_logs("baseline (turn 1)", self.proc_baseline) raise - ans_nc = resp_nc["choices"][0]["message"]["content"] - ans_bl = resp_bl["choices"][0]["message"]["content"] - # With temperature=0 + same prompt, the answer portion should be identical. - # If they diverge it's still OK — we only need a valid prior assistant - # message for turn 2; we use each server's own turn-1 answer. - - user_turn2 = { - "role": "user", - "content": "Now multiply that result by 3.", - } - # Turn 2 history: chat template strips from prior assistant messages. + # Turn 2: build input_ids via the Tito protocol, send with input_ids in body. + input_ids_nc = _build_turn2_input_ids(resp_nc, new_user_text) + input_ids_bl = _build_turn2_input_ids(resp_bl, new_user_text) + try: - resp_nc_t2 = _chat( - BASE_URL_NO_CACHE, - [user_turn1, {"role": "assistant", "content": ans_nc}, user_turn2], - max_tokens=256, + resp_nc_t2 = _chat_token_ids( + BASE_URL_NO_CACHE, input_ids_nc, max_tokens=256 ) - except Exception: - self._dump_logs_on_failure("no_cache (turn 2)", self.proc_no_cache) - raise - try: - resp_bl_t2 = _chat( - BASE_URL_BASELINE, - [user_turn1, {"role": "assistant", "content": ans_bl}, user_turn2], - max_tokens=256, + resp_bl_t2 = _chat_token_ids( + BASE_URL_BASELINE, input_ids_bl, max_tokens=256 ) except Exception: - self._dump_logs_on_failure("baseline (turn 2)", self.proc_baseline) + self._dump_logs("no_cache (turn 2)", self.proc_no_cache) + self._dump_logs("baseline (turn 2)", self.proc_baseline) raise cached_nc = resp_nc_t2["usage"]["prompt_tokens_details"]["cached_tokens"] @@ -234,17 +277,28 @@ def test_cached_tokens_delta_on_turn2(self): prompt_nc = resp_nc_t2["usage"]["prompt_tokens"] prompt_bl = resp_bl_t2["usage"]["prompt_tokens"] - print(f"baseline: prompt_tokens={prompt_bl} cached_tokens={cached_bl}") + print(f"baseline: prompt_tokens={prompt_bl} cached_tokens={cached_bl}") print(f"no-cache-thoughts: prompt_tokens={prompt_nc} cached_tokens={cached_nc}") - # Core assertion: --no-cache-thoughts caches MORE of turn-2's input than baseline. - # The delta reflects the answer slice that was inserted with non-contiguous - # positions on the with-flag server but not on the baseline. + # Both servers should see the same prompt_tokens (we constructed both inputs + # identically; the answer text was identical at temperature=0). + self.assertEqual( + prompt_nc, + prompt_bl, + "turn 2 input lengths diverged — answer differed across servers?", + ) + + # Core assertion: --no-cache-thoughts caches more of turn 2's input than + # baseline. Baseline's turn 1 cache included the priming + thoughts, + # which turn 2's Tito buffer doesn't contain at the same slot, so its + # cache match dies after the user prompt. --no-cache-thoughts's split + # path put the answer at non-contiguous positions and stripped the + # priming — turn 2 aligns through the answer. self.assertGreater( cached_nc, cached_bl, - f"--no-cache-thoughts cached_tokens ({cached_nc}) should exceed baseline " - f"({cached_bl}) by approximately len(turn-1 answer)", + f"--no-cache-thoughts cached_tokens ({cached_nc}) should exceed " + f"baseline ({cached_bl}) by approximately len(turn-1 answer)", ) diff --git a/test/registered/radix_cache/test_no_cache_thoughts_split.py b/test/registered/radix_cache/test_no_cache_thoughts_split.py index 47fe667d601e..d5c29d9e5a97 100644 --- a/test/registered/radix_cache/test_no_cache_thoughts_split.py +++ b/test/registered/radix_cache/test_no_cache_thoughts_split.py @@ -92,58 +92,6 @@ def test_split_no_answer_yet(self): result.thought_kv_indices_to_free.tolist(), [1002, 1003, 1004, 1005] ) - def test_priming_tail_excluded_from_cached_prompt(self): - """When think_start_id is provided and origin_input_ids ends with a - \\n priming tail (i.e. add_generation_prompt added \\n - before decode began), those priming tokens must NOT be part of the - virtual cached prompt. They live only in turn 1's input; future turns' - chat-template rendering of the historical assistant message doesn't - include them. Including them in the cached path causes a token - mismatch on turn 2 at the position right after the assistant header. - - Setup mirrors a typical K2-v3 reasoning turn: - positions: 0 1 2 3 4 5 6 7 8 9 - tokens: A B C \\n t1 t2 X Y - └─prompt──┘ └─priming─┘ └──thoughts────┘ └answer┘ - think_start_id == 500 == the token id - answer_start_position = 8 - - Expected: - virtual_token_ids = [A, B, C, X, Y] - virtual_kv_indices = slots [0, 1, 2, 8, 9] - virtual_positions = [0, 1, 2, 8, 9] - thought_kv_indices_to_free = slots [3, 4, 5, 6, 7] - """ - origin_input_ids = [101, 102, 103, 500, 599] # last 2 are priming \\n - output_ids = [201, 202, 502, 301, 302] # think tokens, , answer - req_to_token_slot = torch.tensor( - [1000, 1001, 1002, 1003, 1004, 1005, 1006, 1007, 1008, 1009], - dtype=torch.int64, - ) - answer_start_position = 8 - - result = split_kv_for_no_cache_thoughts( - origin_input_ids=origin_input_ids, - output_ids=output_ids, - req_to_token_slot=req_to_token_slot, - answer_start_position=answer_start_position, - think_start_id=500, - ) - - self.assertEqual(result.virtual_token_ids, [101, 102, 103, 301, 302]) - self.assertEqual( - result.virtual_kv_indices.tolist(), [1000, 1001, 1002, 1008, 1009] - ) - self.assertEqual(result.virtual_positions.tolist(), [0, 1, 2, 8, 9]) - # Only the actual decoded thought slots (input_len..answer_start-1) get - # freed. The priming slots (prompt_keep_len..input_len-1) stay owned by - # the radix entry that cache_unfinished_req inserted during prefill; - # freeing them here would double-count and trigger leak detection. - self.assertEqual( - result.thought_kv_indices_to_free.tolist(), - [1005, 1006, 1007], - ) - def test_split_long_answer(self): """Multi-token answer with a longer thought slice.""" origin_input_ids = [10, 11, 12] # 3-token prompt at positions 0-2 diff --git a/test/registered/radix_cache/test_release_kv_cache_routing.py b/test/registered/radix_cache/test_release_kv_cache_routing.py index 351c6e33dcb4..01949bcc413a 100644 --- a/test/registered/radix_cache/test_release_kv_cache_routing.py +++ b/test/registered/radix_cache/test_release_kv_cache_routing.py @@ -68,48 +68,6 @@ def test_routes_through_split_when_flag_on(self): self.assertEqual(split.virtual_token_ids, [101, 102, 301, 302, 303]) self.assertEqual(split.virtual_positions.tolist(), [0, 1, 6, 7, 8]) - def test_propagates_think_start_id_from_req(self): - """When req carries _think_start_id, release_kv_cache must pass it through - to split_kv_for_no_cache_thoughts so the priming tail is stripped from the - cached prompt.""" - tree = self._make_tree_cache_mock() - req = self._make_req_mock() - # Simulate add_generation_prompt having added \\n (token id 500) as the - # last 2 tokens of origin_input_ids. Use a different prompt that ends with priming. - req.origin_input_ids = [101, 102, 500, 599] - req.output_ids = [201, 202, 203, 204, 301, 302, 303] - # Total len = 4 + 7 = 11; answer_start is at the same offset within output_ids. - # output_ids: [thought_1, thought_2, thought_3, , X, Y, Z] - # answer_start_position is the absolute position right after : - # 4 (input) + 4 (think+thoughts+/think) = 8 - req.answer_start_position = 8 - # think_start_id == 500 (the opener) - req._think_start_id = 500 - # req_to_token_slot covers 11 tokens. - tree.req_to_token_pool.req_to_token = torch.tensor( - [[100, 101, 102, 103, 104, 105, 106, 107, 108, 109, 110]], - dtype=torch.int64, - ) - - fake_server_args = MagicMock() - fake_server_args.no_cache_thoughts = True - fake_server_args.page_size = 1 - fake_server_args.speculative_algorithm = None - - with patch( - "sglang.srt.mem_cache.common.get_global_server_args", - return_value=fake_server_args, - ): - release_kv_cache(req, tree, is_insert=True) - - split = tree.cache_finished_req.call_args.kwargs.get("split") - self.assertIsNotNone(split) - # Priming tail (slots 2, 3 — the \\n tokens) excluded from cached prompt. - # Cached prompt: [101, 102] from positions [0, 1]. - # Answer: [301, 302, 303] from positions [8, 9, 10]. - self.assertEqual(split.virtual_token_ids, [101, 102, 301, 302, 303]) - self.assertEqual(split.virtual_positions.tolist(), [0, 1, 8, 9, 10]) - def test_no_split_when_flag_off(self): tree = self._make_tree_cache_mock() req = self._make_req_mock() # has require_reasoning + answer_start_position set From bbf13871254ea79dc9d71b281f653bd4f13380e6 Mon Sep 17 00:00:00 2001 From: David <12414531+DavidBellamy@users.noreply.github.com> Date: Sun, 7 Jun 2026 03:13:34 +0000 Subject: [PATCH 10/11] fix(no-cache-thoughts): page-align relocated answer KV; cap split at committed length --no-cache-thoughts crashed the server under concurrent load. Two causes: 1. Page misalignment. Dropping the span lowers each answer token's index in the cached sequence, but its KV stays in the slot decode assigned, so slot % page_size no longer equals index % page_size. The re-inserted answer was then unextendable and tripped alloc_extend's page-alignment assert (concurrency >= 32). Relocate the answer's KV into the page-congruent slots the thoughts vacate (new MLATokenToKVPool.move_kv_cache). The thought slots are no longer freed separately; they are reused by the relocation and reclaimed by a single page-aligned dead-tail free, removing the prior boundary double-free. 2. Read past committed length. The split walked input_len + len(output_ids), one token beyond kv_committed_len. The last generated token's KV can be uncommitted (overlap scheduling), leaving its req_to_token entry at the zero sentinel (reserved page 0); freeing it returned page 0 to the free list repeatedly across requests, a page double-free (concurrency >= 64). Cap the split at kv_committed_len, as the normal cache_finished_req path does. Validated on GLM-4.7-Flash (TP=4) with SGLANG_DEBUG_MEMORY_POOL=1 under forced-decode replay, concurrency 32-512: no allocator asserts, no double-free, no request failures, throughput parity with baseline. --- python/sglang/srt/mem_cache/common.py | 119 +++++++++++++-------- python/sglang/srt/mem_cache/memory_pool.py | 15 +++ python/sglang/srt/mem_cache/radix_cache.py | 32 ++++-- 3 files changed, 113 insertions(+), 53 deletions(-) diff --git a/python/sglang/srt/mem_cache/common.py b/python/sglang/srt/mem_cache/common.py index a53aa58962df..e8d73bd88093 100644 --- a/python/sglang/srt/mem_cache/common.py +++ b/python/sglang/srt/mem_cache/common.py @@ -96,18 +96,33 @@ def derive_extend_position_start( @dataclasses.dataclass class NoCacheThoughtsSplit: - """Result of splitting a finished reasoning request's KV into the radix-bound slice - and the freed-only thought slice. - - The cache_finished_req path consumes virtual_token_ids/positions/kv_indices to - register the answer in the radix tree with its original positions preserved, - then frees thought_kv_indices_to_free directly to the allocator. + """Plan for caching a finished reasoning request without its thought span. + + Dropping the generated ... tokens shifts every answer token's index + in the cached sequence DOWN by len(thoughts), but its KV still physically sits in + the slot decode gave it. Paged KV requires slot % page_size == index % page_size, + so the answer's KV must be RELOCATED into the slots the thoughts are vacating (which + are page-congruent to the answer's new indices) before it can be cached and safely + extended later. cache_finished_req consumes this plan as: + 1. move_kv_cache(move_dst, move_src) -> slide the answer's KV left + 2. insert virtual_token_ids/positions, values from virtual_kv_indices[:aligned] + 3. free virtual_kv_indices[aligned:] -> one page-aligned cut for the dead tail """ + # input + post- answer (thoughts removed); the cached key sequence. virtual_token_ids: List[int] + # The request's FULL original contiguous slot span S[0:total_len]. After the move, + # S[0:kept_len] holds input+answer and S[kept_len:] is the dead tail (stale thought + # slots + the answer's page-unaligned remainder), freed in one page-aligned cut. virtual_kv_indices: torch.Tensor + # Original RoPE positions of the kept tokens (gapped where thoughts were); the K + # vectors already encode these, so positions are preserved while only slots move. virtual_positions: torch.Tensor - thought_kv_indices_to_free: torch.Tensor + # Relocation: move the answer's current slots (move_src = S[answer_start:total_len]) + # into the page-congruent destination (move_dst = S[input_len:kept_len]). Empty when + # there is no thought span or no answer (nothing to relocate). + move_src: torch.Tensor + move_dst: torch.Tensor def split_kv_for_no_cache_thoughts( @@ -115,6 +130,7 @@ def split_kv_for_no_cache_thoughts( output_ids: List[int], req_to_token_slot: torch.Tensor, answer_start_position: int, + committed_len: int, ) -> NoCacheThoughtsSplit: """Compute the split-insertion tensors for a finished reasoning request. @@ -127,68 +143,86 @@ def split_kv_for_no_cache_thoughts( origin_input_ids: input token ids (positions 0..len(input)-1). output_ids: generated token ids (positions len(input)..len(input)+len(output)-1). req_to_token_slot: 1D tensor of kv_indices, one per token in the request's - sequence (length must be >= len(origin_input_ids) + len(output_ids)). + sequence (length must be >= committed_len). answer_start_position: absolute position (in the input+output index space) of the first answer token, i.e. the token immediately after ``. + committed_len: number of tokens whose KV is actually committed + (``req.kv_committed_len``). The final generated token can appear in + output_ids while its KV slot is uncommitted (overlap scheduling), in which + case its req_to_token entry is the zero/unwritten sentinel (reserved page 0). + Walking past committed_len would move/free that sentinel and double-free + page 0, so we cap here exactly as the normal cache_finished_req path does. Returns: - NoCacheThoughtsSplit with: - * virtual_token_ids: input + post-`` answer (thoughts removed). - * virtual_kv_indices: kv_indices for those tokens, gathered from req_to_token_slot. - * virtual_positions: matching original RoPE positions; non-contiguous between - input_len-1 and answer_start_position. - * thought_kv_indices_to_free: kv_indices for slots covering the thought slice. - - When answer_start_position >= len(input) + len(output), the answer hasn't been - generated yet (e.g. the request was cut off mid-thought) and the result contains - only the input prompt slice. + NoCacheThoughtsSplit (see that dataclass for field semantics): the cached key + sequence (input + answer), the request's committed slot span, the kept tokens' + original RoPE positions, and the move_src/move_dst slot tensors that relocate + the answer's KV into page-congruent slots. + + When answer_start_position >= committed_len, the answer hasn't been committed + (e.g. the request was cut off mid-thought) and the result caches only the input + prompt slice (no relocation). """ input_len = len(origin_input_ids) - total_len = input_len + len(output_ids) + # Cap at the committed KV length (see committed_len arg): never touch the + # uncommitted trailing token's unwritten (page-0) slot. + total_len = min(input_len + len(output_ids), committed_len) # Clamp answer_start so we behave sanely if reasoning never finished. answer_start = min(answer_start_position, total_len) - # Slots / positions for input prompt + post- answer. - # The full input prompt (including any \n priming tail from - # add_generation_prompt) stays in the cached entry — TITO rollouts feed - # turn N+1's input as raw token IDs that include turn N's prompt verbatim, - # so keeping the priming preserves cache alignment in that flow. + think_len = answer_start - input_len # decoded thought tokens to drop answer_count = max(total_len - answer_start, 0) answer_output_offset = answer_start - input_len # index into output_ids + kept_len = input_len + answer_count # cached sequence length (= total_len - think_len) + # The full input prompt (including any \n priming tail from + # add_generation_prompt) stays in the cached entry — TITO rollouts feed + # turn N+1's input as raw token IDs that include turn N's prompt verbatim, + # so keeping the priming preserves cache alignment in that flow. The answer slice + # is bounded by answer_count so an uncommitted trailing token is never cached. virtual_token_ids = list(origin_input_ids) + list( - output_ids[answer_output_offset:] if answer_count > 0 else [] + output_ids[answer_output_offset : answer_output_offset + answer_count] + if answer_count > 0 + else [] ) - kept_slot_indices = list(range(input_len)) + list(range(answer_start, total_len)) + # Original RoPE positions of the kept tokens: input is contiguous, then the answer + # keeps its ORIGINAL positions (a gap where the thoughts were). The K vectors already + # encode these positions, so we must not renumber them; only the physical slots move. kept_positions = list(range(input_len)) + list(range(answer_start, total_len)) - # Free the decoded thought slots (between the input prompt and ). - # These were allocated during decode and are owned by the request — safe - # to free without affecting the radix entry that cache_unfinished_req - # inserted at prefill time (which only owns the input-prompt slots). - thought_slot_indices = list(range(input_len, answer_start)) device = req_to_token_slot.device - if kept_slot_indices: - idx_tensor = torch.tensor(kept_slot_indices, dtype=torch.int64, device=device) - virtual_kv_indices = req_to_token_slot[idx_tensor].to(torch.int64).clone() + slots = req_to_token_slot.to(torch.int64) + + # Hand cache_finished_req the request's FULL original slot span. It takes cached + # values from slots[:page_aligned(kept_len)] (after the move) and frees + # slots[page_aligned(kept_len):] in a single page-aligned cut — that one cut covers + # both the stale thought slots and the answer's unaligned tail with no boundary + # double-free. + virtual_kv_indices = slots[:total_len].clone() + + # Relocate the answer's KV LEFT by think_len slots so each answer token lands on the + # slot that originally held its NEW index (slot % page_size == index % page_size). + # The destination slots[input_len:kept_len] are exactly the thought slots plus the + # answer's leading slots — all owned privately by this finished request, so the move + # cannot disturb the shared input prefix already in the radix. Skip when there is no + # thought span (already aligned) or no answer (nothing to relocate). + if think_len > 0 and answer_count > 0: + move_src = slots[answer_start:total_len].clone() + move_dst = slots[input_len:kept_len].clone() else: - virtual_kv_indices = torch.empty((0,), dtype=torch.int64, device=device) + move_src = torch.empty((0,), dtype=torch.int64, device=device) + move_dst = torch.empty((0,), dtype=torch.int64, device=device) virtual_positions = torch.tensor(kept_positions, dtype=torch.int64, device=device) - if thought_slot_indices: - t_idx = torch.tensor(thought_slot_indices, dtype=torch.int64, device=device) - thought_kv_indices_to_free = req_to_token_slot[t_idx].to(torch.int64).clone() - else: - thought_kv_indices_to_free = torch.empty((0,), dtype=torch.int64, device=device) - return NoCacheThoughtsSplit( virtual_token_ids=virtual_token_ids, virtual_kv_indices=virtual_kv_indices, virtual_positions=virtual_positions, - thought_kv_indices_to_free=thought_kv_indices_to_free, + move_src=move_src, + move_dst=move_dst, ) # Needs 2 + 1 slots for mamba request with prefix cache. 2 for ping pong cache, 1 for running mamba state. @@ -666,6 +700,7 @@ def release_kv_cache(req: Req, tree_cache: BasePrefixCache, is_insert: bool = Tr output_ids=req.output_ids, req_to_token_slot=req_to_token_slot, answer_start_position=req.answer_start_position, + committed_len=req.kv_committed_len, ) tree_cache.cache_finished_req(req, is_insert=is_insert, split=split) else: diff --git a/python/sglang/srt/mem_cache/memory_pool.py b/python/sglang/srt/mem_cache/memory_pool.py index e4c158cda9f6..1fbe205ff168 100644 --- a/python/sglang/srt/mem_cache/memory_pool.py +++ b/python/sglang/srt/mem_cache/memory_pool.py @@ -1547,6 +1547,21 @@ def get_value_buffer(self, layer_id: int): def get_kv_buffer(self, layer_id: int): return self.get_key_buffer(layer_id), self.get_value_buffer(layer_id) + def move_kv_cache(self, tgt_loc: torch.Tensor, src_loc: torch.Tensor): + # Relocate the compressed MLA latent for a set of slots. Used by + # --no-cache-thoughts to slide a finished request's answer KV into + # page-congruent slots before caching it. Native indexed copy over every + # layer buffer; the advanced-indexed RHS (kv_cache[src]) is materialized + # before the scatter write, so this is correct even when src and tgt + # overlap. Works for any store_dtype, including the NSA FP8 byte layout, + # since it copies whole slot rows verbatim. + if tgt_loc.numel() == 0: + return + tgt = tgt_loc.view(-1).long() + src = src_loc.view(-1).long() + for kv_cache in self.kv_buffer: + kv_cache[tgt] = kv_cache[src] + def set_kv_buffer( self, layer: RadixAttention, diff --git a/python/sglang/srt/mem_cache/radix_cache.py b/python/sglang/srt/mem_cache/radix_cache.py index 8a0646e11b8e..f2ca18a10000 100644 --- a/python/sglang/srt/mem_cache/radix_cache.py +++ b/python/sglang/srt/mem_cache/radix_cache.py @@ -495,12 +495,11 @@ def cache_finished_req(self, req: Req, is_insert: bool = True, split=None): is_insert: when False, free the request's kv_indices without inserting them into the radix tree (e.g. abort / retract paths). split: when provided (a NoCacheThoughtsSplit from - ``sglang.srt.mem_cache.common.split_kv_for_no_cache_thoughts``), use the - split's virtual token ids / kv_indices / positions for the radix insert - instead of looking them up from the per-request KV slot. The thought - slice in ``split.thought_kv_indices_to_free`` is freed immediately. This - allows skipping thought tokens while preserving original RoPE positions - on the cached answer slice. + ``sglang.srt.mem_cache.common.split_kv_for_no_cache_thoughts``), cache the + thought-stripped sequence: relocate the answer's KV into page-congruent + slots (``split.move_dst`` <- ``split.move_src``), insert the answer with + its original RoPE positions preserved, then free the page-aligned dead + tail (stale thoughts + unaligned answer remainder). """ # In deterministic mode, disable finished request insertion to radix cache if self.disable_finished_insert: @@ -509,14 +508,24 @@ def cache_finished_req(self, req: Req, is_insert: bool = True, split=None): kv_committed_len = req.pop_committed_kv_cache() if split is not None: - # Skip the per-req KV-pool lookup; use the pre-computed virtual slice. + # Skip the per-req KV-pool lookup; use the pre-computed plan. + # split.virtual_kv_indices is the request's FULL original slot span, so a + # single free here returns everything when we are not inserting. if self.disable or not is_insert: self.token_to_kv_pool_allocator.free(split.virtual_kv_indices) - self.token_to_kv_pool_allocator.free(split.thought_kv_indices_to_free) self.dec_lock_ref(req.last_node) return - # Free the thought slice; it never enters the shared cache. - self.token_to_kv_pool_allocator.free(split.thought_kv_indices_to_free) + # Relocate the answer's KV left into the slots the thoughts are vacating, so + # each cached answer token sits in a slot page-congruent to its NEW index in + # the thought-stripped sequence. Paged KV requires slot % page == index % + # page; without this the answer is unreachable for safe extension and trips + # the allocator's page-alignment assert. The move reads its source fully + # before writing (overlap-safe); the vacated thought slots are reclaimed below + # as part of the page-aligned dead tail. + if split.move_src.numel() > 0: + self.token_to_kv_pool_allocator.get_kvcache().move_kv_cache( + split.move_dst, split.move_src + ) keys = ( convert_to_bigram_key(split.virtual_token_ids) if self.is_eagle @@ -543,7 +552,8 @@ def cache_finished_req(self, req: Req, is_insert: bool = True, split=None): self.token_to_kv_pool_allocator.free( split.virtual_kv_indices[req.cache_protected_len : new_prefix_len] ) - # Free any unaligned tail from the page_align trim. + # One page-aligned cut frees the dead tail: the stale thought slots plus the + # answer's page-unaligned remainder. Single free() -> no boundary double-free. self.token_to_kv_pool_allocator.free(split.virtual_kv_indices[len(keys) :]) self.dec_lock_ref(req.last_node) return From a8d44ec34ebe0b46f8d57cb425b69f262c0f6a57 Mon Sep 17 00:00:00 2001 From: David <12414531+DavidBellamy@users.noreply.github.com> Date: Wed, 10 Jun 2026 17:54:51 -0700 Subject: [PATCH 11/11] fix(no-cache-thoughts): initialize KV-copy config for MHA pools MHATokenToKVPool.move_kv_cache requires _kv_copy_config, which is only set when the pool is constructed with enable_kv_cache_copy=True (previously only the speculative-decoding path did). With --no-cache-thoughts on any MHA/GQA model, the first finished reasoning request hits the relocation path and asserts: AssertionError: KV copy not initialized. Set enable_kv_cache_copy=True Enable it when no_cache_thoughts is set, for both the standard and FP4 MHA pools. Validated on bbq-8b (GQA, TP=4): 216-turn agentic replay, 0 failures, no asserts with SGLANG_DEBUG_MEMORY_POOL=1, cross-turn cache hit 0.955. The MLA path (GLM) is unaffected; its move_kv_cache is self-contained. --- .../srt/model_executor/model_runner_kv_cache_mixin.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/python/sglang/srt/model_executor/model_runner_kv_cache_mixin.py b/python/sglang/srt/model_executor/model_runner_kv_cache_mixin.py index a6baa4817ace..236d0990a6ba 100644 --- a/python/sglang/srt/model_executor/model_runner_kv_cache_mixin.py +++ b/python/sglang/srt/model_executor/model_runner_kv_cache_mixin.py @@ -695,6 +695,9 @@ def _init_pools(self: ModelRunner): enable_alt_stream=not self.server_args.enable_pdmux, enable_kv_cache_copy=( self.server_args.speculative_algorithm is not None + # --no-cache-thoughts relocates the answer KV via + # move_kv_cache when it drops a finished think span + or self.server_args.no_cache_thoughts ), ) else: @@ -714,6 +717,9 @@ def _init_pools(self: ModelRunner): enable_alt_stream=not self.server_args.enable_pdmux, enable_kv_cache_copy=( self.server_args.speculative_algorithm is not None + # --no-cache-thoughts relocates the answer KV via + # move_kv_cache when it drops a finished think span + or self.server_args.no_cache_thoughts ), )