[Session] Add streaming mode with SessionAwareCache fast path (#19171)

Co-authored-by: hnyls2002 <lsyincs@gmail.com>
This commit is contained in:
Aurick Qiao
2026-02-27 16:31:08 -08:00
committed by GitHub
co-authored by hnyls2002
parent 2bd2b60b5c
commit c6cb0c9649
12 changed files with 999 additions and 30 deletions
+8
View File
@@ -492,12 +492,18 @@ class Engine(EngineBase):
self,
capacity_of_str_len: int,
session_id: Optional[str] = None,
streaming: bool = False,
timeout: Optional[float] = None,
) -> str:
"""Open a session for multi-turn conversation with shared context.
Args:
capacity_of_str_len: Maximum string length capacity for the session.
session_id: Optional session ID. If not provided, a UUID will be generated.
streaming: Use low-overhead path for realtime streaming (append-only mode).
timeout: If set, the session is automatically closed after being inactive
for this many seconds. Inactivity is measured from session open or the
most recent request submission.
Returns:
The session ID (either the provided one or a newly generated UUID).
@@ -505,6 +511,8 @@ class Engine(EngineBase):
obj = OpenSessionReqInput(
capacity_of_str_len=capacity_of_str_len,
session_id=session_id,
streaming=streaming,
timeout=timeout,
)
return self.loop.run_until_complete(
self.tokenizer_manager.open_session(obj, None)
+2
View File
@@ -1623,6 +1623,8 @@ class ConfigureLoggingReq(BaseReq):
class OpenSessionReqInput(BaseReq):
capacity_of_str_len: int
session_id: Optional[str] = None
streaming: Optional[bool] = None
timeout: Optional[float] = None
@dataclass
+4 -3
View File
@@ -93,6 +93,7 @@ if TYPE_CHECKING:
from typing import Any, Dict
from sglang.srt.configs.model_config import ModelConfig
from sglang.srt.managers.session_controller import Session
from sglang.srt.observability.scheduler_metrics_mixin import PrefillStats
from sglang.srt.speculative.eagle_info import EagleDraftInput
from sglang.srt.speculative.spec_info import SpecInput, SpeculativeAlgorithm
@@ -499,7 +500,7 @@ class Req(ReqDllmMixin):
lora_id: Optional[str] = None,
input_embeds: Optional[List[List[float]]] = None,
token_type_ids: List[int] = None,
session_id: Optional[str] = None,
session: Optional[Session] = None,
custom_logit_processor: Optional[str] = None,
require_reasoning: bool = False,
return_hidden_states: bool = False,
@@ -535,7 +536,7 @@ class Req(ReqDllmMixin):
self.output_ids = []
# fill_ids = origin_input_ids + output_ids. Updated if chunked.
self.fill_ids = []
self.session_id = session_id
self.session = session
self.input_embeds = input_embeds
# For req-level memory management
@@ -874,7 +875,7 @@ class Req(ReqDllmMixin):
match_result = tree_cache.match_prefix(
MatchPrefixParams(
key=RadixKey(token_ids=token_ids, extra_key=self.extra_key),
req=self if tree_cache.supports_mamba() else None,
req=self,
cow_mamba=tree_cache.supports_mamba(),
)
)
+35 -6
View File
@@ -721,6 +721,10 @@ class Scheduler(
else:
self.tree_cache = RadixCache(params)
from sglang.srt.mem_cache.session_aware_cache import SessionAwareCache
self.tree_cache = SessionAwareCache(self.tree_cache)
if (
server_args.disaggregation_mode == "decode"
and server_args.disaggregation_decode_enable_offload_kvcache
@@ -751,6 +755,7 @@ class Scheduler(
self.num_retracted_reqs: int = 0
self.num_paused_reqs: int = 0
self.sessions: Dict[str, Session] = {}
self._last_reap_sessions: float = 0.0
self.forward_sleep_time = None
self._engine_paused = False
@@ -1118,7 +1123,8 @@ class Scheduler(
result = self.run_batch(batch)
self.process_batch_result(batch, result)
else:
# When the server is idle, do self-check and re-init some states
# When the server is idle, do self-check and re-init some states.
# Skip if there are any streaming sessions (latency sensitive).
self.self_check_during_idle()
# Update last_batch
@@ -1349,7 +1355,10 @@ class Scheduler(
return work_reqs, control_reqs
def process_input_requests(self, recv_reqs: List):
now = time.monotonic()
if now - self._last_reap_sessions > 1.0: # reap sessions every second
self._last_reap_sessions = now
self.reap_timed_out_sessions()
for recv_req in recv_reqs:
# If it is a health check generation request and there are running requests, ignore it.
if is_health_check_generate_req(recv_req) and (
@@ -1458,7 +1467,7 @@ class Scheduler(
if not req.finished() or not (mm_inputs := req.multimodal_inputs):
continue
# For session requests, keep mm_inputs for the next request
if req.session_id:
if req.session:
continue
# For non-session requests, clear features and mm_inputs
for item in mm_inputs.mm_items:
@@ -2929,17 +2938,37 @@ class Scheduler(
return OpenSessionReqOutput(session_id, False)
else:
self.sessions[session_id] = Session(
recv_req.capacity_of_str_len, session_id
recv_req.capacity_of_str_len,
session_id,
streaming=bool(recv_req.streaming),
timeout=recv_req.timeout,
)
return OpenSessionReqOutput(session_id, True)
def close_session(self, recv_req: CloseSessionReqInput):
# handle error
session_id = recv_req.session_id
if session_id not in self.sessions:
logger.warning(f"session id {session_id} does not exist, cannot delete.")
else:
del self.sessions[session_id]
self._close_session(session_id)
def _close_session(self, session_id: str):
session = self.sessions[session_id]
if session.streaming and session.req_nodes:
assert len(session.req_nodes) == 1
req = next(iter(session.req_nodes.values())).req
if not req.finished():
req.session = None
self.tree_cache.release_session(session_id)
del self.sessions[session_id]
def reap_timed_out_sessions(self):
timed_out = [
sid for sid, session in self.sessions.items() if session.is_timed_out()
]
for sid in timed_out:
logger.info(f"Session {sid} timed out, closing.")
self._close_session(sid)
def maybe_sleep_on_idle(self):
if self.idle_sleeper is not None:
@@ -175,7 +175,6 @@ class SchedulerOutputProcessorMixin:
release_kv_cache(req, self.tree_cache)
req.time_stats.set_completion_time()
elif not batch.decoding_reqs or req not in batch.decoding_reqs:
# This updates radix so others can match
self.tree_cache.cache_unfinished_req(req)
self.maybe_collect_customized_info(i, req, logits_output)
@@ -92,10 +92,11 @@ class SchedulerRuntimeCheckerMixin:
swa_available_size,
swa_evictable_size,
) = self._get_swa_token_info()
memory_leak = full_num_used != 0 or swa_num_used != 0
session_held = self.tree_cache.session_held_tokens()
memory_leak = (full_num_used - session_held) != 0 or swa_num_used != 0
token_msg = (
f"{self.full_tokens_per_layer=}, {full_available_size=}, {full_evictable_size=}, {self.tree_cache.full_protected_size()=}\n"
f"{self.swa_tokens_per_layer=}, {swa_available_size=}, {swa_evictable_size=}, {self.tree_cache.swa_protected_size()=}\n"
f"{self.swa_tokens_per_layer=}, {swa_available_size=}, {swa_evictable_size=}, {self.tree_cache.swa_protected_size()=}, {session_held=}\n"
)
return memory_leak, token_msg
@@ -110,8 +111,9 @@ class SchedulerRuntimeCheckerMixin:
mamba_available_size,
mamba_evictable_size,
) = self._get_mamba_token_info()
session_held = self.tree_cache.session_held_tokens()
memory_leak = (
full_num_used != self.tree_cache.full_protected_size()
full_num_used != self.tree_cache.full_protected_size() + session_held
or mamba_num_used != self.tree_cache.mamba_protected_size()
)
if memory_leak:
@@ -150,14 +152,11 @@ class SchedulerRuntimeCheckerMixin:
def _check_radix_cache_memory(self: Scheduler):
_, _, available_size, evictable_size = self._get_token_info()
protected_size = self.tree_cache.protected_size()
session_held = self.tree_cache.session_held_tokens()
memory_leak = (available_size + evictable_size) != (
# self.max_total_num_tokens
# if not self.enable_hierarchical_cache
# else self.max_total_num_tokens - protected_size
self.max_total_num_tokens
- protected_size
self.max_total_num_tokens - protected_size - session_held
)
token_msg = f"{self.max_total_num_tokens=}, {available_size=}, {evictable_size=}, {protected_size=}\n"
token_msg = f"{self.max_total_num_tokens=}, {available_size=}, {evictable_size=}, {protected_size=}, {session_held=}\n"
return memory_leak, token_msg
def _get_batch_uncached_size(self: Scheduler, batch: ScheduleBatch) -> int:
@@ -205,7 +204,14 @@ class SchedulerRuntimeCheckerMixin:
log_msg = f"[Mem Check (BUSY)] {available_size=}, {evictable_size=}, {protected_size=}, {uncached_size=}"
logger.info(log_msg)
total_tokens = available_size + evictable_size + protected_size + uncached_size
session_held = self.tree_cache.session_held_tokens()
total_tokens = (
available_size
+ evictable_size
+ protected_size
+ uncached_size
+ session_held
)
assert (
total_tokens == self.max_total_num_tokens
), f"Mem Leak Detected! {total_tokens=} vs {self.max_total_num_tokens=}"
@@ -218,10 +224,12 @@ class SchedulerRuntimeCheckerMixin:
else:
req_total_size = self.req_to_token_pool.size
if len(self.req_to_token_pool.free_slots) != req_total_size:
session_req_count = self.tree_cache.session_held_req_count()
if len(self.req_to_token_pool.free_slots) + session_req_count != req_total_size:
msg = (
"req_to_token_pool memory leak detected!"
f"available_size={len(self.req_to_token_pool.free_slots)}, "
f"session_held={session_req_count}, "
f"total_size={self.req_to_token_pool.size}\n"
)
raise_error_or_warn(
@@ -11,6 +11,7 @@
# ==============================================================================
import logging
import time
import uuid
from typing import Dict, Optional
@@ -65,25 +66,59 @@ class SessionReqNode:
class Session:
def __init__(self, capacity_of_str_len: int, session_id: Optional[str] = None):
def __init__(
self,
capacity_of_str_len: int,
session_id: Optional[str] = None,
streaming: bool = False,
timeout: Optional[float] = None,
):
self.session_id = session_id if session_id is not None else uuid.uuid4().hex
self.capacity_of_str_len = capacity_of_str_len
self.streaming = streaming
self.timeout = timeout
self.last_active_time: float = time.monotonic()
self.req_nodes: Dict[str, SessionReqNode] = {}
def is_timed_out(self) -> bool:
if self.timeout is None:
return False
return time.monotonic() - self.last_active_time > self.timeout
def create_req(self, req: TokenizedGenerateReqInput, tokenizer, vocab_size: int):
assert req.session_params is not None
self.last_active_time = time.monotonic()
session_params = req.session_params
last_req_node = None
last_req = None
abort = False
if session_params.replace:
abort_message = ""
if self.streaming:
# Streaming sessions: only simple appends allowed; reject otherwise.
if session_params.replace:
abort = True
abort_message = "Streaming sessions do not support replace."
elif session_params.drop_previous_output:
abort = True
abort_message = (
"Streaming sessions do not support drop_previous_output."
)
elif session_params.offset and session_params.offset != 0:
abort = True
abort_message = "Streaming sessions do not support offset."
elif self.req_nodes:
assert len(self.req_nodes) == 1
_, last_req_node = self.req_nodes.popitem()
last_req = last_req_node.req
elif session_params.replace:
if session_params.rid is None:
for _, req_node in self.req_nodes.items():
req_node.clear(self.req_nodes)
else:
if session_params.rid not in self.req_nodes:
abort = True
abort_message = "Invalid request session id"
else:
last_req_node = self.req_nodes[session_params.rid]
last_req_node.abort()
@@ -93,18 +128,22 @@ class Session:
if session_params.rid is not None:
if session_params.rid not in self.req_nodes:
abort = True
abort_message = "Invalid request session id"
else:
last_req_node = self.req_nodes[session_params.rid]
last_req = last_req_node.req
if not last_req.finished():
logging.warning(
"The request in a session is appending to a request that hasn't finished."
)
abort = True
abort_message = "Session request is appending to a request that hasn't finished."
logging.warning(abort_message)
if last_req is not None:
# trim bos token if it is an append
if tokenizer is not None and req.input_ids[0] == tokenizer.bos_token_id:
if (
tokenizer is not None
and req.input_ids
and req.input_ids[0] == tokenizer.bos_token_id
):
req.input_ids = req.input_ids[1:]
input_ids = (
@@ -136,6 +175,7 @@ class Session:
else:
input_ids = req.input_ids
input_ids_unpadded = req.input_ids
new_req = Req(
rid=req.rid,
origin_input_text=None,
@@ -143,7 +183,7 @@ class Session:
origin_input_ids_unpadded=input_ids_unpadded,
sampling_params=req.sampling_params,
lora_id=req.lora_id,
session_id=self.session_id,
session=self,
custom_logit_processor=req.custom_logit_processor,
stream=req.stream,
return_logprob=req.return_logprob,
@@ -156,7 +196,11 @@ class Session:
new_req.tokenizer = tokenizer
if abort:
new_req.set_finish_with_abort("Invalid request session id")
new_req.set_finish_with_abort(abort_message)
elif self.streaming:
if last_req is not None:
last_req.session = None
self.req_nodes[req.rid] = SessionReqNode(new_req)
else:
new_req_node = SessionReqNode(new_req, last_req_node)
self.req_nodes[req.rid] = new_req_node
+7
View File
@@ -478,6 +478,13 @@ def release_kv_cache(req: Req, tree_cache: BasePrefixCache, is_insert: bool = Tr
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
# cleanup (overalloc free + pool slot free). This means over-allocated
# tokens from speculative decoding are NOT freed between turns.
if req.req_pool_idx is None:
return
start_p, end_p = req.pop_overallocated_kv_cache()
global_server_args = get_global_server_args()
+1 -1
View File
@@ -158,7 +158,7 @@ class ReqToTokenPool:
reusing = [i for i, r in enumerate(reqs) if r.req_pool_idx is not None]
if not any(r.is_dllm() for r in reqs):
assert (
len(reusing) <= 1
sum(1 for i in reusing if reqs[i].is_chunked > 0) <= 1
), "only one chunked request may reuse req_pool_idx in a batch"
assert all(
reqs[i].is_chunked > 0 or reqs[i].kv_committed_len > 0 for i in reusing
@@ -0,0 +1,311 @@
from __future__ import annotations
from dataclasses import dataclass, field
from typing import TYPE_CHECKING, Any, Dict, Optional
import torch
from sglang.srt.mem_cache.base_prefix_cache import (
BasePrefixCache,
EvictParams,
EvictResult,
MatchPrefixParams,
MatchResult,
)
if TYPE_CHECKING:
from sglang.srt.managers.schedule_batch import Req
class _VirtualNode:
"""Sentinel node for streaming session requests.
Passed to inc_lock_ref / dec_lock_ref so the wrapper can distinguish
streaming-session locks (no-op) from real radix-tree locks (forwarded).
"""
pass
@dataclass
class SessionSlot:
"""Holds KV state between streaming session turns."""
virtual_node: _VirtualNode = field(default_factory=_VirtualNode)
# KV pool state (None means no KV is currently held by this slot)
req_pool_idx: Optional[int] = None
kv_committed_len: int = 0
kv_allocated_len: int = 0
# First req's radix tree node (for dec_lock_ref on session close)
last_node: Any = None
cache_protected_len: int = 0
swa_uuid_for_lock: Optional[str] = None
# SWA state
swa_evicted_seqlen: int = 0
# Mamba states
mamba_pool_idx: Any = None
mamba_ping_pong_track_buffer: Any = None
mamba_next_track_idx: Any = None
mamba_last_track_seqlen: Any = None
mamba_branching_seqlen: Any = None
def save_from_req(self, req: Req, is_first: bool):
"""Save KV state from a finishing request into this slot."""
self.req_pool_idx = req.req_pool_idx
self.kv_committed_len = req.kv_committed_len
self.kv_allocated_len = req.kv_allocated_len
self.swa_evicted_seqlen = req.swa_evicted_seqlen
if is_first:
self.last_node = req.last_node
self.cache_protected_len = req.cache_protected_len
self.swa_uuid_for_lock = req.swa_uuid_for_lock
self.mamba_pool_idx = req.mamba_pool_idx
self.mamba_ping_pong_track_buffer = req.mamba_ping_pong_track_buffer
self.mamba_next_track_idx = req.mamba_next_track_idx
self.mamba_last_track_seqlen = req.mamba_last_track_seqlen
self.mamba_branching_seqlen = req.mamba_branching_seqlen
req.req_pool_idx = None
req.mamba_pool_idx = None
def restore_to_req(self, req: Req):
"""Restore KV state from this slot into an incoming request."""
req.req_pool_idx = self.req_pool_idx
req.kv_committed_len = self.kv_committed_len
req.kv_allocated_len = self.kv_allocated_len
req.swa_evicted_seqlen = self.swa_evicted_seqlen
req.swa_uuid_for_lock = self.swa_uuid_for_lock
req.mamba_pool_idx = self.mamba_pool_idx
req.mamba_ping_pong_track_buffer = self.mamba_ping_pong_track_buffer
req.mamba_next_track_idx = self.mamba_next_track_idx
req.mamba_last_track_seqlen = self.mamba_last_track_seqlen
req.mamba_branching_seqlen = self.mamba_branching_seqlen
self.req_pool_idx = None
self.mamba_pool_idx = None
def _is_streaming(req: Optional[Req]) -> bool:
return req is not None and req.session is not None and req.session.streaming
class SessionAwareCache(BasePrefixCache):
"""Decorator around any BasePrefixCache that manages streaming session KV.
Non-streaming requests are pure pass-through. Streaming requests have their
KV lifecycle managed by SessionSlot objects, avoiding any invasive changes
to the scheduling pipeline.
"""
def __init__(self, inner: BasePrefixCache):
self.inner = inner
self.slots: Dict[str, SessionSlot] = {}
# -- Forward PrefixCacheTrait properties to inner cache --
@property
def req_to_token_pool(self):
return self.inner.req_to_token_pool
@req_to_token_pool.setter
def req_to_token_pool(self, value):
self.inner.req_to_token_pool = value
@property
def token_to_kv_pool_allocator(self):
return self.inner.token_to_kv_pool_allocator
@token_to_kv_pool_allocator.setter
def token_to_kv_pool_allocator(self, value):
self.inner.token_to_kv_pool_allocator = value
@property
def page_size(self):
return self.inner.page_size
@page_size.setter
def page_size(self, value):
self.inner.page_size = value
@property
def disable(self):
return self.inner.disable
@disable.setter
def disable(self, value):
self.inner.disable = value
@property
def metrics_collector(self):
return self.inner.metrics_collector
@metrics_collector.setter
def metrics_collector(self, value):
self.inner.metrics_collector = value
# -- BasePrefixCache abstract methods --
def reset(self):
self.slots.clear()
self.inner.reset()
def match_prefix(self, params: MatchPrefixParams) -> MatchResult:
req = params.req
if not _is_streaming(req):
return self.inner.match_prefix(params)
session_id = req.session.session_id
slot = self.slots.get(session_id)
if slot is None or slot.req_pool_idx is None:
return self.inner.match_prefix(params)
slot.restore_to_req(req)
max_prefix_len = len(params.key.token_ids)
prefix_len = min(req.kv_committed_len, max_prefix_len)
device_indices = self.req_to_token_pool.req_to_token[
req.req_pool_idx, :prefix_len
].to(dtype=torch.int64)
return MatchResult(
device_indices=device_indices,
last_device_node=slot.virtual_node,
last_host_node=slot.virtual_node,
)
def cache_finished_req(self, req: Req, is_insert: bool = True, **kwargs):
if not _is_streaming(req):
return self.inner.cache_finished_req(req, is_insert=is_insert, **kwargs)
session_id = req.session.session_id
slot = self.slots.get(session_id)
is_first = slot is None
if is_first:
slot = SessionSlot()
self.slots[session_id] = slot
slot.save_from_req(req, is_first=is_first)
def cache_unfinished_req(self, req: Req, **kwargs):
if _is_streaming(req) and req.session.session_id in self.slots:
return
self.inner.cache_unfinished_req(req, **kwargs)
def evict(self, params: EvictParams) -> EvictResult:
return self.inner.evict(params)
def inc_lock_ref(self, node: Any):
if isinstance(node, _VirtualNode):
return None
return self.inner.inc_lock_ref(node)
def dec_lock_ref(self, node: Any, swa_uuid_for_lock: Optional[str] = None):
if isinstance(node, _VirtualNode):
return
if swa_uuid_for_lock is not None:
return self.inner.dec_lock_ref(node, swa_uuid_for_lock)
return self.inner.dec_lock_ref(node)
# -- Session lifecycle --
def release_session(self, session_id: str):
"""Release all KV resources held by a streaming session."""
slot = self.slots.pop(session_id, None)
if slot is None:
return
if slot.last_node is not None:
if slot.swa_uuid_for_lock is not None:
self.inner.dec_lock_ref(slot.last_node, slot.swa_uuid_for_lock)
else:
self.inner.dec_lock_ref(slot.last_node)
if slot.req_pool_idx is not None:
start = slot.cache_protected_len
end = slot.kv_allocated_len
if start < end:
kv_indices = self.req_to_token_pool.req_to_token[
slot.req_pool_idx, start:end
]
self.token_to_kv_pool_allocator.free(kv_indices)
self.req_to_token_pool.free_slots.append(slot.req_pool_idx)
def session_held_tokens(self) -> int:
"""Total KV tokens held by session slots, not tracked by the tree."""
total = 0
for slot in self.slots.values():
if slot.req_pool_idx is not None:
total += slot.kv_allocated_len - slot.cache_protected_len
return total
def session_held_req_count(self) -> int:
"""Number of req pool slots held by session slots."""
return sum(1 for s in self.slots.values() if s.req_pool_idx is not None)
# -- Pass-through methods --
def evictable_size(self):
return self.inner.evictable_size()
def full_evictable_size(self):
return self.inner.full_evictable_size()
def swa_evictable_size(self):
return self.inner.swa_evictable_size()
def protected_size(self):
return self.inner.protected_size()
def full_protected_size(self):
return self.inner.full_protected_size()
def swa_protected_size(self):
return self.inner.swa_protected_size()
def total_size(self):
return self.inner.total_size()
def pretty_print(self):
return self.inner.pretty_print()
def init_load_back(self, last_host_node, host_hit_length):
return self.inner.init_load_back(last_host_node, host_hit_length)
def ready_to_load_host_cache(self):
return self.inner.ready_to_load_host_cache()
def check_hicache_events(self):
return self.inner.check_hicache_events()
def take_events(self):
return self.inner.take_events()
def supports_swa(self):
return self.inner.supports_swa()
def supports_mamba(self):
return self.inner.supports_mamba()
def is_chunk_cache(self):
return self.inner.is_chunk_cache()
def is_tree_cache(self):
return self.inner.is_tree_cache()
def available_and_evictable_str(self):
return self.inner.available_and_evictable_str()
def init_metrics_collector(self):
return self.inner.init_metrics_collector()
# Forward attribute access for cache-specific methods (e.g. sanity_check,
# sliding_window_size, all_values_flatten, etc.)
def __getattr__(self, name):
return getattr(self.inner, name)
@@ -3,11 +3,13 @@ Usage:
python3 -m unittest test_session_control.TestSessionControl.test_session_control
python3 -m unittest test_session_control.TestSessionControl.test_session_control_with_branching
python3 -m unittest test_session_control.TestSessionControl.test_session_control_backtrack_with_abort
python3 -m unittest test_session_control.TestSessionControl.test_streaming_session
python3 -m unittest test_session_control.TestSessionControlVision.test_session_control
"""
import asyncio
import json
import time
import unittest
import aiohttp
@@ -15,6 +17,7 @@ import requests
from sglang.srt.utils import kill_process_tree
from sglang.srt.utils.hf_transformers_utils import get_tokenizer
from sglang.test.ci.ci_register import register_cuda_ci
from sglang.test.test_utils import (
DEFAULT_SMALL_MODEL_NAME_FOR_TEST,
DEFAULT_TIMEOUT_FOR_SERVER_LAUNCH,
@@ -23,6 +26,8 @@ from sglang.test.test_utils import (
popen_launch_server,
)
register_cuda_ci(est_time=60, suite="stage-b-test-large-1-gpu")
def remove_prefix(text: str, prefix: str) -> str:
return text[len(prefix) :] if text.startswith(prefix) else text
@@ -429,6 +434,162 @@ class TestSessionControl(unittest.TestCase):
asyncio.run(self.run_session_control_backtrack_with_abort(replace=True))
asyncio.run(self.run_session_control_backtrack_with_abort(replace=False))
def test_streaming_session(self, gen_len=12):
chunks = [
"Let me tell you something about France.",
"The capital of France is",
"The population of the city is",
]
tokenizer = get_tokenizer(self.model)
chunks_ids = [tokenizer.encode(x) for x in chunks]
for i in range(1, len(chunks_ids)):
if chunks_ids[i][0] == tokenizer.bos_token_id:
chunks_ids[i] = chunks_ids[i][1:]
# === Part 1: streaming session ===
requests.post(self.base_url + "/flush_cache")
session_id = requests.post(
self.base_url + "/open_session",
json={"capacity_of_str_len": 1000, "streaming": True},
).json()
rid = None
outputs_from_session = []
prev_kv_len = 0
for turn_idx, chunk_ids in enumerate(chunks_ids):
response = requests.post(
self.base_url + "/generate",
json={
"input_ids": chunk_ids,
"session_params": {"id": session_id, "rid": rid},
"sampling_params": {
"temperature": 0,
"max_new_tokens": gen_len,
"no_stop_trim": True,
"skip_special_tokens": False,
},
},
).json()
rid = response["meta_info"]["id"]
outputs_from_session.append(response["text"])
cached = response["meta_info"]["cached_tokens"]
prompt_tokens = response["meta_info"]["prompt_tokens"]
completion_tokens = response["meta_info"]["completion_tokens"]
if turn_idx == 0:
# Turn 1 should have no cache hit (cache was flushed).
self.assertEqual(
cached, 0, "Turn 1 should have 0 cached tokens (clean start)"
)
else:
# Turns 2+ inherit KV from the previous turn (via inherit_kv_states,
# not radix tree matching). cached_tokens reflects the inherited prefix.
self.assertEqual(
cached,
prev_kv_len,
f"Turn {turn_idx + 1}: should inherit {prev_kv_len} KV tokens from previous turn",
)
prev_kv_len = prompt_tokens + completion_tokens
# Close the session before checking cache/memory state.
ret = requests.post(
self.base_url + "/close_session",
json={"session_id": session_id},
)
self.assertEqual(ret.status_code, 200)
# === Cache verification (after close, before flush) ===
# Assertion 2: turn 1's prompt was inserted to the cache.
verify_resp = requests.post(
self.base_url + "/generate",
json={
"input_ids": chunks_ids[0],
"sampling_params": {"temperature": 0, "max_new_tokens": 1},
},
).json()
self.assertGreater(
verify_resp["meta_info"]["cached_tokens"],
0,
"Turn 1's prompt should be cached in the radix tree",
)
# Assertion 3 (insertion): turn 2's prompt tokens should NOT be in cache.
# The tree should only contain turn 1's extent (prompt + output from
# cache_unfinished_req during decode). Turn 2's prompt starts fresh tokens
# that were never inserted.
verify_resp2 = requests.post(
self.base_url + "/generate",
json={
"input_ids": chunks_ids[1],
"sampling_params": {"temperature": 0, "max_new_tokens": 1},
},
).json()
self.assertEqual(
verify_resp2["meta_info"]["cached_tokens"],
0,
"Turn 2's prompt should not be in cache (no insertion for turns 2+)",
)
# === Memory verification ===
# Assertion 4 & 5: KV is released properly and no memory leak.
# SGLANG_ENABLE_STRICT_MEM_CHECK_DURING_IDLE is True by default;
# the scheduler will crash if it detects a leak during idle.
time.sleep(2)
health_resp = requests.get(self.base_url + "/health")
self.assertEqual(
health_resp.status_code,
200,
"Server should be healthy after session close (no memory leak)",
)
# After flush, all cache should be reclaimed.
requests.post(self.base_url + "/flush_cache")
verify_resp3 = requests.post(
self.base_url + "/generate",
json={
"input_ids": chunks_ids[0],
"sampling_params": {"temperature": 0, "max_new_tokens": 1},
},
).json()
self.assertEqual(
verify_resp3["meta_info"]["cached_tokens"],
0,
"After session close + flush, cache should be fully reclaimed",
)
# === Part 2: non-session baseline for output comparison ===
requests.post(self.base_url + "/flush_cache")
outputs_normal = []
input_ids = chunks_ids[0][:]
for i in range(len(chunks_ids)):
response = requests.post(
self.base_url + "/generate",
json={
"input_ids": input_ids,
"sampling_params": {
"temperature": 0,
"max_new_tokens": gen_len,
"no_stop_trim": True,
"skip_special_tokens": False,
},
},
).json()
outputs_normal.append(response["text"])
if i + 1 < len(chunks_ids):
out_ids = tokenizer.encode(response["text"])
if out_ids and out_ids[0] == tokenizer.bos_token_id:
out_ids = out_ids[1:]
input_ids = input_ids + out_ids + chunks_ids[i + 1]
print("outputs from streaming session:")
print(outputs_from_session)
print("outputs from normal queries:")
print(outputs_normal)
self.assertEqual(outputs_from_session, outputs_normal)
def run_session_control_with_branching(
self, root_prompt, chunks_per_step, gen_len=16
):
@@ -0,0 +1,399 @@
"""
Benchmark: Streaming Session Inter-Turn Latency
Measures per-turn latency across three modes as context grows:
- no_session: re-send full context each turn (radix tree prefix match)
- regular_session: session append (radix tree insert + match)
- streaming_session: session append (O(1) KV direct transfer)
Each mode runs NUM_CONCURRENT parallel sessions, each doing NUM_TURNS sequential
requests (16 input / 8 output per turn).
Usage:
python -m pytest bench_session_latency.py -s
python -m unittest bench_session_latency.BenchSessionLatency.test_streaming_session
python -m unittest bench_session_latency.BenchSessionLatency
"""
import time
import unittest
from concurrent.futures import ThreadPoolExecutor
from dataclasses import dataclass, field
from typing import Dict, List, Optional
import requests
from tabulate import tabulate
from sglang.srt.utils import kill_process_tree
from sglang.srt.utils.hf_transformers_utils import get_tokenizer
from sglang.test.ci.ci_register import register_cuda_ci
from sglang.test.test_utils import (
DEFAULT_MODEL_NAME_FOR_TEST,
DEFAULT_TIMEOUT_FOR_SERVER_LAUNCH,
DEFAULT_URL_FOR_TEST,
CustomTestCase,
popen_launch_server,
)
register_cuda_ci(est_time=100, suite="stage-b-test-large-1-gpu")
NUM_TURNS = 300
INPUT_LEN = 16
GEN_LEN = 8
NUM_CONCURRENT = 4
TAIL_TURNS = 10
SAMPLE_TURNS = 8
FILLER_TEXT = (
"The quick brown fox jumps over the lazy dog. "
"Pack my box with five dozen liquor jugs. "
"How vexingly quick daft zebras jump. "
"Sphinx of black quartz, judge my vow. "
) * 200
SAMPLING_PARAMS = {
"temperature": 0,
"max_new_tokens": GEN_LEN,
"no_stop_trim": True,
"skip_special_tokens": False,
"ignore_eos": True,
}
@dataclass
class TurnResult:
turn: int
context_len: int
cached_tokens: int
prompt_tokens: int
completion_tokens: int
client_latency_ms: float
e2e_latency_ms: float
@dataclass
class ModeResult:
mode: str
turns: List[TurnResult] = field(default_factory=list)
outputs: List[str] = field(default_factory=list)
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _generate_input_chunks(
tokenizer, num_turns: int, input_len: int, offset: int = 0
) -> List[List[int]]:
all_ids = tokenizer.encode(FILLER_TEXT)
if all_ids and all_ids[0] == tokenizer.bos_token_id:
all_ids = all_ids[1:]
start = offset * num_turns * input_len
needed = start + num_turns * input_len
while len(all_ids) < needed:
all_ids = all_ids + all_ids
chunks = [
all_ids[start + i * input_len : start + (i + 1) * input_len]
for i in range(num_turns)
]
if tokenizer.bos_token_id is not None:
chunks[0] = [tokenizer.bos_token_id] + chunks[0]
return chunks
def _send_generate(base_url: str, payload: dict) -> dict:
resp = requests.post(base_url + "/generate", json=payload)
if resp.status_code != 200:
raise RuntimeError(f"Generate failed ({resp.status_code}): {resp.text}")
return resp.json()
def _record_turn(
turn_idx: int, context_len: int, meta: dict, client_latency_ms: float
) -> TurnResult:
return TurnResult(
turn=turn_idx + 1,
context_len=context_len,
cached_tokens=meta["cached_tokens"],
prompt_tokens=meta["prompt_tokens"],
completion_tokens=meta["completion_tokens"],
client_latency_ms=client_latency_ms,
e2e_latency_ms=meta.get("e2e_latency", 0) * 1000,
)
# ---------------------------------------------------------------------------
# Single-session runners (called by worker threads)
# ---------------------------------------------------------------------------
def _run_one_no_session(
base_url: str, tokenizer, chunks: List[List[int]]
) -> ModeResult:
result = ModeResult(mode="no_session")
accumulated_ids: List[int] = []
for turn_idx, chunk_ids in enumerate(chunks):
accumulated_ids.extend(chunk_ids)
t0 = time.perf_counter()
response = _send_generate(
base_url,
{"input_ids": accumulated_ids, "sampling_params": SAMPLING_PARAMS},
)
client_lat = (time.perf_counter() - t0) * 1000
meta = response["meta_info"]
result.turns.append(
_record_turn(turn_idx, len(accumulated_ids), meta, client_lat)
)
result.outputs.append(response["text"])
output_ids = tokenizer.encode(response["text"])
if output_ids and output_ids[0] == tokenizer.bos_token_id:
output_ids = output_ids[1:]
accumulated_ids.extend(output_ids)
return result
def _run_one_session(
base_url: str, chunks: List[List[int]], streaming: bool = False
) -> ModeResult:
mode = "streaming_session" if streaming else "regular_session"
result = ModeResult(mode=mode)
capacity = sum(len(c) for c in chunks) + len(chunks) * GEN_LEN + 1024
open_payload: dict = {"capacity_of_str_len": capacity}
if streaming:
open_payload["streaming"] = True
session_id = requests.post(base_url + "/open_session", json=open_payload).json()
rid = None
context_len = 0
for turn_idx, chunk_ids in enumerate(chunks):
context_len += len(chunk_ids)
t0 = time.perf_counter()
response = _send_generate(
base_url,
{
"input_ids": chunk_ids,
"session_params": {"id": session_id, "rid": rid},
"sampling_params": SAMPLING_PARAMS,
},
)
client_lat = (time.perf_counter() - t0) * 1000
meta = response["meta_info"]
rid = meta["id"]
context_len += meta["completion_tokens"]
result.turns.append(_record_turn(turn_idx, context_len, meta, client_lat))
result.outputs.append(response["text"])
requests.post(base_url + "/close_session", json={"session_id": session_id})
return result
# ---------------------------------------------------------------------------
# Stats & reporting
# ---------------------------------------------------------------------------
def _collect_latencies(
results: List[ModeResult], last_n: Optional[int] = None
) -> List[float]:
lats = []
for r in results:
turns = r.turns[1:] # skip turn 1
if last_n is not None:
turns = r.turns[-last_n:]
lats.extend(t.client_latency_ms for t in turns)
return lats
def _avg(values: List[float]) -> float:
return sum(values) / len(values) if values else 0.0
def _print_mode_table(result: ModeResult, label: str = ""):
tag = f"{result.mode} ({label})" if label else result.mode
print(f"\n [{tag}] {len(result.turns)} turns")
n = len(result.turns)
if n <= SAMPLE_TURNS * 2:
indices = list(range(n))
else:
indices = list(range(SAMPLE_TURNS)) + [-1] + list(range(n - SAMPLE_TURNS, n))
rows = []
for idx in indices:
if idx == -1:
rows.append(["..."] * 5)
continue
t = result.turns[idx]
rows.append(
[
t.turn,
t.context_len,
t.cached_tokens,
f"{t.client_latency_ms:.1f}ms",
f"{t.e2e_latency_ms:.1f}ms",
]
)
print(
tabulate(
rows,
headers=["Turn", "Context", "Cached", "Client Lat", "E2E Lat"],
colalign=("right",) * 5,
)
)
def _print_summary(all_results: Dict[str, List[ModeResult]]):
stats = [
(
mode,
_avg(_collect_latencies(rs)),
_avg(_collect_latencies(rs, last_n=TAIL_TURNS)),
)
for mode, rs in all_results.items()
]
base_all, base_tail = (stats[0][1] or 1.0), (stats[0][2] or 1.0)
tail_label = f"last {TAIL_TURNS}"
print(f"\n SUMMARY ({NUM_CONCURRENT} sessions x {NUM_TURNS} turns)")
rows = [
[
mode,
f"{a:.1f}ms",
f"{t:.1f}ms",
f"{base_all / a:.2f}x" if a else "inf",
f"{base_tail / t:.2f}x" if t else "inf",
]
for mode, a, t in stats
]
print(
tabulate(
rows,
headers=[
"Mode",
"Avg (all)",
f"Avg ({tail_label})",
"Speedup (all)",
f"Speedup ({tail_label})",
],
colalign=("left", "right", "right", "right", "right"),
)
)
# ---------------------------------------------------------------------------
# Test class
# ---------------------------------------------------------------------------
class BenchSessionLatency(CustomTestCase):
@classmethod
def setUpClass(cls):
cls.model = DEFAULT_MODEL_NAME_FOR_TEST
cls.base_url = DEFAULT_URL_FOR_TEST
cls.process = popen_launch_server(
cls.model,
cls.base_url,
timeout=DEFAULT_TIMEOUT_FOR_SERVER_LAUNCH,
other_args=["--attention-backend", "flashinfer"],
)
cls.tokenizer = get_tokenizer(cls.model)
requests.post(cls.base_url + "/flush_cache")
_send_generate(
cls.base_url,
{
"input_ids": cls.tokenizer.encode("Hello world"),
"sampling_params": {"temperature": 0, "max_new_tokens": 1},
},
)
cls.all_results: Dict[str, List[ModeResult]] = {}
@classmethod
def tearDownClass(cls):
if len(cls.all_results) > 1:
_print_summary(cls.all_results)
kill_process_tree(cls.process.pid)
def _run_concurrent_no_session(self) -> List[ModeResult]:
requests.post(self.base_url + "/flush_cache")
def run_one(session_idx):
chunks = _generate_input_chunks(
self.tokenizer, NUM_TURNS, INPUT_LEN, offset=session_idx
)
return _run_one_no_session(self.base_url, self.tokenizer, chunks)
with ThreadPoolExecutor(max_workers=NUM_CONCURRENT) as pool:
return list(pool.map(run_one, range(NUM_CONCURRENT)))
def _run_concurrent_session(self, streaming: bool = False) -> List[ModeResult]:
requests.post(self.base_url + "/flush_cache")
def run_one(session_idx):
chunks = _generate_input_chunks(
self.tokenizer, NUM_TURNS, INPUT_LEN, offset=session_idx
)
return _run_one_session(self.base_url, chunks, streaming=streaming)
with ThreadPoolExecutor(max_workers=NUM_CONCURRENT) as pool:
return list(pool.map(run_one, range(NUM_CONCURRENT)))
# ------------------------------------------------------------------
# Test methods
# ------------------------------------------------------------------
def test_no_session(self):
results = self._run_concurrent_no_session()
self.__class__.all_results["no_session"] = results
_print_mode_table(results[0], label="session 0")
def test_regular_session(self):
results = self._run_concurrent_session(streaming=False)
self.__class__.all_results["regular_session"] = results
_print_mode_table(results[0], label="session 0")
def test_streaming_session(self):
results = self._run_concurrent_session(streaming=True)
self.__class__.all_results["streaming_session"] = results
_print_mode_table(results[0], label="session 0")
reg_list = self.__class__.all_results.get("regular_session")
if reg_list:
reg_out = reg_list[0].outputs
stm_out = results[0].outputs
mismatches = sum(1 for a, b in zip(reg_out, stm_out) if a != b)
self.assertEqual(
mismatches,
0,
f"regular vs streaming (session 0): {mismatches}/{len(reg_out)} turns differ",
)
reg_tail = _avg(_collect_latencies(reg_list, last_n=TAIL_TURNS))
stm_tail = _avg(_collect_latencies(results, last_n=TAIL_TURNS))
speedup = reg_tail / stm_tail if stm_tail > 0 else float("inf")
self.assertGreaterEqual(
speedup,
2.0,
f"streaming should be >=2x faster on last {TAIL_TURNS} turns "
f"(regular={reg_tail:.1f}ms, streaming={stm_tail:.1f}ms, speedup={speedup:.2f}x)",
)
if __name__ == "__main__":
unittest.main()