Add scripted-runtime harness core and wire scheduler/IPC hooks (#27411)

This commit is contained in:
fzyzcjy
2026-06-06 09:07:01 +08:00
committed by GitHub
parent 21201ef718
commit dd176387c6
24 changed files with 1313 additions and 4 deletions
+5
View File
@@ -337,6 +337,11 @@ class Envs:
SGLANG_TEST_PD_DISAGG_DEVICES = EnvStr(None)
SGLANG_TEST_FORCE_OPTIMISTIC_PREFILL_RETRY_PROB = EnvFloat(0.0)
SGLANG_TEST_SCRIPTED_RUNTIME = EnvBool(False)
SGLANG_TEST_SCRIPTED_RUNTIME_IPC_ADDR = EnvStr(None)
SGLANG_TEST_SCRIPTED_RUNTIME_OUT_OF_BAND_ERROR_PATH = EnvStr(None)
SGLANG_TEST_SCRIPTED_RUNTIME_SYS_PATH_ENTRY = EnvStr(None)
# Model Parallel
SGLANG_USE_MESSAGE_QUEUE_BROADCASTER = EnvBool(True)
SGLANG_ONE_VISIBLE_DEVICE_PER_PROCESS = EnvBool(False)
+20
View File
@@ -545,6 +545,8 @@ class Scheduler(
# Init the grammar backend for constrained generation
self.init_grammar_manager()
self.maybe_init_scripted_scheduler_hook()
self.init_request_receiver()
self.init_dp_attn_adapter()
@@ -611,6 +613,7 @@ class Scheduler(
self.ps.attn_tp_rank == 0
or self.server_args.enable_metrics_for_all_schedulers
),
enable_scripted_runtime=envs.SGLANG_TEST_SCRIPTED_RUNTIME.get(),
)
self.load_snapshot_writer = None
@@ -1601,6 +1604,19 @@ class Scheduler(
def init_grammar_manager(self) -> None:
self.grammar_manager = GrammarManager(self)
def maybe_init_scripted_scheduler_hook(self) -> None:
if envs.SGLANG_TEST_SCRIPTED_RUNTIME.get():
from sglang.test.scripted_runtime.scheduler_hook import (
ScriptedSchedulerHook,
)
self.scripted_scheduler_hook = ScriptedSchedulerHook(
scheduler=self,
tokenizer_recv_proxy=self.ipc_channels.recv_from_tokenizer,
)
else:
self.scripted_scheduler_hook = None
def init_request_receiver(self) -> None:
self.request_receiver = SchedulerRequestReceiver(
recv_from_tokenizer=self.ipc_channels.recv_from_tokenizer,
@@ -1623,6 +1639,7 @@ class Scheduler(
get_last_forward_mode=lambda: (
self.last_batch.forward_mode if self.last_batch is not None else None
),
scripted_scheduler_hook=self.scripted_scheduler_hook,
)
def init_dp_attn_adapter(self) -> None:
@@ -2978,6 +2995,9 @@ class Scheduler(
self.forward_ct += 1
batch.forward_iter = self.forward_ct
if self.scripted_scheduler_hook is not None:
self.scripted_scheduler_hook.on_run_batch(batch)
# Whether to run the profiler
self.profiler_manager._profile_batch_predicate(batch)
if self.forward_sleep_time is not None:
@@ -1,5 +1,5 @@
from dataclasses import dataclass
from typing import Optional
from typing import TYPE_CHECKING, Optional, Union
import zmq
@@ -7,10 +7,15 @@ from sglang.srt.managers.scheduler_components.output_sender import SenderWrapper
from sglang.srt.server_args import PortArgs
from sglang.srt.utils.network import get_zmq_socket
if TYPE_CHECKING:
from sglang.test.scripted_runtime.tokenizer_recv_proxy import (
ScriptedTokenizerRecvProxy,
)
@dataclass(frozen=True, slots=True, kw_only=True)
class SchedulerIpcChannels:
recv_from_tokenizer: Optional[zmq.Socket]
recv_from_tokenizer: Union[zmq.Socket, "ScriptedTokenizerRecvProxy"]
recv_from_rpc: Optional[zmq.Socket]
send_to_tokenizer: SenderWrapper
send_to_detokenizer: SenderWrapper
@@ -24,6 +29,7 @@ class SchedulerIpcChannels:
is_rank_zero: bool,
skip_tokenizer_init: bool,
metrics_enabled: bool,
enable_scripted_runtime: bool,
) -> "SchedulerIpcChannels":
context = zmq.Context(2)
@@ -31,6 +37,14 @@ class SchedulerIpcChannels:
recv_from_tokenizer = get_zmq_socket(
context, zmq.PULL, port_args.scheduler_input_ipc_name, False
)
if enable_scripted_runtime:
from sglang.test.scripted_runtime.tokenizer_recv_proxy import (
ScriptedTokenizerRecvProxy,
)
recv_from_tokenizer = ScriptedTokenizerRecvProxy(
underlying=recv_from_tokenizer
)
recv_from_rpc = get_zmq_socket(
context, zmq.DEALER, port_args.rpc_ipc_name, False
)
@@ -34,11 +34,15 @@ if TYPE_CHECKING:
from sglang.srt.configs.model_config import ModelConfig
from sglang.srt.distributed.parallel_state_wrapper import ParallelState
from sglang.srt.server_args import ServerArgs
from sglang.test.scripted_runtime.scheduler_hook import ScriptedSchedulerHook
from sglang.test.scripted_runtime.tokenizer_recv_proxy import (
ScriptedTokenizerRecvProxy,
)
@dataclass(kw_only=True, slots=True, frozen=True)
class SchedulerRequestReceiver:
recv_from_tokenizer: zmq.Socket
recv_from_tokenizer: Union[zmq.Socket, "ScriptedTokenizerRecvProxy"]
recv_from_rpc: Optional[zmq.Socket]
recv_skipper: Any
input_blocker: Any
@@ -56,6 +60,7 @@ class SchedulerRequestReceiver:
max_recv_per_poll: int
stream_output: Callable[..., None]
get_last_forward_mode: Callable[[], Any]
scripted_scheduler_hook: Optional["ScriptedSchedulerHook"] = None
def recv_limit_reached(self, num_recv_reqs: int) -> bool:
if self.max_recv_per_poll < 0:
@@ -67,6 +72,9 @@ class SchedulerRequestReceiver:
) -> List[Union[TokenizedGenerateReqInput, TokenizedEmbeddingReqInput, Any]]:
"""Receive results at tp_rank = 0 and broadcast it to all other TP ranks."""
if self.scripted_scheduler_hook is not None:
self.scripted_scheduler_hook.step()
if self.recv_skipper is not None:
if not self.recv_skipper.handle(self.get_last_forward_mode()):
return []
+1 -1
View File
@@ -231,7 +231,7 @@ def config_socket(socket, socket_type: zmq.SocketType):
set_send_opt()
elif socket_type == zmq.PULL:
set_recv_opt()
elif socket_type in [zmq.DEALER, zmq.REQ, zmq.REP]:
elif socket_type in [zmq.DEALER, zmq.REQ, zmq.REP, zmq.PAIR]:
set_send_opt()
set_recv_opt()
else:
@@ -0,0 +1,68 @@
from __future__ import annotations
import asyncio
import logging
import threading
from concurrent.futures import Future
from typing import Any, Coroutine, Optional
import aiohttp
logger = logging.getLogger(__name__)
JOIN_TIMEOUT_S: float = 10.0
class BackgroundHttpPoster:
def __init__(self) -> None:
self._session: Optional[aiohttp.ClientSession] = None
self._loop = asyncio.new_event_loop()
self._thread = threading.Thread(
target=self._run_loop, name="scripted-runtime-async", daemon=True
)
self._thread.start()
def _run_loop(self) -> None:
asyncio.set_event_loop(self._loop)
self._loop.run_forever()
def submit_coro(self, coro: Coroutine) -> None:
future = asyncio.run_coroutine_threadsafe(coro, self._loop)
future.add_done_callback(self._log_coro_exception)
@staticmethod
def _log_coro_exception(future: Future) -> None:
try:
future.result()
except asyncio.CancelledError:
pass
except Exception:
logger.exception("scripted_runtime: background async coroutine failed")
async def post(self, url: str, json: Any) -> None:
session = self._ensure_session()
async with session.post(url, json=json) as resp:
await resp.read()
def _ensure_session(self) -> aiohttp.ClientSession:
if self._session is None or self._session.closed:
self._session = aiohttp.ClientSession(
connector=aiohttp.TCPConnector(limit=0)
)
return self._session
def close(self) -> None:
try:
if self._session is not None:
future = asyncio.run_coroutine_threadsafe(
self._session.close(), self._loop
)
future.result(timeout=JOIN_TIMEOUT_S)
except Exception:
logger.exception("scripted_runtime: failed to close aiohttp session")
try:
self._loop.call_soon_threadsafe(self._loop.stop)
self._thread.join(timeout=JOIN_TIMEOUT_S)
except Exception:
logger.exception("scripted_runtime: failed to stop background async loop")
@@ -0,0 +1,3 @@
from sglang.test.scripted_runtime.context.api import ScriptedContext
__all__ = ["ScriptedContext"]
@@ -0,0 +1,142 @@
from __future__ import annotations
import logging
from typing import TYPE_CHECKING, Dict, List, Literal, Optional
from sglang.test.scripted_runtime.context import (
engine,
lifecycle,
queries,
radix,
)
from sglang.test.scripted_runtime.context.req_starter import ScriptedContextReqStarter
if TYPE_CHECKING:
from sglang.srt.managers.schedule_batch import Req
from sglang.test.scripted_runtime.background_http_poster import BackgroundHttpPoster
from sglang.test.scripted_runtime.req_handle import ScriptedReqHandle
from sglang.test.scripted_runtime.scheduler_hook import ScriptedSchedulerHook
from sglang.test.scripted_runtime.tokenizer_recv_proxy import (
ScriptedTokenizerRecvProxy,
)
logger = logging.getLogger(__name__)
class ScriptedContext:
def __init__(
self,
*,
scheduler_hook: "ScriptedSchedulerHook",
tokenizer_recv_proxy: Optional["ScriptedTokenizerRecvProxy"],
http_poster: "BackgroundHttpPoster",
) -> None:
assert (
scheduler_hook._is_driver
), "ScriptedContext only exists on the driver rank"
self.scheduler = scheduler_hook.scheduler
self._scheduler_hook = scheduler_hook
self._tokenizer_recv_proxy = tokenizer_recv_proxy
self._http_poster = http_poster
self._seen_rids: set[str] = set()
self._req_starter = ScriptedContextReqStarter(self)
def start_req(
self,
*,
prompt_len: int,
max_new_tokens: int = 8,
rid: Optional[str] = None,
ignore_eos: bool = False,
priority: Optional[int] = None,
dp_rank: Optional[int] = None,
prompt_token: int = 1,
return_logprob: bool = False,
logprob_start_len: Optional[int] = None,
top_logprobs_num: Optional[int] = None,
lora_path: Optional[str] = None,
) -> "ScriptedReqHandle":
return self._req_starter.start_req(
prompt_len=prompt_len,
max_new_tokens=max_new_tokens,
rid=rid,
ignore_eos=ignore_eos,
priority=priority,
dp_rank=dp_rank,
prompt_token=prompt_token,
return_logprob=return_logprob,
logprob_start_len=logprob_start_len,
top_logprobs_num=top_logprobs_num,
lora_path=lora_path,
)
def pause_generation(self, *, mode: Literal["retract", "in_place"]) -> None:
return lifecycle.pause_generation(self, mode=mode)
def continue_generation(self, *, torch_empty_cache: bool = False) -> None:
return lifecycle.continue_generation(self, torch_empty_cache=torch_empty_cache)
def abort_all(self) -> None:
return lifecycle.abort_all(self)
def abort(self, handle: "ScriptedReqHandle") -> None:
return lifecycle.abort(self, rid=handle.rid)
def flush_cache(self) -> None:
return lifecycle.flush_cache(self)
def evict_radix(self, *, prefix_tokens: Optional[List[int]]) -> None:
assert (
prefix_tokens is None
), "evict_radix currently supports only full eviction (prefix_tokens=None)"
return lifecycle.flush_cache(self)
def get_all_node_hit_counts(self) -> Dict[int, int]:
return radix.get_all_node_hit_counts(self)
def get_all_node_lock_refs(self) -> Dict[int, int]:
return radix.get_all_node_lock_refs(self)
@property
def is_idle(self) -> bool:
return queries.is_idle(self)
@property
def is_fully_idle(self) -> bool:
return queries.is_fully_idle(self)
@property
def last_batch_forward_mode(self) -> Optional[str]:
return queries.last_batch_forward_mode(self)
def find_req_by_rid(self, rid: str) -> Optional["Req"]:
return queries.find_req_by_rid(self, rid)
def is_finished(self, rid: str) -> bool:
return queries.is_finished(self, rid)
def is_chunking(self, rid: str) -> bool:
return queries.is_chunking(self, rid)
def status(self, rid: str) -> str:
return queries.status(self, rid)
def remaining_prompt_tokens(self, rid: str) -> int:
return queries.remaining_prompt_tokens(self, rid)
def list_active_reqs(self) -> List["Req"]:
return queries.list_active_reqs(self)
def chunks_done(self, rid: str) -> int:
return queries.chunks_done(self, rid)
def chunked_parks(self, rid: str) -> int:
return queries.chunked_parks(self, rid)
def batch_composition(self) -> Dict[str, List[str]]:
return queries.batch_composition(self)
def engine_stats(self) -> Dict[str, int]:
return engine.engine_stats(self)
@@ -0,0 +1,16 @@
from __future__ import annotations
from typing import TYPE_CHECKING, Dict
if TYPE_CHECKING:
from sglang.test.scripted_runtime.context.api import ScriptedContext
def engine_stats(ctx: "ScriptedContext") -> Dict[str, int]:
s = ctx.scheduler
return {
"kv_pool_free": s.token_to_kv_pool_allocator.available_size(),
"req_pool_free": s.req_to_token_pool.available_size(),
"req_pool_total": s.req_to_token_pool.size,
"page_size": s.page_size,
}
@@ -0,0 +1,37 @@
from __future__ import annotations
import logging
from typing import TYPE_CHECKING, Any, Callable, Dict, Optional
if TYPE_CHECKING:
from sglang.test.scripted_runtime.context.api import ScriptedContext
logger = logging.getLogger(__name__)
RECV_MSG_ARRIVAL_TIMEOUT_S: float = 60.0
def _http_post_and_await_recv_msg(
ctx: "ScriptedContext",
*,
path: str,
json: Optional[Dict[str, Any]],
predicate: Callable[[Any], bool],
description: str,
timeout_s: float = RECV_MSG_ARRIVAL_TIMEOUT_S,
) -> None:
server_args = ctx.scheduler.server_args
url = f"http://{server_args.host}:{server_args.port}{path}"
async def _post() -> None:
try:
await ctx._http_poster.post(url, json)
except Exception: # noqa: BLE001 — fire-and-forget background POST
logger.exception("scripted_runtime: POST %s failed", path)
ctx._http_poster.submit_coro(_post())
ctx._tokenizer_recv_proxy.wait_until_arrived(
predicate,
timeout_s=timeout_s,
description=description,
)
@@ -0,0 +1,75 @@
from __future__ import annotations
from typing import TYPE_CHECKING, Literal
from sglang.srt.managers.io_struct import (
AbortReq,
ContinueGenerationReqInput,
FlushCacheReqInput,
PauseGenerationReqInput,
)
from sglang.test.scripted_runtime.context.http_post import (
_http_post_and_await_recv_msg,
)
if TYPE_CHECKING:
from sglang.test.scripted_runtime.context.api import ScriptedContext
def _await_control(
ctx: "ScriptedContext", *, path: str, json, expect_type: type
) -> None:
_http_post_and_await_recv_msg(
ctx,
path=path,
json=json,
predicate=lambda obj: isinstance(obj, expect_type),
description=expect_type.__name__,
)
def pause_generation(
ctx: "ScriptedContext", *, mode: Literal["retract", "in_place"]
) -> None:
_await_control(
ctx,
path="/pause_generation",
json={"mode": mode},
expect_type=PauseGenerationReqInput,
)
def continue_generation(ctx: "ScriptedContext", *, torch_empty_cache: bool) -> None:
_await_control(
ctx,
path="/continue_generation",
json={"torch_empty_cache": torch_empty_cache},
expect_type=ContinueGenerationReqInput,
)
def abort_all(ctx: "ScriptedContext") -> None:
_await_control(
ctx,
path="/abort_request",
json={"rid": "", "abort_all": True},
expect_type=AbortReq,
)
def abort(ctx: "ScriptedContext", *, rid: str) -> None:
_await_control(
ctx,
path="/abort_request",
json={"rid": rid, "abort_all": False},
expect_type=AbortReq,
)
def flush_cache(ctx: "ScriptedContext") -> None:
_await_control(
ctx,
path="/flush_cache",
json=None,
expect_type=FlushCacheReqInput,
)
@@ -0,0 +1,123 @@
from __future__ import annotations
from typing import TYPE_CHECKING, Dict, Iterator, List, Optional
if TYPE_CHECKING:
from sglang.srt.managers.schedule_batch import Req
from sglang.test.scripted_runtime.context.api import ScriptedContext
def _get_all_reqs(ctx: "ScriptedContext") -> Iterator["Req"]:
s = ctx.scheduler
if s.chunked_req is not None:
yield s.chunked_req
yield from s.waiting_queue
if s.running_batch is not None:
yield from s.running_batch.reqs
if s.last_batch is not None:
yield from s.last_batch.reqs
def list_active_reqs(ctx: "ScriptedContext") -> List["Req"]:
return list(set(_get_all_reqs(ctx)))
def batch_composition(ctx: "ScriptedContext") -> Dict[str, List[str]]:
s = ctx.scheduler
chunked_rid = s.chunked_req.rid if s.chunked_req is not None else None
chunked = [chunked_rid] if chunked_rid is not None else []
running = (
[r.rid for r in s.running_batch.reqs] if s.running_batch is not None else []
)
prefill: List[str] = []
decode: List[str] = []
batch = s.last_batch
if batch is not None and not batch.is_empty() and batch.forward_mode is not None:
bucket = prefill if batch.forward_mode.is_extend() else decode
bucket.extend(r.rid for r in batch.reqs if r.rid != chunked_rid)
return {
"prefill": prefill,
"decode": decode,
"chunked": chunked,
"running": running,
}
def is_idle(ctx: "ScriptedContext") -> bool:
s = ctx.scheduler
return (
s.chunked_req is None
and len(s.waiting_queue) == 0
and (s.running_batch is None or s.running_batch.is_empty())
)
def is_fully_idle(ctx: "ScriptedContext") -> bool:
s = ctx.scheduler
return is_idle(ctx) and (s.last_batch is None or s.last_batch.is_empty())
def last_batch_forward_mode(ctx: "ScriptedContext") -> Optional[str]:
s = ctx.scheduler
if s.last_batch is not None and s.last_batch.forward_mode is not None:
return s.last_batch.forward_mode.name
return None
def find_req_by_rid(ctx: "ScriptedContext", rid: str) -> Optional["Req"]:
req = next((r for r in _get_all_reqs(ctx) if r.rid == rid), None)
if req is not None:
ctx._seen_rids.add(rid)
return req
def is_finished(ctx: "ScriptedContext", rid: str) -> bool:
req = find_req_by_rid(ctx, rid)
if req is None:
return rid in ctx._seen_rids
return req.finished()
def is_chunking(ctx: "ScriptedContext", rid: str) -> bool:
s = ctx.scheduler
return s.chunked_req is not None and s.chunked_req.rid == rid
def status(ctx: "ScriptedContext", rid: str) -> str:
s = ctx.scheduler
if rid in {r.rid for r in s.waiting_queue}:
return "waiting"
req = find_req_by_rid(ctx, rid)
if req is not None:
return "finished" if req.finished() else "running"
if rid in ctx._seen_rids:
return "finished"
return "unknown"
def remaining_prompt_tokens(ctx: "ScriptedContext", rid: str) -> int:
req = find_req_by_rid(ctx, rid)
if req is None:
return 0
return max(0, len(req.origin_input_ids) - req.kv_committed_len)
def chunks_done(ctx: "ScriptedContext", rid: str) -> int:
log = ctx._scheduler_hook._batch_log
held = sum(1 for record in log if record.chunked_rid == rid and rid in record.rids)
if held == 0:
return 0
completed = any(
rid in record.extend_rids and record.chunked_rid != rid for record in log
)
return held + (1 if completed else 0)
def chunked_parks(ctx: "ScriptedContext", rid: str) -> int:
return sum(
1
for record in ctx._scheduler_hook._batch_log
if record.chunked_rid == rid and rid not in record.rids
)
@@ -0,0 +1,34 @@
from __future__ import annotations
from typing import TYPE_CHECKING, Any, Callable, Dict
from sglang.srt.mem_cache.swa_radix_cache import TreeNode as SWATreeNode
if TYPE_CHECKING:
from sglang.test.scripted_runtime.context.api import ScriptedContext
def get_all_node_hit_counts(ctx: "ScriptedContext") -> Dict[int, int]:
return _collect_node_attr(ctx, lambda node: node.hit_count)
def get_all_node_lock_refs(ctx: "ScriptedContext") -> Dict[int, int]:
return _collect_node_attr(ctx, _node_lock_ref)
def _node_lock_ref(node: Any) -> int:
if isinstance(node, SWATreeNode):
return node.full_lock_ref + node.swa_lock_ref
return node.lock_ref
def _collect_node_attr(
ctx: "ScriptedContext", get_value: Callable[[Any], int]
) -> Dict[int, int]:
values: Dict[int, int] = {}
stack = list(ctx.scheduler.tree_cache.root_node.children.values())
while stack:
node = stack.pop()
values[node.id] = get_value(node)
stack.extend(node.children.values())
return values
@@ -0,0 +1,67 @@
from __future__ import annotations
import uuid
from typing import TYPE_CHECKING, Optional
from sglang.test.scripted_runtime.context.http_post import (
_http_post_and_await_recv_msg,
)
from sglang.test.scripted_runtime.req_handle import ScriptedReqHandle
if TYPE_CHECKING:
from sglang.test.scripted_runtime.context.api import ScriptedContext
class ScriptedContextReqStarter:
def __init__(self, ctx: "ScriptedContext") -> None:
self._ctx = ctx
self._req_counter = 0
def start_req(
self,
*,
prompt_len: int,
max_new_tokens: int,
rid: Optional[str],
ignore_eos: bool,
priority: Optional[int],
dp_rank: Optional[int],
prompt_token: int = 1,
return_logprob: bool = False,
logprob_start_len: Optional[int] = None,
top_logprobs_num: Optional[int] = None,
lora_path: Optional[str] = None,
) -> ScriptedReqHandle:
ctx = self._ctx
if rid is None:
rid = f"scripted-{self._req_counter}-{uuid.uuid4().hex}"
self._req_counter += 1
sampling_params = {"max_new_tokens": max_new_tokens, "ignore_eos": ignore_eos}
payload = {
"input_ids": [prompt_token] * prompt_len,
"sampling_params": sampling_params,
"rid": rid,
"stream": True,
}
payload["return_logprob"] = return_logprob
if logprob_start_len is not None:
payload["logprob_start_len"] = logprob_start_len
if top_logprobs_num is not None:
payload["top_logprobs_num"] = top_logprobs_num
if priority is not None:
payload["priority"] = priority
if dp_rank is not None:
payload["routed_dp_rank"] = dp_rank
if lora_path is not None:
payload["lora_path"] = lora_path
_http_post_and_await_recv_msg(
ctx,
path="/generate",
json=payload,
predicate=lambda obj: getattr(obj, "rid", None) == rid,
description=f"request with rid {rid!r}",
)
return ScriptedReqHandle(rid=rid, context=ctx)
@@ -0,0 +1,258 @@
from __future__ import annotations
import multiprocessing as mp
import os
import tempfile
import time
from pathlib import Path
from typing import Any, Callable, Dict, Optional, Tuple
import requests
import zmq
from sglang.srt.entrypoints.http_server import launch_server
from sglang.srt.environ import envs
from sglang.srt.server_args import ServerArgs
from sglang.srt.utils.network import get_free_port, get_zmq_socket_on_host
from sglang.test.scripted_runtime.io_struct import (
HookReady,
OutOfBandError,
RunScript,
ScriptFailed,
ScriptSucceeded,
Shutdown,
)
from sglang.test.scripted_runtime.utils import close_zmq_socket
DEFAULT_RUN_TIMEOUT_S: float = 120.0
SHUTDOWN_JOIN_TIMEOUT_S: float = 60.0
LISTENER_ACCEPT_TIMEOUT_S: float = 300.0
HTTP_READY_TIMEOUT_S: float = 300.0
HTTP_READY_POLL_INTERVAL_S: float = 0.5
SERVER_HOST: str = "127.0.0.1"
class ScriptedHttpServer:
def __init__(
self,
*,
ctx: zmq.Context,
socket: zmq.Socket,
server_process: mp.process.BaseProcess,
out_of_band_error_path: Path,
http_port: int,
) -> None:
self._ctx = ctx
self._socket = socket
self._server_process = server_process
self._out_of_band_error_path = out_of_band_error_path
self._base_url = f"http://{SERVER_HOST}:{http_port}"
self._shutdown_done = False
self._dirty: Optional[str] = None
@classmethod
def start(cls, **engine_kwargs: Any) -> "ScriptedHttpServer":
out_of_band_error_path = _create_oob_error_file()
ctx = zmq.Context()
dispatch_port, socket = get_zmq_socket_on_host(ctx, zmq.PAIR, host=SERVER_HOST)
server_process, http_port = _spawn_server_process(
endpoint=f"tcp://{SERVER_HOST}:{dispatch_port}",
out_of_band_error_path=out_of_band_error_path,
engine_kwargs=engine_kwargs,
)
self = cls(
ctx=ctx,
socket=socket,
server_process=server_process,
out_of_band_error_path=out_of_band_error_path,
http_port=http_port,
)
try:
self._await_handshake()
self._await_http_ready()
except BaseException:
self._teardown()
raise
return self
def execute_script(
self,
script_fn: Callable,
*,
args: Tuple[Any, ...] = (),
timeout_s: float = DEFAULT_RUN_TIMEOUT_S,
) -> None:
if self._dirty:
raise RuntimeError(f"ScriptedHttpServer is dirty: {self._dirty}")
fn_path = f"{script_fn.__module__}:{script_fn.__qualname__}"
self._socket.send_pyobj(RunScript(fn_path=fn_path, args=args))
if not self._socket.poll(int(timeout_s * 1000)):
if not self._server_process.is_alive():
self._dirty = f"server process died before responding to {fn_path!r}"
raise RuntimeError(self._dirty)
self._dirty = f"script {fn_path!r} timed out after {timeout_s}s"
raise TimeoutError(self._dirty)
reply = self._socket.recv_pyobj()
match reply:
case ScriptFailed(traceback=tb):
raise AssertionError(f"scripted-runtime script failed:\n{tb}")
case ScriptSucceeded():
return
case _:
raise RuntimeError(
f"scheduler hook replied with unexpected message {reply!r}"
)
def shutdown(self) -> None:
if self._shutdown_done:
return
fatal_error: Optional[OutOfBandError] = None
try:
try:
self._socket.send_pyobj(Shutdown())
except zmq.ZMQError:
pass
self._server_process.join(timeout=SHUTDOWN_JOIN_TIMEOUT_S)
self._terminate_process(self._server_process)
fatal_error = self._read_out_of_band_error()
finally:
self._teardown()
self._shutdown_done = True
if fatal_error:
raise AssertionError(
f"scripted-runtime server failed:\n{fatal_error.traceback}"
)
def _await_handshake(self) -> None:
if not self._socket.poll(int(LISTENER_ACCEPT_TIMEOUT_S * 1000)):
raise TimeoutError(
f"ScriptedHttpServer: HTTP server did not connect within "
f"{LISTENER_ACCEPT_TIMEOUT_S}s"
)
ready = self._socket.recv_pyobj()
if not isinstance(ready, HookReady):
raise RuntimeError(
f"ScriptedHttpServer: expected HookReady handshake, got {ready!r}"
)
def _await_http_ready(self) -> None:
# HookReady only means the scheduler dispatch loop started; the uvicorn
# entrypoint may not be bound yet. The first script runs
# _reset_engine_state, which POSTs to this server's own HTTP port, so
# block until the port is bound and routing before any script can run.
#
# Wait for *any* HTTP response, not status 200: in scripted mode the
# scheduler is driven by the script, so normal warmup never completes
# and /health stays 503 (server_status == Starting) for the whole run.
# A 503 still proves the socket is bound and routes are registered,
# which is all the control POSTs need.
url = f"{self._base_url}/health"
deadline = time.monotonic() + HTTP_READY_TIMEOUT_S
while time.monotonic() < deadline:
if not self._server_process.is_alive():
raise RuntimeError(
"ScriptedHttpServer: server process died during HTTP startup"
)
try:
requests.get(url, timeout=2.0)
return
except requests.RequestException:
pass
time.sleep(HTTP_READY_POLL_INTERVAL_S)
raise TimeoutError(
f"ScriptedHttpServer: HTTP endpoint {url} not bound within "
f"{HTTP_READY_TIMEOUT_S}s"
)
def _teardown(self) -> None:
try:
close_zmq_socket(self._socket, self._ctx)
except Exception: # noqa: BLE001 — best-effort cleanup
pass
self._terminate_process(self._server_process)
self._cleanup_files()
def _read_out_of_band_error(self) -> Optional[OutOfBandError]:
try:
text = self._out_of_band_error_path.read_text()
except OSError:
return None
text = text.strip()
return OutOfBandError.from_json(text) if text else None
def _cleanup_files(self) -> None:
try:
self._out_of_band_error_path.unlink()
except OSError:
pass
@staticmethod
def _terminate_process(process: mp.process.BaseProcess) -> None:
if not process.is_alive():
return
process.terminate()
process.join(timeout=10.0)
if process.is_alive():
process.kill()
process.join(timeout=10.0)
def _create_oob_error_file() -> Path:
err_fd, err_path = tempfile.mkstemp(
prefix="scripted_runtime_oob_error_", suffix=".json"
)
os.close(err_fd)
return Path(err_path)
def _spawn_server_process(
*,
endpoint: str,
out_of_band_error_path: Path,
engine_kwargs: Dict[str, Any],
) -> Tuple[mp.process.BaseProcess, int]:
mp_ctx = mp.get_context("spawn")
launch_kwargs: Dict[str, Any] = dict(
host=SERVER_HOST,
port=get_free_port(),
kv_canary="raise",
kv_canary_real_data="partial",
kv_canary_sweep_interval=100,
disable_piecewise_cuda_graph=True,
)
launch_kwargs.update(engine_kwargs)
http_port = launch_kwargs["port"]
server_process = mp_ctx.Process(
target=_launch_scripted_http_server,
kwargs=launch_kwargs,
name="scripted-runtime-http-server",
daemon=False,
)
sys_path_entry = str(Path(__file__).resolve().parent)
with (
envs.SGLANG_TEST_SCRIPTED_RUNTIME.override(True),
envs.SGLANG_TEST_SCRIPTED_RUNTIME_IPC_ADDR.override(endpoint),
envs.SGLANG_TEST_SCRIPTED_RUNTIME_OUT_OF_BAND_ERROR_PATH.override(
str(out_of_band_error_path)
),
envs.SGLANG_TEST_SCRIPTED_RUNTIME_SYS_PATH_ENTRY.override(sys_path_entry),
):
server_process.start()
return server_process, http_port
def _launch_scripted_http_server(**engine_kwargs: Any) -> None:
launch_server(ServerArgs(**engine_kwargs))
@@ -0,0 +1,51 @@
from __future__ import annotations
import dataclasses
import json
from dataclasses import dataclass
from typing import Any, Tuple, Union
@dataclass(frozen=True, slots=True)
class RunScript:
fn_path: str
args: Tuple[Any, ...] = ()
@dataclass(frozen=True, slots=True)
class Shutdown:
pass
@dataclass(frozen=True, slots=True)
class HookReady:
pass
@dataclass(frozen=True, slots=True)
class ScriptSucceeded:
pass
@dataclass(frozen=True, slots=True)
class ScriptFailed:
traceback: str
@dataclass(frozen=True, slots=True)
class OutOfBandError:
traceback: str
def to_json(self) -> str:
return json.dumps(dataclasses.asdict(self))
@classmethod
def from_json(cls, text: str) -> "OutOfBandError":
return cls(**json.loads(text))
ScriptedCommand = Union[RunScript, Shutdown]
ScriptedReply = Union[HookReady, ScriptSucceeded, ScriptFailed]
@@ -0,0 +1,51 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import TYPE_CHECKING, Optional
if TYPE_CHECKING:
from sglang.srt.managers.schedule_batch import Req
from sglang.test.scripted_runtime.context.api import ScriptedContext
@dataclass(frozen=True, slots=True)
class ScriptedReqHandle:
rid: str
context: "ScriptedContext"
@property
def req(self) -> Optional["Req"]:
return self.context.find_req_by_rid(self.rid)
@property
def finished(self) -> bool:
return self.context.is_finished(self.rid)
@property
def is_chunking(self) -> bool:
return self.context.is_chunking(self.rid)
@property
def chunks_done(self) -> int:
return self.context.chunks_done(self.rid)
@property
def status(self) -> str:
return self.context.status(self.rid)
@property
def remaining_prompt_tokens(self) -> int:
return self.context.remaining_prompt_tokens(self.rid)
@property
def kv_pages(self) -> int:
req = self.req
if req is None or req.req_pool_idx is None:
return 0
page_size = self.context.scheduler.page_size
return (req.kv_allocated_len + page_size - 1) // page_size
@property
def lock_refs(self) -> int:
node = self.req.last_node
return node.lock_ref if node is not None else 0
@@ -0,0 +1,190 @@
from __future__ import annotations
import logging
import sys
import traceback
from dataclasses import dataclass
from pathlib import Path
from typing import TYPE_CHECKING, Generator, List, Optional, Tuple
import zmq
from sglang.srt.environ import envs
from sglang.srt.utils.network import get_zmq_socket
from sglang.test.scripted_runtime.background_http_poster import BackgroundHttpPoster
from sglang.test.scripted_runtime.context import ScriptedContext
from sglang.test.scripted_runtime.io_struct import (
HookReady,
OutOfBandError,
RunScript,
ScriptFailed,
ScriptSucceeded,
Shutdown,
)
from sglang.test.scripted_runtime.utils import (
close_zmq_socket,
ensure_script_importable,
resolve_fn,
)
if TYPE_CHECKING:
from sglang.srt.managers.scheduler import Scheduler
from sglang.test.scripted_runtime.tokenizer_recv_proxy import (
ScriptedTokenizerRecvProxy,
)
logger = logging.getLogger(__name__)
RESET_DRAIN_MAX_STEPS: int = 200
@dataclass(frozen=True, slots=True)
class ScriptedBatchRecord:
forward_iter: int
mode: Optional[str]
rids: Tuple[str, ...]
extend_rids: Tuple[str, ...]
chunked_rid: Optional[str]
def _reset_engine_state(ctx: ScriptedContext) -> Generator:
scheduler = ctx.scheduler
ctx.abort_all()
for _ in range(RESET_DRAIN_MAX_STEPS):
yield
if (
scheduler.chunked_req is None
and len(scheduler.waiting_queue) == 0
and scheduler.running_batch.is_empty()
):
break
server_args = scheduler.server_args
for _ in range(2 * (server_args.pp_size + server_args.pp_async_batch_depth)):
yield
ctx.flush_cache()
yield
class ScriptedSchedulerHook:
def __init__(
self,
*,
scheduler: "Scheduler",
tokenizer_recv_proxy: Optional["ScriptedTokenizerRecvProxy"],
) -> None:
self.scheduler = scheduler
self._is_driver = (
scheduler.ps.pp_rank == 0
and scheduler.ps.tp_rank == 0
and scheduler.ps.attn_cp_rank == 0
)
self._batch_log: List["ScriptedBatchRecord"] = []
if self._is_driver:
ensure_script_importable(
envs.SGLANG_TEST_SCRIPTED_RUNTIME_SYS_PATH_ENTRY.get()
)
self._http_poster: Optional[BackgroundHttpPoster] = BackgroundHttpPoster()
self._context: Optional[ScriptedContext] = ScriptedContext(
scheduler_hook=self,
tokenizer_recv_proxy=tokenizer_recv_proxy,
http_poster=self._http_poster,
)
self._script_fn_generator: Optional[Generator] = self._run_dispatch_loop()
else:
self._http_poster = None
self._context = None
self._script_fn_generator = None
def _run_dispatch_loop(self) -> Generator:
endpoint = envs.SGLANG_TEST_SCRIPTED_RUNTIME_IPC_ADDR.get()
ctx_zmq = zmq.Context()
socket = get_zmq_socket(ctx_zmq, zmq.PAIR, endpoint, bind=False)
try:
socket.send_pyobj(HookReady())
while True:
msg = socket.recv_pyobj()
match msg:
case Shutdown():
return
case RunScript(fn_path=fn_path, args=args):
fn = resolve_fn(fn_path)
ctx = self._context
yield from _reset_engine_state(ctx)
self._batch_log.clear()
sub_gen = fn(ctx, *args)
try:
yield from sub_gen
except Exception:
socket.send_pyobj(
ScriptFailed(traceback=traceback.format_exc())
)
else:
socket.send_pyobj(ScriptSucceeded())
case _:
raise ValueError(f"dispatch loop: unknown command {msg!r}")
finally:
close_zmq_socket(socket, ctx_zmq)
self._http_poster.close()
def on_run_batch(self, batch) -> None:
if not self._is_driver:
return
chunked = self.scheduler.chunked_req
self._batch_log.append(
ScriptedBatchRecord(
forward_iter=batch.forward_iter,
mode=(
batch.forward_mode.name.lower()
if batch.forward_mode is not None
else None
),
rids=tuple(r.rid for r in batch.reqs),
extend_rids=(
tuple(r.rid for r in batch.reqs)
if batch.forward_mode is not None and batch.forward_mode.is_extend()
else ()
),
chunked_rid=chunked.rid if chunked is not None else None,
)
)
def step(self) -> None:
if not self._is_driver:
return
done, exc_tb = _advance_generator(self._script_fn_generator)
if not done:
return
if exc_tb is not None:
_write_out_of_band_error(exc_tb)
sys.exit(0 if exc_tb is None else 1)
def _write_out_of_band_error(exc_tb: str) -> None:
path = envs.SGLANG_TEST_SCRIPTED_RUNTIME_OUT_OF_BAND_ERROR_PATH.get()
if not path:
return
error = OutOfBandError(traceback=exc_tb or "<no traceback>")
try:
Path(path).write_text(error.to_json())
except OSError:
logger.exception(
"Failed to write scripted_runtime out-of-band error to %s", path
)
def _advance_generator(generator: Generator) -> Tuple[bool, Optional[str]]:
try:
next(generator)
return False, None
except StopIteration:
return True, None
except Exception:
logger.exception("Failed to advance generator")
return True, traceback.format_exc()
@@ -0,0 +1,29 @@
from __future__ import annotations
from typing import Any, ClassVar, Dict
from sglang.test.scripted_runtime.http_server import ScriptedHttpServer
from sglang.test.test_utils import CustomTestCase
class ScriptedTestCase(CustomTestCase):
ENGINE_KWARGS: ClassVar[Dict[str, Any]] = {}
server: ClassVar[ScriptedHttpServer]
@classmethod
def setUpClass(cls) -> None:
super().setUpClass()
if not cls.ENGINE_KWARGS:
raise AssertionError(
f"{cls.__name__} must set ENGINE_KWARGS to a non-empty dict"
)
cls.server = ScriptedHttpServer.start(**cls.ENGINE_KWARGS)
@classmethod
def tearDownClass(cls) -> None:
try:
cls.server.shutdown()
finally:
super().tearDownClass()
@@ -0,0 +1,55 @@
from __future__ import annotations
import time
from collections import deque
from typing import Any, Callable
import zmq
class ScriptedTokenizerRecvProxy:
def __init__(self, *, underlying: zmq.Socket) -> None:
self._underlying = underlying
self._buffer: deque = deque()
def recv_pyobj(self, flags: int = 0) -> Any:
self._drain_underlying()
if self._buffer:
return self._buffer.popleft()
if flags & zmq.NOBLOCK:
raise zmq.ZMQError(zmq.EAGAIN, "Resource temporarily unavailable")
raise RuntimeError(
"ScriptedTokenizerRecvProxy.recv_pyobj: blocking recv is not supported"
)
def wait_until_arrived(
self,
predicate: Callable[[Any], bool],
*,
timeout_s: float,
description: str = "matching object",
) -> None:
start_len = len(self._buffer)
deadline = time.monotonic() + timeout_s
while True:
self._drain_underlying()
for i, obj in enumerate(self._buffer):
if i >= start_len and predicate(obj):
return
if time.monotonic() >= deadline:
raise TimeoutError(
f"ScriptedTokenizerRecvProxy: no {description} arrived on the "
f"recv_from_tokenizer socket within {timeout_s}s"
)
time.sleep(0.005)
def _drain_underlying(self) -> None:
while True:
try:
req = self._underlying.recv_pyobj(zmq.NOBLOCK)
except zmq.ZMQError:
break
self._buffer.append(req)
@@ -0,0 +1,33 @@
from __future__ import annotations
import importlib
import sys
from typing import Callable, Optional
import zmq
def close_zmq_socket(socket: zmq.Socket, ctx: zmq.Context) -> None:
socket.setsockopt(zmq.LINGER, 0)
socket.close()
ctx.term()
def ensure_script_importable(sys_path_entry: Optional[str]) -> None:
if sys_path_entry and sys_path_entry not in sys.path:
sys.path.insert(0, sys_path_entry)
def resolve_fn(qualified: str) -> Callable:
module_name, sep, fn_name = qualified.partition(":")
if not sep or not module_name or not fn_name:
raise ValueError(
f"scripted-runtime fn path must be 'module.path:function_name', "
f"got {qualified!r}"
)
obj = importlib.import_module(module_name)
for part in fn_name.split("."):
obj = getattr(obj, part)
if not callable(obj):
raise TypeError(f"resolved object is not callable: {qualified!r} -> {obj!r}")
return obj