[Session] Add streaming mode with SessionAwareCache fast path (#19171)
Co-authored-by: hnyls2002 <lsyincs@gmail.com>
This commit is contained in:
@@ -492,12 +492,18 @@ class Engine(EngineBase):
|
|||||||
self,
|
self,
|
||||||
capacity_of_str_len: int,
|
capacity_of_str_len: int,
|
||||||
session_id: Optional[str] = None,
|
session_id: Optional[str] = None,
|
||||||
|
streaming: bool = False,
|
||||||
|
timeout: Optional[float] = None,
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Open a session for multi-turn conversation with shared context.
|
"""Open a session for multi-turn conversation with shared context.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
capacity_of_str_len: Maximum string length capacity for the session.
|
capacity_of_str_len: Maximum string length capacity for the session.
|
||||||
session_id: Optional session ID. If not provided, a UUID will be generated.
|
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:
|
Returns:
|
||||||
The session ID (either the provided one or a newly generated UUID).
|
The session ID (either the provided one or a newly generated UUID).
|
||||||
@@ -505,6 +511,8 @@ class Engine(EngineBase):
|
|||||||
obj = OpenSessionReqInput(
|
obj = OpenSessionReqInput(
|
||||||
capacity_of_str_len=capacity_of_str_len,
|
capacity_of_str_len=capacity_of_str_len,
|
||||||
session_id=session_id,
|
session_id=session_id,
|
||||||
|
streaming=streaming,
|
||||||
|
timeout=timeout,
|
||||||
)
|
)
|
||||||
return self.loop.run_until_complete(
|
return self.loop.run_until_complete(
|
||||||
self.tokenizer_manager.open_session(obj, None)
|
self.tokenizer_manager.open_session(obj, None)
|
||||||
|
|||||||
@@ -1623,6 +1623,8 @@ class ConfigureLoggingReq(BaseReq):
|
|||||||
class OpenSessionReqInput(BaseReq):
|
class OpenSessionReqInput(BaseReq):
|
||||||
capacity_of_str_len: int
|
capacity_of_str_len: int
|
||||||
session_id: Optional[str] = None
|
session_id: Optional[str] = None
|
||||||
|
streaming: Optional[bool] = None
|
||||||
|
timeout: Optional[float] = None
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
|
|||||||
@@ -93,6 +93,7 @@ if TYPE_CHECKING:
|
|||||||
from typing import Any, Dict
|
from typing import Any, Dict
|
||||||
|
|
||||||
from sglang.srt.configs.model_config import ModelConfig
|
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.observability.scheduler_metrics_mixin import PrefillStats
|
||||||
from sglang.srt.speculative.eagle_info import EagleDraftInput
|
from sglang.srt.speculative.eagle_info import EagleDraftInput
|
||||||
from sglang.srt.speculative.spec_info import SpecInput, SpeculativeAlgorithm
|
from sglang.srt.speculative.spec_info import SpecInput, SpeculativeAlgorithm
|
||||||
@@ -499,7 +500,7 @@ class Req(ReqDllmMixin):
|
|||||||
lora_id: Optional[str] = None,
|
lora_id: Optional[str] = None,
|
||||||
input_embeds: Optional[List[List[float]]] = None,
|
input_embeds: Optional[List[List[float]]] = None,
|
||||||
token_type_ids: List[int] = None,
|
token_type_ids: List[int] = None,
|
||||||
session_id: Optional[str] = None,
|
session: Optional[Session] = None,
|
||||||
custom_logit_processor: Optional[str] = None,
|
custom_logit_processor: Optional[str] = None,
|
||||||
require_reasoning: bool = False,
|
require_reasoning: bool = False,
|
||||||
return_hidden_states: bool = False,
|
return_hidden_states: bool = False,
|
||||||
@@ -535,7 +536,7 @@ class Req(ReqDllmMixin):
|
|||||||
self.output_ids = []
|
self.output_ids = []
|
||||||
# fill_ids = origin_input_ids + output_ids. Updated if chunked.
|
# fill_ids = origin_input_ids + output_ids. Updated if chunked.
|
||||||
self.fill_ids = []
|
self.fill_ids = []
|
||||||
self.session_id = session_id
|
self.session = session
|
||||||
self.input_embeds = input_embeds
|
self.input_embeds = input_embeds
|
||||||
|
|
||||||
# For req-level memory management
|
# For req-level memory management
|
||||||
@@ -874,7 +875,7 @@ class Req(ReqDllmMixin):
|
|||||||
match_result = tree_cache.match_prefix(
|
match_result = tree_cache.match_prefix(
|
||||||
MatchPrefixParams(
|
MatchPrefixParams(
|
||||||
key=RadixKey(token_ids=token_ids, extra_key=self.extra_key),
|
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(),
|
cow_mamba=tree_cache.supports_mamba(),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -721,6 +721,10 @@ class Scheduler(
|
|||||||
else:
|
else:
|
||||||
self.tree_cache = RadixCache(params)
|
self.tree_cache = RadixCache(params)
|
||||||
|
|
||||||
|
from sglang.srt.mem_cache.session_aware_cache import SessionAwareCache
|
||||||
|
|
||||||
|
self.tree_cache = SessionAwareCache(self.tree_cache)
|
||||||
|
|
||||||
if (
|
if (
|
||||||
server_args.disaggregation_mode == "decode"
|
server_args.disaggregation_mode == "decode"
|
||||||
and server_args.disaggregation_decode_enable_offload_kvcache
|
and server_args.disaggregation_decode_enable_offload_kvcache
|
||||||
@@ -751,6 +755,7 @@ class Scheduler(
|
|||||||
self.num_retracted_reqs: int = 0
|
self.num_retracted_reqs: int = 0
|
||||||
self.num_paused_reqs: int = 0
|
self.num_paused_reqs: int = 0
|
||||||
self.sessions: Dict[str, Session] = {}
|
self.sessions: Dict[str, Session] = {}
|
||||||
|
self._last_reap_sessions: float = 0.0
|
||||||
self.forward_sleep_time = None
|
self.forward_sleep_time = None
|
||||||
self._engine_paused = False
|
self._engine_paused = False
|
||||||
|
|
||||||
@@ -1118,7 +1123,8 @@ class Scheduler(
|
|||||||
result = self.run_batch(batch)
|
result = self.run_batch(batch)
|
||||||
self.process_batch_result(batch, result)
|
self.process_batch_result(batch, result)
|
||||||
else:
|
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()
|
self.self_check_during_idle()
|
||||||
|
|
||||||
# Update last_batch
|
# Update last_batch
|
||||||
@@ -1349,7 +1355,10 @@ class Scheduler(
|
|||||||
return work_reqs, control_reqs
|
return work_reqs, control_reqs
|
||||||
|
|
||||||
def process_input_requests(self, recv_reqs: List):
|
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:
|
for recv_req in recv_reqs:
|
||||||
# If it is a health check generation request and there are running requests, ignore it.
|
# If it is a health check generation request and there are running requests, ignore it.
|
||||||
if is_health_check_generate_req(recv_req) and (
|
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):
|
if not req.finished() or not (mm_inputs := req.multimodal_inputs):
|
||||||
continue
|
continue
|
||||||
# For session requests, keep mm_inputs for the next request
|
# For session requests, keep mm_inputs for the next request
|
||||||
if req.session_id:
|
if req.session:
|
||||||
continue
|
continue
|
||||||
# For non-session requests, clear features and mm_inputs
|
# For non-session requests, clear features and mm_inputs
|
||||||
for item in mm_inputs.mm_items:
|
for item in mm_inputs.mm_items:
|
||||||
@@ -2929,17 +2938,37 @@ class Scheduler(
|
|||||||
return OpenSessionReqOutput(session_id, False)
|
return OpenSessionReqOutput(session_id, False)
|
||||||
else:
|
else:
|
||||||
self.sessions[session_id] = Session(
|
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)
|
return OpenSessionReqOutput(session_id, True)
|
||||||
|
|
||||||
def close_session(self, recv_req: CloseSessionReqInput):
|
def close_session(self, recv_req: CloseSessionReqInput):
|
||||||
# handle error
|
|
||||||
session_id = recv_req.session_id
|
session_id = recv_req.session_id
|
||||||
if session_id not in self.sessions:
|
if session_id not in self.sessions:
|
||||||
logger.warning(f"session id {session_id} does not exist, cannot delete.")
|
logger.warning(f"session id {session_id} does not exist, cannot delete.")
|
||||||
else:
|
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):
|
def maybe_sleep_on_idle(self):
|
||||||
if self.idle_sleeper is not None:
|
if self.idle_sleeper is not None:
|
||||||
|
|||||||
@@ -175,7 +175,6 @@ class SchedulerOutputProcessorMixin:
|
|||||||
release_kv_cache(req, self.tree_cache)
|
release_kv_cache(req, self.tree_cache)
|
||||||
req.time_stats.set_completion_time()
|
req.time_stats.set_completion_time()
|
||||||
elif not batch.decoding_reqs or req not in batch.decoding_reqs:
|
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.tree_cache.cache_unfinished_req(req)
|
||||||
|
|
||||||
self.maybe_collect_customized_info(i, req, logits_output)
|
self.maybe_collect_customized_info(i, req, logits_output)
|
||||||
|
|||||||
@@ -92,10 +92,11 @@ class SchedulerRuntimeCheckerMixin:
|
|||||||
swa_available_size,
|
swa_available_size,
|
||||||
swa_evictable_size,
|
swa_evictable_size,
|
||||||
) = self._get_swa_token_info()
|
) = 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 = (
|
token_msg = (
|
||||||
f"{self.full_tokens_per_layer=}, {full_available_size=}, {full_evictable_size=}, {self.tree_cache.full_protected_size()=}\n"
|
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
|
return memory_leak, token_msg
|
||||||
|
|
||||||
@@ -110,8 +111,9 @@ class SchedulerRuntimeCheckerMixin:
|
|||||||
mamba_available_size,
|
mamba_available_size,
|
||||||
mamba_evictable_size,
|
mamba_evictable_size,
|
||||||
) = self._get_mamba_token_info()
|
) = self._get_mamba_token_info()
|
||||||
|
session_held = self.tree_cache.session_held_tokens()
|
||||||
memory_leak = (
|
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()
|
or mamba_num_used != self.tree_cache.mamba_protected_size()
|
||||||
)
|
)
|
||||||
if memory_leak:
|
if memory_leak:
|
||||||
@@ -150,14 +152,11 @@ class SchedulerRuntimeCheckerMixin:
|
|||||||
def _check_radix_cache_memory(self: Scheduler):
|
def _check_radix_cache_memory(self: Scheduler):
|
||||||
_, _, available_size, evictable_size = self._get_token_info()
|
_, _, available_size, evictable_size = self._get_token_info()
|
||||||
protected_size = self.tree_cache.protected_size()
|
protected_size = self.tree_cache.protected_size()
|
||||||
|
session_held = self.tree_cache.session_held_tokens()
|
||||||
memory_leak = (available_size + evictable_size) != (
|
memory_leak = (available_size + evictable_size) != (
|
||||||
# self.max_total_num_tokens
|
self.max_total_num_tokens - protected_size - session_held
|
||||||
# if not self.enable_hierarchical_cache
|
|
||||||
# else self.max_total_num_tokens - protected_size
|
|
||||||
self.max_total_num_tokens
|
|
||||||
- protected_size
|
|
||||||
)
|
)
|
||||||
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
|
return memory_leak, token_msg
|
||||||
|
|
||||||
def _get_batch_uncached_size(self: Scheduler, batch: ScheduleBatch) -> int:
|
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=}"
|
log_msg = f"[Mem Check (BUSY)] {available_size=}, {evictable_size=}, {protected_size=}, {uncached_size=}"
|
||||||
logger.info(log_msg)
|
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 (
|
assert (
|
||||||
total_tokens == self.max_total_num_tokens
|
total_tokens == self.max_total_num_tokens
|
||||||
), f"Mem Leak Detected! {total_tokens=} vs {self.max_total_num_tokens=}"
|
), f"Mem Leak Detected! {total_tokens=} vs {self.max_total_num_tokens=}"
|
||||||
@@ -218,10 +224,12 @@ class SchedulerRuntimeCheckerMixin:
|
|||||||
else:
|
else:
|
||||||
req_total_size = self.req_to_token_pool.size
|
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 = (
|
msg = (
|
||||||
"req_to_token_pool memory leak detected!"
|
"req_to_token_pool memory leak detected!"
|
||||||
f"available_size={len(self.req_to_token_pool.free_slots)}, "
|
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"
|
f"total_size={self.req_to_token_pool.size}\n"
|
||||||
)
|
)
|
||||||
raise_error_or_warn(
|
raise_error_or_warn(
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
# ==============================================================================
|
# ==============================================================================
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
import time
|
||||||
import uuid
|
import uuid
|
||||||
from typing import Dict, Optional
|
from typing import Dict, Optional
|
||||||
|
|
||||||
@@ -65,25 +66,59 @@ class SessionReqNode:
|
|||||||
|
|
||||||
|
|
||||||
class Session:
|
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.session_id = session_id if session_id is not None else uuid.uuid4().hex
|
||||||
self.capacity_of_str_len = capacity_of_str_len
|
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] = {}
|
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):
|
def create_req(self, req: TokenizedGenerateReqInput, tokenizer, vocab_size: int):
|
||||||
assert req.session_params is not None
|
assert req.session_params is not None
|
||||||
|
self.last_active_time = time.monotonic()
|
||||||
session_params = req.session_params
|
session_params = req.session_params
|
||||||
|
|
||||||
last_req_node = None
|
last_req_node = None
|
||||||
last_req = None
|
last_req = None
|
||||||
abort = False
|
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:
|
if session_params.rid is None:
|
||||||
for _, req_node in self.req_nodes.items():
|
for _, req_node in self.req_nodes.items():
|
||||||
req_node.clear(self.req_nodes)
|
req_node.clear(self.req_nodes)
|
||||||
else:
|
else:
|
||||||
if session_params.rid not in self.req_nodes:
|
if session_params.rid not in self.req_nodes:
|
||||||
abort = True
|
abort = True
|
||||||
|
abort_message = "Invalid request session id"
|
||||||
else:
|
else:
|
||||||
last_req_node = self.req_nodes[session_params.rid]
|
last_req_node = self.req_nodes[session_params.rid]
|
||||||
last_req_node.abort()
|
last_req_node.abort()
|
||||||
@@ -93,18 +128,22 @@ class Session:
|
|||||||
if session_params.rid is not None:
|
if session_params.rid is not None:
|
||||||
if session_params.rid not in self.req_nodes:
|
if session_params.rid not in self.req_nodes:
|
||||||
abort = True
|
abort = True
|
||||||
|
abort_message = "Invalid request session id"
|
||||||
else:
|
else:
|
||||||
last_req_node = self.req_nodes[session_params.rid]
|
last_req_node = self.req_nodes[session_params.rid]
|
||||||
last_req = last_req_node.req
|
last_req = last_req_node.req
|
||||||
if not last_req.finished():
|
if not last_req.finished():
|
||||||
logging.warning(
|
|
||||||
"The request in a session is appending to a request that hasn't finished."
|
|
||||||
)
|
|
||||||
abort = True
|
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:
|
if last_req is not None:
|
||||||
# trim bos token if it is an append
|
# 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:]
|
req.input_ids = req.input_ids[1:]
|
||||||
|
|
||||||
input_ids = (
|
input_ids = (
|
||||||
@@ -136,6 +175,7 @@ class Session:
|
|||||||
else:
|
else:
|
||||||
input_ids = req.input_ids
|
input_ids = req.input_ids
|
||||||
input_ids_unpadded = req.input_ids
|
input_ids_unpadded = req.input_ids
|
||||||
|
|
||||||
new_req = Req(
|
new_req = Req(
|
||||||
rid=req.rid,
|
rid=req.rid,
|
||||||
origin_input_text=None,
|
origin_input_text=None,
|
||||||
@@ -143,7 +183,7 @@ class Session:
|
|||||||
origin_input_ids_unpadded=input_ids_unpadded,
|
origin_input_ids_unpadded=input_ids_unpadded,
|
||||||
sampling_params=req.sampling_params,
|
sampling_params=req.sampling_params,
|
||||||
lora_id=req.lora_id,
|
lora_id=req.lora_id,
|
||||||
session_id=self.session_id,
|
session=self,
|
||||||
custom_logit_processor=req.custom_logit_processor,
|
custom_logit_processor=req.custom_logit_processor,
|
||||||
stream=req.stream,
|
stream=req.stream,
|
||||||
return_logprob=req.return_logprob,
|
return_logprob=req.return_logprob,
|
||||||
@@ -156,7 +196,11 @@ class Session:
|
|||||||
new_req.tokenizer = tokenizer
|
new_req.tokenizer = tokenizer
|
||||||
|
|
||||||
if abort:
|
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:
|
else:
|
||||||
new_req_node = SessionReqNode(new_req, last_req_node)
|
new_req_node = SessionReqNode(new_req, last_req_node)
|
||||||
self.req_nodes[req.rid] = new_req_node
|
self.req_nodes[req.rid] = new_req_node
|
||||||
|
|||||||
@@ -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)
|
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()
|
start_p, end_p = req.pop_overallocated_kv_cache()
|
||||||
|
|
||||||
global_server_args = get_global_server_args()
|
global_server_args = get_global_server_args()
|
||||||
|
|||||||
@@ -158,7 +158,7 @@ class ReqToTokenPool:
|
|||||||
reusing = [i for i, r in enumerate(reqs) if r.req_pool_idx is not None]
|
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):
|
if not any(r.is_dllm() for r in reqs):
|
||||||
assert (
|
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"
|
), "only one chunked request may reuse req_pool_idx in a batch"
|
||||||
assert all(
|
assert all(
|
||||||
reqs[i].is_chunked > 0 or reqs[i].kv_committed_len > 0 for i in reusing
|
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)
|
||||||
+161
@@ -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
|
||||||
python3 -m unittest test_session_control.TestSessionControl.test_session_control_with_branching
|
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_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
|
python3 -m unittest test_session_control.TestSessionControlVision.test_session_control
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import json
|
import json
|
||||||
|
import time
|
||||||
import unittest
|
import unittest
|
||||||
|
|
||||||
import aiohttp
|
import aiohttp
|
||||||
@@ -15,6 +17,7 @@ import requests
|
|||||||
|
|
||||||
from sglang.srt.utils import kill_process_tree
|
from sglang.srt.utils import kill_process_tree
|
||||||
from sglang.srt.utils.hf_transformers_utils import get_tokenizer
|
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 (
|
from sglang.test.test_utils import (
|
||||||
DEFAULT_SMALL_MODEL_NAME_FOR_TEST,
|
DEFAULT_SMALL_MODEL_NAME_FOR_TEST,
|
||||||
DEFAULT_TIMEOUT_FOR_SERVER_LAUNCH,
|
DEFAULT_TIMEOUT_FOR_SERVER_LAUNCH,
|
||||||
@@ -23,6 +26,8 @@ from sglang.test.test_utils import (
|
|||||||
popen_launch_server,
|
popen_launch_server,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
register_cuda_ci(est_time=60, suite="stage-b-test-large-1-gpu")
|
||||||
|
|
||||||
|
|
||||||
def remove_prefix(text: str, prefix: str) -> str:
|
def remove_prefix(text: str, prefix: str) -> str:
|
||||||
return text[len(prefix) :] if text.startswith(prefix) else text
|
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=True))
|
||||||
asyncio.run(self.run_session_control_backtrack_with_abort(replace=False))
|
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(
|
def run_session_control_with_branching(
|
||||||
self, root_prompt, chunks_per_step, gen_len=16
|
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()
|
||||||
Reference in New Issue
Block a user