chore: import upstream snapshot with attribution
PR Test (NPU) / check-changes (push) Has been cancelled
PR Test (NPU) / pr-gate (push) Has been cancelled
PR Test (NPU) / set-image-config (push) Has been cancelled
PR Test (NPU) / stage-b-test-1-npu-a2 (0) (push) Has been cancelled
PR Test (NPU) / stage-b-test-1-npu-a2 (1) (push) Has been cancelled
PR Test (NPU) / stage-b-test-2-npu-a2 (0) (push) Has been cancelled
PR Test (NPU) / stage-b-test-2-npu-a2 (1) (push) Has been cancelled
PR Test (NPU) / stage-b-test-4-npu-a3 (push) Has been cancelled
PR Test (NPU) / stage-b-test-16-npu-a3 (push) Has been cancelled
PR Test (NPU) / multimodal-gen-test-1-npu-a3 (push) Has been cancelled
PR Test (NPU) / multimodal-gen-test-2-npu-a3 (push) Has been cancelled
PR Test (Arm64) / pr-gate (push) Has been cancelled
PR Test (Arm64) / check-changes (push) Has been cancelled
PR Test (Arm64) / build-test (push) Has been cancelled
PR Test (sgl-router) / gate (push) Has been cancelled
PR Test (sgl-router) / tier-1 — lint (push) Has been cancelled
PR Test (sgl-router) / tier-2 — build + test (push) Has been cancelled
PR Test (sgl-router) / tier-3 — docker (placeholder) (push) Has been cancelled
PR Test (sgl-router) / tier-3 — k8s integration (push) Has been cancelled
PR Test (sgl-router) / tier-3 — e2e (push) Has been cancelled
PR Test (sgl-router) / finish (push) Has been cancelled
PR Test (NPU) / single-node-poc (map[name:qwen3_6_27b_w8a8_1p_in64k_out1k_50ms runner:linux-aarch64-a3-2 test_case:test/registered/ascend/performance/qwen3_6_27b/test_npu_qwen3_6_27b_w8a8_1p_in64k_out1k_50ms.py test_type:perf]) (push) Has been cancelled
PR Test (NPU) / pr-test-npu-finish (push) Has been cancelled
PR Test (Xeon) / pr-gate (push) Has been cancelled
PR Test (Xeon) / check-changes (push) Has been cancelled
PR Test (Xeon) / build-test (, xeon-gnr, base-b-test-cpu) (push) Has been cancelled
PR Test (XPU) / check-changes (push) Has been cancelled
PR Test (XPU) / pr-gate (push) Has been cancelled
PR Test (XPU) / stage-a-test-1-gpu-xpu (push) Has been cancelled
PR Test (XPU) / wait-for-stage-a (push) Has been cancelled
PR Test (XPU) / stage-b-test-1-gpu-xpu (push) Has been cancelled
PR Test (XPU) / finish (push) Has been cancelled
CI Model Inventory / build-inventory (push) Has been cancelled
Lint / lint (push) Has been cancelled
PR Benchmark (SMG Components) / Benchmark Compilation Check (push) Has been cancelled
PR Benchmark (SMG Components) / Benchmark - Manual Policy (push) Has been cancelled
PR Benchmark (SMG Components) / Benchmark - Request Processing (push) Has been cancelled
PR Benchmark (SMG Components) / Benchmark Summary (push) Has been cancelled
PR Test (SMG) / build-wheel (push) Has been cancelled
Release SGLang Model Gateway to PyPI / build on windows (x86_64 - auto) (push) Has been cancelled
Release SGLang Model Gateway to PyPI / build on macos (x86_64 - auto) (push) Has been cancelled
PR Test (SMG) / python-unit-tests (push) Has been cancelled
PR Test (SMG) / unit-tests (push) Has been cancelled
PR Test (SMG) / benchmarks (push) Has been cancelled
PR Test (SMG) / chat-completions (push) Has been cancelled
PR Test (SMG) / chat-completions-4gpu (push) Has been cancelled
PR Test (SMG) / e2e (push) Has been cancelled
PR Test (SMG) / docker-build-test (push) Has been cancelled
PR Test (SMG) / k8s-integration (push) Has been cancelled
PR Test (SMG) / finish (push) Has been cancelled
PR Test (SMG) / summarize-benchmarks (push) Has been cancelled
Release SGLang Model Gateway Docker Image / publish (push) Has been cancelled
Release SGLang Model Gateway to PyPI / build on macos (aarch64 - auto) (push) Has been cancelled
Release SGLang Model Gateway to PyPI / build on linux (aarch64 - auto) (push) Has been cancelled
Release SGLang Model Gateway to PyPI / build on linux (x86_64 - auto) (push) Has been cancelled
Release SGLang Model Gateway to PyPI / build on linux (aarch64 - musllinux_1_1) (push) Has been cancelled
Release SGLang Model Gateway to PyPI / build on linux (x86_64 - musllinux_1_1) (push) Has been cancelled
Release SGLang Model Gateway to PyPI / Build SDist (push) Has been cancelled
Release SGLang Model Gateway to PyPI / Upload to PyPI (push) Has been cancelled
Release SGLang Kernels / build-cu129-matrix (aarch64, 12.9, 3.10, arm-kernel-build-node) (push) Has been cancelled
Release SGLang Kernels / build-cu129-matrix (x86_64, 12.9, 3.10, x64-kernel-build-node) (push) Has been cancelled
Release SGLang Kernels / release-cu129 (push) Has been cancelled
Release SGLang Kernels / build-cu130-matrix (aarch64, 13.0, 3.10, arm-kernel-build-node) (push) Has been cancelled
Release SGLang Kernels / build-cu130-matrix (x86_64, 13.0, 3.10, x64-kernel-build-node) (push) Has been cancelled
Release SGLang Kernels / release-cu130 (push) Has been cancelled
Release SGLang Kernels / build-rocm-matrix (3.10, 700) (push) Has been cancelled
Release SGLang Kernels / build-rocm-matrix (3.10, 720) (push) Has been cancelled
Release SGLang Kernels / release-rocm700 (push) Has been cancelled
Release SGLang Kernels / release-rocm720 (push) Has been cancelled
Release SGLang Kernels / build-musa43 (43, 3.10) (push) Has been cancelled
Release SGLang Kernels / release-musa43 (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 12:38:16 +08:00
commit 94057c3d3e
7152 changed files with 2120455 additions and 0 deletions
@@ -0,0 +1,171 @@
"""
Asynchronous dynamic batch tokenizer for SGLang.
This module provides an async tokenizer with dynamic batching capabilities
to reduce tokenization overhead when multiple requests arrive concurrently.
"""
import asyncio
import logging
from concurrent.futures import ThreadPoolExecutor
from functools import partial
from typing import Any, Dict, List, Optional
logger = logging.getLogger(__name__)
class AsyncDynamicbatchTokenizer:
"""Asynchronous tokenizer with dynamic batching for single string prompts.
Dynamically batches pending encode requests from a queue to reduce overhead.
Only handles single string prompts - regular batch processing of multiple
strings per request should be handled at a higher level.
A single-thread ThreadPoolExecutor is used so the event loop stays responsive.
Note: Uses lazy initialization for asyncio components because this class
is instantiated in TokenizerManager.__init__() before the event loop starts.
"""
def __init__(
self,
tokenizer,
max_batch_size: int = 32,
batch_wait_timeout_s: float = 0.002,
) -> None:
self.tokenizer = tokenizer
self.max_batch_size = max_batch_size
self.batch_wait_timeout_s = batch_wait_timeout_s
# Single queue for all encode requests - initialized lazily
self._queue: Optional[asyncio.Queue] = None
self._batcher_task: Optional[asyncio.Task] = None
# Single-thread executor for blocking tokenizer calls
self._executor = ThreadPoolExecutor(max_workers=1)
self._initialized = False
def _ensure_initialized(self):
"""Lazy initialization of event loop dependent components."""
if not self._initialized:
self._queue = asyncio.Queue()
self._batcher_task = asyncio.create_task(self._dynamic_batch_loop())
self._initialized = True
async def __call__(self, prompt: str, **kwargs) -> Any:
"""Encode a single prompt."""
return await self.encode(prompt, **kwargs)
async def encode(self, prompt: str, **kwargs) -> Any:
"""Encode a single prompt."""
self._ensure_initialized()
result_future: asyncio.Future = asyncio.get_running_loop().create_future()
await self._queue.put((prompt, kwargs, result_future))
return await result_future
async def _dynamic_batch_loop(self):
"""Dynamically batch incoming encode requests for efficiency."""
while True:
try:
# Get the first request
prompt, kwargs, result_future = await self._queue.get()
# Collect requests into dynamic batch
prompts = [prompt]
kwargs_list = [kwargs]
result_futures = [result_future]
# Check if there are more items immediately available in the queue
# If queue is empty, process single item immediately without timeout
if self._queue.empty():
# No other requests waiting, process immediately
pass
else:
# There might be more requests, wait for dynamic batching opportunity
start_time = asyncio.get_running_loop().time()
# Collect more requests up to max_batch_size or batch_wait_timeout_s
while len(prompts) < self.max_batch_size:
elapsed = asyncio.get_running_loop().time() - start_time
if elapsed >= self.batch_wait_timeout_s:
break
remaining_time = self.batch_wait_timeout_s - elapsed
try:
prompt, kwargs, result_future = await asyncio.wait_for(
self._queue.get(), remaining_time
)
prompts.append(prompt)
kwargs_list.append(kwargs)
result_futures.append(result_future)
except asyncio.TimeoutError:
break
# Log dynamic batch information
logger.debug(
f"AsyncDynamicbatchTokenizer: Processing dynamic batch of size {len(prompts)}"
)
# Process the dynamic batch
await self._process_dynamic_batch(prompts, kwargs_list, result_futures)
except Exception as e:
logger.error(f"Error in dynamic batch loop: {e}")
# Continue the loop to handle other requests
async def _process_dynamic_batch(
self,
prompts: List[str],
kwargs_list: List[Dict],
result_futures: List[asyncio.Future],
) -> None:
"""Process a dynamic batch of encode requests for single string prompts."""
# Check if all kwargs are identical for efficient batch processing
first_kw = kwargs_list[0]
can_batch = all(kw == first_kw for kw in kwargs_list[1:])
kwargs = first_kw if can_batch else None
try:
# If every request uses identical kwargs we can run a single
# batch tokenizer call for a big speed-up.
if can_batch and len(prompts) > 1:
encode_fn = partial(self.tokenizer, prompts, **kwargs)
results = await asyncio.get_running_loop().run_in_executor(
self._executor, encode_fn
)
for i, fut in enumerate(result_futures):
if not fut.done():
data = {k: v[i] for k, v in results.items()}
fut.set_result(data)
else:
# Process each request individually due to different kwargs
if len(prompts) > 1 and not can_batch:
logger.warning(
f"AsyncDynamicbatchTokenizer: Dynamic batching disabled for batch of {len(prompts)} "
f"requests due to differing kwargs. This reduces performance benefits. "
f"Consider using consistent tokenization parameters across requests."
)
encode_fn = lambda prompts=prompts, kwargs=kwargs_list: [
self.tokenizer(p, **kw) for p, kw in zip(prompts, kwargs_list)
]
results = await asyncio.get_running_loop().run_in_executor(
self._executor, encode_fn
)
for fut, res in zip(result_futures, results):
if not fut.done():
fut.set_result(res)
except Exception as e:
logger.error(f"Error in dynamic batch processing: {e}")
for fut in result_futures:
if not fut.done():
fut.set_exception(e)
def __del__(self):
"""Clean up background tasks."""
if hasattr(self, "_batcher_task") and self._batcher_task:
if not self._batcher_task.done():
self._batcher_task.cancel()
if hasattr(self, "_executor"):
self._executor.shutdown(wait=False)
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,90 @@
from __future__ import annotations
import asyncio
import copy
from typing import Callable, Generic, List, Optional, TypeVar
T = TypeVar("T")
class FanOutCommunicator(Generic[T]):
"""Fan-out request + collect response primitive over zmq.
One send is fanned out to `fan_out` recipients; the caller awaits until
all `fan_out` responses are collected. Supports two modes:
- "queueing": requests are serialized; concurrent callers wait in a FIFO queue.
- "watching": concurrent callers share a single in-flight request and all
receive the same result when it completes.
Only one request is in-flight at any time in either mode.
"""
def __init__(
self,
send: Callable[[T], None],
fan_out: int,
mode: str = "queueing",
):
self._send = send
self._fan_out = fan_out
self._mode = mode
self._result_event: Optional[asyncio.Event] = None
self._result_values: Optional[List[T]] = None
self._queueing_lock = asyncio.Lock()
assert mode in ["queueing", "watching"]
async def queueing_call(self, obj: T):
# asyncio.Lock is FIFO-fair: a new caller cannot acquire while earlier
# callers are still waiting, so requests are strictly serialized in
# arrival order. It also releases on exception/cancellation, so a
# failed caller never blocks the callers queued behind it.
async with self._queueing_lock:
if obj is not None:
self._send(obj)
self._result_event = asyncio.Event()
self._result_values = []
await self._result_event.wait()
result_values = self._result_values
self._result_event = self._result_values = None
return result_values
async def watching_call(self, obj):
if self._result_event is None:
assert self._result_values is None
self._result_values = []
self._result_event = asyncio.Event()
if obj is not None:
self._send(obj)
# Capture local refs before await -- after event fires, the first
# awakened coroutine clears shared state; later awaiters use local refs.
values = self._result_values
event = self._result_event
await event.wait()
result_values = copy.deepcopy(values)
if self._result_event is event:
self._result_event = self._result_values = None
return result_values
async def __call__(self, obj):
if self._mode == "queueing":
return await self.queueing_call(obj)
else:
return await self.watching_call(obj)
def handle_recv(self, recv_obj: T):
self._result_values.append(recv_obj)
if len(self._result_values) == self._fan_out:
self._result_event.set()
@staticmethod
def merge_results(results):
all_success = all([r.success for r in results])
all_message = [r.message for r in results]
all_message = " | ".join(all_message)
return all_success, all_message
@@ -0,0 +1,70 @@
"""
Copyright 2023-2025 SGLang Team
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
"""
"""
Configure the logging settings of a server.
Usage:
python3 -m sglang.srt.managers.configure_logging --url http://localhost:30000
"""
import argparse
import requests
if __name__ == "__main__":
parser = argparse.ArgumentParser()
parser.add_argument("--url", type=str, default="http://localhost:30000")
parser.add_argument(
"--log-level",
type=str,
default=None,
choices=["debug", "info", "warning", "error", "critical"],
help="Set runtime log level",
)
parser.add_argument("--log-requests", action="store_true")
parser.add_argument("--log-requests-level", type=int, default=3)
parser.add_argument(
"--dump-requests-folder", type=str, default="/tmp/sglang_request_dump"
)
parser.add_argument("--dump-requests-threshold", type=int, default=1000)
parser.add_argument(
"--dump-requests-exclude-meta-keys",
type=str,
default=None,
help=(
"Comma-separated meta_info keys to strip from each dumped request "
"(e.g. 'routed_experts,hidden_states'). Pass an empty string to "
"keep all keys. If not set, the server default is used."
),
)
args = parser.parse_args()
payload = {
"log_requests": args.log_requests,
"log_requests_level": args.log_requests_level, # Log full requests
"dump_requests_folder": args.dump_requests_folder,
"dump_requests_threshold": args.dump_requests_threshold,
"log_level": args.log_level,
}
if args.dump_requests_exclude_meta_keys is not None:
payload["dump_requests_exclude_meta_keys"] = [
k.strip()
for k in args.dump_requests_exclude_meta_keys.split(",")
if k.strip()
]
response = requests.post(args.url + "/configure_logging", json=payload)
assert response.status_code == 200
@@ -0,0 +1,715 @@
# Copyright 2023-2024 SGLang Team
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# ==============================================================================
"""A controller that dispatches requests to multiple data parallel workers."""
import faulthandler
import logging
import multiprocessing as mp
import signal
import threading
import time
from enum import Enum, auto
from typing import Callable, List, Optional
import psutil
import setproctitle
import zmq
from sglang.srt.environ import envs
from sglang.srt.layers.dp_attention import compute_dp_attention_world_info
from sglang.srt.managers.io_struct import (
ActiveRanksOutput,
BatchTokenizedEmbeddingReqInput,
BatchTokenizedGenerateReqInput,
BlockReqInput,
ProfileReq,
TokenizedEmbeddingReqInput,
TokenizedGenerateReqInput,
sock_recv,
sock_send,
unwrap_from_pickle,
wrap_as_pickle,
)
from sglang.srt.managers.load_snapshot import create_load_snapshot_reader
from sglang.srt.managers.schedule_batch import Req
from sglang.srt.managers.scheduler import run_scheduler_process
from sglang.srt.observability.cpu_monitor import start_cpu_monitor_thread
from sglang.srt.observability.req_time_stats import DPControllerReqTimeStats
from sglang.srt.observability.trace import process_tracing_init, trace_set_thread_info
from sglang.srt.server_args import (
DP_ATTENTION_HANDSHAKE_PORT_DELTA,
PortArgs,
ServerArgs,
)
from sglang.srt.utils import numa_utils
from sglang.srt.utils.common import (
configure_logger,
kill_itself_when_parent_died,
maybe_reindex_device_id,
)
from sglang.srt.utils.network import (
NetworkAddress,
bind_port,
get_zmq_socket,
get_zmq_socket_on_host,
)
from sglang.srt.utils.torch_memory_saver_adapter import TorchMemorySaverAdapter
from sglang.srt.utils.watchdog import Watchdog
from sglang.utils import TypeBasedDispatcher, get_exception_traceback
logger = logging.getLogger(__name__)
SCHEDULER_PIDS_ARG = "scheduler_pids"
class LoadBalanceMethod(Enum):
"""Load balance method."""
ROUND_ROBIN = auto()
FOLLOW_BOOTSTRAP_ROOM = auto()
TOTAL_REQUESTS = auto()
TOTAL_TOKENS = auto()
@classmethod
def from_str(cls, method: str):
method = method.upper()
try:
return cls[method]
except KeyError as exc:
raise ValueError(f"Invalid load balance method: {method}") from exc
class DPBudget:
def __init__(self, dp_size: int):
self.dp_size = dp_size
self.total_requests = [0] * dp_size
self.total_tokens = [0] * dp_size
self.last_timestamp = [0.0] * dp_size
def update_budget(self, loads):
"""Update budget from shm snapshots, skipping stale reads."""
for load in loads:
if load.timestamp == self.last_timestamp[load.dp_rank]:
continue
self.last_timestamp[load.dp_rank] = load.timestamp
self.total_requests[load.dp_rank] = (
load.num_running_reqs + load.num_waiting_reqs
)
self.total_tokens[load.dp_rank] = load.num_total_tokens
def dispatch(self, method: LoadBalanceMethod, estimated_tokens: int = 0):
if method == LoadBalanceMethod.TOTAL_REQUESTS:
target_rank = self.total_requests.index(min(self.total_requests))
elif method == LoadBalanceMethod.TOTAL_TOKENS:
# Use total_requests as a tie-breaker when total_tokens are equal
target_rank = min(
range(self.dp_size),
key=lambda i: (self.total_tokens[i], self.total_requests[i]),
)
else:
return None
# Increment the load of that worker by one as a heuristic
self.total_requests[target_rank] += 1
self.total_tokens[target_rank] += estimated_tokens
return target_rank
class DataParallelController:
"""A controller that dispatches requests to multiple data parallel workers."""
def __init__(
self,
server_args: ServerArgs,
port_args: PortArgs,
run_scheduler_process_func: Callable,
) -> None:
# Parse args
self.server_args = server_args
self.port_args = port_args
self.load_balance_method = LoadBalanceMethod.from_str(
server_args.load_balance_method
)
self.run_scheduler_process_func = run_scheduler_process_func
# Init inter-process communication
self.context = zmq.Context(1 + server_args.dp_size)
if server_args.node_rank == 0:
self.recv_from_tokenizer = get_zmq_socket(
self.context, zmq.PULL, port_args.scheduler_input_ipc_name, False
)
# Dispatch method
self.round_robin_counter = 0
dispatch_lookup = {
LoadBalanceMethod.ROUND_ROBIN: self.round_robin_scheduler,
LoadBalanceMethod.FOLLOW_BOOTSTRAP_ROOM: self.follow_bootstrap_room_scheduler,
LoadBalanceMethod.TOTAL_REQUESTS: self.total_requests_scheduler,
LoadBalanceMethod.TOTAL_TOKENS: self.total_tokens_scheduler,
}
self.dispatching = dispatch_lookup[self.load_balance_method]
self.refresh_load_budget_on_dispatch = self.load_balance_method in (
LoadBalanceMethod.TOTAL_REQUESTS,
LoadBalanceMethod.TOTAL_TOKENS,
)
# Load balance budget
self.dp_budget = DPBudget(server_args.dp_size)
self.load_snapshot_reader = create_load_snapshot_reader(
server_args,
port_args,
caller="DataParallelController",
)
self._last_refresh_time = 0.0
# To protect changing env vars to set CUDA_VISIBLE_DEVICES.
self.env_lock = threading.Lock()
# Launch data parallel workers
self.scheduler_procs = []
self.workers: List[zmq.Socket] = [None] * server_args.dp_size
self.status: List[bool] = [True] * server_args.dp_size
if server_args.enable_dp_attention:
self.launch_dp_attention_schedulers(server_args, port_args)
# When local control broadcast is enabled, send control messages to
# every DP group leader (attn_tp_rank=0) so each leader broadcasts
# within its own attn_tp_group instead of the full tp_group.
# Otherwise fall back to the original behaviour: send to only the
# first leader, which then broadcasts over the full tp_group.
local_ctrl = server_args.enable_dp_attention_local_control_broadcast
self.control_message_step = 1 if local_ctrl else server_args.tp_size
else:
self.launch_dp_schedulers(server_args, port_args)
self.control_message_step = 1
self.init_dispatcher()
self.soft_watchdog = Watchdog.create(
debug_name="DataParallelController",
watchdog_timeout=server_args.soft_watchdog_timeout,
soft=True,
test_stuck_time=envs.SGLANG_TEST_STUCK_DP_CONTROLLER.get(),
)
if server_args.enable_metrics:
start_cpu_monitor_thread("data_parallel_controller")
def send_to_all_workers(self, obj):
for i, worker in enumerate(self.workers):
if self.status[i]:
sock_send(worker, obj)
def send_control_message(self, obj):
# Send control messages to first worker of tp group
for worker in self.workers[:: self.control_message_step]:
sock_send(worker, obj)
def update_active_ranks(self, ranks: ActiveRanksOutput):
self.status = ranks.status
def refresh_load_budget(self):
# Throttle to at most once per 20ms. When a burst of requests
# arrives, dispatching_with_trace() calls this before every
# dispatch. Each call reads the latest scheduler snapshot and
# overwrites the speculative +1 increments that DPBudget.dispatch()
# added for previously dispatched requests in this burst. Without
# throttling, the budget resets to the (stale) scheduler-reported
# value on every request, causing the entire burst to land on a
# single DP rank. The 20ms interval lets the burst complete
# using speculative counters, then refreshes from the real
# scheduler load for the next batch.
now = time.perf_counter()
if now - self._last_refresh_time < 0.02:
return
self._last_refresh_time = now
self.dp_budget.update_budget(self.load_snapshot_reader.read_all())
def dispatching_with_trace(self, req: Req, refresh_load_budget: bool = True):
if refresh_load_budget and self.refresh_load_budget_on_dispatch:
self.refresh_load_budget()
time_stats = DPControllerReqTimeStats.new_from_obj(
unwrap_from_pickle(req.time_stats)
)
time_stats.set_dp_dispatch_time()
req.time_stats = wrap_as_pickle(time_stats)
self.dispatching(req)
req.time_stats = time_stats
req.time_stats.set_dp_dispatch_finish_time()
def dispatch_batch_generate(self, batch_req: BatchTokenizedGenerateReqInput):
if self.refresh_load_budget_on_dispatch:
self.refresh_load_budget()
for req in batch_req:
self.dispatching_with_trace(req, refresh_load_budget=False)
def dispatch_batch_embedding(self, batch_req: BatchTokenizedEmbeddingReqInput):
if self.refresh_load_budget_on_dispatch:
self.refresh_load_budget()
for req in batch_req:
self.dispatching_with_trace(req, refresh_load_budget=False)
def init_dispatcher(self):
self._request_dispatcher = TypeBasedDispatcher(
[
(TokenizedGenerateReqInput, self.dispatching_with_trace),
(TokenizedEmbeddingReqInput, self.dispatching_with_trace),
(BatchTokenizedGenerateReqInput, self.dispatch_batch_generate),
(BatchTokenizedEmbeddingReqInput, self.dispatch_batch_embedding),
(BlockReqInput, self.send_to_all_workers),
(ProfileReq, self.send_to_all_workers),
(ActiveRanksOutput, self.update_active_ranks),
]
)
self._request_dispatcher.add_fallback_fn(self.send_control_message)
def launch_dp_schedulers(self, server_args, port_args):
base_gpu_id = 0
threads = []
sockets = []
ready_events = []
for dp_rank in range(server_args.dp_size):
tmp_port_args = PortArgs.init_new(server_args)
tmp_port_args.tokenizer_ipc_name = port_args.tokenizer_ipc_name
tmp_port_args.detokenizer_ipc_name = port_args.detokenizer_ipc_name
tmp_port_args.instance_id = port_args.instance_id
# This port is checked free in PortArgs.init_new.
# We hold it first so that the next dp worker gets a different port
sockets.append(bind_port(tmp_port_args.nccl_port))
ready_event = threading.Event()
ready_events.append(ready_event)
# Create a thread for each worker
thread = threading.Thread(
target=self.launch_tensor_parallel_group_thread,
args=(server_args, tmp_port_args, base_gpu_id, dp_rank, ready_event),
)
threads.append(thread)
base_gpu_id += (
server_args.tp_size * server_args.pp_size * server_args.gpu_id_step
)
if server_args.node_rank == 0:
self.workers[dp_rank] = get_zmq_socket(
self.context,
zmq.PUSH,
tmp_port_args.scheduler_input_ipc_name,
True,
)
# Free all sockets before starting the threads to launch TP workers
for sock in sockets:
sock.close()
# Start all threads
for thread in threads:
thread.start()
for event in ready_events:
event.wait()
def launch_tensor_parallel_group_thread(
self,
server_args: ServerArgs,
port_args: PortArgs,
base_gpu_id: int,
dp_rank: int,
ready_event: threading.Event,
):
self.launch_tensor_parallel_group(server_args, port_args, base_gpu_id, dp_rank)
ready_event.set()
# This thread cannot be closed because otherwise the `kill_itself_when_parent_died`
# function in scheduler.py will kill the scheduler.
while True:
time.sleep(30 * 24 * 3600)
def _broadcast_worker_ports(
self, server_args: ServerArgs, worker_ports: Optional[List[int]] = None
) -> List[int]:
"""Broadcast worker ports from node 0 to all other nodes.
Node 0 acts as the server, waiting for all other nodes to connect and
sending them the pre-allocated worker ports. Other nodes act as clients,
connecting to node 0 to receive their copy of the worker ports.
Args:
server_args: Server arguments containing node configuration.
worker_ports: Pre-allocated worker ports to broadcast.
Returns:
List of worker ports (same on all nodes after broadcast).
"""
# Determine the endpoint for inter-node communication
if server_args.dist_init_addr is None:
na = NetworkAddress(
server_args.host or "127.0.0.1",
server_args.port + DP_ATTENTION_HANDSHAKE_PORT_DELTA,
)
else:
na = NetworkAddress.parse(server_args.dist_init_addr)
na = NetworkAddress(na.host, na.port + DP_ATTENTION_HANDSHAKE_PORT_DELTA)
endpoint = na.to_tcp()
if server_args.node_rank == 0:
# Node 0: Broadcast worker ports to all other nodes
return self._broadcast_ports_as_server(
endpoint, server_args.nnodes - 1, worker_ports
)
else:
# Other nodes: Receive worker ports from node 0
return self._receive_ports_as_client(endpoint, server_args.node_rank)
def _broadcast_ports_as_server(
self, endpoint: str, expected_clients: int, worker_ports: List[int]
) -> List[int]:
"""Broadcast worker ports to all client nodes."""
logger.debug(f"Broadcasting worker ports to {expected_clients} client nodes")
logger.debug(f"Worker ports: {worker_ports}")
rep_socket = get_zmq_socket(self.context, zmq.REP, endpoint, True)
try:
connected_clients = 0
while connected_clients < expected_clients:
# Wait for client handshake
client_rank = sock_recv(rep_socket)
logger.debug(f"Received handshake from node {client_rank}")
# Send worker ports to client
sock_send(rep_socket, wrap_as_pickle(worker_ports))
connected_clients += 1
logger.debug(
f"Sent worker ports to {connected_clients}/{expected_clients} nodes"
)
logger.debug("Worker port broadcast completed")
return worker_ports
finally:
if self.server_args.elastic_ep_backend is None:
rep_socket.close()
else:
threading.Thread(
target=self._reply_ports_as_server,
args=(rep_socket, worker_ports),
daemon=True,
).start()
def _reply_ports_as_server(self, rep_socket: zmq.Socket, worker_ports: List[int]):
"""
Runs as a background thread to broadcast worker ports for recovered EP ranks
"""
while True:
# Wait for client handshake
try:
client_rank = sock_recv(rep_socket)
except Exception:
logger.exception(
"Failed to recv/decode handshake in reply thread; continue"
)
continue
logger.debug(f"Received handshake from node {client_rank}")
# Send worker ports to client
sock_send(rep_socket, wrap_as_pickle(worker_ports))
logger.debug(f"Sent worker ports to node {client_rank}")
def _receive_ports_as_client(self, endpoint: str, node_rank: int) -> List[int]:
"""Receive worker ports from the server node."""
logger.debug(f"Connecting to node 0 to receive worker ports")
req_socket = get_zmq_socket(self.context, zmq.REQ, endpoint, False)
req_socket.setsockopt(zmq.RCVTIMEO, 600 * 1000) # 10 minute timeout
req_socket.setsockopt(zmq.SNDTIMEO, 600 * 1000)
try:
# Send handshake with our node rank
sock_send(req_socket, wrap_as_pickle(str(node_rank)))
# Receive worker ports
worker_ports = sock_recv(req_socket)
logger.debug(f"Received {len(worker_ports)} worker ports from node 0")
return worker_ports
except zmq.Again:
logger.error("Timeout waiting for worker ports from node 0")
raise RuntimeError(
"Failed to receive worker ports from node 0 within timeout"
)
finally:
req_socket.close()
def launch_dp_attention_schedulers(
self, server_args: ServerArgs, port_args: PortArgs
):
if server_args.dist_init_addr is None:
bind_host = "127.0.0.1"
else:
bind_host = NetworkAddress.parse(server_args.dist_init_addr).host
# Pre-allocate worker ports on node 0 to avoid conflicts
worker_ports = []
if server_args.node_rank == 0:
for dp_rank in range(server_args.dp_size):
worker_port, worker_socket = get_zmq_socket_on_host(
self.context, zmq.PUSH, host=bind_host
)
worker_ports.append(worker_port)
self.workers[dp_rank] = worker_socket
logger.debug(
"Assigned port %s to worker %s on host %s",
worker_port,
dp_rank,
bind_host,
)
broadcasted_ports = self._broadcast_worker_ports(
server_args, worker_ports if worker_ports else None
)
self.launch_tensor_parallel_group(
server_args, port_args, 0, None, broadcasted_ports
)
def launch_tensor_parallel_group(
self,
server_args: ServerArgs,
port_args: PortArgs,
base_gpu_id: int,
dp_rank: Optional[int],
worker_ports: Optional[List[int]] = None,
):
if not server_args.enable_dp_attention:
logger.info(f"Launch DP{dp_rank} starting at GPU #{base_gpu_id}.")
memory_saver_adapter = TorchMemorySaverAdapter.create(
enable=server_args.enable_memory_saver
)
scheduler_pipe_readers = []
pp_size_per_node = max(server_args.pp_size // server_args.nnodes, 1)
nnodes_per_pp_rank = max(server_args.nnodes // server_args.pp_size, 1)
pp_rank_range = range(
pp_size_per_node * (server_args.node_rank // nnodes_per_pp_rank),
pp_size_per_node * (server_args.node_rank // nnodes_per_pp_rank + 1),
)
nnodes_per_tp_group = nnodes_per_pp_rank
tp_size_per_node = server_args.tp_size // nnodes_per_tp_group
tp_rank_range = range(
tp_size_per_node * (server_args.node_rank % nnodes_per_tp_group),
tp_size_per_node * (server_args.node_rank % nnodes_per_tp_group + 1),
)
attn_cp_rank = 0
moe_dp_rank = 0
for pp_rank in pp_rank_range:
for tp_rank in tp_rank_range:
rank_port_args = port_args
if server_args.enable_dp_attention:
# dp attention has different sharding logic
_, _, dp_rank, _ = compute_dp_attention_world_info(
server_args.enable_dp_attention,
tp_rank,
server_args.tp_size,
server_args.dp_size,
server_args.attn_cp_size,
)
# compute zmq ports for this dp rank
rank_port_args = PortArgs.init_new(
server_args, dp_rank, worker_ports
)
# Data parallelism reuses the tensor parallelism group,
# so all dp ranks should use the same nccl port.
rank_port_args.nccl_port = port_args.nccl_port
rank_port_args.instance_id = port_args.instance_id
reader, writer = mp.Pipe(duplex=False)
gpu_id = (
server_args.base_gpu_id
+ base_gpu_id
+ ((pp_rank % pp_size_per_node) * tp_size_per_node)
+ (tp_rank % tp_size_per_node) * server_args.gpu_id_step
)
attn_dp_size = (
server_args.dp_size if server_args.enable_dp_attention else 1
)
# Parallelism hierarchy (outermost to innermost):
# - Attention: Global(TP) -> DP -> ATTN_CP -> ATTN_TP (innermost)
# - MoE: Global(TP) -> MOE_DP -> EP -> MOE_TP (innermost)
attn_tp_size = (
server_args.tp_size // attn_dp_size // server_args.attn_cp_size
)
attn_cp_rank = (tp_rank // attn_tp_size) % server_args.attn_cp_size
moe_dp_rank = tp_rank // (
server_args.tp_size // server_args.moe_dp_size
)
moe_ep_rank = (
tp_rank
% (server_args.tp_size // server_args.moe_dp_size)
// (
server_args.tp_size
// server_args.moe_dp_size
// server_args.ep_size
)
)
with self.env_lock, maybe_reindex_device_id(gpu_id) as gpu_id:
proc = mp.Process(
target=self.run_scheduler_process_func,
args=(
server_args,
rank_port_args,
gpu_id,
tp_rank,
attn_cp_rank,
moe_dp_rank,
moe_ep_rank,
pp_rank,
dp_rank,
writer,
),
)
with (
memory_saver_adapter.configure_subprocess(),
numa_utils.configure_subprocess(server_args, gpu_id),
):
proc.start()
self.scheduler_procs.append(proc)
scheduler_pipe_readers.append(reader)
# Wait for model to finish loading
scheduler_info = []
for i in range(len(scheduler_pipe_readers)):
scheduler_info.append(scheduler_pipe_readers[i].recv())
self.max_total_num_tokens = scheduler_info[0]["max_total_num_tokens"]
self.max_req_input_len = scheduler_info[0]["max_req_input_len"]
def maybe_external_dp_rank_routing(self, req: Req):
if req.routed_dp_rank is not None:
logger.debug(f"Direct routing to DP rank {req.routed_dp_rank}")
sock_send(self.workers[req.routed_dp_rank], req)
return True
return False
def round_robin_scheduler(self, req: Req):
if self.maybe_external_dp_rank_routing(req):
return
while True:
if self.status[self.round_robin_counter]:
logger.debug(f"Choose worker {self.round_robin_counter}")
sock_send(self.workers[self.round_robin_counter], req)
self.round_robin_counter = (self.round_robin_counter + 1) % len(
self.workers
)
break
self.round_robin_counter = (self.round_robin_counter + 1) % len(
self.workers
)
def follow_bootstrap_room_scheduler(self, req: Req):
if self.maybe_external_dp_rank_routing(req):
return
assert req.bootstrap_room is not None, (
"req.bootstrap_room should not be None. Do not send requests directly to "
"prefill or decode instances; send to the router instead."
)
target_rank = req.bootstrap_room % len(self.workers)
sock_send(self.workers[target_rank], req)
def total_requests_scheduler(self, req: Req):
if self.maybe_external_dp_rank_routing(req):
return
target_worker = self.dp_budget.dispatch(LoadBalanceMethod.TOTAL_REQUESTS)
sock_send(self.workers[target_worker], req)
def total_tokens_scheduler(self, req: Req):
if self.maybe_external_dp_rank_routing(req):
return
estimated_tokens = len(req.input_ids)
target_worker = self.dp_budget.dispatch(
LoadBalanceMethod.TOTAL_TOKENS, estimated_tokens=estimated_tokens
)
sock_send(self.workers[target_worker], req)
def event_loop(self):
while True:
while True:
self.soft_watchdog.feed()
try:
recv_req = sock_recv(self.recv_from_tokenizer, flags=zmq.NOBLOCK)
except zmq.ZMQError:
break
self._request_dispatcher(recv_req)
def run_data_parallel_controller_process(
server_args: ServerArgs,
port_args: PortArgs,
pipe_writer,
run_scheduler_process_func: Callable = run_scheduler_process,
):
setproctitle.setproctitle("sglang::data_parallel_controller")
faulthandler.enable()
kill_itself_when_parent_died()
parent_process = psutil.Process().parent()
configure_logger(server_args)
if server_args.enable_trace:
process_tracing_init(
server_args.otlp_traces_endpoint,
"sglang",
trace_modules=server_args.trace_modules,
)
thread_label = "DP Controller"
if server_args.disaggregation_mode == "prefill":
thread_label = "Prefill DP Controller"
elif server_args.disaggregation_mode == "decode":
thread_label = "Decode DP Controller"
trace_set_thread_info(thread_label)
try:
controller = DataParallelController(
server_args, port_args, run_scheduler_process_func
)
scheduler_pids = [
proc.pid for proc in controller.scheduler_procs if proc is not None
]
pipe_writer.send(
{
"status": "ready",
"max_total_num_tokens": controller.max_total_num_tokens,
"max_req_input_len": controller.max_req_input_len,
SCHEDULER_PIDS_ARG: scheduler_pids,
}
)
if server_args.node_rank == 0:
controller.event_loop()
for proc in controller.scheduler_procs:
proc.join()
logger.error(
f"Scheduler or DataParallelController {proc.pid} terminated with {proc.exitcode}"
)
except Exception:
traceback = get_exception_traceback()
logger.error(f"DataParallelController hit an exception: {traceback}")
parent_process.send_signal(signal.SIGQUIT)
@@ -0,0 +1,508 @@
# Copyright 2023-2024 SGLang Team
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# ==============================================================================
"""DetokenizerManager is a process that detokenizes the token ids."""
import dataclasses
import logging
import os
import signal
from collections import OrderedDict, defaultdict
from typing import Dict, List, Optional, Tuple, Union
import psutil
import pybase64
import setproctitle
import torch
import zmq
from sglang.srt.constants import HEALTH_CHECK_RID_PREFIX
from sglang.srt.environ import envs
from sglang.srt.managers.io_struct import (
BatchEmbeddingOutput,
BatchStrOutput,
BatchTokenIDOutput,
ConfigureLoggingReq,
FreezeGCReq,
sock_recv,
sock_send,
)
from sglang.srt.managers.multi_tokenizer_mixin import MultiHttpWorkerDetokenizerMixin
from sglang.srt.observability.cpu_monitor import start_cpu_monitor_thread
from sglang.srt.server_args import PortArgs, ServerArgs
from sglang.srt.utils import configure_logger, freeze_gc, kill_itself_when_parent_died
from sglang.srt.utils.hf_transformers_utils import get_tokenizer
from sglang.srt.utils.network import get_zmq_socket
from sglang.srt.utils.patch_tokenizer import decode_without_hf_kwargs
from sglang.srt.utils.watchdog import Watchdog
from sglang.utils import (
TypeBasedDispatcher,
find_printable_text,
get_exception_traceback,
)
logger = logging.getLogger(__name__)
# Maximum number of request states that detokenizer can hold. When exceeded,
# oldest request states will be evicted. Default: 65536 (1<<16).
# For more details, see: https://github.com/sgl-project/sglang/issues/2812
# Use power of 2 values for better memory allocation.
DETOKENIZER_MAX_STATES = int(os.environ.get("SGLANG_DETOKENIZER_MAX_STATES", 1 << 16))
@dataclasses.dataclass
class DecodeStatus:
"""Store the status of incremental decoding."""
decoded_text: str
decode_ids: List[int]
surr_offset: int
read_offset: int
# Offset that's sent to tokenizer for incremental update.
sent_offset: int = 0
decoded_text_len: int = dataclasses.field(init=False)
decoded_text_chunks: List[str] = dataclasses.field(default_factory=list)
def __post_init__(self):
self.decoded_text_len = len(self.decoded_text)
def append_decoded_text(self, text: str):
if text:
self.decoded_text_chunks.append(text)
self.decoded_text_len += len(text)
def get_decoded_text(self) -> str:
if self.decoded_text_chunks:
self.decoded_text += "".join(self.decoded_text_chunks)
self.decoded_text_chunks.clear()
return self.decoded_text
class DetokenizerManager(MultiHttpWorkerDetokenizerMixin):
"""DetokenizerManager is a process that detokenizes the token ids."""
def __init__(
self,
server_args: ServerArgs,
port_args: PortArgs,
):
# Init inter-process communication
self.init_ipc_channels(port_args, server_args)
# Init tokenizer
self.init_tokenizer(server_args)
# Init running status
self.init_running_status(server_args)
# Init dispatcher
self.init_request_dispatcher()
def init_ipc_channels(self, port_args: PortArgs, server_args: ServerArgs):
context = zmq.Context(2)
self.recv_from_scheduler = get_zmq_socket(
context, zmq.PULL, port_args.detokenizer_ipc_name, True
)
# In multi-tokenizer mode, results are pushed back to each TokenizerWorker
# directly via SocketMapping inside multi_http_worker_event_loop, so the
# single send_to_tokenizer socket is unused.
if server_args.tokenizer_worker_num == 1:
self.send_to_tokenizer = get_zmq_socket(
context, zmq.PUSH, port_args.tokenizer_ipc_name, False
)
def init_tokenizer(self, server_args: ServerArgs):
if server_args.skip_tokenizer_init:
self.tokenizer = None
else:
self.tokenizer = get_tokenizer(
server_args.tokenizer_path,
tokenizer_mode=server_args.tokenizer_mode,
trust_remote_code=server_args.trust_remote_code,
revision=server_args.revision,
tokenizer_backend=server_args.tokenizer_backend,
)
def init_running_status(self, server_args: ServerArgs):
self.decode_status = LimitedCapacityDict(capacity=DETOKENIZER_MAX_STATES)
self.disable_tokenizer_batch_decode = server_args.disable_tokenizer_batch_decode
self.is_tool_call_parser_gpt_oss = server_args.tool_call_parser == "gpt-oss"
self.soft_watchdog = Watchdog.create(
debug_name="DetokenizerManager",
watchdog_timeout=server_args.soft_watchdog_timeout,
soft=True,
test_stuck_time=envs.SGLANG_TEST_STUCK_DETOKENIZER.get(),
)
if server_args.enable_metrics:
start_cpu_monitor_thread("detokenizer")
def init_request_dispatcher(self):
self._request_dispatcher = TypeBasedDispatcher(
[
(BatchEmbeddingOutput, self.handle_batch_embedding_out),
(BatchTokenIDOutput, self.handle_batch_token_id_out),
(FreezeGCReq, self.handle_freeze_gc_req),
(ConfigureLoggingReq, self.handle_configure_logging_req),
]
)
def event_loop(self):
"""The event loop that handles requests"""
while True:
with self.soft_watchdog.disable():
recv_obj = sock_recv(self.recv_from_scheduler)
output = self._request_dispatcher(recv_obj)
if output is not None:
sock_send(self.send_to_tokenizer, output)
self.soft_watchdog.feed()
def trim_matched_stop(
self, output: Union[str, List[int]], finished_reason: Dict, no_stop_trim: bool
):
if not finished_reason:
return output
matched = finished_reason.get("matched", None)
if not matched:
return output
# TODO(lmzheng): handle the case where multiple stop strs are hit
# Trim stop str.
if isinstance(matched, str) and isinstance(output, str):
pos = output.find(matched)
if pos == -1:
return output
end = pos + len(matched)
return output[:end] if no_stop_trim else output[:pos]
# Trim stop token.
if isinstance(matched, int) and isinstance(output, list):
if no_stop_trim:
return output
# 200012 <|call|> is the tool call token and one of eos tokens for gpt-oss model
if output[-1] == 200012 and self.is_tool_call_parser_gpt_oss:
return output
assert len(output) > 0
# NOTE: We can always assume the last token is the matched stop token
return output[:-1]
return output
def handle_batch_embedding_out(self, recv_obj: BatchEmbeddingOutput):
# If it is embedding model, no detokenization is needed.
return recv_obj
def _grouped_batch_decode(
self,
ids_list: List[List[int]],
skip_list: List[bool],
space_list: List[bool],
) -> List[str]:
"""Batch decode with grouping by (skip_special_tokens, spaces_between_special_tokens)."""
n = len(ids_list)
if n == 0:
return []
# Empty token spans decode to "" but tokenizer.batch_decode (and the
# slow per-row decode_without_hf_kwargs path) still pays per-row
# overhead; under high-concurrency streaming this adds up. Filter
# empties out, decode the rest, then scatter back.
keep_idx: Optional[List[int]] = None
if not all(ids_list):
keep_idx = [i for i, ids in enumerate(ids_list) if ids]
if not keep_idx:
return [""] * n
ids_list = [ids_list[i] for i in keep_idx]
skip_list = [skip_list[i] for i in keep_idx]
space_list = [space_list[i] for i in keep_idx]
if not getattr(self.tokenizer, "is_fast", False):
decoded = [
decode_without_hf_kwargs(self.tokenizer, ids, skip)
for ids, skip in zip(ids_list, skip_list)
]
else:
# fast path: all rows share the same (skip, space) flags.
first_skip, first_space = skip_list[0], space_list[0]
if all(
s == first_skip and sp == first_space
for s, sp in zip(skip_list, space_list)
):
decoded = self.tokenizer.batch_decode(
ids_list,
skip_special_tokens=first_skip,
spaces_between_special_tokens=first_space,
)
else:
# Group indices by (skip, space) tuple and decode each group.
groups: Dict[Tuple[bool, bool], List[int]] = defaultdict(list)
for idx, (skip, space) in enumerate(zip(skip_list, space_list)):
groups[(skip, space)].append(idx)
decoded = [""] * len(ids_list)
for (skip, space), indices in groups.items():
group_decoded = self.tokenizer.batch_decode(
[ids_list[idx] for idx in indices],
skip_special_tokens=skip,
spaces_between_special_tokens=space,
)
for idx, text in zip(indices, group_decoded):
decoded[idx] = text
if keep_idx is None:
return decoded
results = [""] * n
for i, text in zip(keep_idx, decoded):
results[i] = text
return results
def _decode_batch_token_id_output(self, recv_obj: BatchTokenIDOutput):
bs = len(recv_obj.rids)
# Initialize decode status
read_ids, surr_ids = [], []
for i in range(bs):
rid = recv_obj.rids[i]
if rid not in self.decode_status:
s = DecodeStatus(
decoded_text=recv_obj.decoded_texts[i],
decode_ids=list(recv_obj.decode_ids[i]),
surr_offset=0,
read_offset=recv_obj.read_offsets[i],
)
self.decode_status[rid] = s
else:
s = self.decode_status[rid]
s.decode_ids.extend(recv_obj.decode_ids[i])
read_ids.append(
self.trim_matched_stop(
s.decode_ids[s.surr_offset :],
recv_obj.finished_reasons[i],
recv_obj.no_stop_trim[i],
)
)
surr_ids.append(s.decode_ids[s.surr_offset : s.read_offset])
# Decode token ids to strings
if not self.disable_tokenizer_batch_decode:
surr_texts = self._grouped_batch_decode(
surr_ids,
recv_obj.skip_special_tokens,
recv_obj.spaces_between_special_tokens,
)
read_texts = self._grouped_batch_decode(
read_ids,
recv_obj.skip_special_tokens,
recv_obj.spaces_between_special_tokens,
)
else:
# Do not use batch decode to prevent some detokenization edge cases (e.g., gpt-oss).
surr_texts = [
self.tokenizer.decode(
surr, skip_special_tokens=skip, spaces_between_special_tokens=space
)
for surr, skip, space in zip(
surr_ids,
recv_obj.skip_special_tokens,
recv_obj.spaces_between_special_tokens,
)
]
read_texts = [
self.tokenizer.decode(
read, skip_special_tokens=skip, spaces_between_special_tokens=space
)
for read, skip, space in zip(
read_ids,
recv_obj.skip_special_tokens,
recv_obj.spaces_between_special_tokens,
)
]
# Incremental decoding
output_strs = []
for i in range(bs):
rid = recv_obj.rids[i]
try:
s = self.decode_status[rid]
except KeyError:
raise RuntimeError(
f"Decode status not found for request {rid}. "
"It may be due to the request being evicted from the decode status due to memory pressure. "
"Please increase the maximum number of requests by setting "
"the SGLANG_DETOKENIZER_MAX_STATES environment variable to a bigger value than the default value. "
f"The current value is {DETOKENIZER_MAX_STATES}. "
"For more details, see: https://github.com/sgl-project/sglang/issues/2812"
)
new_text = read_texts[i][len(surr_texts[i]) :]
if recv_obj.finished_reasons[i] is None:
# Streaming. Invariant: sent_offset >= decoded_text_len. The
# gap (`pending`) is "printable but uncommitted" text emitted
# in a prior "" recovery step; we skip it from this step's
# emission so we don't double-send.
pending = s.sent_offset - s.decoded_text_len
if new_text and not new_text.endswith(""):
# Clean text: commit to decoded_text and advance offsets.
s.append_decoded_text(new_text)
s.surr_offset = s.read_offset
s.read_offset = len(s.decode_ids)
s.sent_offset = s.decoded_text_len
output_strs.append(new_text[pending:] if pending else new_text)
else:
# Incomplete UTF-8: emit the printable prefix only; do not
# commit (token offsets stay so the next iteration retries
# with more tokens).
printable = find_printable_text(new_text)
s.sent_offset = s.decoded_text_len + len(printable)
output_strs.append(printable[pending:] if pending else printable)
continue
if rid in self.decode_status:
del self.decode_status[rid]
# Finished: materialize once, trim the matched stop, emit the tail.
output_str = self.trim_matched_stop(
s.get_decoded_text() + new_text,
recv_obj.finished_reasons[i],
recv_obj.no_stop_trim[i],
)
incremental_output = output_str[s.sent_offset :]
s.sent_offset = len(output_str)
output_strs.append(incremental_output)
return output_strs
@staticmethod
def _b64_encode_per_request(
data_list: Optional[List[Optional[torch.Tensor]]],
) -> Optional[List[Optional[str]]]:
"""Encode a per-request list of tensors as base64 strings, off the
tokenizer hot path. Returns None when the input is None; per-item None
stays None.
"""
if data_list is None:
return None
return [
(
pybase64.b64encode(item.numpy().tobytes()).decode("utf-8")
if item is not None
else None
)
for item in data_list
]
def handle_batch_token_id_out(self, recv_obj: BatchTokenIDOutput):
# If handling idle batch, set output_strs to [].
output_strs = (
self._decode_batch_token_id_output(recv_obj)
if len(recv_obj.rids) > 0
else []
)
routed_experts = self._b64_encode_per_request(recv_obj.routed_experts)
indexer_topk = self._b64_encode_per_request(recv_obj.indexer_topk)
return BatchStrOutput(
rids=recv_obj.rids,
http_worker_ipcs=recv_obj.http_worker_ipcs,
finished_reasons=recv_obj.finished_reasons,
output_strs=output_strs,
output_ids=recv_obj.output_ids,
prompt_tokens=recv_obj.prompt_tokens,
reasoning_tokens=recv_obj.reasoning_tokens,
completion_tokens=recv_obj.completion_tokens,
cached_tokens=recv_obj.cached_tokens,
cached_tokens_details=recv_obj.cached_tokens_details,
image_tokens=recv_obj.image_tokens,
audio_tokens=recv_obj.audio_tokens,
video_tokens=recv_obj.video_tokens,
spec_verify_ct=recv_obj.spec_verify_ct,
spec_num_correct_drafts=recv_obj.spec_num_correct_drafts,
spec_num_block_accept_tokens=recv_obj.spec_num_block_accept_tokens,
spec_num_cap_tokens=recv_obj.spec_num_cap_tokens,
spec_correct_drafts_histogram=recv_obj.spec_correct_drafts_histogram,
spec_cap_lens_histogram=recv_obj.spec_cap_lens_histogram,
input_token_logprobs_val=recv_obj.input_token_logprobs_val,
input_token_logprobs_idx=recv_obj.input_token_logprobs_idx,
output_token_logprobs_val=recv_obj.output_token_logprobs_val,
output_token_logprobs_idx=recv_obj.output_token_logprobs_idx,
input_top_logprobs_val=recv_obj.input_top_logprobs_val,
input_top_logprobs_idx=recv_obj.input_top_logprobs_idx,
output_top_logprobs_val=recv_obj.output_top_logprobs_val,
output_top_logprobs_idx=recv_obj.output_top_logprobs_idx,
input_token_ids_logprobs_val=recv_obj.input_token_ids_logprobs_val,
input_token_ids_logprobs_idx=recv_obj.input_token_ids_logprobs_idx,
output_token_ids_logprobs_val=recv_obj.output_token_ids_logprobs_val,
output_token_ids_logprobs_idx=recv_obj.output_token_ids_logprobs_idx,
output_token_entropy_val=recv_obj.output_token_entropy_val,
output_hidden_states=recv_obj.output_hidden_states,
routed_experts=routed_experts,
indexer_topk=indexer_topk,
customized_info=recv_obj.customized_info,
placeholder_tokens_idx=None,
placeholder_tokens_val=None,
retraction_counts=recv_obj.retraction_counts,
token_steps=recv_obj.token_steps,
dp_ranks=recv_obj.dp_ranks,
time_stats=recv_obj.time_stats,
)
def handle_freeze_gc_req(self, recv_req: FreezeGCReq):
freeze_gc("Detokenizer Manager")
return None
def handle_configure_logging_req(self, recv_req: ConfigureLoggingReq):
if recv_req.log_level is not None:
logging.getLogger().setLevel(recv_req.log_level.upper())
def is_health_check_request(rid: Optional[str]) -> bool:
return isinstance(rid, str) and rid.startswith(HEALTH_CHECK_RID_PREFIX)
class LimitedCapacityDict(OrderedDict):
def __init__(self, capacity: int, *args, **kwargs):
super().__init__(*args, **kwargs)
self.capacity = capacity
def __setitem__(self, key, value):
if len(self) >= self.capacity:
# Remove the oldest element (first item in the dict)
self.popitem(last=False)
# Set the new item
super().__setitem__(key, value)
def run_detokenizer_process(
server_args: ServerArgs,
port_args: PortArgs,
detokenizer_manager_class=DetokenizerManager,
):
kill_itself_when_parent_died()
setproctitle.setproctitle("sglang::detokenizer")
configure_logger(server_args)
parent_process = psutil.Process().parent()
manager = None
try:
manager = detokenizer_manager_class(server_args, port_args)
if server_args.tokenizer_worker_num == 1:
manager.event_loop()
else:
manager.multi_http_worker_event_loop()
except Exception:
traceback = get_exception_traceback()
logger.error(f"DetokenizerManager hit an exception: {traceback}")
if manager is not None:
manager.maybe_clear_socket_mapping()
parent_process.send_signal(signal.SIGQUIT)
@@ -0,0 +1,44 @@
"""Start bootstrap/kv-store-related server"""
import os
from sglang.srt.disaggregation.utils import (
DisaggregationMode,
KVClassType,
TransferBackend,
get_kv_class,
)
from sglang.srt.server_args import ServerArgs
def start_disagg_service(
server_args: ServerArgs,
):
# Start kv bootstrap server on prefill
disagg_mode = DisaggregationMode(server_args.disaggregation_mode)
transfer_backend = TransferBackend(server_args.disaggregation_transfer_backend)
if disagg_mode == DisaggregationMode.PREFILL:
# only start bootstrap server on prefill tm
kv_bootstrap_server_class = get_kv_class(
transfer_backend, KVClassType.BOOTSTRAP_SERVER
)
bootstrap_server = kv_bootstrap_server_class(
host=server_args.host,
port=server_args.disaggregation_bootstrap_port,
)
is_create_store = (
server_args.node_rank == 0 and transfer_backend == TransferBackend.ASCEND
)
if is_create_store:
try:
from memfabric_hybrid import create_config_store
ascend_url = os.getenv("ASCEND_MF_STORE_URL")
create_config_store(ascend_url)
except Exception as e:
error_message = f"Failed create mf store, invalid ascend_url."
error_message += f" With exception {e}"
raise error_message
return bootstrap_server
+58
View File
@@ -0,0 +1,58 @@
# Copyright 2023-2024 SGLang Team
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# ==============================================================================
"""
Structs for embedding injection.
These are placed in a separate module to avoid circular imports between
io_struct.py and schedule_batch.py.
"""
from typing import List
import msgspec
import torch
class PositionalEmbeds(msgspec.Struct, array_like=True):
"""Embeddings to place at specific token positions.
Accepts either a list of [1, hidden_dim] tensors or a pre-stacked [N, hidden_dim] tensor.
In both cases, __post_init__ stacks into a single [N, hidden_dim] tensor to reduce
ZMQ serialization overhead.
Attributes:
embeds: Stacked tensor of shape [N, hidden_dim] after __post_init__.
positions: List of positions where embeddings should be injected.
"""
embeds: torch.Tensor
positions: List[int]
def __post_init__(self):
# Normalize list of tensors into a single [N, hidden_dim] tensor.
# Dispatch by element rank to avoid a per-element unsqueeze.
if isinstance(self.embeds, list):
if not self.embeds:
self.embeds = torch.cat(self.embeds, dim=0) # raises — empty is invalid
elif self.embeds[0].dim() == 1:
# [hidden_dim] elements → stack adds the leading dim.
self.embeds = torch.stack(self.embeds, dim=0)
else:
# [1, hidden_dim] (already has the leading dim) → plain concat.
self.embeds = torch.cat(self.embeds, dim=0)
if self.embeds.shape[0] != len(self.positions):
raise ValueError(
f"embeds length ({self.embeds.shape[0]}) != "
f"positions length ({len(self.positions)})"
)
@@ -0,0 +1,843 @@
# to be combined with the sparse coordinator class and sparse algorithm family
import logging
from typing import List, NamedTuple, Union
import torch
from sglang.jit_kernel.hisparse import (
load_cache_to_device_buffer_dsv4_mla,
load_cache_to_device_buffer_mla,
)
from sglang.srt.managers.schedule_batch import Req
from sglang.srt.mem_cache.allocator.hisparse import (
DeepSeekV4HiSparseTokenToKVPoolAllocator,
HiSparseTokenToKVPoolAllocator,
)
from sglang.srt.mem_cache.hisparse_memory_pool import (
HiSparseDSATokenToKVPool,
)
from sglang.srt.mem_cache.memory_pool import ReqToTokenPool
from sglang.srt.mem_cache.memory_pool_host import (
DeepSeekV4PagedHostPool,
MLATokenToKVPoolHost,
)
from sglang.srt.utils import get_device_module, is_hip
device_module = get_device_module()
_is_hip = is_hip()
logger = logging.getLogger(__name__)
class HiSparseAct(NamedTuple):
start_event: device_module.Event
finish_event: device_module.Event
req: Req
class HiSparseTokenStats(NamedTuple):
device_tokens: int
device_token_usage: float
host_tokens: int
host_token_usage: float
class HiSparseCoordinator:
def __init__(
self,
req_to_token_pool: ReqToTokenPool,
token_to_kv_pool_allocator: Union[
HiSparseTokenToKVPoolAllocator,
DeepSeekV4HiSparseTokenToKVPoolAllocator,
],
top_k: int,
device_buffer_size: int,
device: str,
tp_group,
host_to_device_ratio: int = 2,
swap_in_block_size: int = 960,
):
self.req_to_token_pool = req_to_token_pool
self.token_to_kv_pool_allocator = token_to_kv_pool_allocator
self.top_k = top_k
self.device_buffer_size = device_buffer_size
self.device = device
self.swap_in_block_size = swap_in_block_size
self.compress_ratio = self.token_to_kv_pool_allocator.compress_ratio
self.is_dsv4_hisparse = isinstance(
self.token_to_kv_pool_allocator, DeepSeekV4HiSparseTokenToKVPoolAllocator
)
if self.is_dsv4_hisparse:
self.mem_pool_device = self.token_to_kv_pool_allocator.hisparse_kvcache
page_size = self.mem_pool_device.page_size
num_host_pages = (
self.token_to_kv_pool_allocator.size_full // self.compress_ratio
+ page_size
- 1
) // page_size
self.mem_pool_host = DeepSeekV4PagedHostPool(
pool_name="dsv4_hisparse_c4",
device_buffers=self.mem_pool_device.kv_buffer,
item_bytes=self.mem_pool_device.bytes_per_page_padded,
num_host_pages=num_host_pages,
slot_page_size=page_size,
layout="layer_first",
)
self.item_size_bytes = (
self.mem_pool_device.kv_cache_total_dim
* self.mem_pool_device.store_dtype.itemsize
)
else:
assert isinstance(
self.token_to_kv_pool_allocator, HiSparseTokenToKVPoolAllocator
)
self.mem_pool_device: HiSparseDSATokenToKVPool = (
self.token_to_kv_pool_allocator.get_kvcache()
)
self.mem_pool_host = MLATokenToKVPoolHost(
device_pool=self.mem_pool_device,
host_to_device_ratio=host_to_device_ratio,
host_size=0,
page_size=self.mem_pool_device.page_size,
layout="layer_first",
override_kv_cache_dim=self.mem_pool_device.kv_cache_dim,
)
self.item_size_bytes = self.mem_pool_host.token_stride_size
self.page_size = self.mem_pool_device.page_size
max_num_req_slots = req_to_token_pool.req_to_token.shape[0]
max_context_len = req_to_token_pool.max_context_len
max_compressed_context_len = (
max_context_len + self.compress_ratio - 1
) // self.compress_ratio
# to have an extra page for new tokens
self.padded_buffer_size = (
self.device_buffer_size + self.mem_pool_device.page_size
)
self.req_to_device_buffer = torch.zeros(
(max_num_req_slots, self.padded_buffer_size),
dtype=torch.int64,
device=device,
)
self.req_device_buffer_size = torch.zeros(
max_num_req_slots, dtype=torch.int64, device="cpu"
)
self.req_to_host_pool = torch.full(
(max_num_req_slots, max_compressed_context_len + self.page_size),
-1,
dtype=torch.int64,
device=device,
)
self.req_to_host_pool_allocated_len = torch.zeros(
max_num_req_slots, dtype=torch.int64, device="cpu"
)
self.write_staging_stream = device_module.Stream()
self.decode_backup_stream = device_module.Stream()
self.ack_staging_queue: List[HiSparseAct] = []
self.decode_producer_stream = None
self._backup_done_event = device_module.Event()
self._has_pending_backup = False
self.tp_group = tp_group
self.tp_world_size = torch.distributed.get_world_size(group=self.tp_group)
# initialize data structures for swap-in kernel
layer_num = self.mem_pool_device.layer_num
self.req_device_buffer_tokens = torch.full(
(layer_num, max_num_req_slots, self.padded_buffer_size),
-1,
dtype=torch.int32,
device=device,
)
self.req_device_buffer_token_locs = torch.full(
(layer_num, max_num_req_slots, self.padded_buffer_size),
-1,
dtype=torch.int32,
device=device,
)
self._lru_init = torch.arange(
self.device_buffer_size, dtype=torch.int16, device=device
)
self.lru_slots = (
self._lru_init.view(1, 1, -1)
.repeat(layer_num, max_num_req_slots, 1)
.contiguous()
)
self._device_buffer_arange_i32 = torch.arange(
self.device_buffer_size, dtype=torch.int32, device=device
)
# Pre-allocated output buffer for swap_in_selected_pages (CUDA-graph safe)
self.top_k_device_locs_buffer = torch.full(
(max_num_req_slots, self.top_k), -1, dtype=torch.int32, device=device
)
self.raw_indices_buffer = torch.full(
(max_num_req_slots, self.top_k), -1, dtype=torch.int32, device=device
)
# Scalar tensor: number of real (non-padded) requests in the batch.
# Updated before each graph replay so padded blocks early-return.
self.num_real_reqs = torch.zeros(1, dtype=torch.int32, device=device)
# CPU flag: True means "skip backup on the next decode step" because
# staging already backed up all prefill tokens. Cleared after one step.
self._skip_first_backup = [False] * max_num_req_slots
def set_decode_producer_stream(self, stream) -> None:
self.decode_producer_stream = stream
def destroy(self) -> None:
# Drain in-flight transfers so the buffer is idle, then unregister it.
# See HostKVCache.destroy for why the explicit unregister matters.
self.write_staging_stream.synchronize()
self.decode_backup_stream.synchronize()
self.mem_pool_host.destroy()
def get_token_stats(self) -> HiSparseTokenStats:
device_allocator = self.token_to_kv_pool_allocator.hisparse_attn_allocator
device_capacity = device_allocator.size
device_tokens = device_capacity - device_allocator.available_size()
host_capacity = self.mem_pool_host.size
host_tokens = host_capacity - self.mem_pool_host.available_size()
return HiSparseTokenStats(
device_tokens=device_tokens,
device_token_usage=(
device_tokens / device_capacity if device_capacity > 0 else 0.0
),
host_tokens=host_tokens,
host_token_usage=(
host_tokens / host_capacity if host_capacity > 0 else 0.0
),
)
def admit_request_into_staging(self, req: Req) -> None:
req.hisparse_staging = True
full_kv_indices = self.req_to_token_pool.req_to_token[
req.req_pool_idx, : req.extend_range.end
].to(dtype=torch.int64, copy=True)
device_indices = (
self.mem_pool_device.translate_loc_from_full_to_hisparse_device(
full_kv_indices
)
)
prefill_len = len(device_indices)
host_indices = self.mem_pool_host.alloc_paged_token_slots(
self.req_to_host_pool,
self.req_to_host_pool_allocated_len,
req.req_pool_idx,
0,
prefill_len,
)
start_event = device_module.Event()
finish_event = device_module.Event()
start_event.record()
with device_module.stream(self.write_staging_stream):
start_event.wait(self.write_staging_stream)
self.mem_pool_host.backup_from_device_all_layer(
self.mem_pool_device,
host_indices,
device_indices,
io_backend="kernel",
)
finish_event.record()
if host_indices.is_cuda:
host_indices.record_stream(self.write_staging_stream)
if device_indices.is_cuda:
device_indices.record_stream(self.write_staging_stream)
self.ack_staging_queue.append(HiSparseAct(start_event, finish_event, req))
def admit_request_direct(self, req: Req) -> None:
"""Direct-to-host path: KV data already resides in host pool via RDMA.
Skips staging DMA entirely. Only allocates a small device buffer
(4KB) for decode-time swap-in, then marks the request as ready.
Host indices were already written to req_to_host_pool.
Metadata fixups after alloc_device_buffer():
- alloc_device_buffer() sets device_buffer_tokens = [0, 1, ..., buf_size-1],
which tells the swap-in kernel that those tokens are cached in the device
buffer. In the staging path this is correct (prefill filled the buffer),
but here the buffer is empty.
"""
self.alloc_device_buffer(req)
host_len = self.host_token_len(req.kv_allocated_len)
if host_len <= self.device_buffer_size:
# Short sequences (seq_len <= device_buffer_size): the kernel fast path
# returns device_buffer_locs directly without any host loading, so we
# must preload all tokens from host pool into the device buffer
# TODO(hzh0425): Optimize this.
self._preload_to_device_buffer(req)
else:
# Long sequence: reset device_buffer_tokens to -1 so the kernel
# sees all slots as empty -> every top-k lookup is a miss -> host load.
self.req_device_buffer_tokens[
:, req.req_pool_idx, : self.device_buffer_size
] = -1
req.hisparse_staging = False
self._skip_first_backup[req.req_pool_idx] = True
logger.debug("HiSparse: admitting request %s directly", req.rid)
def host_token_len(self, kv_allocated_len: int) -> int:
if self.is_dsv4_hisparse:
return kv_allocated_len // self.compress_ratio
return kv_allocated_len
def _preload_to_device_buffer(self, req: Req) -> None:
"""Preload all tokens from host pool into the device buffer."""
n = self.host_token_len(req.kv_allocated_len)
host_indices = self.req_to_host_pool[req.req_pool_idx, :n]
device_locs = self.req_to_device_buffer[req.req_pool_idx, :n]
for layer_id in range(self.mem_pool_device.layer_num):
self.mem_pool_host.load_to_device_per_layer(
self.mem_pool_device,
host_indices,
device_locs,
layer_id,
io_backend="kernel",
)
def alloc_device_buffer(self, req: Req) -> None:
if self.is_dsv4_hisparse:
allocated_len = req.extend_range.end
alloc_size = self.padded_buffer_size
else:
allocated_len = req.kv_allocated_len
page_size = self.mem_pool_device.page_size
# Allocate only enough for current tokens (page-aligned).
# When prefill already fills device_buffer_size, include the reserved page.
alloc_size = min(
((allocated_len + page_size - 1) // page_size) * page_size,
self.device_buffer_size,
)
if alloc_size == self.device_buffer_size:
alloc_size = self.padded_buffer_size
compressed_logical_indices = (
self.mem_pool_device.translate_loc_from_full_to_compressed(
self.req_to_token_pool.req_to_token[req.req_pool_idx, :allocated_len]
)
)
compressed_len = len(compressed_logical_indices)
buffer_indices = self.token_to_kv_pool_allocator.alloc_device_buffer(
compressed_logical_indices, alloc_size
)
if buffer_indices is None:
logger.error(
"HiSparse: alloc_device_buffer failed for req %s "
"(compressed_len=%d, alloc_size=%d)",
req.rid,
compressed_len,
alloc_size,
)
raise RuntimeError("HiSparse alloc_device_buffer returned None")
buffer_indices = buffer_indices.to(torch.int32)
self.req_to_device_buffer[req.req_pool_idx, :alloc_size] = buffer_indices
self.req_device_buffer_size[req.req_pool_idx] = alloc_size
self.req_device_buffer_tokens[
:, req.req_pool_idx, : self.device_buffer_size
] = self._device_buffer_arange_i32
self.req_device_buffer_token_locs[:, req.req_pool_idx, :alloc_size] = (
buffer_indices[:alloc_size]
)
def _grow_device_buffers(
self,
seq_lens: torch.Tensor,
req_pool_indices: torch.Tensor,
seq_lens_cpu: torch.Tensor,
req_pool_indices_cpu: torch.Tensor,
) -> torch.Tensor:
"""Grow device buffers for requests whose sequence length exceeds current capacity."""
current_caps = self.req_device_buffer_size[req_pool_indices_cpu]
short_reqs_cpu = seq_lens_cpu <= self.device_buffer_size
needs_grow_cpu = short_reqs_cpu & (seq_lens_cpu > current_caps)
if torch.any(needs_grow_cpu):
page_size = self.mem_pool_device.page_size
grow_indices = torch.where(needs_grow_cpu)[0]
# Compute all grow sizes on CPU, then do a single bulk allocation
req_idxs = []
old_caps = []
new_caps = []
grow_sizes = []
total_grow = 0
for i in grow_indices.tolist():
req_idx = int(req_pool_indices_cpu[i])
current_cap = int(current_caps[i])
seq_len = int(seq_lens_cpu[i])
new_cap = min(
((seq_len + page_size - 1) // page_size) * page_size,
self.device_buffer_size,
)
if new_cap == self.device_buffer_size:
new_cap = self.padded_buffer_size
grow_size = new_cap - current_cap
if grow_size <= 0:
continue
req_idxs.append(req_idx)
old_caps.append(current_cap)
new_caps.append(new_cap)
grow_sizes.append(grow_size)
total_grow += grow_size
if total_grow > 0:
all_new_indices = (
self.token_to_kv_pool_allocator.hisparse_attn_allocator.alloc(
total_grow
)
)
if all_new_indices is None:
logger.error(
"HiSparse: _grow_device_buffers bulk alloc failed "
"(total_grow=%d)",
total_grow,
)
raise RuntimeError(
f"HiSparse _grow_device_buffers failed (total_grow={total_grow})"
)
offset = 0
for req_idx, current_cap, new_cap, grow_size in zip(
req_idxs, old_caps, new_caps, grow_sizes
):
chunk = all_new_indices[offset : offset + grow_size]
offset += grow_size
self.req_to_device_buffer[req_idx, current_cap:new_cap] = chunk
self.req_device_buffer_token_locs[
:, req_idx, current_cap:new_cap
] = chunk
self.req_device_buffer_size[req_idx] = new_cap
reserved_positions = (seq_lens - 1).clamp(max=self.device_buffer_size)
return self.req_to_device_buffer[req_pool_indices, reserved_positions]
def has_ongoing_staging(self) -> bool:
return len(self.ack_staging_queue) > 0
def collect_ready_reqs(self) -> List[Req]:
ready_reqs: List[Req] = []
if len(self.ack_staging_queue) == 0:
return ready_reqs
finish_count = 0
for _, finish_event, _ in self.ack_staging_queue:
if not finish_event.query():
break
finish_count += 1
queue_size = torch.tensor(finish_count, dtype=torch.int, device="cpu")
if self.tp_world_size > 1:
# synchronize TP workers to make sure the same update to scheduler
torch.distributed.all_reduce(
queue_size,
op=torch.distributed.ReduceOp.MIN,
group=self.tp_group,
)
finish_count = int(queue_size.item())
while finish_count > 0:
_, _, req = self.ack_staging_queue.pop(0)
# prepare device buffer and update req
self.alloc_device_buffer(req)
self._skip_first_backup[req.req_pool_idx] = True
req.hisparse_staging = False
finish_count -= 1
ready_reqs.append(req)
return ready_reqs
def map_last_loc_to_buffer(
self,
seq_lens: torch.Tensor,
out_cache_loc: torch.Tensor,
req_pool_indices: torch.Tensor,
seq_lens_cpu: torch.Tensor,
req_pool_indices_cpu: torch.Tensor,
) -> None:
self._eager_backup_previous_token(
seq_lens, req_pool_indices, seq_lens_cpu, req_pool_indices_cpu
)
if not self.is_dsv4_hisparse:
# Grow device buffers if needed and resolve the latest-token slot.
reserved_buffer_loc = self._grow_device_buffers(
seq_lens, req_pool_indices, seq_lens_cpu, req_pool_indices_cpu
)
self.req_device_buffer_token_locs[
:, req_pool_indices, self.device_buffer_size
] = reserved_buffer_loc.to(torch.int32)
compressed_locs = self.token_to_kv_pool_allocator.get_last_loc_compressed(
out_cache_loc
)
# ROCm: the decode remap creates a temporary hisparse device slot per
# new token (via the page_size==1 allocator path). Free the stale
# slot before pointing the mapping at the reserved device-buffer slot,
# otherwise the temporary slots leak and corrupt later swap-in lookups.
# CUDA keeps the original behavior: the swap-in kernel consumes only
# top_k_device_locs, so stale mapping entries are harmless there.
if _is_hip:
previous_locs = self.mem_pool_device._translate_loc_to_hisparse_device(
compressed_locs
)
stale_locs = previous_locs[
(previous_locs > 0) & (previous_locs != reserved_buffer_loc)
]
if stale_locs.numel() > 0:
self.token_to_kv_pool_allocator.free_hisparse_indices(stale_locs)
self.mem_pool_device.full_to_hisparse_device_index_mapping[
compressed_locs
] = reserved_buffer_loc
return
active_reqs = seq_lens % self.compress_ratio == 0
if not torch.any(active_reqs):
return
active_seq_lens = seq_lens[active_reqs]
active_out_cache_loc = out_cache_loc[active_reqs]
active_req_pool_indices = req_pool_indices[active_reqs]
compressed_seq_lens = active_seq_lens // self.compress_ratio
reserved_positions = (compressed_seq_lens - 1).clamp(
max=self.device_buffer_size
)
reserved_buffer_loc = self.req_to_device_buffer[
active_req_pool_indices, reserved_positions
]
self.req_device_buffer_token_locs[
:, active_req_pool_indices, self.device_buffer_size
] = reserved_buffer_loc.to(torch.int32)
compressed_locs = self.token_to_kv_pool_allocator.get_last_loc_compressed(
active_out_cache_loc
)
self.mem_pool_device.full_to_hisparse_device_index_mapping[compressed_locs] = (
reserved_buffer_loc
)
def _eager_backup_previous_token(
self,
seq_lens: torch.Tensor,
req_pool_indices: torch.Tensor,
seq_lens_cpu: torch.Tensor,
req_pool_indices_cpu: torch.Tensor,
) -> None:
"""Back up the previous compressed token to host memory.
Each newly produced compressed token (one per `compress_ratio` decode
steps) must be backed up to host so the swap-in kernel can later
recover it.
Two cases are skipped:
- The first decode step right after staging: all prefill tokens were
already backed up during staging, so there is nothing new to save.
- Steps where `(seq_len - 1) % compress_ratio != 0`: no new compressed
token was produced this step.
"""
# Build the list of batch positions that need a host backup.
# Skip the first decode step after staging (prefill already backed up),
# and skip non-aligned steps that did not produce a new compressed token.
backup_indices = []
for i in range(len(seq_lens_cpu)):
req_idx = int(req_pool_indices_cpu[i])
if self._skip_first_backup[req_idx]:
self._skip_first_backup[req_idx] = False
continue
if (int(seq_lens_cpu[i]) - 1) % self.compress_ratio == 0:
backup_indices.append(i)
if not backup_indices:
return
backup_indices_gpu = torch.tensor(
backup_indices, dtype=torch.int64, device=self.device
)
backup_req_indices = req_pool_indices[backup_indices_gpu]
# The previous compressed token's position and its device buffer slot:
# compressed_pos = (seq_len - 1) // compress_ratio - 1
# - short: slot = compressed_pos (within the regular buffer)
# - long: slot = device_buffer_size (the reserved slot)
prev_seq_lens = seq_lens[backup_indices_gpu] - 1
compressed_prev_seq_lens = prev_seq_lens // self.compress_ratio
actual_compressed_pos = compressed_prev_seq_lens - 1
buffer_slot = actual_compressed_pos.clamp(max=self.device_buffer_size)
device_locs = self.req_to_device_buffer[backup_req_indices, buffer_slot]
host_locs_list = []
for i in backup_indices:
req_idx = int(req_pool_indices_cpu[i])
start_pos = (int(seq_lens_cpu[i]) - 1) // self.compress_ratio - 1
host_locs = self.mem_pool_host.alloc_paged_token_slots(
self.req_to_host_pool,
self.req_to_host_pool_allocated_len,
req_idx,
start_pos,
1,
)
host_locs_list.append(host_locs)
host_locs = torch.cat(host_locs_list)
self.wait_for_pending_backup()
schedule_stream = device_module.current_stream()
with device_module.stream(self.decode_backup_stream):
self.decode_backup_stream.wait_stream(schedule_stream)
if self.decode_producer_stream is not None:
self.decode_backup_stream.wait_stream(self.decode_producer_stream)
self.mem_pool_host.backup_from_device_all_layer(
self.mem_pool_device,
host_locs,
device_locs,
io_backend="kernel",
)
self._backup_done_event.record()
if host_locs.is_cuda:
host_locs.record_stream(self.decode_backup_stream)
if backup_req_indices.is_cuda:
backup_req_indices.record_stream(self.decode_backup_stream)
if actual_compressed_pos.is_cuda:
actual_compressed_pos.record_stream(self.decode_backup_stream)
if device_locs.is_cuda:
device_locs.record_stream(self.decode_backup_stream)
self._has_pending_backup = True
def wait_for_pending_backup(self) -> None:
if not self._has_pending_backup:
return
self._backup_done_event.wait(device_module.current_stream())
self._has_pending_backup = False
def naive_load_topk(
self,
req_pool_indices: torch.Tensor,
seq_lens: torch.Tensor,
top_k_tokens: torch.Tensor,
layer_id: int,
) -> torch.Tensor:
"""Load top-k selected tokens into device memory and return their device indices.
This is a naive per-request loop implementation for debugging/validation.
Production code uses swap_in_selected_pages (JIT CUDA kernel) instead.
Note: dsv4 hisparse is not supported — DeepSeekV4SingleKVPoolHost has no
load_to_device_per_layer and indices live in compressed space. Currently
only used as a kernel oracle in test_hisparse_unit.py (non-dsv4 path).
Args:
req_pool_indices: Pool indices for each request. Shape: (num_reqs,)
seq_lens: Sequence lengths for each request. Shape: (num_reqs,)
top_k_tokens: Selected token positions per request. Shape: (num_reqs, top_k)
layer_id: The layer to load KV cache for.
Returns:
Device KV cache indices for the selected tokens. Shape: (num_reqs, top_k)
"""
assert (
not self.is_dsv4_hisparse
), "naive_load_topk is not implemented for dsv4 hisparse"
num_reqs = req_pool_indices.size(0)
top_k_indices = torch.full(
(num_reqs, self.top_k), -1, dtype=torch.int32, device=self.device
)
for i in range(num_reqs):
seq_len = int(seq_lens[i].item())
top_n = min(seq_len, self.top_k)
if top_n == 0:
continue
req_idx = int(req_pool_indices[i].item())
selected_tokens = top_k_tokens[i, :top_n].to(dtype=torch.int64)
assert torch.all(
selected_tokens >= 0
), f"Req {req_idx}: selected tokens contain negative positions"
assert torch.all(selected_tokens < seq_len), (
f"Req {req_idx}: selected tokens {selected_tokens.tolist()} "
f"out of range for seq_len={seq_len}"
)
if seq_len <= self.device_buffer_size:
device_indices = self.req_to_device_buffer[req_idx, selected_tokens]
else:
device_indices = torch.empty(
top_n, dtype=torch.int64, device=self.device
)
is_latest_token = selected_tokens == (seq_len - 1)
needs_host_load = ~is_latest_token
device_indices[is_latest_token] = self.req_to_device_buffer[
req_idx, self.device_buffer_size
]
num_to_load = int(needs_host_load.sum().item())
if num_to_load > 0:
tokens_to_load = selected_tokens[needs_host_load]
host_locs = self.req_to_host_pool[req_idx, tokens_to_load]
invalid_mask = host_locs < 0
if torch.any(invalid_mask):
bad_positions = tokens_to_load[invalid_mask].tolist()
raise AssertionError(
f"Req {req_idx} (seq_len={seq_len}, layer={layer_id}): "
f"missing host backup at token positions {bad_positions}"
)
buffer_locs = self.req_to_device_buffer[req_idx, :num_to_load]
device_indices[needs_host_load] = buffer_locs
self.mem_pool_host.load_to_device_per_layer(
self.mem_pool_device,
host_locs,
buffer_locs,
layer_id,
io_backend="kernel",
)
top_k_indices[i, :top_n] = device_indices.to(torch.int32)
return top_k_indices
def abort_staging_request(self, req: Req) -> None:
"""Remove a request from the staging queue and free its host + device resources.
Must be called when aborting a request that has been admitted into staging
but has not yet completed (i.e. req.hisparse_staging is True).
"""
# Remove from staging queue
self.ack_staging_queue = [
act for act in self.ack_staging_queue if act.req is not req
]
# Wait for any in-flight staging DMA to complete before freeing
self.write_staging_stream.synchronize()
prefill_len = req.extend_range.end
allocated_locs = self.req_to_token_pool.req_to_token[
req.req_pool_idx, :prefill_len
]
self.token_to_kv_pool_allocator.free_hisparse(allocated_locs)
# Free host memory that was allocated during admit_request_into_staging
host_indices = self.mem_pool_host.allocated_host_indices(
self.req_to_host_pool,
req.req_pool_idx,
self.req_to_host_pool_allocated_len[req.req_pool_idx],
)
if host_indices.numel() > 0:
self.mem_pool_host.free(host_indices)
self.req_to_host_pool[req.req_pool_idx, :] = -1
self.req_to_host_pool_allocated_len[req.req_pool_idx] = 0
self._skip_first_backup[req.req_pool_idx] = False
req.hisparse_staging = False
def retract_req(self, req: Req) -> None:
if req.hisparse_staging:
self.abort_staging_request(req)
else:
self.request_finished(req)
def request_finished(self, req: Req):
# release resources only after the execution of a potential overlapped batch
if self.decode_producer_stream is not None:
device_module.current_stream().wait_stream(self.decode_producer_stream)
self.wait_for_pending_backup()
# Use kv_allocated_len (not seqlen): under speculative decoding the
# allocator can over-allocate beyond the committed seqlen, and those
# extra slots may carry stale mapping entries pointing at buffer slots
# we just freed via free_hisparse_indices(all_hi). If left set, the
# subsequent release_kv_cache -> allocator.free -> free_hisparse path
# re-frees them (double-free into the page allocator's free list).
allocated_len = req.kv_allocated_len
# release memory -- only free actually-allocated buffer indices
current_cap = int(self.req_device_buffer_size[req.req_pool_idx])
if current_cap > 0:
side_buf_hi = self.req_to_device_buffer[req.req_pool_idx, :current_cap]
all_hi = torch.unique(side_buf_hi[side_buf_hi > 0])
if all_hi.numel() > 0:
self.token_to_kv_pool_allocator.free_hisparse_indices(all_hi)
allocated_locs = self.req_to_token_pool.req_to_token[
req.req_pool_idx, :allocated_len
]
compressed_locs = self.mem_pool_device.translate_loc_from_full_to_compressed(
allocated_locs
)
self.mem_pool_device.full_to_hisparse_device_index_mapping[compressed_locs] = 0
host_indices = self.mem_pool_host.allocated_host_indices(
self.req_to_host_pool,
req.req_pool_idx,
self.req_to_host_pool_allocated_len[req.req_pool_idx],
)
if host_indices.numel() > 0:
self.mem_pool_host.free(host_indices)
# clear req info
self.req_device_buffer_tokens[:, req.req_pool_idx, :] = -1
self.req_device_buffer_token_locs[:, req.req_pool_idx, :] = -1
self.req_to_device_buffer[req.req_pool_idx, :] = 0
self.req_device_buffer_size[req.req_pool_idx] = 0
self.req_to_host_pool[req.req_pool_idx, :] = -1
self.req_to_host_pool_allocated_len[req.req_pool_idx] = 0
self.lru_slots[:, req.req_pool_idx, :].copy_(self._lru_init)
self._skip_first_backup[req.req_pool_idx] = False
def swap_in_selected_pages(
self,
req_pool_indices: torch.Tensor,
compressed_seq_lens: torch.Tensor,
top_k_result: torch.Tensor,
layer_id: int,
) -> torch.Tensor:
"""Swap selected top-k tokens into device memory and return their indices."""
num_reqs = req_pool_indices.size(0)
top_k_indices = self.top_k_device_locs_buffer[:num_reqs]
top_k_indices.fill_(-1)
swap_in_fn = (
load_cache_to_device_buffer_dsv4_mla
if self.is_dsv4_hisparse
else load_cache_to_device_buffer_mla
)
swap_in_fn(
top_k_tokens=top_k_result,
device_buffer_tokens=self.req_device_buffer_tokens[layer_id],
host_cache_locs=self.req_to_host_pool,
device_buffer_locs=self.req_device_buffer_token_locs[layer_id],
host_cache=self.mem_pool_host.kv_buffer[layer_id],
device_buffer=self.mem_pool_device.kv_buffer[layer_id],
top_k_device_locs=top_k_indices,
req_pool_indices=req_pool_indices,
seq_lens=compressed_seq_lens,
lru_slots=self.lru_slots[layer_id],
item_size_bytes=self.item_size_bytes,
num_top_k=self.top_k,
hot_buffer_size=self.device_buffer_size,
page_size=1,
block_size=self.swap_in_block_size,
num_real_reqs=self.num_real_reqs,
)
return top_k_indices
File diff suppressed because it is too large Load Diff
+654
View File
@@ -0,0 +1,654 @@
"""Load snapshot: publish scheduler load metrics for DP balancing and /v1/loads.
Architecture
------------
Each scheduler periodically publishes a ``LoadSnapshot`` containing its
current load metrics (running reqs, tokens, throughput, ...). Two
transport backends are supported:
**SHM mode** (single-node, default)::
Scheduler ──ShmLoadSnapshotWriter──▶ /dev/shm mmap file
TokenizerManager ──ShmLoadSnapshotReader───┘ (for /v1/loads)
DataParallelController ──ShmLoadSnapshotReader─┘ (for dispatch)
**ZMQ mode** (multi-node DP attention, or ``SGLANG_LOAD_SNAPSHOT_USE_ZMQ=1``)::
Scheduler (any node) ──ZmqLoadSnapshotWriter (PUSH)──▶ network
ZmqShmLoadSnapshotReader (PULL, node 0) ◀─────────────────┘
│ drains zmq, writes to SHM
/dev/shm mmap file (node 0)
TokenizerManager / DataParallelController ──ShmLoadSnapshotReader──┘
Shared memory does not work across nodes, so multi-node DP attention
requires the ZMQ transport. The ``ZmqShmLoadSnapshotReader`` on node 0
receives snapshots from all schedulers via zmq PUSH/PULL and writes them
into the local SHM file. All readers (TokenizerManager,
DataParallelController) on
node 0 then read from SHM.
``zmq_reader_owner()`` decides which process on node 0 binds the zmq
PULL socket (only one can bind); the other reads plain SHM.
"""
from __future__ import annotations
import fcntl
import hashlib
import logging
import mmap
import os
import struct
from contextlib import contextmanager
from typing import Optional
import msgspec
import msgspec.msgpack
import msgspec.structs
from sglang.srt.environ import envs
from sglang.srt.utils.network import is_zmq_endpoint_ipv6
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def should_use_zmq(server_args) -> bool:
"""Whether to use zmq PUSH/PULL instead of shared memory for load snapshots.
Shared memory (mmap) only works within a single node. When schedulers
run on multiple nodes (multi-node DP attention), they cannot write to
the SHM file on node 0, so we fall back to zmq transport. The env var
``SGLANG_LOAD_SNAPSHOT_USE_ZMQ`` forces zmq mode for testing.
"""
return (
server_args.enable_dp_attention and server_args.nnodes > 1
) or envs.SGLANG_LOAD_SNAPSHOT_USE_ZMQ.get()
_LOAD_AWARE_METHODS = frozenset({"total_requests", "total_tokens"})
def _tokenizer_load_snapshot_owner_caller(server_args) -> str:
"""The caller that plays the tokenizer-side zmq owner role.
In multi-tokenizer mode (``tokenizer_worker_num > 1``) there are N
independent ``TokenizerWorker`` processes that would all try to bind the
same zmq PULL endpoint. Instead, the single ``MultiTokenizerRouter``
process owns the socket (polls zmq -> SHM) and every worker reads SHM.
"""
if server_args.tokenizer_worker_num > 1:
return "MultiTokenizerRouter"
return "TokenizerManager"
def zmq_reader_owner(server_args, caller: str) -> bool:
"""Decide which process owns the zmq PULL socket.
Exactly one of ``"DataParallelController"``, ``"TokenizerManager"``, or
``"MultiTokenizerRouter"`` must return True when zmq mode is active. The
owner polls zmq -> SHM; the others read SHM.
Rules:
- Non-zero node_rank: no TokenizerManager, DataParallelController only
launches schedulers and waits -> nobody owns it.
- dp_size == 1: no DataParallelController exists -> tokenizer-side owner
owns it.
- dp_size > 1, load-aware method: DataParallelController polls on every
dispatch via refresh_load_budget() -> DataParallelController owns it.
- dp_size > 1, round-robin / other: DataParallelController never reads
load data -> tokenizer-side owner owns it (polls on /v1/loads calls).
The tokenizer-side owner is the ``"MultiTokenizerRouter"`` caller in
multi-tokenizer mode, otherwise the ``"TokenizerManager"`` caller.
"""
if not should_use_zmq(server_args):
return False
if server_args.node_rank != 0:
return False
tokenizer_owner = _tokenizer_load_snapshot_owner_caller(server_args)
if server_args.dp_size == 1:
return caller == tokenizer_owner
if server_args.load_balance_method.lower() in _LOAD_AWARE_METHODS:
return caller == "DataParallelController"
return caller == tokenizer_owner
# ---------------------------------------------------------------------------
# LoadSnapshot data class
# ---------------------------------------------------------------------------
class MemoryMetrics(msgspec.Struct, array_like=True):
"""Memory breakdown metrics."""
weight_gb: float
kv_cache_gb: float
graph_gb: float
token_capacity: int
class SpeculativeMetrics(msgspec.Struct, array_like=True):
"""Speculative decoding metrics."""
accept_length: float
accept_rate: float
class LoRAMetrics(msgspec.Struct, array_like=True):
"""LoRA adapter pool metrics."""
slots_used: int
slots_total: int
utilization: float
class DisaggregationMetrics(msgspec.Struct, array_like=True):
"""PD disaggregation metrics."""
mode: str # "prefill", "decode", or "null"
prefill_bootstrap_queue_reqs: int = 0
prefill_inflight_queue_reqs: int = 0
decode_prealloc_queue_reqs: int = 0
decode_transfer_queue_reqs: int = 0
decode_retracted_queue_reqs: int = 0
kv_transfer_speed_gb_s: float = 0.0
kv_transfer_latency_ms: float = 0.0
class QueueMetrics(msgspec.Struct, array_like=True):
"""Detailed queue info breakdown."""
waiting: int
grammar: int
paused: int
retracted: int
_CORE_KEYS = (
"timestamp",
"dp_rank",
"num_running_reqs",
"num_waiting_reqs",
"num_waiting_uncached_tokens",
"num_used_tokens",
"num_total_tokens",
"max_total_num_tokens",
"max_running_requests",
"token_usage",
"gen_throughput",
"cache_hit_rate",
"utilization",
)
class LoadSnapshot(msgspec.Struct, omit_defaults=True):
"""Per-DP-rank load metrics: the SHM/zmq wire format and the /v1/loads source."""
timestamp: float = 0.0
dp_rank: int = 0
num_running_reqs: int = 0
num_waiting_reqs: int = 0
num_waiting_uncached_tokens: int = 0
num_used_tokens: int = 0
num_total_tokens: int = 0
max_total_num_tokens: int = 0
max_running_requests: int = 0
token_usage: float = 0.0
gen_throughput: float = 0.0
cache_hit_rate: float = 0.0
utilization: float = 0.0
memory: Optional[MemoryMetrics] = None
speculative: Optional[SpeculativeMetrics] = None
lora: Optional[LoRAMetrics] = None
disaggregation: Optional[DisaggregationMetrics] = None
queues: Optional[QueueMetrics] = None
VALID_SECTIONS = frozenset(
{"core", "memory", "spec", "lora", "disagg", "queues", "all"}
)
def to_dict(self, include: Optional[set[str]] = None) -> dict:
load = {key: getattr(self, key) for key in _CORE_KEYS}
if include is None or "all" in include:
include_all = True
else:
if not (include <= self.VALID_SECTIONS):
raise ValueError(
f"Invalid include sections: {include - self.VALID_SECTIONS}. "
f"Valid options: {sorted(self.VALID_SECTIONS)}"
)
if include == {"core"}:
return load
include_all = False
for field, include_name, section in (
("memory", "memory", self.memory),
("speculative", "spec", self.speculative),
("lora", "lora", self.lora),
("disaggregation", "disagg", self.disaggregation),
("queues", "queues", self.queues),
):
if section is None or (not include_all and include_name not in include):
continue
load[field] = msgspec.structs.asdict(section)
return load
def _enc_hook(obj):
"""Coerce numpy scalars to native Python; msgpack has no numpy types."""
to_item = getattr(obj, "item", None)
if to_item is not None:
return to_item()
raise NotImplementedError(f"cannot encode {type(obj).__name__} in load snapshot")
snapshot_encoder = msgspec.msgpack.Encoder(enc_hook=_enc_hook)
snapshot_decoder = msgspec.msgpack.Decoder(LoadSnapshot)
# ---------------------------------------------------------------------------
# SHM file layout utilities
# ---------------------------------------------------------------------------
MAGIC = b"SLNS"
VERSION = 2
HEADER_STRUCT = struct.Struct("<4sHHI")
SLOT_LEN_STRUCT = struct.Struct("<I")
SLOT_SIZE = 16 * 1024
@contextmanager
def file_lock(fd: int, lock_type: int):
fcntl.flock(fd, lock_type)
try:
yield
finally:
fcntl.flock(fd, fcntl.LOCK_UN)
def shm_path_for(ipc_name: str) -> str:
name = os.path.basename(ipc_name.rstrip("/")) or "default"
safe_name = "".join(c if c.isalnum() or c in "._-" else "_" for c in name)
digest = hashlib.blake2s(ipc_name.encode(), digest_size=4).hexdigest()
return f"/dev/shm/sglang_loads_{safe_name}_{digest}.shm"
def file_size(dp_size: int, slot_size: int = SLOT_SIZE) -> int:
return HEADER_STRUCT.size + dp_size * slot_size
def slot_offset(dp_rank: int, slot_size: int = SLOT_SIZE) -> int:
return HEADER_STRUCT.size + dp_rank * slot_size
# ---------------------------------------------------------------------------
# Writers
# ---------------------------------------------------------------------------
class ShmLoadSnapshotWriter:
def __init__(
self, path: str, dp_size: int, dp_rank: int, publish_interval: int = 1
):
if dp_rank < 0 or dp_rank >= dp_size:
raise ValueError(f"invalid dp_rank={dp_rank} for dp_size={dp_size}")
self.publish_interval = max(1, publish_interval)
self.publish_counter = 0
self.path = path
self.dp_size = dp_size
self.dp_rank = dp_rank
self.slot_size = SLOT_SIZE
self.fd = -1
size = file_size(dp_size, self.slot_size)
self.fd = os.open(path, os.O_CREAT | os.O_RDWR, 0o600)
try:
with file_lock(self.fd, fcntl.LOCK_EX):
os.ftruncate(self.fd, size)
self.mmap = mmap.mmap(self.fd, size, access=mmap.ACCESS_WRITE)
HEADER_STRUCT.pack_into(
self.mmap, 0, MAGIC, VERSION, dp_size, self.slot_size
)
self._write_payload(LoadSnapshot(dp_rank=dp_rank))
except Exception:
if self.fd >= 0:
os.close(self.fd)
raise
def write(self, snapshot: LoadSnapshot) -> None:
if snapshot.dp_rank != self.dp_rank:
raise ValueError(
f"snapshot dp_rank={snapshot.dp_rank} does not match writer dp_rank={self.dp_rank}"
)
with file_lock(self.fd, fcntl.LOCK_EX):
self._write_payload(snapshot)
def _write_payload(self, snapshot: LoadSnapshot) -> None:
payload = snapshot_encoder.encode(snapshot)
max_payload_size = self.slot_size - SLOT_LEN_STRUCT.size
if len(payload) > max_payload_size:
raise ValueError(
f"load snapshot payload size {len(payload)} exceeds slot payload "
f"capacity {max_payload_size}"
)
offset = slot_offset(self.dp_rank, self.slot_size)
payload_start = offset + SLOT_LEN_STRUCT.size
payload_end = payload_start + len(payload)
slot_end = offset + self.slot_size
SLOT_LEN_STRUCT.pack_into(self.mmap, offset, 0)
self.mmap[payload_start:payload_end] = payload
self.mmap[payload_end:slot_end] = b"\0" * (slot_end - payload_end)
SLOT_LEN_STRUCT.pack_into(self.mmap, offset, len(payload))
def close(self) -> None:
self.mmap.close()
os.close(self.fd)
class ZmqLoadSnapshotWriter:
"""Sends load snapshots via zmq PUSH to a ZmqShmLoadSnapshotReader.
CONFLATE is set so only the latest message is kept in the send
buffer when the reader is slower than the writer.
"""
def __init__(
self, endpoint: str, dp_size: int, dp_rank: int, publish_interval: int = 1
):
import zmq as _zmq
if dp_rank < 0 or dp_rank >= dp_size:
raise ValueError(f"invalid dp_rank={dp_rank} for dp_size={dp_size}")
self.publish_interval = max(1, publish_interval)
self.publish_counter = 0
self.dp_size = dp_size
self.dp_rank = dp_rank
self._zmq = _zmq
self._ctx = _zmq.Context.instance()
self._socket = self._ctx.socket(_zmq.PUSH)
if is_zmq_endpoint_ipv6(endpoint):
self._socket.setsockopt(_zmq.IPV6, 1)
self._socket.setsockopt(_zmq.LINGER, 0)
self._socket.setsockopt(_zmq.CONFLATE, 1)
self._socket.connect(endpoint)
def write(self, snapshot: LoadSnapshot) -> None:
if snapshot.dp_rank != self.dp_rank:
raise ValueError(
f"snapshot dp_rank={snapshot.dp_rank} does not match "
f"writer dp_rank={self.dp_rank}"
)
try:
self._socket.send(snapshot_encoder.encode(snapshot), self._zmq.NOBLOCK)
except self._zmq.Again:
pass
def close(self) -> None:
self._socket.close()
# ---------------------------------------------------------------------------
# Readers
# ---------------------------------------------------------------------------
class ShmLoadSnapshotReader:
def __init__(self, path: str, dp_size: int):
self.path = path
self.dp_size = dp_size
self.mmap: Optional[mmap.mmap] = None
self.fd: Optional[int] = None
self.slot_size = SLOT_SIZE
self._header_warning_logged = False
self._attach()
def _attach(self) -> bool:
if self.mmap is not None:
return True
try:
fd = os.open(self.path, os.O_RDONLY)
except FileNotFoundError:
return False
size = os.fstat(fd).st_size
if size < HEADER_STRUCT.size:
os.close(fd)
return False
try:
with file_lock(fd, fcntl.LOCK_SH):
mapped = mmap.mmap(fd, size, access=mmap.ACCESS_READ)
magic, version, dp_size, slot_size = HEADER_STRUCT.unpack_from(
mapped, 0
)
except (OSError, ValueError):
os.close(fd)
return False
if (
magic != MAGIC
or version != VERSION
or dp_size != self.dp_size
or slot_size < SLOT_LEN_STRUCT.size
or size < file_size(self.dp_size, slot_size)
):
mapped.close()
os.close(fd)
if not self._header_warning_logged:
logger.warning("load shm header mismatch at %s", self.path)
self._header_warning_logged = True
return False
self.mmap = mapped
self.fd = fd
self.slot_size = slot_size
return True
def read(self, dp_rank: int) -> Optional[LoadSnapshot]:
if dp_rank < 0 or dp_rank >= self.dp_size:
return None
if not self._attach():
return None
assert self.fd is not None
with file_lock(self.fd, fcntl.LOCK_SH):
return self._read_slot(dp_rank)
def _read_slot(self, dp_rank: int) -> Optional[LoadSnapshot]:
assert self.mmap is not None
offset = slot_offset(dp_rank, self.slot_size)
(payload_len,) = SLOT_LEN_STRUCT.unpack_from(self.mmap, offset)
max_payload_size = self.slot_size - SLOT_LEN_STRUCT.size
if payload_len == 0 or payload_len > max_payload_size:
return None
payload_start = offset + SLOT_LEN_STRUCT.size
payload_end = payload_start + payload_len
try:
return snapshot_decoder.decode(self.mmap[payload_start:payload_end])
except Exception as e:
logger.debug("load snapshot decode failed for rank %s: %s", dp_rank, e)
return None
def read_all(self) -> list[LoadSnapshot]:
if not self._attach():
return []
assert self.fd is not None
with file_lock(self.fd, fcntl.LOCK_SH):
loads = []
for r in range(self.dp_size):
load = self._read_slot(r)
if load is not None:
loads.append(load)
return loads
def close(self) -> None:
if self.mmap is not None:
self.mmap.close()
self.mmap = None
if self.fd is not None:
os.close(self.fd)
self.fd = None
class ZmqShmLoadSnapshotReader:
"""Receives snapshots via zmq PULL from writers, writes to SHM, reads from SHM.
Transparently wraps a ShmLoadSnapshotReader. Every read() / read_all()
first drains the PULL socket into SHM so callers always see fresh data.
"""
def __init__(self, endpoint: str, shm_path: str, dp_size: int):
import zmq as _zmq
self._zmq = _zmq
self._ctx = _zmq.Context.instance()
self._socket = self._ctx.socket(_zmq.PULL)
if is_zmq_endpoint_ipv6(endpoint):
self._socket.setsockopt(_zmq.IPV6, 1)
self._socket.setsockopt(_zmq.LINGER, 0)
self._socket.setsockopt(_zmq.CONFLATE, 1)
self._socket.bind(endpoint)
self._endpoint = endpoint
self._shm_path = shm_path
self.dp_size = dp_size
self._shm_reader = ShmLoadSnapshotReader(shm_path, dp_size)
self._shm_writers: dict[int, ShmLoadSnapshotWriter] = {}
def _poll(self) -> None:
"""Drain zmq messages and write latest per dp_rank to SHM."""
latest: dict[int, LoadSnapshot] = {}
while True:
try:
data = self._socket.recv(self._zmq.NOBLOCK)
except self._zmq.Again:
break
try:
snapshot = snapshot_decoder.decode(data)
if 0 <= snapshot.dp_rank < self.dp_size:
latest[snapshot.dp_rank] = snapshot
except Exception as e:
logger.warning("load snapshot zmq decode failed: %s", e)
for dp_rank, snapshot in latest.items():
if dp_rank not in self._shm_writers:
self._shm_writers[dp_rank] = ShmLoadSnapshotWriter(
self._shm_path, self.dp_size, dp_rank
)
try:
self._shm_writers[dp_rank].write(snapshot)
except Exception as e:
logger.warning(
"load snapshot shm write failed for rank %d: %s", dp_rank, e
)
def fileno(self) -> int:
"""Edge-triggered fd that becomes readable when zmq messages arrive.
Lets an owner process register the reader with an event loop and drain
it via ``poll()`` instead of polling on a timer.
"""
return self._socket.getsockopt(self._zmq.FD)
def poll(self) -> None:
"""Drain the zmq PULL socket into SHM.
Public entry point so an owner process (e.g. MultiTokenizerRouter) can
keep SHM fresh without touching internals.
"""
self._poll()
def read(self, dp_rank: int) -> Optional[LoadSnapshot]:
self._poll()
return self._shm_reader.read(dp_rank)
def read_all(self) -> list[LoadSnapshot]:
self._poll()
return self._shm_reader.read_all()
def close(self) -> None:
for w in self._shm_writers.values():
w.close()
self._shm_writers.clear()
self._shm_reader.close()
self._socket.close()
if self._endpoint.startswith("ipc://"):
try:
os.unlink(self._endpoint[len("ipc://") :])
except OSError:
pass
# ---------------------------------------------------------------------------
# Factory functions
# ---------------------------------------------------------------------------
def _zmq_addr_for(port_args) -> str:
"""Return the zmq PUSH/PULL address from PortArgs.
For dp_attention (TCP mode), uses the ``load_collector_ipc_name`` field
stored in PortArgs. For single-node IPC (env-var override), derives
a deterministic IPC path from ``instance_id``.
"""
ipc_name = getattr(port_args, "load_collector_ipc_name", "")
if ipc_name:
return ipc_name
safe = "".join(
c if c.isalnum() or c in "._-" else "_" for c in port_args.instance_id
)
digest = hashlib.blake2s(port_args.instance_id.encode(), digest_size=4).hexdigest()
return f"ipc:///tmp/sglang_load_collector_{safe}_{digest}.sock"
def create_load_snapshot_writer(
server_args,
port_args,
dp_size: int,
dp_rank: int,
publish_interval: int = 1,
):
"""Return a SHM or ZMQ writer based on server configuration."""
if should_use_zmq(server_args):
return ZmqLoadSnapshotWriter(
_zmq_addr_for(port_args), dp_size, dp_rank, publish_interval
)
return ShmLoadSnapshotWriter(
shm_path_for(port_args.instance_id), dp_size, dp_rank, publish_interval
)
def create_load_snapshot_reader(server_args, port_args, caller: str):
"""Create a load snapshot reader.
Args:
caller: ``"DataParallelController"``, ``"TokenizerManager"``, or
``"MultiTokenizerRouter"`` -- determines who binds the zmq PULL
socket when zmq mode is active.
"""
dp_size = server_args.dp_size
if zmq_reader_owner(server_args, caller):
return ZmqShmLoadSnapshotReader(
_zmq_addr_for(port_args), shm_path_for(port_args.instance_id), dp_size
)
return ShmLoadSnapshotReader(shm_path_for(port_args.instance_id), dp_size)
@@ -0,0 +1,41 @@
from typing import Optional
def resolve_min_free_slots(
user_value: Optional[int],
max_running_requests: int,
is_dflash_family: bool = False,
) -> Optional[int]:
"""Resolve the min-free-slots threshold (None = disabled).
A user value (>1) is capped to the DFlash formula so the trigger never
delays more aggressively than the legacy heuristic. When unset, DFlash
workloads fall back to the formula (preserving the always-on behavior);
other workloads stay disabled. Also disabled when max_running_requests < 8.
"""
max_running_requests = max(0, int(max_running_requests))
formula = min(4, max(2, (max_running_requests + 5) // 6))
if user_value is None:
user_value = formula if is_dflash_family else None
if user_value is None or user_value <= 1:
return None
if max_running_requests < 8:
return None
return min(user_value, formula)
class MinFreeSlotsDelayer:
"""Delay fresh prefill admissions until at least ``min_free_slots`` running-
request slots free up, batching them into one admission instead of one at a
time. Useful when each admission is expensive (e.g. DFlash's draft prefill).
Per-rank local: running-batch slots are private to each DP rank, so a rank
with free slots does not wait for a congested peer.
"""
def __init__(self, min_free_slots: int):
self._min_free_slots = min_free_slots
def should_delay(self, *, running_bs: int, num_allocatable_reqs: int) -> bool:
return running_bs > 0 and num_allocatable_reqs < self._min_free_slots
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,782 @@
from __future__ import annotations
# Copyright 2023-2024 SGLang Team
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# ==============================================================================
"""
Mixin classes and utils for multi-http-worker mode
This file uses multiple processes to handle requests and tokenization, reducing the overhead of python and http server.
"""
import asyncio
import logging
import multiprocessing as multiprocessing
import os
import pickle
import signal
import sys
import threading
import zlib
from multiprocessing import shared_memory
from typing import TYPE_CHECKING, Any, Dict, List, Optional, Type
import psutil
import setproctitle
import zmq
import zmq.asyncio
from sglang.srt.disaggregation.utils import DisaggregationMode, TransferBackend
from sglang.srt.managers.disagg_service import start_disagg_service
from sglang.srt.managers.io_struct import (
BaseBatchReq,
BaseReq,
BatchEmbeddingOutput,
BatchStrOutput,
BatchTokenIDOutput,
ContinueGenerationReqInput,
FreezeGCReq,
PauseContinueBroadcastReq,
PauseGenerationReqInput,
TokenizerWorkerRegistrationReq,
async_sock_recv,
async_sock_send,
sock_recv,
sock_send,
unwrap_from_pickle,
wrap_as_pickle,
)
from sglang.srt.managers.load_snapshot import (
create_load_snapshot_reader,
zmq_reader_owner,
)
from sglang.srt.managers.tokenizer_manager import TokenizerManager
from sglang.srt.server_args import PortArgs, ServerArgs
from sglang.srt.utils import (
configure_logger,
kill_itself_when_parent_died,
kill_process_tree,
)
from sglang.srt.utils.network import get_zmq_socket
from sglang.utils import get_exception_traceback
if TYPE_CHECKING:
from sglang.srt.managers.detokenizer_manager import DetokenizerManager
logger = logging.getLogger(__name__)
class SocketMapping:
def __init__(self):
self._zmq_context = zmq.Context()
self._mapping: Dict[str, zmq.Socket] = {}
def clear_all_sockets(self):
for socket in self._mapping.values():
socket.close()
self._mapping.clear()
def _register_ipc_mapping(self, ipc_name: str, is_tokenizer: bool):
type_str = "tokenizer" if is_tokenizer else "detokenizer"
if ipc_name in self._mapping:
logger.warning(f"{type_str} already registered {ipc_name=}, skipping...")
return
logger.info(f"Registering {type_str} {ipc_name=} in SocketMapping...")
socket = get_zmq_socket(self._zmq_context, zmq.PUSH, ipc_name, False)
self._mapping[ipc_name] = socket
def send_output(self, ipc_name: str, output: Any, is_tokenizer: bool = False):
if ipc_name is None:
# Some unhandled cases
logger.warning(f"IPC name is None, output type={type(output)}, skipping...")
return
if ipc_name not in self._mapping:
self._register_ipc_mapping(ipc_name, is_tokenizer=is_tokenizer)
sock_send(self._mapping[ipc_name], output)
def _extract_field_by_index(
output: Any, field_name: str, index: int, check_length: bool = True
) -> Any:
"""Extract a field value from output by index, handling None and length checks.
Args:
output: The output object containing the field
field_name: The name of the field to extract
index: The index to access in the field list
check_length: If True, check both field existence and length. If False, only check field existence.
Returns:
A list containing the field value at index, or None if not available.
"""
field = getattr(output, field_name, None)
if field is None:
return None
should_wrap_result = field_name in ("customized_info", "time_stats")
if should_wrap_result:
field = unwrap_from_pickle(field)
if field is None:
return None
if isinstance(field, dict):
new_field = {}
for k, v in field.items():
if len(v) > index:
new_field[k] = [v[index]] if should_wrap_result else v[index]
else:
new_field[k] = [None] if should_wrap_result else None
if should_wrap_result:
return wrap_as_pickle(new_field) if new_field else None
return new_field
if check_length:
if len(field) <= index:
return None
new_field = [field[index]]
return wrap_as_pickle(new_field) if should_wrap_result else new_field
def _handle_output_by_index(output, i):
"""NOTE: A maintainable method is better here."""
if isinstance(output, BatchTokenIDOutput):
new_output = BatchTokenIDOutput(
rids=[output.rids[i]],
spec_verify_ct=_extract_field_by_index(output, "spec_verify_ct", i),
spec_num_correct_drafts=_extract_field_by_index(
output, "spec_num_correct_drafts", i
),
spec_correct_drafts_histogram=_extract_field_by_index(
output, "spec_correct_drafts_histogram", i
),
spec_num_block_accept_tokens=_extract_field_by_index(
output, "spec_num_block_accept_tokens", i
),
spec_num_cap_tokens=_extract_field_by_index(
output, "spec_num_cap_tokens", i
),
spec_cap_lens_histogram=_extract_field_by_index(
output, "spec_cap_lens_histogram", i
),
time_stats=_extract_field_by_index(output, "time_stats", i),
finished_reasons=_extract_field_by_index(output, "finished_reasons", i),
decoded_texts=_extract_field_by_index(output, "decoded_texts", i),
decode_ids=_extract_field_by_index(output, "decode_ids", i),
read_offsets=_extract_field_by_index(output, "read_offsets", i),
output_ids=_extract_field_by_index(output, "output_ids", i),
skip_special_tokens=_extract_field_by_index(
output, "skip_special_tokens", i
),
spaces_between_special_tokens=_extract_field_by_index(
output, "spaces_between_special_tokens", i
),
no_stop_trim=_extract_field_by_index(output, "no_stop_trim", i),
prompt_tokens=_extract_field_by_index(output, "prompt_tokens", i),
completion_tokens=_extract_field_by_index(output, "completion_tokens", i),
reasoning_tokens=_extract_field_by_index(output, "reasoning_tokens", i),
cached_tokens=_extract_field_by_index(output, "cached_tokens", i),
cached_tokens_details=_extract_field_by_index(
output, "cached_tokens_details", i
),
image_tokens=_extract_field_by_index(output, "image_tokens", i),
audio_tokens=_extract_field_by_index(output, "audio_tokens", i),
video_tokens=_extract_field_by_index(output, "video_tokens", i),
input_token_logprobs_val=_extract_field_by_index(
output, "input_token_logprobs_val", i, check_length=False
),
input_token_logprobs_idx=_extract_field_by_index(
output, "input_token_logprobs_idx", i, check_length=False
),
output_token_logprobs_val=_extract_field_by_index(
output, "output_token_logprobs_val", i, check_length=False
),
output_token_logprobs_idx=_extract_field_by_index(
output, "output_token_logprobs_idx", i, check_length=False
),
input_top_logprobs_val=_extract_field_by_index(
output, "input_top_logprobs_val", i, check_length=False
),
input_top_logprobs_idx=_extract_field_by_index(
output, "input_top_logprobs_idx", i, check_length=False
),
output_top_logprobs_val=_extract_field_by_index(
output, "output_top_logprobs_val", i, check_length=False
),
output_top_logprobs_idx=_extract_field_by_index(
output, "output_top_logprobs_idx", i, check_length=False
),
input_token_ids_logprobs_val=_extract_field_by_index(
output, "input_token_ids_logprobs_val", i, check_length=False
),
input_token_ids_logprobs_idx=_extract_field_by_index(
output, "input_token_ids_logprobs_idx", i, check_length=False
),
output_token_ids_logprobs_val=_extract_field_by_index(
output, "output_token_ids_logprobs_val", i, check_length=False
),
output_token_ids_logprobs_idx=_extract_field_by_index(
output, "output_token_ids_logprobs_idx", i, check_length=False
),
output_token_entropy_val=_extract_field_by_index(
output, "output_token_entropy_val", i, check_length=False
),
output_hidden_states=_extract_field_by_index(
output, "output_hidden_states", i, check_length=False
),
routed_experts=_extract_field_by_index(
output, "routed_experts", i, check_length=False
),
indexer_topk=_extract_field_by_index(
output, "indexer_topk", i, check_length=False
),
retraction_counts=_extract_field_by_index(output, "retraction_counts", i),
placeholder_tokens_idx=None,
placeholder_tokens_val=None,
token_steps=_extract_field_by_index(
output, "token_steps", i, check_length=False
),
customized_info=_extract_field_by_index(
output, "customized_info", i, check_length=False
),
dp_ranks=_extract_field_by_index(output, "dp_ranks", i, check_length=False),
)
elif isinstance(output, BatchEmbeddingOutput):
new_output = BatchEmbeddingOutput(
rids=[output.rids[i]],
finished_reasons=_extract_field_by_index(output, "finished_reasons", i),
embeddings=_extract_field_by_index(output, "embeddings", i),
prompt_tokens=_extract_field_by_index(output, "prompt_tokens", i),
cached_tokens=_extract_field_by_index(output, "cached_tokens", i),
placeholder_tokens_idx=None,
placeholder_tokens_val=None,
)
elif isinstance(output, BatchStrOutput):
new_output = BatchStrOutput(
rids=[output.rids[i]],
spec_verify_ct=_extract_field_by_index(output, "spec_verify_ct", i),
spec_num_correct_drafts=_extract_field_by_index(
output, "spec_num_correct_drafts", i
),
spec_correct_drafts_histogram=_extract_field_by_index(
output, "spec_correct_drafts_histogram", i
),
spec_num_block_accept_tokens=_extract_field_by_index(
output, "spec_num_block_accept_tokens", i
),
spec_num_cap_tokens=_extract_field_by_index(
output, "spec_num_cap_tokens", i
),
spec_cap_lens_histogram=_extract_field_by_index(
output, "spec_cap_lens_histogram", i
),
time_stats=_extract_field_by_index(output, "time_stats", i),
finished_reasons=_extract_field_by_index(output, "finished_reasons", i),
output_strs=_extract_field_by_index(output, "output_strs", i),
output_ids=_extract_field_by_index(output, "output_ids", i),
prompt_tokens=_extract_field_by_index(output, "prompt_tokens", i),
completion_tokens=_extract_field_by_index(output, "completion_tokens", i),
reasoning_tokens=_extract_field_by_index(output, "reasoning_tokens", i),
cached_tokens=_extract_field_by_index(output, "cached_tokens", i),
cached_tokens_details=_extract_field_by_index(
output, "cached_tokens_details", i
),
image_tokens=_extract_field_by_index(output, "image_tokens", i),
audio_tokens=_extract_field_by_index(output, "audio_tokens", i),
video_tokens=_extract_field_by_index(output, "video_tokens", i),
input_token_logprobs_val=_extract_field_by_index(
output, "input_token_logprobs_val", i, check_length=False
),
input_token_logprobs_idx=_extract_field_by_index(
output, "input_token_logprobs_idx", i, check_length=False
),
output_token_logprobs_val=_extract_field_by_index(
output, "output_token_logprobs_val", i, check_length=False
),
output_token_logprobs_idx=_extract_field_by_index(
output, "output_token_logprobs_idx", i, check_length=False
),
input_top_logprobs_val=_extract_field_by_index(
output, "input_top_logprobs_val", i, check_length=False
),
input_top_logprobs_idx=_extract_field_by_index(
output, "input_top_logprobs_idx", i, check_length=False
),
output_top_logprobs_val=_extract_field_by_index(
output, "output_top_logprobs_val", i, check_length=False
),
output_top_logprobs_idx=_extract_field_by_index(
output, "output_top_logprobs_idx", i, check_length=False
),
input_token_ids_logprobs_val=_extract_field_by_index(
output, "input_token_ids_logprobs_val", i, check_length=False
),
input_token_ids_logprobs_idx=_extract_field_by_index(
output, "input_token_ids_logprobs_idx", i, check_length=False
),
output_token_ids_logprobs_val=_extract_field_by_index(
output, "output_token_ids_logprobs_val", i, check_length=False
),
output_token_ids_logprobs_idx=_extract_field_by_index(
output, "output_token_ids_logprobs_idx", i, check_length=False
),
output_token_entropy_val=_extract_field_by_index(
output, "output_token_entropy_val", i, check_length=False
),
output_hidden_states=_extract_field_by_index(
output, "output_hidden_states", i, check_length=False
),
routed_experts=_extract_field_by_index(
output, "routed_experts", i, check_length=False
),
indexer_topk=_extract_field_by_index(
output, "indexer_topk", i, check_length=False
),
customized_info=_extract_field_by_index(
output, "customized_info", i, check_length=False
),
dp_ranks=_extract_field_by_index(output, "dp_ranks", i, check_length=False),
placeholder_tokens_idx=None,
placeholder_tokens_val=None,
retraction_counts=_extract_field_by_index(output, "retraction_counts", i),
token_steps=_extract_field_by_index(
output, "token_steps", i, check_length=False
),
)
else:
new_output = output
return new_output
class MultiHttpWorkerDetokenizerMixin:
"""Mixin class for DetokenizerManager"""
def maybe_clear_socket_mapping(self: DetokenizerManager):
if hasattr(self, "socket_mapping"):
self.socket_mapping.clear_all_sockets()
def multi_http_worker_event_loop(self: DetokenizerManager):
"""The event loop that handles requests, for multi multi-http-worker mode"""
self.socket_mapping = SocketMapping()
while True:
recv_obj = sock_recv(self.recv_from_scheduler)
output = self._request_dispatcher(recv_obj)
if output is None:
continue
# Fan out the output back to the originating tokenizer worker(s).
# In multi-detokenizer mode the upstream MultiDetokenizerRouter may
# forward either batched or single requests, so handle both shapes.
if isinstance(recv_obj, BaseBatchReq):
for i, ipc_name in enumerate(recv_obj.http_worker_ipcs):
new_output = _handle_output_by_index(output, i)
self.socket_mapping.send_output(
ipc_name, new_output, is_tokenizer=True
)
elif isinstance(recv_obj, BaseReq):
self.socket_mapping.send_output(
recv_obj.http_worker_ipc, output, is_tokenizer=True
)
else:
raise ValueError(
f"multi_http_worker_event_loop got unexpected req type {type(recv_obj)}"
)
class MultiTokenizerRouter:
"""A router between tokenizer managers and the scheduler/detokenizer manager.
Forward: tokenizer managers → router → scheduler.
Backward: detokenizer manager → router → tokenizer managers.
Also broadcasts pause/continue to all tokenizer managers for consistent is_pause state.
"""
def __init__(
self,
server_args: ServerArgs,
port_args: PortArgs,
):
self.server_args = server_args
context = zmq.asyncio.Context(3)
self.recv_from_detokenizer = get_zmq_socket(
context, zmq.PULL, port_args.tokenizer_ipc_name, True
)
self.send_to_scheduler = get_zmq_socket(
context, zmq.PUSH, port_args.scheduler_input_ipc_name, True
)
self.receive_from_worker = get_zmq_socket(
context, zmq.PULL, port_args.tokenizer_worker_ipc_name, True
)
self._loop = asyncio.new_event_loop()
self._thread = threading.Thread(target=self._run_loop, daemon=True)
self._thread.start()
self._task = asyncio.run_coroutine_threadsafe(
self.router_worker_obj(), self._loop
)
self._handle_task = asyncio.run_coroutine_threadsafe(
print_exception_wrapper(self.handle_loop), self._loop
)
# In multi-tokenizer mode the N TokenizerWorker processes cannot each
# bind the zmq PULL socket used for load snapshots, so the single
# MultiTokenizerRouter process owns it (zmq -> SHM) and the workers
# read SHM only. Drain it event-driven via the socket's fd instead of
# polling on a timer.
self.load_snapshot_reader = None
if zmq_reader_owner(server_args, "MultiTokenizerRouter"):
self.load_snapshot_reader = create_load_snapshot_reader(
server_args, port_args, caller="MultiTokenizerRouter"
)
self._loop.call_soon_threadsafe(self._register_load_snapshot_reader)
self.disaggregation_bootstrap_server = start_disagg_service(self.server_args)
# Worker IPC names for pause/continue broadcasting
self.all_worker_ipcs: set[str] = set()
# Shared socket mapping (both coroutines run on self._loop, so safe)
self.socket_mapping = SocketMapping()
def _run_loop(self):
self._loop.run_forever()
def _register_load_snapshot_reader(self):
"""Drain zmq load snapshots into SHM whenever the PULL socket is readable.
zmq exposes an edge-triggered fd; ``poll()`` drains it until empty, which
also re-arms the fd, so TokenizerWorkers reading SHM stay up to date
without any timer.
"""
assert self.load_snapshot_reader is not None
self._loop.add_reader(
self.load_snapshot_reader.fileno(), self.load_snapshot_reader.poll
)
# Drain anything already queued before the fd was registered.
self.load_snapshot_reader.poll()
async def router_worker_obj(self):
"""Forward path: workers → scheduler, with pause/continue broadcast."""
while True:
recv_obj = await async_sock_recv(self.receive_from_worker)
if isinstance(recv_obj, TokenizerWorkerRegistrationReq):
if recv_obj.worker_ipc_name not in self.all_worker_ipcs:
self.all_worker_ipcs.add(recv_obj.worker_ipc_name)
logger.info(
f"Router registered worker IPC: {recv_obj.worker_ipc_name} "
f"(total: {len(self.all_worker_ipcs)})"
)
continue
if isinstance(
recv_obj, (PauseGenerationReqInput, ContinueGenerationReqInput)
):
# Broadcast to ALL workers so every worker's is_pause is set
is_pause = isinstance(recv_obj, PauseGenerationReqInput)
broadcast = PauseContinueBroadcastReq(is_pause=is_pause)
for ipc_name in self.all_worker_ipcs:
self.socket_mapping.send_output(ipc_name, broadcast)
# Forward to scheduler rank 0 (it broadcasts to all TP/PP/DP
# ranks internally). Skip for abort mode which drains via polling.
if not (
isinstance(recv_obj, PauseGenerationReqInput)
and recv_obj.mode == "abort"
):
await async_sock_send(self.send_to_scheduler, recv_obj)
continue
await async_sock_send(self.send_to_scheduler, recv_obj)
async def handle_loop(self):
"""Backward path: detokenizer → route results to correct worker."""
while True:
recv_obj = await async_sock_recv(self.recv_from_detokenizer)
await self._distribute_result_to_workers(recv_obj)
async def _distribute_result_to_workers(self, recv_obj):
if isinstance(recv_obj, BaseReq):
ipc_names = [recv_obj.http_worker_ipc]
elif isinstance(recv_obj, BaseBatchReq):
ipc_names = recv_obj.http_worker_ipcs
else:
raise ValueError(f"Unknown recv_obj type: {type(recv_obj)}")
for i, ipc_name in enumerate(ipc_names):
new_recv_obj = _handle_output_by_index(recv_obj, i)
self.socket_mapping.send_output(ipc_name, new_recv_obj)
class MultiDetokenizerRouter:
"""Route scheduler outputs to one of N DetokenizerManager workers.
Each request is pinned to a worker by hashing its ``http_worker_ipc`` with
``zlib.crc32`` (deterministic across runs), so all outputs of the same rid
always land on the same detokenizer and ``decode_status`` stays consistent.
"""
def __init__(self, ipc_name_list: List[str], port_args: PortArgs):
self.ipc_name_list = ipc_name_list
self.num_workers = len(ipc_name_list)
self.socket_mapping = SocketMapping()
context = zmq.Context(2)
self.recv_from_scheduler = get_zmq_socket(
context, zmq.PULL, port_args.detokenizer_ipc_name, True
)
def _pick(self, key: str) -> str:
return self.ipc_name_list[zlib.crc32(key.encode()) % self.num_workers]
def _send(self, ipc_name: str, obj: Any) -> None:
self.socket_mapping.send_output(ipc_name, obj, is_tokenizer=False)
def event_loop(self):
while True:
recv_obj = sock_recv(self.recv_from_scheduler)
# FreezeGCReq must freeze every detokenizer process.
if isinstance(recv_obj, FreezeGCReq):
for ipc in self.ipc_name_list:
self._send(ipc, recv_obj)
continue
# Single request: route by its own http_worker_ipc.
if isinstance(recv_obj, BaseReq):
assert (
recv_obj.http_worker_ipc is not None
), f"Single req {recv_obj.rid=} missing http_worker_ipc"
self._send(self._pick(recv_obj.http_worker_ipc), recv_obj)
continue
# Batch request.
if isinstance(recv_obj, BaseBatchReq):
# Idle/no-op batch (rids=[]): broadcast to all detokenizers
if not recv_obj.rids:
for ipc in self.ipc_name_list:
self._send(ipc, recv_obj)
continue
ipcs = recv_obj.http_worker_ipcs
assert (
ipcs is not None
and len(ipcs) == len(recv_obj.rids)
and all(x is not None for x in ipcs)
), f"Batch req {recv_obj.rids=} has invalid http_worker_ipcs"
# Split per-item and route each by its own ipc.
for i, ipc_key in enumerate(ipcs):
one = _handle_output_by_index(recv_obj, i)
if one is recv_obj:
raise TypeError(f"Cannot split {type(recv_obj)}")
one.http_worker_ipcs = [ipc_key]
self._send(self._pick(ipc_key), one)
continue
raise ValueError(
f"MultiDetokenizerRouter got unsupported type {type(recv_obj)}"
)
def run_multi_detokenizer_router_process(
ipc_name_list: List[str],
server_args: ServerArgs,
port_args: PortArgs,
):
kill_itself_when_parent_died()
setproctitle.setproctitle("sglang::detokenizer_router")
configure_logger(server_args)
parent_process = psutil.Process().parent()
router = None
try:
router = MultiDetokenizerRouter(ipc_name_list, port_args)
router.event_loop()
except Exception:
traceback = get_exception_traceback()
logger.error(f"MultiDetokenizerRouter hit an exception: {traceback}")
if router is not None:
router.socket_mapping.clear_all_sockets()
parent_process.send_signal(signal.SIGQUIT)
class TokenizerWorker(TokenizerManager):
"""Tokenizer Worker in multi-http-worker mode"""
def __init__(
self,
server_args: ServerArgs,
port_args: PortArgs,
):
setproctitle.setproctitle(f"sglang::tokenizer_worker:{os.getpid()}")
import torch
torch.set_num_threads(1)
# prevent init prefill bootstrapserver again
disaggregation_mode = server_args.disaggregation_mode
server_args.override(
"tokenizer_worker.suppress_bootstrap", disaggregation_mode="null"
)
super().__init__(server_args, port_args)
self.worker_id = os.getpid()
self.tokenizer_ipc_name = port_args.tokenizer_ipc_name
# For PD disaggregtion
self.server_args.override(
"tokenizer_worker.restore_disaggregation_mode",
disaggregation_mode=disaggregation_mode,
)
self.disaggregation_mode = DisaggregationMode(
self.server_args.disaggregation_mode
)
self.disaggregation_transfer_backend = TransferBackend(
self.server_args.disaggregation_transfer_backend
)
# Register this worker with the router for pause/continue broadcasting
reg = TokenizerWorkerRegistrationReq(worker_ipc_name=self.tokenizer_ipc_name)
self._dispatch_to_scheduler(reg)
# Future for awaiting pause/continue broadcast confirmation
self._pause_continue_future: Optional[asyncio.Future] = None
# Register PauseContinueBroadcastReq in the result dispatcher so
# handle_loop routes it to _handle_pause_continue_broadcast
from sglang.utils import TypeBasedDispatcher
self._result_dispatcher += TypeBasedDispatcher(
[(PauseContinueBroadcastReq, self._handle_pause_continue_broadcast)]
)
async def pause_generation(self, obj: PauseGenerationReqInput):
loop = asyncio.get_event_loop()
self._pause_continue_future = loop.create_future()
# Send to router which will broadcast to all workers
# (router also handles forwarding to scheduler for non-abort modes)
self._dispatch_to_scheduler(obj)
await self._pause_continue_future
if obj.mode == "abort":
# Abort polling: only the originator checks its own lock state
while True:
self.abort_request(abort_all=True)
is_locked = await self.model_update_lock.is_locked()
if not is_locked:
break
await asyncio.sleep(1.0)
async def continue_generation(self, obj: ContinueGenerationReqInput):
loop = asyncio.get_event_loop()
self._pause_continue_future = loop.create_future()
self._dispatch_to_scheduler(obj)
await self._pause_continue_future
def _handle_pause_continue_broadcast(self, obj: PauseContinueBroadcastReq):
"""Called from handle_loop when a broadcast arrives from the router."""
loop = asyncio.get_event_loop()
loop.create_task(self._apply_pause_continue_broadcast(obj))
async def _apply_pause_continue_broadcast(self, obj: PauseContinueBroadcastReq):
"""Apply pause/continue state under the condition lock."""
async with self.is_pause_cond:
if obj.is_pause:
self.is_pause = True
else:
self.is_pause = False
self.is_pause_cond.notify_all()
# Resolve the pending future if this worker initiated the pause/continue
if self._pause_continue_future and not self._pause_continue_future.done():
self._pause_continue_future.set_result(True)
self._pause_continue_future = None
def get_tokenizer_worker_class(server_args: ServerArgs) -> Type[TokenizerWorker]:
worker_class = server_args.get_tokenizer_worker_class()
if not isinstance(worker_class, type) or not issubclass(
worker_class, TokenizerWorker
):
raise TypeError(
"ServerArgs.get_tokenizer_worker_class() must return a TokenizerWorker "
f"subclass, got {worker_class!r}"
)
return worker_class
async def print_exception_wrapper(func):
"""
Sometimes an asyncio function does not print exception.
We do another wrapper to handle the exception.
"""
try:
await func()
except Exception:
traceback = get_exception_traceback()
logger.error(f"MultiTokenizerRouter hit an exception: {traceback}")
if hasattr(func, "__self__") and isinstance(
func.__self__, MultiTokenizerRouter
):
func.__self__.dump_requests_before_crash()
kill_process_tree(os.getpid(), include_parent=True)
sys.exit(1)
def get_main_process_id() -> int:
"""Get the main process ID."""
return multiprocessing.current_process()._parent_pid
def write_to_shared_memory(obj, name: str) -> shared_memory.SharedMemory:
"""Write data to shared memory"""
serialized = pickle.dumps(obj)
size = len(serialized)
try:
# Try to open existing shared memory
shm = shared_memory.SharedMemory(name=name)
# If size is insufficient, close and recreate
if shm.size < size:
shm.close()
shm.unlink()
shm = shared_memory.SharedMemory(create=True, size=size, name=name)
except FileNotFoundError:
# If not present, create new shared memory
shm = shared_memory.SharedMemory(create=True, size=size, name=name)
shm.buf[:size] = serialized
return shm
def read_from_shared_memory(name: str) -> Any:
"""Read data from shared memory"""
try:
shm = shared_memory.SharedMemory(name=name)
data = pickle.loads(bytes(shm.buf))
shm.close()
return data
except FileNotFoundError:
raise FileNotFoundError(f"Shared memory {name} not found")
def write_data_for_multi_tokenizer(
port_args: PortArgs, server_args: ServerArgs, scheduler_info: Dict
):
"""Write args information to share memory for multi-tokenizer"""
# get main process ID
main_pid = get_main_process_id()
current_pid = os.getpid()
logger.info(f"main process ID: {main_pid}, current process ID: {current_pid}")
args = (port_args, server_args, scheduler_info)
args_shm = write_to_shared_memory(args, f"multi_tokenizer_args_{current_pid}")
args_shm.close()
return args_shm
@@ -0,0 +1,83 @@
# TODO: also move pad_input_ids into this module
import importlib
import inspect
import logging
import pkgutil
from sglang.srt.configs.model_config import ModelImpl
from sglang.srt.multimodal.processors.base_processor import BaseMultimodalProcessor
from sglang.srt.server_args import ServerArgs
logger = logging.getLogger(__name__)
PROCESSOR_MAPPING = {}
def import_processors(package_name: str, overwrite: bool = False):
package = importlib.import_module(package_name)
for _, name, ispkg in pkgutil.iter_modules(package.__path__, package_name + "."):
if not ispkg:
try:
module = importlib.import_module(name)
except Exception as e:
logger.warning(f"Ignore import error when loading {name}: {e}")
continue
all_members = inspect.getmembers(module, inspect.isclass)
classes = [
member
for name, member in all_members
if member.__module__ == module.__name__
]
for cls in (
cls for cls in classes if issubclass(cls, BaseMultimodalProcessor)
):
assert hasattr(cls, "models")
for arch in getattr(cls, "models"):
if overwrite:
for model_cls, processor_cls in PROCESSOR_MAPPING.items():
if model_cls.__name__ == arch.__name__:
del PROCESSOR_MAPPING[model_cls]
break
PROCESSOR_MAPPING[arch] = cls
def get_mm_processor(
hf_config,
server_args: ServerArgs,
processor,
transport_mode,
model_config=None,
**kwargs,
) -> BaseMultimodalProcessor:
model_impl = str(getattr(server_args, "model_impl", "auto")).lower()
uses_transformers_backend = model_impl == "transformers"
if model_impl == "auto" and model_config is not None:
from sglang.srt.model_loader.utils import get_resolved_model_impl
uses_transformers_backend = (
get_resolved_model_impl(model_config) == ModelImpl.TRANSFORMERS
)
for model_cls, processor_cls in PROCESSOR_MAPPING.items():
if model_cls.__name__ not in hf_config.architectures:
continue
if not uses_transformers_backend or getattr(
processor_cls, "supports_transformers_backend", False
):
return processor_cls(
hf_config, server_args, processor, transport_mode, **kwargs
)
if uses_transformers_backend:
from sglang.srt.multimodal.processors.transformers_auto import (
TransformersAutoMultimodalProcessor,
)
return TransformersAutoMultimodalProcessor(
hf_config, server_args, processor, transport_mode, **kwargs
)
raise ValueError(
f"No processor registered for architecture: {hf_config.architectures}.\n"
f"Registered architectures: {[model_cls.__name__ for model_cls in PROCESSOR_MAPPING.keys()]}"
)
+527
View File
@@ -0,0 +1,527 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import TYPE_CHECKING, Any, Optional, Sequence
import msgspec
import torch
from sglang.kernels.ops.speculative.gather_spec_extras import gather_spec_extras
from sglang.srt.environ import envs
from sglang.srt.utils import is_cuda, is_hip, is_npu
if TYPE_CHECKING:
from sglang.srt.layers.attention.base_attn_backend import AttentionBackend
from sglang.srt.managers.schedule_batch import ScheduleBatch
from sglang.srt.mem_cache.memory_pool import ReqToTokenPool
from sglang.srt.server_args import ServerArgs
from sglang.srt.speculative.eagle_info import EagleDraftInput
from sglang.srt.speculative.spec_info import SpeculativeAlgorithm
def decide_needs_cpu_seq_lens(
server_args: ServerArgs,
attn_backends: Sequence[AttentionBackend],
) -> bool:
"""Whether FutureMap must publish seq_lens_cpu / sum.
OR over per-backend needs_cpu_seq_lens; force True under TBO (it reads the
CPU mirror outside the backend layer to split the batch) or ngram (its
USE_FULL_MASK verify path reads the host mirror regardless of backend).
"""
# Local import: keep overlap_utils' module-level deps leaf-only so it stays
# importable everywhere; spec_info pulls in the spec/schedule_batch graph.
from sglang.srt.speculative.spec_info import SpeculativeAlgorithm
if server_args.enable_two_batch_overlap:
# FIXME: support TBO without seq lens cpu value
return True
algo = SpeculativeAlgorithm.from_string(server_args.speculative_algorithm)
if algo.is_ngram():
# ngram's USE_FULL_MASK verify path reads seq_lens_cpu per req to size
# the tree mask, regardless of the attn backend (e.g. Triton opts out).
return True
# Skip unset slots (e.g. draft_extend_attn_backend on some spec configs);
# missing flag -> True so undeclared backends stay on the legacy path.
return any(
getattr(b, "needs_cpu_seq_lens", True) for b in attn_backends if b is not None
)
def decide_needs_confidence_relay(server_args: ServerArgs) -> bool:
from sglang.srt.speculative.ragged_verify import (
RaggedVerifyMode,
read_ragged_verify_mode,
)
from sglang.srt.speculative.spec_info import SpeculativeAlgorithm
algo = SpeculativeAlgorithm.from_string(server_args.speculative_algorithm)
if not algo.is_dspark():
return False
return read_ragged_verify_mode() is not RaggedVerifyMode.STATIC
_is_cuda = is_cuda()
_is_hip = is_hip()
_is_npu = is_npu()
# Token-buf consume tracking: init to -1, assert non-negative on gather,
# write -1 back. Catches "gather without intermediate stash" bugs. CI enables
# via the existing SGLANG_IS_IN_CI; off in production.
_DEBUG_ASSERT = envs.SGLANG_IS_IN_CI.get()
@torch.compile(dynamic=True, disable=_is_npu)
def _assert_nonneg_and_invalidate(
values: torch.Tensor, buf: torch.Tensor, indices: torch.Tensor
) -> None:
"""Fused: assert all `values >= 0` and scatter -1 into `buf[indices]`.
Compiled so the reduction + assert + scatter run as one kernel launch."""
torch._assert_async((values >= 0).all())
buf[indices] = -1
def resolve_forward_inputs(batch: ScheduleBatch, future_map: FutureMap) -> None:
"""Materialize input_ids at forward entry. Two sources:
- Prefill: H2D copy from pinned CPU staging (prefill_input_ids_cpu).
- Decode/spec_v2: gather from FutureMap (last iter's sampled token).
"""
if batch.prefill_input_ids_cpu is not None:
prefill_gpu = batch.prefill_input_ids_cpu.to(batch.device, non_blocking=True)
if batch.mix_running_indices is not None:
decode_gpu = future_map.output_tokens_buf[batch.mix_running_indices]
if _DEBUG_ASSERT:
_assert_nonneg_and_invalidate(
decode_gpu,
future_map.output_tokens_buf,
batch.mix_running_indices,
)
batch.input_ids = torch.cat([prefill_gpu, decode_gpu])
else:
batch.input_ids = prefill_gpu
batch.prefill_input_ids_cpu = None
batch.mix_running_indices = None
elif batch.input_ids is None and future_map.spec_algo.is_none():
batch.input_ids = future_map.output_tokens_buf[batch.req_pool_indices]
if _DEBUG_ASSERT:
_assert_nonneg_and_invalidate(
batch.input_ids, future_map.output_tokens_buf, batch.req_pool_indices
)
# Only the overlap path relays spec extras through the future_map; the
# synchronous (non-overlap) V2 path installs next_draft_input directly.
if batch.enable_overlap and not batch.spec_algorithm.is_none():
future_map._resolve_spec_extras(batch)
CONFIDENCE_RELAY_RING_LAG: int = 2
CONFIDENCE_RELAY_RING_DEPTH: int = CONFIDENCE_RELAY_RING_LAG + 1
class ResolvedConfidence(msgspec.Struct):
confidence: torch.Tensor
generation: torch.Tensor
@dataclass
class RelayPayload:
"""Per-iteration stash payload for the FutureMap bufs. Non-spec fills only
`bonus_tokens`; which spec extras get relayed is decided by
`FutureMap.spec_algo`, not by this payload's shape."""
bonus_tokens: torch.Tensor
topk_p: Optional[torch.Tensor] = None
topk_index: Optional[torch.Tensor] = None
hidden_states: Optional[torch.Tensor] = None
draft_probs: Optional[torch.Tensor] = None
dsa_topk_indices: Optional[torch.Tensor] = None
@classmethod
def from_draft_input(cls, draft_input: EagleDraftInput) -> RelayPayload:
return cls(
bonus_tokens=draft_input.bonus_tokens,
topk_p=draft_input.topk_p,
topk_index=draft_input.topk_index,
hidden_states=draft_input.hidden_states,
draft_probs=getattr(draft_input, "draft_probs", None),
dsa_topk_indices=getattr(draft_input, "dsa_topk_indices", None),
)
class ConfidenceRelay(msgspec.Struct):
device: torch.device
req_pool_size: int
pool: Any
confidence_buf: Optional[torch.Tensor] = None
conf_ring: Optional[torch.Tensor] = None
gen_ring: Optional[torch.Tensor] = None
copy_done: Optional[list] = None
ring_pos: int = 0
initialized: bool = False
def _lazy_init(self, confidence: torch.Tensor) -> None:
self.initialized = True
gamma = confidence.shape[-1]
self.confidence_buf = torch.empty(
(self.req_pool_size, gamma), dtype=torch.float32, device=self.device
)
if _is_cuda:
depth = CONFIDENCE_RELAY_RING_DEPTH
self.conf_ring = torch.empty(
(depth, self.req_pool_size, gamma),
dtype=torch.float32,
pin_memory=True,
)
self.gen_ring = torch.zeros((depth, self.req_pool_size), dtype=torch.int64)
self.copy_done = [
torch.get_device_module(self.device).Event() for _ in range(depth)
]
def scatter(self, indices: torch.Tensor, confidence: torch.Tensor) -> None:
if not self.initialized:
self._lazy_init(confidence)
self.confidence_buf[indices] = confidence.to(self.confidence_buf.dtype)
def issue_ring_copy(self, *, stream, publish_ready) -> None:
if not self.initialized or stream is None or publish_ready is None:
return
slot = self.ring_pos % CONFIDENCE_RELAY_RING_DEPTH
stream.wait_event(publish_ready)
with torch.get_device_module(self.device).stream(stream):
self.conf_ring[slot].copy_(self.confidence_buf, non_blocking=True)
self.copy_done[slot].record()
self.gen_ring[slot].copy_(self.pool.req_generation)
self.ring_pos += 1
def resolve(
self, batch: ScheduleBatch, *, stream, publish_ready
) -> Optional[ResolvedConfidence]:
if not self.initialized:
return None
draft_input = batch.spec_info
if draft_input is None:
return None
fi = draft_input.future_indices
if fi is None or fi.shape[0] == 0:
return None
if stream is None or publish_ready is None:
idx = batch.req_pool_indices
idx_cpu = batch.req_pool_indices_cpu
return ResolvedConfidence(
confidence=self.confidence_buf[idx].cpu(),
generation=self.pool.req_generation[idx_cpu].clone(),
)
if self.ring_pos < CONFIDENCE_RELAY_RING_LAG:
return None
slot = (self.ring_pos - CONFIDENCE_RELAY_RING_LAG) % CONFIDENCE_RELAY_RING_DEPTH
if not self.copy_done[slot].query():
return None
idx_cpu = batch.req_pool_indices_cpu
return ResolvedConfidence(
confidence=self.conf_ring[slot][idx_cpu],
generation=self.gen_ring[slot][idx_cpu],
)
class FutureMap:
"""Always-on pool-indexed relay for cross-iter values. Forward writes via
publish/stash; next iter reads via resolve_forward_inputs / resolve_seq_lens_cpu.
"""
def __init__(
self,
device: torch.device,
spec_algo: SpeculativeAlgorithm,
req_to_token_pool: ReqToTokenPool,
needs_cpu_seq_lens: bool = True,
needs_confidence_relay: bool = False,
):
# Bufs indexed by req_pool_idx; slot 0 mirrors KV padding row so
# CUDA-graph padded batches (req_pool_idx == 0) are harmless.
self.device = device
self.spec_algo = spec_algo
# Computed by decide_needs_cpu_seq_lens(); see that helper for the
# full decision (per-backend flag + TBO / piecewise CG overrides).
self.needs_cpu_seq_lens = needs_cpu_seq_lens
self.needs_confidence_relay = needs_confidence_relay
self.req_pool_size = req_to_token_pool.req_to_token.shape[0]
if _DEBUG_ASSERT:
# Poisoned init: every row must be written before its first gather.
self.output_tokens_buf = torch.full(
(self.req_pool_size,), -1, dtype=torch.int64, device=self.device
)
self.new_seq_lens_buf = torch.full(
(self.req_pool_size,), -1, dtype=torch.int64, device=self.device
)
else:
self.output_tokens_buf = torch.empty(
(self.req_pool_size,), dtype=torch.int64, device=self.device
)
self.new_seq_lens_buf = torch.empty(
(self.req_pool_size,), dtype=torch.int64, device=self.device
)
# Pinned host copy of new_seq_lens_buf + private stream for fwd-prepare
# D2H pulls (gated only on publish, off the schedule stream). CUDA-only:
# recovers occupancy lost to the WAR barrier (also CUDA-only); other
# platforms have no barrier and use the plain .cpu() bootstrap path.
if _is_cuda:
self.new_seq_lens_cpu_pinned = torch.empty(
(self.req_pool_size,), dtype=torch.int64, pin_memory=True
)
self.fwd_prepare_d2h_stream = torch.get_device_module(self.device).Stream()
else:
self.new_seq_lens_cpu_pinned = None
self.fwd_prepare_d2h_stream = None
# Lazy-inited on the first non-empty stash (peeks tensor shapes); non-spec's is a no-op.
self._forward_buf_initialized = False
self.publish_ready = None # lazy device.Event(); only spec_v2 needs it
# Debug consume-once state: armed by a recording publish, consumed by
# resolve; arm/consume strictly alternate across all batch interleavings.
self._publish_fresh = False
self.confidence_relay = ConfidenceRelay(
device=self.device,
req_pool_size=self.req_pool_size,
pool=req_to_token_pool,
)
def _lazy_init_forward_buf(self, payload: RelayPayload):
# Local import (see decide_needs_cpu_seq_lens): keep module-level deps leaf.
from sglang.srt.speculative.spec_utils import spec_need_hidden_states
self._forward_buf_initialized = True
# Spec extras are gated by spec_algo, not by the payload's shape, so a
# non-spec stash allocates no extra bufs (only output_tokens_buf).
self.need_topk = self.spec_algo.is_some() and self.spec_algo.need_topk()
self.need_hidden_states = (
self.spec_algo.is_some()
and spec_need_hidden_states()
and payload.hidden_states is not None
)
if self.need_topk:
topk_p0 = payload.topk_p[0]
topk_index0 = payload.topk_index[0]
self.topk_p_buf = torch.empty(
(self.req_pool_size, *topk_p0.shape),
dtype=topk_p0.dtype,
device=self.device,
)
self.topk_index_buf = torch.empty(
(self.req_pool_size, *topk_index0.shape),
dtype=topk_index0.dtype,
device=self.device,
)
if self.need_hidden_states:
hidden_states0 = payload.hidden_states[0]
self.hidden_states_buf = torch.empty(
(self.req_pool_size, *hidden_states0.shape),
dtype=hidden_states0.dtype,
device=self.device,
)
self.draft_probs_buf = None
if payload.draft_probs is not None:
draft_probs0 = payload.draft_probs[0]
self.draft_probs_buf = torch.empty(
(self.req_pool_size, *draft_probs0.shape),
dtype=draft_probs0.dtype,
device=self.device,
)
self.dsa_topk_indices_buf = None
if payload.dsa_topk_indices is not None:
seed0 = payload.dsa_topk_indices[0]
self.dsa_topk_indices_buf = torch.empty(
(self.req_pool_size, *seed0.shape),
dtype=payload.dsa_topk_indices.dtype,
device=self.device,
)
def resolve_confidence_cpu(
self, batch: ScheduleBatch
) -> Optional[ResolvedConfidence]:
if not self.needs_confidence_relay:
return None
return self.confidence_relay.resolve(
batch,
stream=self.fwd_prepare_d2h_stream,
publish_ready=self.publish_ready,
)
def _resolve_spec_extras(self, batch: ScheduleBatch) -> None:
if self.spec_algo.is_ngram():
# FIXME: remove once precomputed draft is supported.
return
draft_input: EagleDraftInput = batch.spec_info
if draft_input is None:
# FIXME(lsyin): only prefill; not compatible with mixed mode
return
indices = draft_input.future_indices
if indices.shape[0] == 0:
return
# FIXME: indices = batch.req_pool_indices, pinned 2 iters via
# record_batch_in_overlap; record_stream here is redundant.
indices.record_stream(torch.get_device_module(self.device).current_stream())
if self.need_topk:
hidden_states_buf = (
self.hidden_states_buf if self.need_hidden_states else None
)
(
draft_input.topk_p,
draft_input.topk_index,
bonus_tokens,
hidden_states,
) = gather_spec_extras(
indices,
self.topk_p_buf,
self.topk_index_buf,
self.output_tokens_buf,
hidden_states_buf,
)
draft_input.bonus_tokens = bonus_tokens
if hidden_states is not None:
draft_input.hidden_states = hidden_states
if self.draft_probs_buf is not None and draft_input.draft_probs is not None:
draft_input.draft_probs = self.draft_probs_buf[indices]
else:
draft_input.bonus_tokens = self.output_tokens_buf[indices]
if self.need_hidden_states and not self.need_topk:
draft_input.hidden_states = self.hidden_states_buf[indices]
if self.dsa_topk_indices_buf is not None:
draft_input.dsa_topk_indices = self.dsa_topk_indices_buf[indices]
if _DEBUG_ASSERT:
_assert_nonneg_and_invalidate(
draft_input.bonus_tokens, self.output_tokens_buf, indices
)
def resolve_seq_lens_cpu(self, batch: ScheduleBatch) -> None:
# Lazy pull from new_seq_lens_buf for spec_v2 (accept_lens not known to
# schedule). The CPU mirror is gated by needs_cpu_seq_lens; backends that
# opt out take the GPU-only path below. A private D2H stream overlaps the copy.
draft_input = batch.spec_info
if draft_input is None:
return
fi = draft_input.future_indices
if fi is None:
return
if self.publish_ready is not None:
if _DEBUG_ASSERT:
# Consume-once: every event wait must be re-armed by a fresh
# forward publish; a stale consume means a publish went missing.
assert self._publish_fresh, "resolve without a fresh forward publish"
self._publish_fresh = False
if _is_hip:
# Temporary workaround: Event.wait() regresses TPOT on AMD MI355.
self.publish_ready.synchronize()
else:
self.publish_ready.wait()
batch.seq_lens = self.new_seq_lens_buf[fi]
if not self.needs_cpu_seq_lens:
# GPU gather above is kept (SB.seq_lens must advance each verify);
# skip the .cpu() D2H. Downstream takes the GPU-only path.
batch.seq_lens_cpu = None
batch.seq_lens_sum = None
if _DEBUG_ASSERT:
# Poison consumed rows: each row must be re-published/seeded
# before the next resolve gathers it (safe here: the forward's
# re-publish is fenced behind this stream via wait_stream).
_assert_nonneg_and_invalidate(batch.seq_lens, self.new_seq_lens_buf, fi)
return
if self.fwd_prepare_d2h_stream is None or self.publish_ready is None:
batch.seq_lens_cpu = batch.seq_lens.cpu() # bootstrap / non-CUDA
batch.seq_lens_sum = int(batch.seq_lens_cpu.sum())
if _DEBUG_ASSERT:
_assert_nonneg_and_invalidate(batch.seq_lens, self.new_seq_lens_buf, fi)
return
# Mechanism: don't sync the schedule stream; gate a private stream on the
# publish event and copy into the static pinned buffer.
self.fwd_prepare_d2h_stream.wait_event(self.publish_ready)
with torch.get_device_module(self.device).stream(self.fwd_prepare_d2h_stream):
self.new_seq_lens_cpu_pinned.copy_(self.new_seq_lens_buf, non_blocking=True)
self.fwd_prepare_d2h_stream.synchronize()
# FIXME: fi == batch.req_pool_indices; unify future_indices and req_pool_indices.
batch.seq_lens_cpu = self.new_seq_lens_cpu_pinned[batch.req_pool_indices_cpu]
batch.seq_lens_sum = int(batch.seq_lens_cpu.sum())
if _DEBUG_ASSERT:
# After the D2H copy completed (synchronize above), so the pinned
# mirror is not poisoned.
_assert_nonneg_and_invalidate(batch.seq_lens, self.new_seq_lens_buf, fi)
def publish(
self,
future_indices: torch.Tensor,
new_seq_lens: torch.Tensor,
confidence: Optional[torch.Tensor] = None,
) -> None:
indices = future_indices
if indices.shape[0] == 0:
return # DP idle
self.new_seq_lens_buf[indices] = new_seq_lens.to(self.new_seq_lens_buf.dtype)
publish_confidence = self.needs_confidence_relay and confidence is not None
if publish_confidence:
self.confidence_relay.scatter(indices, confidence)
# Only spec_v2 needs the event; it gates the seq_lens D2H on the private stream.
if self.spec_algo.is_some():
device_module = torch.get_device_module(self.device)
if self.publish_ready is None:
self.publish_ready = device_module.Event()
else:
# Chain the records: event fire implies every prior publish is
# visible, so an off-forward-stream publish (PD-decode prebuilt
# seeding) cannot drop the in-flight forward's fence.
device_module.current_stream().wait_event(self.publish_ready)
self.publish_ready.record()
self._publish_fresh = True
if publish_confidence:
self.confidence_relay.issue_ring_copy(
stream=self.fwd_prepare_d2h_stream,
publish_ready=self.publish_ready,
)
def stash(self, future_indices: torch.Tensor, payload: RelayPayload) -> None:
if self.spec_algo.is_ngram():
# FIXME: remove once precomputed draft is supported.
return
indices = future_indices
if indices.shape[0] == 0:
# DP idle: payload is empty stub; lazy-init shape peek would IndexError.
return
if not self._forward_buf_initialized:
self._lazy_init_forward_buf(payload)
self.output_tokens_buf[indices] = payload.bonus_tokens.to(
self.output_tokens_buf.dtype
)
if self.need_topk:
self.topk_p_buf[indices] = payload.topk_p.to(self.topk_p_buf.dtype)
self.topk_index_buf[indices] = payload.topk_index.to(
self.topk_index_buf.dtype
)
if self.need_hidden_states:
self.hidden_states_buf[indices] = payload.hidden_states.to(
self.hidden_states_buf.dtype
)
if self.draft_probs_buf is not None and payload.draft_probs is not None:
self.draft_probs_buf[indices] = payload.draft_probs
if (
self.dsa_topk_indices_buf is not None
and payload.dsa_topk_indices is not None
):
self.dsa_topk_indices_buf[indices] = payload.dsa_topk_indices.to(
self.dsa_topk_indices_buf.dtype
)
@@ -0,0 +1,406 @@
import dataclasses
import logging
import time
from dataclasses import dataclass, field
from typing import TYPE_CHECKING, NamedTuple, Optional
import torch
from sglang.srt.environ import envs
from sglang.srt.utils import get_bool_env_var
if TYPE_CHECKING:
from sglang.srt.observability.metrics_collector import SchedulerMetricsCollector
_DEBUG_LOG = get_bool_env_var("SGLANG_PREFILL_DELAYER_DEBUG_LOG")
logger = logging.getLogger(__name__)
@dataclass(frozen=True)
class _State:
delayed_count: int = 0
start_time: float = field(default_factory=time.perf_counter)
def bump_delayed_count(self) -> "_State":
return dataclasses.replace(self, delayed_count=self.delayed_count + 1)
class _NegotiateOutput(NamedTuple):
next_state: Optional[_State]
input_estimation: str
output_allow: bool
output_reason: str
num_prefillable: int
num_token_watermark_force_allow: int
# Accumulated wait of the prefill being released on this pass. Carried
# explicitly because `next_state` is None on every release path and thus
# cannot convey it to the metrics observation.
wait_forward_passes: int = 0
wait_seconds: float = 0.0
class PrefillDelayer:
def __init__(
self,
dp_size: int,
attn_tp_size: int,
cpu_group,
server_args,
max_delay_passes: int,
token_usage_low_watermark: Optional[float],
metrics_collector: Optional["SchedulerMetricsCollector"] = None,
device: Optional["torch.device"] = "cpu",
device_group=None,
):
self._max_delay_passes = max_delay_passes
self._token_usage_low_watermark = token_usage_low_watermark
# Queue-based trigger is opt-in: activates only when queue_min_ratio
# is explicitly set. Additive with the slot-based trigger.
self._queue_min_ratio = server_args.prefill_delayer_queue_min_ratio
# Fall back to 5000ms if unset; this is a local safety cap, not a
# semantic default, so we don't surface it via ServerArgs.
self._max_delay_ms = server_args.prefill_delayer_max_delay_ms
if self._max_delay_ms is None:
self._max_delay_ms = 5000.0
self._queue_trigger_enabled = self._queue_min_ratio is not None
logger.info(
f"PrefillDelayer initialized with "
f"max_delay_passes={self._max_delay_passes} "
f"token_usage_low_watermark={self._token_usage_low_watermark} "
f"queue_min_ratio={self._queue_min_ratio} "
f"max_delay_ms={self._max_delay_ms} "
f"queue_trigger_enabled={self._queue_trigger_enabled}"
)
self.dp_size = dp_size
self.enable_dp_attention = server_args.enable_dp_attention
dp_size_dim = dp_size if self.enable_dp_attention else 1
# Mirror scheduler_dp_attn_mixin's NCCL all-gather path: when the
# env flag is on (or overlap scheduling is disabled), ride the NCCL
# device group on `device` instead of gloo on CPU.
use_nccl = (
server_args.disable_overlap_schedule
or envs.SGLANG_NCCL_ALL_GATHER_IN_OVERLAP_SCHEDULER_SYNC_BATCH.get()
)
if use_nccl:
assert (
device_group is not None
), "device_group is required when using NCCL for PrefillDelayer all-gather"
self._gather_group = device_group
self._gather_device = device
else:
self._gather_group = cpu_group
self._gather_device = "cpu"
# Fields packed per rank into the all-gather tensor: prefillable,
# token_watermark_force_allow, running_batch, max_prefill_bs,
# waiting_queue_len.
self._global_info_buffer = torch.empty(
(dp_size_dim, attn_tp_size, 5),
dtype=torch.int64,
device=self._gather_device,
)
self._metrics_collector = metrics_collector
self._curr_state: Optional[_State] = None
self.skip_first_delayer = True
assert (
not server_args.disable_overlap_schedule
), "To use PrefillDelayer, disable_overlap_schedule must be False."
def _negotiate_should_allow_prefill(
self,
local_prefillable: bool,
token_usage: float,
running_batch: int = 0,
max_prefill_bs: int = 0,
max_running_requests: int = 0,
waiting_queue_len: int = 0,
) -> _NegotiateOutput:
out = self._negotiate_should_allow_prefill_pure(
prev_state=self._curr_state,
local_prefillable=local_prefillable,
token_usage=token_usage,
running_batch=running_batch,
max_prefill_bs=max_prefill_bs,
max_running_requests=max_running_requests,
waiting_queue_len=waiting_queue_len,
)
self._curr_state = out.next_state
return out
# (Almost) pure function, do not modify self state
def _negotiate_should_allow_prefill_pure(
self,
prev_state: Optional[_State],
local_prefillable: bool,
token_usage: float,
running_batch: int = 0,
max_prefill_bs: int = 0,
max_running_requests: int = 0,
waiting_queue_len: int = 0,
) -> _NegotiateOutput:
# Compute local states
local_token_watermark_force_allow = (
local_prefillable
and ((x := self._token_usage_low_watermark) is not None)
and (token_usage < x)
)
# Gather global states
tp0_info = self._gather_info(
local_prefillable=local_prefillable,
local_token_watermark_force_allow=local_token_watermark_force_allow,
running_batch=running_batch,
max_prefill_bs=max_prefill_bs,
waiting_queue_len=waiting_queue_len,
)
global_prefillable = tp0_info[:, 0]
global_token_watermark_force_allow = tp0_info[:, 1]
global_running_batch = tp0_info[:, 2]
global_max_prefill_bs = tp0_info[:, 3]
global_waiting_queue_len = tp0_info[:, 4]
# Compute derived global states
if global_prefillable.min().item() > 0:
prefillable_status = "all"
elif global_prefillable.max().item() == 0:
prefillable_status = "none"
else:
prefillable_status = "mixed"
global_exists_token_watermark_force_allow = (
global_token_watermark_force_allow.max().item() > 0
)
debug_info = dict(
input_estimation=prefillable_status,
num_prefillable=global_prefillable.sum().item(),
num_token_watermark_force_allow=global_token_watermark_force_allow.sum().item(),
)
# Wait accumulated so far, taken from prev_state. Release paths attach
# this so the wait histograms observe the real value; delay paths leave
# the defaults (0) since the wait isn't finished and isn't observed.
wait_info = dict(
wait_forward_passes=prev_state.delayed_count if prev_state else 0,
wait_seconds=(
(time.perf_counter() - prev_state.start_time) if prev_state else 0.0
),
)
# Compute outputs
if prefillable_status == "all":
# Safety valve: low KV usage means GPU is underutilized, skip
# delay. Mirrors the check in the "mixed" branch.
if global_exists_token_watermark_force_allow:
return _NegotiateOutput(
next_state=None,
output_allow=True,
output_reason="token_watermark",
**debug_info,
**wait_info,
)
if not self.enable_dp_attention:
max_running_requests = (
max_running_requests + self.dp_size - 1
) // self.dp_size
global_running_batch_max = int(global_running_batch.max().item())
global_max_prefill_bs_max = int(global_max_prefill_bs.max().item())
global_waiting_queue_max = int(global_waiting_queue_len.max().item())
# Queue-based trigger: delay prefill until the waiting queue
# reaches queue_min = min(running_req * ratio, max_prefill_bs),
# capped by a wall-clock timeout to bound worst-case TTFT.
# Targets workloads where decode requests finish one-at-a-time
# and fragment prefill into many tiny batches.
queue_condition = False
if self._queue_trigger_enabled and global_running_batch_max > 0:
queue_min_effective = min(
int(global_running_batch_max * self._queue_min_ratio),
global_max_prefill_bs_max,
)
queue_condition = (
queue_min_effective > 0
and global_waiting_queue_max < queue_min_effective
)
if queue_condition and prev_state is not None:
elapsed_ms = (time.perf_counter() - prev_state.start_time) * 1000.0
if elapsed_ms >= self._max_delay_ms:
queue_condition = False
slot_condition = (
max_running_requests - global_running_batch_max
< global_max_prefill_bs_max
)
if slot_condition or queue_condition:
# When the "max_decode_bs - running_bs < max_prefill_bs" condition is met,
# the first merge_batch causes the decoding to fail to reach the maximum batch size.
if self.skip_first_delayer:
self.skip_first_delayer = False
pass
else:
next_state = prev_state or _State()
next_state = next_state.bump_delayed_count()
return _NegotiateOutput(
next_state=next_state,
output_allow=False,
output_reason="delay",
**debug_info,
)
exist_previous_wait = prev_state is not None
return _NegotiateOutput(
next_state=None,
output_allow=True,
output_reason="wait_success" if exist_previous_wait else "no_wait",
**debug_info,
**wait_info,
)
elif prefillable_status == "none":
return _NegotiateOutput(
next_state=None,
# It does not matter whether we allow or not, thus we allow for simplicity
output_allow=True,
output_reason="",
**debug_info,
**wait_info,
)
elif prefillable_status == "mixed":
if global_exists_token_watermark_force_allow:
return _NegotiateOutput(
next_state=None,
output_allow=True,
output_reason="token_watermark",
**debug_info,
**wait_info,
)
prev_delayed_count = prev_state.delayed_count if prev_state else 0
if prev_delayed_count < self._max_delay_passes - 1:
next_state = prev_state or _State()
next_state = next_state.bump_delayed_count()
return _NegotiateOutput(
next_state=next_state,
output_allow=False,
output_reason="delay",
**debug_info,
)
else:
return _NegotiateOutput(
next_state=None,
output_allow=True,
output_reason="wait_timeout",
**debug_info,
**wait_info,
)
else:
raise NotImplementedError
def _gather_info(
self,
local_prefillable: bool,
local_token_watermark_force_allow: bool,
running_batch: int = 0,
max_prefill_bs: int = 0,
waiting_queue_len: int = 0,
):
local_info = torch.tensor(
[
int(local_prefillable),
int(local_token_watermark_force_allow),
running_batch,
max_prefill_bs,
waiting_queue_len,
],
device=self._gather_device,
dtype=torch.int64,
)
torch.distributed.all_gather_into_tensor(
self._global_info_buffer.flatten(),
local_info,
group=self._gather_group,
)
tp0_info = self._global_info_buffer[:, 0, :]
return tp0_info
class PrefillDelayerSinglePassExecutor:
def __init__(self, prefill_delayer: PrefillDelayer, token_usage: float):
self._prefill_delayer = prefill_delayer
self._token_usage = token_usage
self._result: Optional[_NegotiateOutput] = None
@property
def _called(self) -> bool:
return self._result is not None
def finalize(self, *, actual_prefill: bool):
if not self._called:
self.negotiate_should_allow_prefill(local_prefillable=False)
_record_single_pass_result(
actual_execution=actual_prefill,
output=self._result,
metrics_collector=self._prefill_delayer._metrics_collector,
)
def negotiate_should_allow_prefill(
self,
local_prefillable: bool,
running_batch: int = 0,
max_prefill_bs: int = 0,
max_running_requests: int = 0,
waiting_queue_len: int = 0,
) -> bool:
if not self._called:
self._result = self._prefill_delayer._negotiate_should_allow_prefill(
local_prefillable=local_prefillable,
token_usage=self._token_usage,
running_batch=running_batch,
max_prefill_bs=max_prefill_bs,
max_running_requests=max_running_requests,
waiting_queue_len=waiting_queue_len,
)
return self._result.output_allow
def _record_single_pass_result(
actual_execution: bool,
output: _NegotiateOutput,
metrics_collector: Optional["SchedulerMetricsCollector"],
) -> None:
if _DEBUG_LOG:
if output.output_allow and (output.output_reason == "wait_timeout"):
logger.info(
f"PrefillDelayer timeout thus not forbid prefill "
f"(num_prefillable={output.num_prefillable}, "
f"actual_execution={actual_execution})"
)
elif output.output_allow and (output.output_reason == "token_watermark"):
logger.info(
f"PrefillDelayer force allow prefill due to low watermark. "
f"(num_prefillable={output.num_prefillable}, "
f"num_token_watermark_force_allow={output.num_token_watermark_force_allow}, "
f"actual_execution={actual_execution})"
)
else:
assert output.output_reason in {
"",
"wait_success",
"no_wait",
"delay",
}
if metrics_collector is not None:
metrics_collector.observe_prefill_delayer_outcome(
forward_passes=output.wait_forward_passes,
wait_seconds=output.wait_seconds,
input_estimation=output.input_estimation,
output_allow=output.output_allow,
output_reason=output.output_reason,
actual_execution=actual_execution,
)
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,982 @@
from __future__ import annotations
import logging
from dataclasses import dataclass
from typing import (
TYPE_CHECKING,
Callable,
List,
Optional,
Tuple,
Union,
)
import torch
from sglang.srt.disaggregation.utils import DisaggregationMode
from sglang.srt.environ import envs
from sglang.srt.layers.logits_processor import LogitsProcessorOutput
from sglang.srt.managers.schedule_batch import (
FINISH_ABORT,
FINISH_MATCHED_TOKEN,
Req,
ScheduleBatch,
)
from sglang.srt.mem_cache.common import (
maybe_cache_unfinished_req,
release_kv_cache,
)
from sglang.srt.runtime_context import get_server_args
from sglang.srt.speculative.base_spec_worker import BaseSpecWorker
from sglang.srt.state_capturer.indexer_topk import get_global_indexer_capturer
from sglang.srt.state_capturer.routed_experts import get_global_experts_capturer
if TYPE_CHECKING:
from sglang.srt.configs.model_config import ModelConfig
from sglang.srt.disaggregation.decode_kvcache_offload_manager import (
DecodeKVCacheOffloadManager,
)
from sglang.srt.managers.hisparse_coordinator import HiSparseCoordinator
from sglang.srt.managers.scheduler_components.logprob_result_processor import (
SchedulerLogprobResultProcessor,
)
from sglang.srt.managers.scheduler_components.metrics_reporter import (
SchedulerMetricsReporter,
)
from sglang.srt.managers.scheduler_components.output_streamer import (
SchedulerOutputStreamer,
)
from sglang.srt.managers.tp_worker import BaseTpWorker
from sglang.srt.managers.utils import (
EmbeddingBatchResult,
GenerationBatchResult,
)
from sglang.srt.mem_cache.allocator import BaseTokenToKVPoolAllocator
from sglang.srt.mem_cache.base_prefix_cache import BasePrefixCache
from sglang.srt.mem_cache.memory_pool import ReqToTokenPool
from sglang.srt.observability.metrics_collector import SchedulerMetricsCollector
from sglang.srt.server_args import ServerArgs
logger = logging.getLogger(__name__)
@dataclass(kw_only=True, slots=True, frozen=True)
class SchedulerBatchResultProcessor:
is_generation: bool
disaggregation_mode: DisaggregationMode
enable_overlap: bool
enable_overlap_mlx: bool
server_args: ServerArgs
model_config: ModelConfig
token_to_kv_pool_allocator: BaseTokenToKVPoolAllocator
tree_cache: BasePrefixCache
hisparse_coordinator: Optional[HiSparseCoordinator]
req_to_token_pool: ReqToTokenPool
decode_offload_manager: Optional[DecodeKVCacheOffloadManager]
metrics_collector: SchedulerMetricsCollector
metrics_reporter: SchedulerMetricsReporter
draft_worker: BaseTpWorker
model_worker: BaseTpWorker
logprob_result_processor: SchedulerLogprobResultProcessor
output_streamer: SchedulerOutputStreamer
abort_request: Callable
def process_batch_result_prebuilt(self, batch: ScheduleBatch):
assert self.disaggregation_mode == DisaggregationMode.DECODE
use_free_group = self.server_args.disaggregation_decode_enable_radix_cache
if use_free_group:
self.token_to_kv_pool_allocator.free_group_begin()
for req in batch.reqs:
req.time_stats.set_decode_prebuilt_finish_time()
req.update_finish_state()
if req.finished():
req.time_stats.set_quick_finish_time()
if self.server_args.enable_hisparse:
self.hisparse_coordinator.request_finished(req)
release_kv_cache(req, self.tree_cache)
# Note: Logprobs should be handled on the prefill engine.
self.output_streamer.stream_output(batch.reqs, batch.return_logprob)
if use_free_group:
self.token_to_kv_pool_allocator.free_group_end()
def _maybe_collect_routed_experts(self, req: Req):
"""Collect routed experts for a finished request.
Returns immediately if `return_routed_experts` was not set on the
request, so non-opted-in reqs don't pay the host-gather cost.
Honors the caller's absolute start so the response covers
`[start_len, seqlen - 1)`. The default start_len is 0, which returns
the full sequence.
Logs a soft warning if the resulting tensor's row count differs from
the expected `seqlen - 1 - start_len`, to catch silent regressions.
"""
if not req.return_routed_experts:
return
capturer = get_global_experts_capturer()
if capturer is None:
return
start_len = req.routed_experts_start_len
seqlen = len(req.origin_input_ids) + len(req.output_ids_through_stop)
req.routed_experts = capturer.get_topk(
req_pool_idx=req.req_pool_idx,
seqlen=seqlen,
req_to_token_pool=self.req_to_token_pool,
start_len=start_len,
)
expected_rows = max(0, seqlen - 1 - start_len)
if (
req.routed_experts is not None
and req.routed_experts.shape[0] != expected_rows
):
logger.warning(
"routed_experts row-count mismatch for req %s: got %d, expected %d "
"(seqlen=%d, raw_seqlen=%d, cached_tokens=%d, start_len=%s). "
"This indicates a silent bug.",
req.rid,
req.routed_experts.shape[0],
expected_rows,
seqlen,
req.seqlen,
req.cached_tokens,
req.routed_experts_start_len,
)
def _maybe_collect_indexer_topk(self, req: Req):
capturer = get_global_indexer_capturer()
if capturer is None:
return
seqlen = len(req.origin_input_ids) + len(req.output_ids_through_stop)
req.indexer_topk = capturer.get_topk(
req_pool_idx=req.req_pool_idx,
seqlen=seqlen,
req_to_token_pool=self.req_to_token_pool,
)
def _maybe_collect_customized_info(
self,
i: int,
req: Req,
logits_output: LogitsProcessorOutput,
):
if logits_output is not None and logits_output.customized_info is not None:
if req.customized_info is None:
req.customized_info = {}
for k, v in logits_output.customized_info.items():
if k not in req.customized_info:
req.customized_info[k] = []
# Copy the element so it doesn't retain the entire batch
# tensor/array via a view reference.
elem = v[i]
if isinstance(elem, torch.Tensor):
elem = elem.clone()
elif hasattr(elem, "copy") and callable(elem.copy):
elem = elem.copy()
req.customized_info[k].append(elem)
def process_batch_result_prefill(
self,
batch: ScheduleBatch,
result: Union[GenerationBatchResult, EmbeddingBatchResult],
):
skip_stream_req = None
if self.is_generation:
if result.copy_done is not None:
result.copy_done.synchronize()
if result.routed_experts_output is not None:
result.routed_experts_output.finalize()
result.routed_experts_output = None
if result.indexer_topk_output is not None:
result.indexer_topk_output.finalize()
result.indexer_topk_output = None
(
logits_output,
next_token_ids,
extend_input_len_per_req,
extend_logprob_start_len_per_req,
) = (
result.logits_output,
result.next_token_ids,
result.extend_input_len_per_req,
result.extend_logprob_start_len_per_req,
)
# Move next_token_ids and logprobs to cpu
next_token_ids = next_token_ids.tolist()
self.move_logprobs_to_cpu(batch=batch, logits_output=logits_output)
self._validate_pp_skip_output_comm(batch, result)
hidden_state_offset = 0
# Check finish conditions
logprob_pt = 0
for i, (req, next_token_id) in enumerate(zip(batch.reqs, next_token_ids)):
if (
req.finished() and req.inflight_middle_chunks <= 0
) or req.is_retracted:
# Decode req in a mixed batch, or a retracted req. Keep an
# aborted middle chunk in the chunked branch long enough to
# drain its accounting without streaming it.
continue
if req.inflight_middle_chunks <= 0:
req.time_stats.set_prefill_finished_time()
# req output_ids are set here
req.output_ids.append(next_token_id)
self._maybe_update_reasoning_tokens(req, next_token_id)
req.update_finish_state()
if req.finished():
self._maybe_collect_routed_experts(req)
self._maybe_collect_indexer_topk(req)
release_kv_cache(req, self.tree_cache)
req.time_stats.set_completion_time()
elif not batch.decoding_reqs or req not in batch.decoding_reqs:
maybe_cache_unfinished_req(req, self.tree_cache)
if self.server_args.enable_hisparse:
self.hisparse_coordinator.admit_request_into_staging(req)
self._maybe_collect_customized_info(i, req, logits_output)
if batch.return_logprob:
logprob_pt = self._apply_prefill_logprobs(
req=req,
i=i,
logits_output=logits_output,
extend_input_len_per_req=extend_input_len_per_req,
extend_logprob_start_len_per_req=extend_logprob_start_len_per_req,
next_token_ids=next_token_ids,
logprob_pt=logprob_pt,
)
if (
req.return_hidden_states
and logits_output.hidden_states is not None
):
hidden_state_offset = self._append_prefill_hidden_states(
req=req,
logits_output=logits_output,
hidden_state_offset=hidden_state_offset,
)
if req.grammar is not None:
self._apply_prefill_grammar(
req=req, next_token_id=next_token_id
)
else:
# being chunked reqs' prefill is not finished
req.inflight_middle_chunks -= 1
# There is only at most one request being currently chunked.
# Because this request does not finish prefill,
# we don't want to stream the request currently being chunked.
skip_stream_req = req
# Incrementally update input logprobs.
if batch.return_logprob:
logprob_pt = self._apply_chunked_prefill_logprobs(
req=req,
i=i,
logits_output=logits_output,
extend_input_len_per_req=extend_input_len_per_req,
extend_logprob_start_len_per_req=extend_logprob_start_len_per_req,
logprob_pt=logprob_pt,
)
req.time_stats.set_last_chunked_prefill_finish_time()
else: # embedding or reward model
if result.copy_done is not None:
result.copy_done.synchronize()
embeddings = self._convert_embeddings(result=result)
phs = result.pooled_hidden_states
if phs is not None:
if isinstance(phs, list):
phs = [t.cpu().detach() for t in phs]
else:
phs = phs.cpu().detach()
# Check finish conditions
for i, req in enumerate(batch.reqs):
if req.is_retracted:
continue
req.embedding = embeddings[i]
if req.return_pooled_hidden_states and phs is not None:
req.pooled_hidden_state = phs[i]
if req.inflight_middle_chunks <= 0:
req.time_stats.set_prefill_finished_time()
# Dummy output token for embedding models
req.output_ids.append(0)
req.update_finish_state()
if req.finished():
release_kv_cache(req, self.tree_cache)
req.time_stats.set_completion_time()
else:
maybe_cache_unfinished_req(req, self.tree_cache)
else:
# being chunked reqs' prefill is not finished
req.inflight_middle_chunks -= 1
req.time_stats.set_last_chunked_prefill_finish_time()
self.output_streamer.stream_output(
batch.reqs, batch.return_logprob, skip_stream_req
)
can_run_cuda_graph = result.can_run_cuda_graph
self.metrics_reporter.report_prefill_stats(
batch=batch,
prefill_stats=batch.prefill_stats,
can_run_cuda_graph=can_run_cuda_graph,
dp_cooperation_info=batch.dp_cooperation_info,
)
def _convert_embeddings(self, *, result: EmbeddingBatchResult) -> list:
is_sparse = envs.SGLANG_EMBEDDINGS_SPARSE_HEAD.is_set()
embeddings = result.embeddings
if is_sparse:
batch_ids, token_ids = embeddings.indices()
values = embeddings.values()
embeddings = [{} for _ in range(embeddings.size(0))]
for i in range(batch_ids.shape[0]):
embeddings[batch_ids[i].item()][token_ids[i].item()] = values[i].item()
else:
if isinstance(embeddings, torch.Tensor):
embeddings = embeddings.tolist()
else:
embeddings = [tensor.tolist() for tensor in embeddings]
return embeddings
def move_logprobs_to_cpu(
self,
*,
batch: ScheduleBatch,
logits_output: LogitsProcessorOutput,
) -> None:
if batch.return_logprob:
if logits_output.next_token_logprobs is not None:
logits_output.next_token_logprobs = (
logits_output.next_token_logprobs.tolist()
)
if logits_output.input_token_logprobs is not None:
logits_output.input_token_logprobs = tuple(
logits_output.input_token_logprobs.tolist()
)
if logits_output.next_token_top_logprobs_val:
logits_output.next_token_top_logprobs_val = [
v.tolist() for v in logits_output.next_token_top_logprobs_val
]
logits_output.next_token_top_logprobs_idx = [
x.tolist() for x in logits_output.next_token_top_logprobs_idx
]
if logits_output.next_token_token_ids_logprobs_val:
logits_output.next_token_token_ids_logprobs_val = [
v.tolist() for v in logits_output.next_token_token_ids_logprobs_val
]
def _apply_prefill_logprobs(
self,
*,
req: Req,
i: int,
logits_output: LogitsProcessorOutput,
extend_input_len_per_req: Optional[List[int]],
extend_logprob_start_len_per_req: Optional[List[int]],
next_token_ids: List[int],
logprob_pt: int,
) -> int:
assert extend_logprob_start_len_per_req is not None
assert extend_input_len_per_req is not None
extend_logprob_start_len = extend_logprob_start_len_per_req[i]
extend_input_len = extend_input_len_per_req[i]
num_input_logprobs = self.logprob_result_processor.calculate_num_input_logprobs(
req,
extend_input_len,
extend_logprob_start_len,
)
if req.return_logprob:
self.logprob_result_processor.add_logprob_return_values(
i,
req,
logprob_pt,
next_token_ids,
num_input_logprobs,
logits_output,
)
logprob_pt += num_input_logprobs
return logprob_pt
@staticmethod
def _validate_pp_skip_output_comm(
batch: ScheduleBatch,
result: Union[GenerationBatchResult, EmbeddingBatchResult],
):
"""Validate PP skip output comm correctness.
- When skip=True: all reqs must be middle chunks (inflight_middle_chunks > 0)
so placeholder zeros are never consumed via req.output_ids.append().
- When skip=False: at least one req should consume next_token_ids
(inflight_middle_chunks <= 0), otherwise warn.
"""
if not envs.SGLANG_PP_SKIP_PURE_CHUNKED_OUTPUT_COMM.get():
return
if not getattr(result, "skipped_output_comm", False):
if batch.forward_mode.is_extend() and not batch.forward_mode.is_prebuilt():
has_consumed_output = any(
req.inflight_middle_chunks <= 0
for req in batch.reqs
if not req.finished() and not req.is_retracted
)
if not has_consumed_output and len(batch.reqs) > 0:
chunks = list([r.inflight_middle_chunks for r in batch.reqs])
logger.warning(
f"PP non-skip output comm: no req consumed next_token_ids. "
f"contains_last_prefill_chunk={batch.contains_last_prefill_chunk}, "
f"num_reqs={len(batch.reqs)}, all inflight_middle_chunks={chunks}"
)
return
for req in batch.reqs:
if not req.finished() and not req.is_retracted:
assert req.inflight_middle_chunks > 0, (
f"PP skip output comm invariant violated: req {req.rid} "
f"has inflight_middle_chunks={req.inflight_middle_chunks} "
f"but output was skipped (contains_last_prefill_chunk="
f"{batch.contains_last_prefill_chunk}). "
f"Placeholder zeros would be appended to output_ids."
)
def _append_prefill_hidden_states(
self,
*,
req: Req,
logits_output: LogitsProcessorOutput,
hidden_state_offset: int,
) -> int:
req.hidden_states.append(
logits_output.hidden_states[
hidden_state_offset : (
hidden_state_offset := hidden_state_offset
+ len(req.origin_input_ids)
)
]
.cpu()
.clone()
.tolist()
)
return hidden_state_offset
def _apply_prefill_grammar(self, *, req: Req, next_token_id: int) -> None:
# FIXME: this try-except block is for handling unexpected xgrammar issue.
try:
req.grammar.accept_token(next_token_id)
except ValueError as e:
# Grammar accept_token can raise ValueError if the token is not in the grammar.
# This can happen if the grammar is not set correctly or the token is invalid.
logger.error(
f"Grammar accept_token failed for req {req.rid} with token {next_token_id}: {e}"
)
req.to_finish = FINISH_ABORT()
req.grammar.finished = req.finished()
def _apply_chunked_prefill_logprobs(
self,
*,
req: Req,
i: int,
logits_output: LogitsProcessorOutput,
extend_input_len_per_req: Optional[List[int]],
extend_logprob_start_len_per_req: Optional[List[int]],
logprob_pt: int,
) -> int:
extend_logprob_start_len = extend_logprob_start_len_per_req[i]
extend_input_len = extend_input_len_per_req[i]
if extend_logprob_start_len < extend_input_len:
# Update input logprobs.
num_input_logprobs = (
self.logprob_result_processor.calculate_num_input_logprobs(
req,
extend_input_len,
extend_logprob_start_len,
)
)
if req.return_logprob:
self.logprob_result_processor.add_input_logprob_return_values(
i,
req,
logits_output,
logprob_pt,
num_input_logprobs,
last_prefill_chunk=False,
)
logprob_pt += num_input_logprobs
return logprob_pt
def _resolve_spec_v2_tokens(
self,
result: GenerationBatchResult,
batch: ScheduleBatch,
) -> List[List[int]]:
"""Resolve the padded next token ids for spec-v2 (overlap and non-overlap)."""
assert result.next_token_ids.is_cpu
assert result.accept_lens.is_cpu
next_token_ids = result.next_token_ids.tolist()
accept_lens = result.accept_lens.tolist()
result.num_correct_drafts = sum(accept_lens) - len(batch.reqs)
result.num_correct_drafts_per_req_cpu = [x - 1 for x in accept_lens]
block_accept_lens = (
result.block_accept_lens.tolist()
if result.block_accept_lens is not None
else None
)
result.num_block_accept_tokens = (
sum(block_accept_lens) if block_accept_lens else 0
)
cap_lens = result.cap_lens.tolist() if result.cap_lens is not None else None
result.num_cap_tokens = sum(cap_lens) if cap_lens else 0
# Feed the adaptive controller now that accept_lens is on CPU,
# instead of doing a synchronous GPU→CPU copy in the worker hot path.
# BaseSpecWorker provides a no-op default for non-adaptive workers.
self.model_worker.on_verify_complete_cpu(
result.num_correct_drafts_per_req_cpu, batch_size=len(batch.reqs)
)
predict_tokens = []
# In adaptive spec-v2, the worker state may already have switched when this
# delayed result is processed. Use the draft token count recorded on result.
stride = result.speculative_num_draft_tokens
assert stride is not None, "spec-v2 result missing speculative_num_draft_tokens"
for i, req in enumerate(batch.reqs):
accept_tokens = next_token_ids[i * stride : i * stride + accept_lens[i]]
if req.is_retracted or req.finished():
# Nothing to settle: no worker pre-claims the bonus, so
# kv_committed_len already holds the committed prefix.
pass
else:
if req.grammar is not None:
# Stop accepting once the grammar terminates, so the
# over-drafted suffix is never committed to KV nor emitted.
# This advances the grammar FSM; the result loop only syncs
# grammar.finished.
accept_tokens = self._accept_grammar_tokens(req, accept_tokens)
# Commit the full accepted run (drafts + bonus).
num_accept_tokens = len(accept_tokens)
req.kv_committed_len += num_accept_tokens
req.spec_verify_ct += 1
num_correct_drafts = result.num_correct_drafts_per_req_cpu[i]
req.spec_num_correct_drafts += num_correct_drafts
req.update_spec_correct_drafts_histogram(num_correct_drafts)
if block_accept_lens is not None:
req.spec_num_block_accept_tokens += block_accept_lens[i]
if cap_lens is not None:
req.spec_num_cap_tokens += cap_lens[i]
req.update_spec_cap_lens_histogram(cap_lens[i])
predict_tokens.append(accept_tokens)
return predict_tokens
def _accept_grammar_tokens(
self, req: Req, tokens: Union[int, List[int]]
) -> List[int]:
"""Advance the grammar over the accepted token(s), stopping at the token
that terminates it.
``tokens`` is a single sampled token (normal decode) or the whole
verified run (spec decode). Returns the retained prefix; for spec the
suffix past grammar completion is dropped so it is never committed to KV
nor emitted. Advances the grammar FSM only -- ``grammar.finished`` is
synced by the caller once the finish state is updated.
"""
if isinstance(tokens, int):
tokens = [tokens]
retained = []
try:
for token_id in tokens:
req.grammar.accept_token(token_id)
retained.append(token_id)
if req.grammar.is_terminated():
break
except ValueError as e:
# accept_token raises ValueError if the token is not in the grammar
# (misconfigured grammar or invalid token); abort the request.
logger.error(
f"Grammar accept_token failed for req {req.rid} with token "
f"{tokens}: {e}"
)
req.to_finish = FINISH_ABORT()
return retained
def process_batch_result_idle(
self,
batch: ScheduleBatch,
result: GenerationBatchResult,
):
if result.copy_done is not None:
result.copy_done.synchronize()
self.output_streamer._stream_output_generation(
batch.reqs, batch.return_logprob, is_idle_batch=True
)
def process_batch_result_decode(
self,
batch: ScheduleBatch,
result: GenerationBatchResult,
):
if result.copy_done is not None:
result.copy_done.synchronize()
if result.routed_experts_output is not None:
result.routed_experts_output.finalize()
result.routed_experts_output = None
if result.indexer_topk_output is not None:
result.indexer_topk_output.finalize()
result.indexer_topk_output = None
logits_output, next_token_ids, can_run_cuda_graph = (
result.logits_output,
result.next_token_ids,
result.can_run_cuda_graph,
)
next_token_ids, next_token_logprobs = self._normalize_decode_outputs(
batch=batch,
result=result,
logits_output=logits_output,
next_token_ids=next_token_ids,
)
self.metrics_reporter.num_generated_tokens += len(batch.reqs)
if not batch.spec_algorithm.is_none():
self.metrics_reporter.update_spec_metrics(
batch.batch_size(),
result.num_correct_drafts,
num_block_accept_tokens=result.num_block_accept_tokens,
num_cap_tokens=result.num_cap_tokens,
)
if self.server_args.enable_metrics:
self.metrics_collector.increment_decode_cuda_graph_pass(
value=can_run_cuda_graph
)
self.token_to_kv_pool_allocator.free_group_begin()
for i, req in enumerate(batch.reqs):
req: Req
if (self.enable_overlap or self.enable_overlap_mlx) and (
req.finished() or req.is_retracted
):
# NOTE: This (req.finished() or req.is_retracted) should only happen when overlap scheduling is enabled.
# And all the over-allocated tokens will be freed in `release_kv_cache`.
continue
# next_token_id is a per-req list: 1 token for non-spec, the verified
# run for spec (already grammar-truncated in _resolve_spec_v2_tokens).
next_token_id = next_token_ids[i]
is_spec = not batch.spec_algorithm.is_none()
req.output_ids.extend(next_token_id)
new_accept_len = len(next_token_id)
self._maybe_update_reasoning_tokens(req, next_token_id)
req.time_stats.set_last_decode_finish_time()
req.update_finish_state(new_accept_len)
self._handle_finish_state_updated_req(req, batch, result, i, logits_output)
if req.return_logprob:
self._apply_decode_logprobs(
req=req,
i=i,
batch=batch,
next_token_id=next_token_id,
next_token_logprobs=next_token_logprobs,
logits_output=logits_output,
)
if req.return_hidden_states and logits_output.hidden_states is not None:
# hidden_states is [bs * stride, hidden_dim], one row per emitted
# token; stride = speculative_num_draft_tokens for spec, 1 for non-spec.
stride = result.speculative_num_draft_tokens or 1
accept_len = len(next_token_id)
start = i * stride
req.hidden_states.extend(
logits_output.hidden_states[start : start + accept_len]
.cpu()
.tolist()
)
if req.grammar is not None:
if not is_spec:
# Normal decode advances the grammar for its single token
# here; spec already advanced it in _resolve_spec_v2_tokens.
self._accept_grammar_tokens(req, next_token_id)
req.grammar.finished = req.finished()
self.output_streamer.stream_output(batch.reqs, batch.return_logprob)
self.token_to_kv_pool_allocator.free_group_end()
self.metrics_reporter.forward_ct_decode = (
self.metrics_reporter.forward_ct_decode + 1
) % (1 << 30)
self.metrics_reporter.report_decode_stats(
can_run_cuda_graph,
running_batch=batch,
num_correct_drafts=result.num_correct_drafts,
)
def _normalize_decode_outputs(
self,
*,
batch: ScheduleBatch,
result: GenerationBatchResult,
logits_output: LogitsProcessorOutput,
next_token_ids: Union[torch.Tensor, List[int]],
) -> Tuple[Union[List[int], List[List[int]]], Optional[List[float]]]:
next_token_logprobs = None
# Normalize to a uniform per-req list of accepted tokens (List[List[int]]):
# spec unpacks the padded verify output; non-spec wraps its single token.
if not batch.spec_algorithm.is_none():
next_token_ids = self._resolve_spec_v2_tokens(result, batch)
else:
# CUDA workers return a device tensor, MLX a host list[int]; both -> list.
ids = (
next_token_ids.tolist()
if torch.is_tensor(next_token_ids)
else next_token_ids
)
next_token_ids = [[t] for t in ids]
if batch.return_logprob:
next_token_logprobs = logits_output.next_token_logprobs.tolist()
if logits_output.next_token_top_logprobs_val:
logits_output.next_token_top_logprobs_val = [
v.tolist() for v in logits_output.next_token_top_logprobs_val
]
logits_output.next_token_top_logprobs_idx = [
x.tolist() for x in logits_output.next_token_top_logprobs_idx
]
if logits_output.next_token_token_ids_logprobs_val:
logits_output.next_token_token_ids_logprobs_val = [
v.tolist() for v in logits_output.next_token_token_ids_logprobs_val
]
return next_token_ids, next_token_logprobs
def _apply_decode_logprobs(
self,
*,
req: Req,
i: int,
batch: ScheduleBatch,
next_token_id: Union[int, List[int]],
next_token_logprobs: list,
logits_output: LogitsProcessorOutput,
) -> None:
# accepted_ids is already a per-req list; non-spec logprobs are flat, so
# the scalar logprob still needs wrapping.
if not batch.spec_algorithm.is_none():
accepted_logprobs = next_token_logprobs[i]
accepted_ids = next_token_id
max_accept = len(accepted_logprobs)
else:
accepted_logprobs = [next_token_logprobs[i]]
accepted_ids = next_token_id
max_accept = 1
for j, tok_id in enumerate(accepted_ids):
req.logprob.output_token_logprobs_val.append(accepted_logprobs[j])
req.logprob.output_token_logprobs_idx.append(tok_id)
if req.logprob.top_logprobs_num > 0:
flat_idx = i * max_accept + j
req.logprob.output_top_logprobs_val.append(
logits_output.next_token_top_logprobs_val[flat_idx]
)
req.logprob.output_top_logprobs_idx.append(
logits_output.next_token_top_logprobs_idx[flat_idx]
)
if req.logprob.token_ids_logprob is not None:
flat_idx = i * max_accept + j
req.logprob.output_token_ids_logprobs_val.append(
logits_output.next_token_token_ids_logprobs_val[flat_idx]
)
req.logprob.output_token_ids_logprobs_idx.append(
logits_output.next_token_token_ids_logprobs_idx[flat_idx]
)
def _handle_finish_state_updated_req(
self,
req: Req,
batch: ScheduleBatch,
result: GenerationBatchResult,
i: int,
logits_output: LogitsProcessorOutput,
):
# Called here (after update_finish_state) so req.finished() is valid
# for mamba_lazy_post_decode_at_boundary inside.
self._mamba_prefix_cache_update(req, batch, result, i)
if (
self.server_args.disaggregation_decode_enable_offload_kvcache
and not req.finished()
):
self.decode_offload_manager.offload_kv_cache(req)
if req.finished():
# isinstance narrowing: create_worker may also return plain
# TpModelWorker-based drafts, which carry no spec-worker hooks.
if isinstance(self.draft_worker, BaseSpecWorker):
self.draft_worker.note_request_finished(
rid=req.rid,
natural_stop=isinstance(req.finished_reason, FINISH_MATCHED_TOKEN),
)
# delete feature to save memory
if req.multimodal_inputs is not None and req.session is None:
req.multimodal_inputs.release_features()
self._maybe_collect_routed_experts(req)
self._maybe_collect_indexer_topk(req)
if self.server_args.disaggregation_decode_enable_offload_kvcache:
# Asynchronously offload KV cache; release_kv_cache will be called after Device->Host transfer completes
if not self.decode_offload_manager.offload_kv_cache(req):
self.decode_offload_manager.finalize_release_on_finish(req)
else:
if self.server_args.enable_hisparse:
self.hisparse_coordinator.request_finished(req)
prepare_release = getattr(
self.model_worker, "prepare_for_kv_cache_release", None
)
if callable(prepare_release):
prepare_release(req)
is_insert = (
req.mamba_lazy_is_insert
if get_server_args().enable_mamba_extra_buffer_lazy()
else True
)
release_kv_cache(req, self.tree_cache, is_insert=is_insert)
req.time_stats.set_completion_time()
self._maybe_collect_customized_info(i, req, logits_output)
def _maybe_update_reasoning_tokens(
self,
req: Req,
next_token_id: Union[int, List[int]],
):
think_end_id = self.model_config.think_end_id
if req.require_reasoning and think_end_id is not None:
req.update_reasoning_tokens(next_token_id, think_end_id)
def _mamba_prefix_cache_update(
self,
req: Req,
batch: ScheduleBatch,
result: GenerationBatchResult,
i: int,
) -> None:
"""Update mamba track state at ping-pong boundaries.
Non-lazy: swap the ping-pong index so the next forward writes to
the alternate slot.
Lazy: keep the same index (prealloc handles the swap) and run
post-decode cleanup to free the temporary second slot.
"""
if req.mamba_ping_pong_track_buffer is None:
return
lazy = get_server_args().enable_mamba_extra_buffer_lazy()
at_boundary, track_seqlen = self._mamba_check_track_boundary(
req, batch, result, i
)
if not at_boundary:
return
req.mamba_last_track_seqlen = track_seqlen
if lazy:
self.mamba_lazy_post_decode_at_boundary(req, batch)
else:
req.mamba_next_track_idx = (
batch.req_to_token_pool.get_mamba_ping_pong_other_idx(
req.mamba_next_track_idx
)
)
def _mamba_check_track_boundary(self, req, batch, result, i):
"""Check if this decode step crosses a mamba track interval boundary.
Returns (at_boundary, track_seqlen). The boundary condition
matches what the forward's tracking mask used:
``prepare_for_decode`` increments both ``seq_lens_cpu`` and
``kv_committed_len`` by 1, then checks
``seq_lens_cpu % interval == 0``. Using ``kv_committed_len``
here reproduces that check exactly, and the value is always a
multiple of ``interval`` (hence page-aligned).
For spec decode, the boundary is detected by comparing the
accepted seq_len range against interval boundaries.
"""
interval = get_server_args().mamba_track_interval
if batch.spec_algorithm.is_none():
if req.kv_committed_len % interval == 0:
return True, req.kv_committed_len
elif result.num_correct_drafts_per_req_cpu is not None:
cur = req.seqlen - 1
prev = cur - result.num_correct_drafts_per_req_cpu[i] - 1
if cur // interval != prev // interval:
return True, cur // interval * interval
return False, 0
def mamba_lazy_post_decode_at_boundary(self, req: Req, batch: ScheduleBatch):
"""Post-decode cleanup at a lazy-mode track boundary.
Finished reqs: if prealloc failed (other slot is -1), the forward
overwrote the only slot with corrupted state, so mark
is_insert=False to skip the cache insert. If the other slot is
occupied (stale prealloc from an overlap extra forward), free it
so the prealloc assert in the next prepare_for_decode holds.
Running reqs: free the old ping-pong slot so we go back to
holding only 1 slot until the next boundary.
"""
other_idx = 1 - req.mamba_next_track_idx
other_val = req.mamba_ping_pong_track_buffer[other_idx].item()
if other_val != -1:
pool = batch.req_to_token_pool
pool.mamba_allocator.free(
req.mamba_ping_pong_track_buffer[other_idx].unsqueeze(0)
)
pool.set_mamba_ping_pong_slot(req, other_idx, -1)
elif req.finished():
req.mamba_lazy_is_insert = False
@@ -0,0 +1,322 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import TYPE_CHECKING, Callable, Optional
import torch
from sglang.srt.batch_overlap.two_batch_overlap import TboDPAttentionPreparer
from sglang.srt.configs.model_config import ModelConfig
from sglang.srt.distributed.parallel_state import get_tp_group
from sglang.srt.distributed.parallel_state_wrapper import ParallelState
from sglang.srt.environ import envs
from sglang.srt.managers.schedule_batch import ScheduleBatch
from sglang.srt.mem_cache.allocator import BaseTokenToKVPoolAllocator
from sglang.srt.mem_cache.base_prefix_cache import BasePrefixCache
from sglang.srt.mem_cache.memory_pool import ReqToTokenPool
from sglang.srt.model_executor.cuda_graph_config import (
Backend,
Phase,
check_cuda_graph_backend,
cuda_graph_fully_disabled,
)
from sglang.srt.model_executor.forward_batch_info import ForwardMode
from sglang.srt.observability.metrics_collector import DPCooperationInfo
from sglang.srt.server_args import ServerArgs
from sglang.srt.speculative.spec_info import SpeculativeAlgorithm
from sglang.srt.utils.common import require_mlp_tp_gather
if TYPE_CHECKING:
from sglang.srt.distributed.parallel_state import GroupCoordinator
_ENABLE_METRICS_DP_ATTENTION = envs.SGLANG_ENABLE_METRICS_DP_ATTENTION.get()
@dataclass
class MLPSyncBatchInfo:
dp_size: int
tp_size: int
cp_size: int
num_tokens: int
num_tokens_for_logprob: int
can_cuda_graph: bool
is_extend_in_batch: bool
local_can_run_tbo: bool
local_forward_mode: int
can_run_breakable_cuda_graph: bool
# some gathered elements
tp0_info: torch.Tensor = None
global_num_tokens: list[int] = None
global_num_tokens_for_logprob: list[int] = None
tbo_split_seq_index: torch.Tensor = None
global_forward_mode: int = None
dp_cooperation_info: Optional[DPCooperationInfo] = None
def _get_local_tensor(self, device, dtype=torch.int64) -> torch.Tensor:
return torch.tensor(
[
self.num_tokens,
self.num_tokens_for_logprob,
int(self.can_cuda_graph),
int(self.is_extend_in_batch),
int(self.local_can_run_tbo),
self.local_forward_mode,
int(self.can_run_breakable_cuda_graph),
],
device=device,
dtype=dtype,
)
def _get_fallback_tensor(self, device, dtype=torch.int64) -> torch.Tensor:
return torch.tensor(
[
0, # num_tokens
0, # num_tokens_for_logprob
1, # can_cuda_graph
0, # is_extend_in_batch
1, # local_can_run_tbo
ForwardMode.IDLE.value, # local_forward_mode
0, # can_run_breakable_cuda_graph
],
device=device,
dtype=dtype,
)
def all_gather(self, device, group: torch.distributed.ProcessGroup):
local_info_tensor = self._get_local_tensor(device=device)
global_info_tensor = torch.empty(
(self.dp_size, self.tp_size * self.cp_size, 7),
dtype=torch.int64,
device=device,
)
torch.distributed.all_gather_into_tensor(
global_info_tensor.flatten(),
local_info_tensor,
group=group,
)
if device == "cpu":
tp_active_ranks = get_tp_group().active_ranks_cpu
else:
tp_active_ranks = get_tp_group().active_ranks
# Set fallback values for inactive ranks
tp_info = global_info_tensor.view(self.dp_size * self.tp_size * self.cp_size, 7)
tp_info[tp_active_ranks == 0] = self._get_fallback_tensor(device=device)
tp0_info = global_info_tensor[:, 0, :]
self.tp0_info = tp0_info
# Perform only one Device-to-Host (D2H) memory copy
cpu_data = tp0_info[:, :2].cpu()
self.global_num_tokens = cpu_data[:, 0].tolist()
self.global_num_tokens_for_logprob = cpu_data[:, 1].tolist()
self.can_cuda_graph = bool(tp0_info[:, 2].min().item())
self.is_extend_in_batch = bool(tp0_info[:, 3].max().item())
self.can_run_breakable_cuda_graph = bool(tp0_info[:, 6].min().item())
if _ENABLE_METRICS_DP_ATTENTION:
self.dp_cooperation_info = DPCooperationInfo.create(tp0_info[:, 5].tolist())
def _update_gather_batch(
batch: ScheduleBatch,
mlp_sync_info: MLPSyncBatchInfo,
require_mlp_tp_gather: bool,
skip_all_gather=False,
):
# TODO: handle the case when moe_dense_tp_size != 1
if not require_mlp_tp_gather:
batch.global_num_tokens = [mlp_sync_info.num_tokens]
batch.global_num_tokens_for_logprob = [mlp_sync_info.num_tokens_for_logprob]
else:
batch.global_num_tokens = mlp_sync_info.global_num_tokens
batch.global_num_tokens_for_logprob = (
mlp_sync_info.global_num_tokens_for_logprob
)
if not skip_all_gather:
batch.is_extend_in_batch = mlp_sync_info.is_extend_in_batch
batch.tbo_split_seq_index = mlp_sync_info.tbo_split_seq_index
batch.global_forward_mode = mlp_sync_info.global_forward_mode
# Check forward mode for cuda graph
batch.can_run_dp_cuda_graph = mlp_sync_info.can_cuda_graph
batch.can_run_dp_breakable_cuda_graph = mlp_sync_info.can_run_breakable_cuda_graph
def prepare_mlp_sync_batch_raw(
local_batch: ScheduleBatch,
dp_size: int,
attn_tp_size: int,
attn_cp_size: int,
tp_group: GroupCoordinator,
get_idle_batch: Callable[[], ScheduleBatch],
disable_cuda_graph: bool,
require_mlp_tp_gather: bool,
disable_overlap_schedule: bool,
offload_tags: set[str],
):
# Check if other DP workers have running batches
if (
local_batch is None
or local_batch.forward_mode.is_prebuilt()
or local_batch.forward_mode.is_idle()
):
num_tokens = 0
num_tokens_for_logprob = 0
elif local_batch.forward_mode.is_decode():
num_tokens = local_batch.batch_size()
num_tokens_for_logprob = num_tokens
else:
num_tokens = local_batch.extend_num_tokens
num_tokens_for_logprob = sum(
# We should have at least 1 token for sample in every case.
max(extend_len - logprob_start_len, 1)
for logprob_start_len, extend_len in zip(
local_batch.extend_logprob_start_lens,
local_batch.extend_lens,
)
)
assert (
local_batch.return_logprob
or num_tokens_for_logprob == local_batch.batch_size()
)
skip_all_gather = envs.SGLANG_SCHEDULER_SKIP_ALL_GATHER.get()
can_cuda_graph = (
local_batch is None
or local_batch.forward_mode.is_decode_or_idle()
or local_batch.forward_mode.is_prebuilt()
) and not disable_cuda_graph
# Idle/None ranks are permissive (like can_cuda_graph): the all-gather
# min()-reduces this across DP ranks, so a prefill batch with idle ranks
# still resolves to True (idle ranks become a padded dummy extend).
can_run_breakable_cuda_graph = (
local_batch is None
or local_batch.forward_mode.is_idle()
or local_batch.forward_mode in (ForwardMode.EXTEND, ForwardMode.MIXED)
) and check_cuda_graph_backend(Phase.PREFILL, Backend.BREAKABLE)
is_extend_in_batch = local_batch.forward_mode.is_extend() if local_batch else False
if local_batch is not None:
local_batch.is_extend_in_batch = is_extend_in_batch
tbo_preparer = TboDPAttentionPreparer()
if len(offload_tags) == 0 and (
disable_overlap_schedule
or envs.SGLANG_NCCL_ALL_GATHER_IN_OVERLAP_SCHEDULER_SYNC_BATCH.get()
):
group = tp_group.device_group
device = tp_group.device
else:
group = tp_group.cpu_group
device = "cpu"
local_can_run_tbo, local_forward_mode = tbo_preparer.prepare_all_gather(local_batch)
mlp_sync_info = MLPSyncBatchInfo(
dp_size=dp_size,
tp_size=attn_tp_size,
cp_size=attn_cp_size,
num_tokens=num_tokens,
num_tokens_for_logprob=num_tokens_for_logprob,
can_cuda_graph=can_cuda_graph,
is_extend_in_batch=is_extend_in_batch,
local_can_run_tbo=local_can_run_tbo,
local_forward_mode=local_forward_mode,
can_run_breakable_cuda_graph=can_run_breakable_cuda_graph,
)
if not skip_all_gather:
mlp_sync_info.all_gather(device=device, group=group)
mlp_sync_info.tbo_split_seq_index, mlp_sync_info.global_forward_mode = (
tbo_preparer.compute_output(
mlp_sync_info.tp0_info[:, 4:6],
)
)
# Decide whether to emit idle batch
if skip_all_gather:
# Skip idle batch when attn-dp=1
need_idle_batch = dp_size > 1
else:
need_idle_batch = max(mlp_sync_info.global_num_tokens) > 0
batch_to_gather = local_batch
if need_idle_batch:
if local_batch is None:
batch_to_gather = local_batch = get_idle_batch()
elif local_batch.forward_mode.is_prebuilt():
# NOTE: for prebuilt batch, we add an inner idle batch to run MLP sync
batch_to_gather = local_batch.inner_idle_batch = get_idle_batch()
if batch_to_gather is not None:
_update_gather_batch(
batch_to_gather, mlp_sync_info, require_mlp_tp_gather, skip_all_gather
)
if _ENABLE_METRICS_DP_ATTENTION and local_batch is not None:
local_batch.dp_cooperation_info = mlp_sync_info.dp_cooperation_info
return local_batch
@dataclass(kw_only=True, slots=True, frozen=True)
class SchedulerDPAttnAdapter:
tp_group: GroupCoordinator
req_to_token_pool: ReqToTokenPool
token_to_kv_pool_allocator: BaseTokenToKVPoolAllocator
tree_cache: BasePrefixCache
offload_tags: set[str]
ps: ParallelState
server_args: ServerArgs
model_config: ModelConfig
enable_overlap: bool
spec_algorithm: SpeculativeAlgorithm
get_require_mlp_sync: Callable[[], bool]
def prepare_mlp_sync_batch(self, local_batch: ScheduleBatch):
return prepare_mlp_sync_batch_raw(
local_batch,
dp_size=self.server_args.dp_size,
attn_tp_size=self.ps.attn_tp_size,
attn_cp_size=self.ps.attn_cp_size,
tp_group=self.tp_group,
get_idle_batch=self.get_idle_batch,
disable_cuda_graph=cuda_graph_fully_disabled(),
require_mlp_tp_gather=require_mlp_tp_gather(self.server_args),
disable_overlap_schedule=self.server_args.disable_overlap_schedule,
offload_tags=self.offload_tags,
)
def maybe_prepare_mlp_sync_batch(
self,
batch: Optional[ScheduleBatch],
need_sync: Optional[bool] = None,
) -> Optional[ScheduleBatch]:
"""
Helper to prepare MLP sync batch for DP attention.
Should be called after get_new_batch_prefill().
Args:
batch: The batch to process
need_sync: If specified, overrides self.get_require_mlp_sync() for prepare_mlp_sync_batch decision
"""
if need_sync if need_sync is not None else self.get_require_mlp_sync():
batch = self.prepare_mlp_sync_batch(batch)
return batch
def get_idle_batch(self) -> ScheduleBatch:
idle_batch = ScheduleBatch.init_new(
[],
self.req_to_token_pool,
self.token_to_kv_pool_allocator,
self.tree_cache,
self.model_config,
self.enable_overlap,
self.spec_algorithm,
)
idle_batch.prepare_for_idle()
return idle_batch
@@ -0,0 +1,65 @@
import logging
import time
from typing import Callable, Optional, Tuple
from sglang.srt.managers.io_struct import FlushCacheReqInput, FlushCacheReqOutput
from sglang.srt.managers.scheduler_components.ipc_channels import (
SchedulerIpcChannels,
)
class SchedulerFlushWrapper:
def __init__(
self,
*,
flush_cache: Callable[[], bool],
is_fully_idle: Callable[[], bool],
ipc_channels: SchedulerIpcChannels,
) -> None:
self._flush_cache = flush_cache
self._is_fully_idle = is_fully_idle
self._ipc_channels = ipc_channels
self._pending: Optional[Tuple[FlushCacheReqInput, float]] = None
def handle(self, recv_req: FlushCacheReqInput) -> Optional[FlushCacheReqOutput]:
if self._pending is not None:
return FlushCacheReqOutput(
success=False,
message="Another flush_cache is already in progress.",
)
timeout_s = float(recv_req.timeout_s or 0.0)
if timeout_s <= 0.0:
return FlushCacheReqOutput(success=self._flush_cache())
if self._is_fully_idle():
return FlushCacheReqOutput(success=self._flush_cache())
self._pending = (recv_req, time.monotonic() + timeout_s)
return None
def check_pending(self) -> None:
if self._pending is None:
return
pending_req, deadline = self._pending
if self._is_fully_idle():
success = self._flush_cache()
self._pending = None
self._ipc_channels.send_to_tokenizer.send_output(
FlushCacheReqOutput(success=success), pending_req
)
return
if time.monotonic() >= deadline:
logging.warning(
"Deferred flush_cache timed out while waiting for idle state."
)
self._pending = None
self._ipc_channels.send_to_tokenizer.send_output(
FlushCacheReqOutput(
success=False, message="Timed out waiting for idle state."
),
pending_req,
)
@@ -0,0 +1,35 @@
import zmq
from sglang.srt.environ import envs
from sglang.srt.observability.req_time_stats import real_time
from sglang.srt.platforms import current_platform
class IdleSleeper:
"""
In setups which have long inactivity periods it is desirable to reduce
system power consumption when sglang does nothing. This would lead not only
to power savings, but also to more CPU thermal headroom when a request
eventually comes. This is important in cases when multiple GPUs are connected
as each GPU would otherwise pin one thread at 100% CPU usage.
The simplest solution is to use zmq.Poller on all sockets that may receive
data that needs handling immediately.
"""
def __init__(self, sockets):
self.poller = zmq.Poller()
self.last_empty_time = real_time()
for s in sockets:
self.poller.register(s, zmq.POLLIN)
self.empty_cache_interval = envs.SGLANG_EMPTY_CACHE_INTERVAL.get()
def maybe_sleep(self):
self.poller.poll(1000)
if (
self.empty_cache_interval > 0
and real_time() - self.last_empty_time > self.empty_cache_interval
):
self.last_empty_time = real_time()
current_platform.empty_cache()
@@ -0,0 +1,486 @@
from __future__ import annotations
import logging
from collections import deque
from dataclasses import dataclass, field
from typing import (
TYPE_CHECKING,
Callable,
Deque,
List,
Optional,
Tuple,
)
import torch
from sglang.srt.disaggregation.utils import DisaggregationMode
from sglang.srt.environ import envs
from sglang.srt.managers.scheduler_components.pool_stats_observer import (
PoolStats,
SchedulerPoolStatsObserver,
)
from sglang.srt.mem_cache.allocator import BaseTokenToKVPoolAllocator
from sglang.srt.mem_cache.base_prefix_cache import BasePrefixCache
from sglang.srt.mem_cache.memory_pool import ReqToTokenPool
from sglang.srt.server_args import ServerArgs
from sglang.srt.utils.common import (
ceil_align,
raise_error_or_warn,
)
from sglang.srt.utils.watchdog import WatchdogRaw
if TYPE_CHECKING:
from sglang.srt.managers.scheduler import Scheduler
logger = logging.getLogger(__name__)
# Number of recent busy-check messages buffered for the level-1 dump-on-leak path.
BUSY_MEM_CHECK_LOG_RING_SIZE = 1000
@dataclass(kw_only=True, slots=True)
class SchedulerInvariantChecker:
is_hybrid_swa: bool
is_hybrid_ssm: bool
disaggregation_mode: DisaggregationMode
page_size: int
full_tokens_per_layer: Optional[int]
swa_tokens_per_layer: Optional[int]
max_total_num_tokens: int
server_args: ServerArgs
tree_cache: BasePrefixCache
token_to_kv_pool_allocator: BaseTokenToKVPoolAllocator
req_to_token_pool: ReqToTokenPool
pool_stats_observer: SchedulerPoolStatsObserver
get_last_batch: Callable
get_running_batch: Callable
count_req_pool_leak_warnings: int = 0
count_memory_leak_warnings: int = 0
recent_busy_msgs: Deque[str] = field(
default_factory=lambda: deque(maxlen=BUSY_MEM_CHECK_LOG_RING_SIZE)
)
@staticmethod
def _check_pool_invariant(
pool_name: str,
available: int,
evictable: int,
protected: int,
session_held: int,
total: int,
uncached: int = 0,
) -> Tuple[bool, str]:
"""Check: available + evictable + protected + session_held + uncached == total."""
total_accounted = available + evictable + protected + session_held + uncached
leak = total_accounted != total
msg = (
f"[{pool_name}] {total=}, {available=}, {evictable=}, "
f"{protected=}, {session_held=}, {uncached=}"
)
return leak, msg
def _check_full_pool(self, ps: PoolStats, uncached: int = 0) -> Tuple[bool, str]:
if self.is_hybrid_swa and not self.full_tokens_per_layer:
return False, ""
if self.is_hybrid_swa:
protected = self.tree_cache.full_protected_size()
session_held = self.pool_stats_observer.session_held_full_tokens()
total = self.full_tokens_per_layer
elif self.is_hybrid_ssm:
# Branch on cache type for the protected accessor (MambaRadixCache
# splits full/mamba; ChunkCache only has the single protected_size).
# Use the allocator's `.size` for `total`: static max_total_num_tokens for
# non-unified pools, the dynamic byte-coordinated cap (matching
# `available_size`) for the unified pool.
if self.tree_cache.supports_mamba():
protected = self.tree_cache.full_protected_size()
else:
protected = self.tree_cache.protected_size()
session_held = self.pool_stats_observer.session_held_tokens()
total = self.token_to_kv_pool_allocator.size
else:
protected = self.tree_cache.protected_size()
session_held = self.pool_stats_observer.session_held_tokens()
total = self.max_total_num_tokens
full_evictable_size = ps.full_evictable_size
allocator = self.token_to_kv_pool_allocator
if getattr(self.server_args, "dcp_size", 1) > 1 and allocator.page_size > 1:
# DCP stores logical tokens in widened physical pages. Prefix cache
# counters are logical-token based, while the allocator frees whole
# physical pages, so round cached tokens up to physical page units.
full_evictable_size = (
(full_evictable_size + allocator.page_size - 1)
// allocator.page_size
* allocator.page_size
)
leak, msg = self._check_pool_invariant(
"full",
ps.full_available_size,
full_evictable_size,
protected,
session_held,
total,
uncached,
)
if (
leak
and getattr(self.server_args, "dcp_size", 1) > 1
and allocator.page_size > 1
):
# Radix/Mamba cache accounting is logical-token based while DCP full
# KV allocation is physical-page based. Partial physical pages can
# leave a small page-level slack even when all pages are owned by
# either the allocator or the prefix cache.
return False, f"{msg}, dcp_physical_page_slack_allowed=True"
return leak, msg
def _check_swa_pool(self, ps: PoolStats, uncached: int = 0) -> Tuple[bool, str]:
return self._check_pool_invariant(
"swa",
ps.swa_available_size,
ps.swa_evictable_size,
self.tree_cache.swa_protected_size(),
self.pool_stats_observer.session_held_swa_tokens(),
self.swa_tokens_per_layer,
uncached,
)
def _check_mamba_pool(self, ps: PoolStats) -> Tuple[bool, str]:
ckpt_pool = getattr(self.req_to_token_pool, "mamba_ckpt_pool", None)
if ckpt_pool is not None:
return self._check_mamba_pool_with_int8(ps, ckpt_pool)
leak, msg = self._check_pool_invariant(
"mamba",
ps.mamba_available_size,
ps.mamba_evictable_size,
self.tree_cache.mamba_protected_size(),
self.pool_stats_observer.session_held_mamba_slots(),
self.req_to_token_pool.mamba_pool.size,
)
if leak:
# Page-level leak diagnosis for mamba
free_full_pages = set(
self.token_to_kv_pool_allocator.free_pages.tolist()
+ self.token_to_kv_pool_allocator.release_pages.tolist()
)
cached_full_pages = set(self.tree_cache.all_values_flatten().tolist())
expected_full_pages = set(
range(1, self.token_to_kv_pool_allocator.size + 1)
)
leaked_full_pages = (
expected_full_pages - free_full_pages - cached_full_pages
)
mamba_allocator = self.req_to_token_pool.mamba_allocator
free_mamba_pages = set(mamba_allocator.free_slots.tolist())
cached_mamba_pages = set(
self.tree_cache.all_mamba_values_flatten().tolist()
)
expected_mamba_pages = set(range(1, mamba_allocator.size + 1))
leaked_mamba_pages = (
expected_mamba_pages - free_mamba_pages - cached_mamba_pages
)
msg += (
f", leaked_full_pages={leaked_full_pages or None}"
f", leaked_mamba_pages={leaked_mamba_pages or None}"
)
return leak, msg
def _check_mamba_pool_with_int8(self, ps: PoolStats, ckpt_pool) -> Tuple[bool, str]:
"""Two-pool invariant for int8 mamba checkpoints.
The radix-cached states live in the int8 checkpoint pool, NOT the active
bf16 pool. So the single-pool equation (active.available + radix_cached ==
active.size) is wrong -- it double-counts the radix states against a pool
that does not hold them. Instead check the two pools independently:
* active bf16 pool: backs running requests only; the radix owns ZERO
active slots. Checked at idle (in-flight == 0) -> available == total.
* int8 checkpoint pool: backs the radix-cached states; its occupancy is
exactly the radix evictable + protected counts.
"""
active_leak, active_msg = self._check_pool_invariant(
"mamba-active",
ps.mamba_available_size,
ps.mamba_evictable_size, # 0 in int8 mode (radix owns no active slots)
0,
self.pool_stats_observer.session_held_mamba_slots(),
self.req_to_token_pool.mamba_pool.size,
)
int8_leak, int8_msg = self._check_pool_invariant(
"mamba-int8",
ckpt_pool.available_size(),
self.tree_cache.mamba_evictable_size(),
self.tree_cache.mamba_protected_size(),
0,
ckpt_pool.num_slots,
)
return active_leak or int8_leak, active_msg + "\n" + int8_msg
def _get_total_uncached_sizes(
self,
) -> Tuple[int, int]:
"""Sum uncached tokens for full and SWA pools across all active batches.
Returns (full_uncached, swa_uncached). For non-SWA models, swa_uncached is 0.
For full pool: uncached = allocated - cache_protected_len
For SWA pool: uncached = allocated - max(cache_protected_len, swa_evicted_seqlen)
"""
# After decode: running_batch IS last_batch (same object), count once.
# After prefill: they differ, both hold uncached tokens.
# Use identity (is / is not), not membership or ==: ScheduleBatch's
# dataclass __eq__ compares tensor fields and raises on ambiguous bools.
last_batch = self.get_last_batch()
running_batch = self.get_running_batch()
batches = [last_batch]
if (
running_batch is not None
and running_batch is not last_batch
and not running_batch.is_empty()
):
batches.append(running_batch)
full_uncached = 0
swa_uncached = 0
for batch in batches:
for req in batch.reqs:
assert req.kv_committed_freed == req.kv_overallocated_freed
if req.kv_committed_freed or req.req_pool_idx is None:
continue
allocated_len = req.kv_allocated_len
if self.page_size > 1:
allocated_len = ceil_align(allocated_len, self.page_size)
assert req.cache_protected_len % self.page_size == 0
full_uncached += allocated_len - req.cache_protected_len
if self.is_hybrid_swa:
swa_uncached += allocated_len - max(
req.cache_protected_len, req.swa_evicted_seqlen
)
return full_uncached, swa_uncached
def self_check_during_busy(self):
if self.get_last_batch() is None:
return
ps = self.pool_stats_observer.get_pool_stats()
full_uncached, swa_uncached = self._get_total_uncached_sizes()
full_leak, full_msg = self._check_full_pool(ps, uncached=full_uncached)
swa_leak, swa_msg = False, ""
if self.is_hybrid_swa:
swa_leak, swa_msg = self._check_swa_pool(ps, uncached=swa_uncached)
level = envs.SGLANG_ENABLE_STRICT_MEM_CHECK_DURING_BUSY.get()
full_line = f"[Mem Check (BUSY)] {full_msg}"
swa_line = f"[Mem Check (BUSY)] {swa_msg}" if swa_msg else None
if level > 1:
# Verbose: log every iteration.
logger.info(full_line)
if swa_line:
logger.info(swa_line)
elif level == 1:
# Quiet: buffer and stay silent; flush the recent ones only on a leak.
self.recent_busy_msgs.append(full_line)
if swa_line:
self.recent_busy_msgs.append(swa_line)
if full_leak or swa_leak:
for msg in self.recent_busy_msgs:
logger.info(msg)
assert not full_leak, f"Full Pool Mem Leak Detected! {full_msg}"
assert not swa_leak, f"SWA Pool Mem Leak Detected! {swa_msg}"
if envs.SGLANG_CHECK_KV_PAGE_INVARIANTS.get():
self._check_kv_page_invariants()
def _check_kv_page_invariants(self):
"""committed<=allocated for every req/slot, and no double free:
A. no owner references a page that is in the free pool (use-after-free).
B. the free pool has no duplicate pages (two owners freed the same page).
All heavy work runs on GPU to avoid per-token device->host sync."""
rtt = self.req_to_token_pool.req_to_token
row_width = rtt.shape[1]
def _add_owner(req_or_slot, label, rpi, committed, allocated):
assert 0 <= committed <= allocated <= row_width
owners.append((label, rpi, allocated))
owners: list[tuple[str, Optional[int], int]] = []
batch = self.get_last_batch()
if batch is not None:
for req in batch.reqs:
_add_owner(
req,
f"req {req.rid}",
req.req_pool_idx,
req.kv_committed_len,
req.kv_allocated_len,
)
sess = getattr(self.tree_cache, "slots", None)
if sess:
for sid, slot in sess.items():
if getattr(slot, "is_holding_kv", False):
_add_owner(
slot,
f"slot {sid[:8]}",
slot.req_pool_idx,
slot.kv_committed_len,
slot.kv_allocated_len,
)
active = [
(label, rpi, al) for label, rpi, al in owners if rpi is not None and al > 0
]
if not active:
return
idx = torch.as_tensor([rpi for _, rpi, _ in active], device=rtt.device)
allocs = torch.as_tensor([al for _, _, al in active], device=rtt.device)
mask = torch.arange(row_width, device=rtt.device)[None, :] < allocs[:, None]
owner_pages = rtt[idx][mask] // self.page_size
# Sub-allocators to check: a flat allocator is its own single sub; a
# hybrid-SWA wrapper exposes full_attn_allocator + swa_attn_allocator.
alloc = self.token_to_kv_pool_allocator
sub_allocs = (
[alloc]
if getattr(alloc, "free_pages", None) is not None
else [
sub
for n in ("full_attn_allocator", "swa_attn_allocator")
if (sub := getattr(alloc, n, None)) is not None
and getattr(sub, "free_pages", None) is not None
]
)
if not sub_allocs:
return
def _free_pages(a):
free = a.free_pages
release = getattr(a, "release_pages", None)
return (
torch.cat((free, release))
if release is not None and len(release) > 0
else free
)
# Check B: every sub-pool's free set has no duplicate pages.
for i, sub in enumerate(sub_allocs):
free = _free_pages(sub)
uniq = torch.unique(free)
if uniq.numel() != free.numel():
raise_error_or_warn(
self,
envs.SGLANG_ENABLE_STRICT_MEM_CHECK_DURING_IDLE.get(),
"count_memory_leak_warnings",
f"KV double free: sub-pool {i} has {free.numel() - uniq.numel()} duplicate pages.",
)
# Check A: owner pages (full-pool indices) must not be in the full free
# set (sub_allocs[0] is the full pool, even on hybrid-SWA).
full_unique = torch.unique(_free_pages(sub_allocs[0]))
stale = owner_pages[torch.isin(owner_pages, full_unique)]
if stale.numel() > 0:
raise_error_or_warn(
self,
envs.SGLANG_ENABLE_STRICT_MEM_CHECK_DURING_IDLE.get(),
"count_memory_leak_warnings",
f"KV page use-after-free: {stale.numel()} owner page refs are in "
f"the free pool, sample pages={torch.unique(stale)[:8].tolist()}.",
)
def _check_req_pool(self):
if self.disaggregation_mode == DisaggregationMode.DECODE:
req_total_size = (
self.req_to_token_pool.size + self.req_to_token_pool.pre_alloc_size
)
else:
req_total_size = self.req_to_token_pool.size
session_req_count = self.pool_stats_observer.session_held_req_count()
if len(self.req_to_token_pool.free_slots) + session_req_count != req_total_size:
msg = (
"req_to_token_pool memory leak detected!"
f"available_size={len(self.req_to_token_pool.free_slots)}, "
f"session_held={session_req_count}, "
f"total_size={self.req_to_token_pool.size}\n"
)
raise_error_or_warn(
self,
envs.SGLANG_ENABLE_STRICT_MEM_CHECK_DURING_IDLE.get(),
"count_req_pool_leak_warnings",
msg,
)
def _report_leak(self, pool_name: str, token_msg: str):
msg = f"{pool_name} memory leak detected! {token_msg}"
raise_error_or_warn(
self,
envs.SGLANG_ENABLE_STRICT_MEM_CHECK_DURING_IDLE.get(),
"count_memory_leak_warnings",
msg,
)
def _check_all_pools(
self, ps: PoolStats, uncached: int = 0
) -> Tuple[bool, List[str]]:
"""Check memory invariant across all pools. Returns (has_leak, messages)."""
has_leak = False
messages = []
full_leak, full_msg = self._check_full_pool(ps, uncached=uncached)
has_leak |= full_leak
messages.append(full_msg)
if self.is_hybrid_swa:
swa_leak, swa_msg = self._check_swa_pool(ps)
has_leak |= swa_leak
messages.append(swa_msg)
if self.is_hybrid_ssm and self.tree_cache.supports_mamba():
mamba_leak, mamba_msg = self._check_mamba_pool(ps)
has_leak |= mamba_leak
messages.append(mamba_msg)
return has_leak, messages
def _check_tree_cache(self):
if (
self.tree_cache.is_tree_cache()
and (self.is_hybrid_swa and self.tree_cache.supports_swa())
or (self.is_hybrid_ssm and self.tree_cache.supports_mamba())
):
self.tree_cache.sanity_check()
def create_scheduler_watchdog(
scheduler: Scheduler, watchdog_timeout: float, soft: bool = False
) -> WatchdogRaw:
def dump_info() -> str:
if scheduler.is_initializing:
return ""
_, messages = scheduler.invariant_checker._check_all_pools(
scheduler.pool_stats_observer.get_pool_stats(),
)
return (
f"{scheduler.cur_batch_for_debug.batch_size()=}\n"
f"{scheduler.cur_batch_for_debug.reqs=}\n" + "\n".join(messages)
)
return WatchdogRaw(
debug_name="Scheduler",
get_counter=lambda: scheduler.forward_ct,
is_active=lambda: (
scheduler.is_initializing or scheduler.cur_batch_for_debug is not None
),
watchdog_timeout=watchdog_timeout,
soft=soft,
dump_info=dump_info,
)
@@ -0,0 +1,87 @@
from dataclasses import dataclass
from typing import TYPE_CHECKING, Optional, Union
import zmq
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: Union[zmq.Socket, "ScriptedTokenizerRecvProxy"]
recv_from_rpc: Optional[zmq.Socket]
send_to_tokenizer: SenderWrapper
send_to_detokenizer: SenderWrapper
send_metrics_from_scheduler: Optional[zmq.Socket]
@classmethod
def create(
cls,
*,
port_args: PortArgs,
is_rank_zero: bool,
skip_tokenizer_init: bool,
metrics_enabled: bool,
enable_scripted_runtime: bool,
) -> "SchedulerIpcChannels":
context = zmq.Context(2)
if is_rank_zero:
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
)
send_to_tokenizer_raw = get_zmq_socket(
context, zmq.PUSH, port_args.tokenizer_ipc_name, False
)
if skip_tokenizer_init:
# Directly send to the TokenizerManager
send_to_detokenizer_raw = get_zmq_socket(
context, zmq.PUSH, port_args.tokenizer_ipc_name, False
)
else:
# Send to the DetokenizerManager
send_to_detokenizer_raw = get_zmq_socket(
context, zmq.PUSH, port_args.detokenizer_ipc_name, False
)
send_to_tokenizer = SenderWrapper(send_to_tokenizer_raw)
send_to_detokenizer = SenderWrapper(send_to_detokenizer_raw)
else:
recv_from_tokenizer = None
recv_from_rpc = None
send_to_tokenizer = SenderWrapper(None)
send_to_detokenizer = SenderWrapper(None)
if metrics_enabled:
send_metrics_from_scheduler = get_zmq_socket(
context, zmq.PUSH, port_args.metrics_ipc_name, False
)
else:
send_metrics_from_scheduler = None
return cls(
recv_from_tokenizer=recv_from_tokenizer,
recv_from_rpc=recv_from_rpc,
send_to_tokenizer=send_to_tokenizer,
send_to_detokenizer=send_to_detokenizer,
send_metrics_from_scheduler=send_metrics_from_scheduler,
)
@@ -0,0 +1,107 @@
from __future__ import annotations
import time
from dataclasses import dataclass
from typing import (
TYPE_CHECKING,
Any,
Callable,
Optional,
)
import msgspec
import zmq
from sglang.srt.disaggregation.kv_events import (
EventPublisherFactory,
KVEventBatch,
select_kv_publisher_dp_rank,
)
from sglang.srt.managers.io_struct import hook_custom_types, sock_send
if TYPE_CHECKING:
from sglang.srt.distributed.parallel_state_wrapper import ParallelState
from sglang.srt.mem_cache.base_prefix_cache import BasePrefixCache
class SchedulerStats: ... # type: ignore[no-redef]
class KvMetrics(msgspec.Struct, tag=True, kw_only=True, array_like=True):
request_active_slots: int = 0
request_total_slots: int = 0
kv_active_blocks: int = 0
kv_total_blocks: int = 0
num_requests_waiting: int = 0
gpu_cache_usage_perc: float = 0.0
gpu_prefix_cache_hit_rate: float = 0.0
data_parallel_rank: int = 0
hook_custom_types(KvMetrics)
@dataclass(kw_only=True, slots=True)
class SchedulerKvEventsPublisher:
kv_events_config: Optional[str]
ps: ParallelState
attn_tp_rank: int
attn_cp_rank: int
attn_dp_rank: int
dp_rank: Optional[int]
tree_cache: BasePrefixCache
send_metrics_from_scheduler: Optional[zmq.Socket]
max_running_requests: int
max_total_num_tokens: int
get_stats: Callable
enable_kv_cache_events: bool = False
kv_event_publisher: Any = None
def __post_init__(self) -> None:
self.init_kv_events(self.kv_events_config)
def init_kv_events(self, kv_events_config: Optional[str]):
self.enable_kv_cache_events = bool(
kv_events_config
and self.ps.pp_rank == 0
and self.ps.attn_tp_rank == 0
and self.ps.attn_cp_rank == 0
)
if self.enable_kv_cache_events:
self.kv_event_publisher = EventPublisherFactory.create(
kv_events_config,
select_kv_publisher_dp_rank(
self.ps.attn_dp_size, self.ps.attn_dp_rank, self.ps.dp_rank
),
)
def emit_kv_metrics(self):
if not self.enable_kv_cache_events:
return
kv_metrics = KvMetrics()
kv_metrics.request_active_slots = self.get_stats().num_running_reqs.total
kv_metrics.request_total_slots = self.max_running_requests
kv_metrics.kv_active_blocks = int(
self.get_stats().token_usage * self.max_total_num_tokens
)
kv_metrics.kv_total_blocks = self.max_total_num_tokens
kv_metrics.num_requests_waiting = self.get_stats().num_queue_reqs.total
kv_metrics.gpu_cache_usage_perc = self.get_stats().token_usage
kv_metrics.gpu_prefix_cache_hit_rate = self.get_stats().cache_hit_rate
kv_metrics.data_parallel_rank = (
self.ps.dp_rank if self.ps.dp_rank is not None else 0
)
if not self.send_metrics_from_scheduler.closed:
sock_send(self.send_metrics_from_scheduler, kv_metrics)
def publish_kv_events(self):
if not self.enable_kv_cache_events:
return
events = self.tree_cache.take_events()
if events:
batch = KVEventBatch(ts=time.time(), events=events)
self.kv_event_publisher.publish(batch)
@@ -0,0 +1,205 @@
from __future__ import annotations
import logging
import time
from dataclasses import dataclass
from typing import TYPE_CHECKING, Callable
from sglang.srt.disaggregation.utils import DisaggregationMode
from sglang.srt.managers.load_snapshot import (
DisaggregationMetrics,
LoadSnapshot,
LoRAMetrics,
MemoryMetrics,
QueueMetrics,
SpeculativeMetrics,
)
if TYPE_CHECKING:
from sglang.srt.distributed.parallel_state_wrapper import ParallelState
from sglang.srt.managers.scheduler_components.pool_stats_observer import (
SchedulerPoolStatsObserver,
)
from sglang.srt.managers.tp_worker import BaseTpWorker
from sglang.srt.mem_cache.allocator import BaseTokenToKVPoolAllocator
from sglang.srt.server_args import ServerArgs
from sglang.srt.speculative.spec_info import SpeculativeAlgorithm
logger = logging.getLogger(__name__)
@dataclass(kw_only=True, slots=True, frozen=True)
class SchedulerLoadInquirer:
disaggregation_mode: DisaggregationMode
ps: ParallelState
server_args: ServerArgs
max_total_num_tokens: int
max_running_requests: int
pool_stats_observer: SchedulerPoolStatsObserver
tp_worker: BaseTpWorker
token_to_kv_pool_allocator: BaseTokenToKVPoolAllocator
spec_algorithm: SpeculativeAlgorithm
get_running_batch: Callable
get_waiting_queue: Callable
get_stats: Callable
get_chunked_req: Callable
get_disagg_prefill_bootstrap_queue: Callable
get_disagg_prefill_inflight_queue: Callable
get_disagg_decode_prealloc_queue: Callable
get_disagg_decode_transfer_queue: Callable
get_spec_total_num_accept_tokens: Callable
get_spec_total_num_forward_ct: Callable
def _get_num_pending_tokens(self, chunk_deduct: int = 0) -> int:
"""Get the total number of tokens pending prefill.
This includes tokens from waiting queue requests plus remaining tokens
from the currently chunked request.
Args:
chunk_deduct: extra tokens to subtract from the chunked request's
remaining count. At batch-scheduling time the current chunk
has been planned but ``prefix_indices`` does not yet include it,
so callers pass ``extend_input_len`` here. At load-reporting
time ``prefix_indices`` is already up-to-date, so the default
0 is correct.
"""
num_pending_tokens = sum(req.seqlen for req in self.get_waiting_queue())
if self.get_chunked_req() is not None:
req = self.get_chunked_req()
num_pending_tokens += req.seqlen - len(req.prefix_indices) - chunk_deduct
return num_pending_tokens
def get_num_waiting_uncached_tokens(self) -> int:
"""Get uncached input tokens waiting for prefill compute."""
if self.disaggregation_mode == DisaggregationMode.DECODE:
return 0
num_tokens = 0
for req in self.get_waiting_queue():
# if match-in-waiting-queue disabled, this metric returns seq_lens
num_tokens += max(0, req.seqlen - req.num_matched_prefix_tokens)
cr = self.get_chunked_req()
if cr is not None:
num_tokens += max(0, cr.seqlen - len(cr.prefix_indices))
return num_tokens
def get_loads(self) -> LoadSnapshot:
"""Build the per-DP-rank load snapshot for DP balancing and /v1/loads."""
stats = self.get_stats()
num_running_reqs = len(self.get_running_batch().reqs)
waiting_queues = [self.get_waiting_queue()]
pending_token_queues = [self.get_waiting_queue()]
if self.disaggregation_mode == DisaggregationMode.PREFILL:
prefill_bootstrap_queue = self.get_disagg_prefill_bootstrap_queue().queue
waiting_queues.append(prefill_bootstrap_queue)
pending_token_queues.append(prefill_bootstrap_queue)
elif self.disaggregation_mode == DisaggregationMode.DECODE:
decode_prealloc_queue = self.get_disagg_decode_prealloc_queue().queue
decode_transfer_queue = self.get_disagg_decode_transfer_queue().queue
decode_retracted_queue = (
self.get_disagg_decode_prealloc_queue().retracted_queue
)
waiting_queues.append(decode_prealloc_queue)
waiting_queues.append(decode_transfer_queue)
waiting_queues.append(decode_retracted_queue)
# In disaggregated decode, transfer-queue requests and transferred
# waiting-queue requests have already pre-allocated decode-side KV
# slots, so they are already included in num_used_tokens.
pending_token_queues = [decode_prealloc_queue, decode_retracted_queue]
num_waiting_reqs = sum(len(queue) for queue in waiting_queues)
num_used_tokens, kv_token_usage = (
self.pool_stats_observer.get_pool_stats().get_kv_token_stats()
)
num_total_tokens = num_used_tokens + sum(
req.seqlen for queue in pending_token_queues for req in queue
)
memory = None
try:
memory = MemoryMetrics(
weight_gb=round(self.tp_worker.model_runner.weight_load_mem_usage, 3),
kv_cache_gb=round(
self.token_to_kv_pool_allocator.get_kvcache().mem_usage, 3
),
graph_gb=round(self.tp_worker.model_runner.graph_mem_usage, 3),
token_capacity=int(self.max_total_num_tokens),
)
except (AttributeError, TypeError) as e:
logger.debug(f"Memory metrics not available: {e}")
speculative = None
if (
not self.spec_algorithm.is_none()
and self.get_spec_total_num_forward_ct() > 0
):
speculative = SpeculativeMetrics(
accept_length=(
self.get_spec_total_num_accept_tokens()
/ self.get_spec_total_num_forward_ct()
),
accept_rate=stats.spec_accept_rate,
)
lora = None
if self.server_args.enable_lora:
lora = LoRAMetrics(
slots_used=stats.lora_pool_slots_used,
slots_total=stats.lora_pool_slots_total,
utilization=stats.lora_pool_utilization,
)
mode_str = "null"
prefill_bootstrap = prefill_inflight = 0
decode_prealloc = decode_transfer = decode_retracted = 0
if self.disaggregation_mode == DisaggregationMode.PREFILL:
mode_str = "prefill"
prefill_bootstrap = len(self.get_disagg_prefill_bootstrap_queue().queue)
prefill_inflight = len(self.get_disagg_prefill_inflight_queue())
elif self.disaggregation_mode == DisaggregationMode.DECODE:
mode_str = "decode"
decode_prealloc = len(self.get_disagg_decode_prealloc_queue().queue)
decode_transfer = len(self.get_disagg_decode_transfer_queue().queue)
decode_retracted = len(
self.get_disagg_decode_prealloc_queue().retracted_queue
)
disaggregation = DisaggregationMetrics(
mode=mode_str,
prefill_bootstrap_queue_reqs=prefill_bootstrap,
prefill_inflight_queue_reqs=prefill_inflight,
decode_prealloc_queue_reqs=decode_prealloc,
decode_transfer_queue_reqs=decode_transfer,
decode_retracted_queue_reqs=decode_retracted,
kv_transfer_speed_gb_s=stats.kv_transfer_speed_gb_s,
kv_transfer_latency_ms=stats.kv_transfer_latency_ms,
)
queues = QueueMetrics(
waiting=len(self.get_waiting_queue()),
grammar=stats.num_grammar_queue_reqs,
paused=stats.num_paused_reqs,
retracted=stats.num_retracted_reqs,
)
return LoadSnapshot(
dp_rank=int(self.ps.dp_rank) if self.ps.dp_rank is not None else 0,
timestamp=time.time(),
num_running_reqs=num_running_reqs,
num_waiting_reqs=num_waiting_reqs,
num_waiting_uncached_tokens=self.get_num_waiting_uncached_tokens(),
num_used_tokens=num_used_tokens,
num_total_tokens=num_total_tokens,
max_total_num_tokens=self.max_total_num_tokens,
max_running_requests=self.max_running_requests,
token_usage=round(kv_token_usage, 4),
gen_throughput=round(stats.gen_throughput, 2),
cache_hit_rate=round(stats.cache_hit_rate, 4),
utilization=round(stats.utilization, 4),
memory=memory,
speculative=speculative,
lora=lora,
disaggregation=disaggregation,
queues=queues,
)
@@ -0,0 +1,337 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import (
List,
Tuple,
)
import torch
from sglang.srt.configs.model_config import ModelConfig
from sglang.srt.layers.logits_processor import LogitsProcessorOutput
from sglang.srt.managers.schedule_batch import Req
from sglang.srt.server_args import (
MIS_DELIMITER_TOKEN_ID,
ServerArgs,
)
@dataclass(kw_only=True, slots=True, frozen=True)
class SchedulerLogprobResultProcessor:
server_args: ServerArgs
model_config: ModelConfig
def _process_input_token_logprobs(
self, req: Req, input_token_logprobs: List
) -> None:
"""Process input token logprobs values and indices."""
is_multi_item_scoring = self._is_multi_item_scoring(req)
# Process logprob values - handle multi-item scoring vs regular requests
if is_multi_item_scoring:
# Multi-item scoring: use all logprobs as-is
req.logprob.input_token_logprobs_val = input_token_logprobs
else:
# Regular request: add None at start, remove last (sampling token)
req.logprob.input_token_logprobs_val = [None] + input_token_logprobs[:-1]
# Process logprob indices based on scoring type
if is_multi_item_scoring:
# MIS scores come from input_token_ids_logprobs, not input_token_logprobs.
# But the shared pipeline requires input_token_logprobs_idx to be the same
# length as input_token_logprobs_val (validated at line 816). We fill with
# MIS_DELIMITER_TOKEN_ID as a dummy — score_request() ignores this field.
delimiter_count = len(req.multi_item_delimiter_indices)
input_token_logprobs_idx = [MIS_DELIMITER_TOKEN_ID] * delimiter_count
else:
# Regular request: include all tokens from logprob_start_len onwards
input_token_logprobs_idx = req.origin_input_ids[req.logprob_start_len :]
# Clip padded hash values from image tokens to prevent detokenization errors
req.logprob.input_token_logprobs_idx = [
x if x < self.model_config.vocab_size - 1 else 0
for x in input_token_logprobs_idx
]
def _process_input_top_logprobs(self, req: Req) -> None:
"""Process input top logprobs."""
if req.logprob.top_logprobs_num <= 0:
return
is_multi_item_scoring = self._is_multi_item_scoring(req)
# Initialize arrays - multi-item scoring starts empty, others start with None
req.logprob.input_top_logprobs_val = [] if is_multi_item_scoring else [None]
req.logprob.input_top_logprobs_idx = [] if is_multi_item_scoring else [None]
# Extend arrays with temp values
for val, idx in zip(
req.temp_input_top_logprobs_val,
req.temp_input_top_logprobs_idx,
strict=True,
):
req.logprob.input_top_logprobs_val.extend(val)
req.logprob.input_top_logprobs_idx.extend(idx)
# Remove last token (sampling token) for non multi-item scoring requests
if not is_multi_item_scoring:
req.logprob.input_top_logprobs_val.pop()
req.logprob.input_top_logprobs_idx.pop()
# Clean up temp storage
req.temp_input_top_logprobs_idx = None
req.temp_input_top_logprobs_val = None
def _process_input_token_ids_logprobs(self, req: Req) -> None:
"""Process input token IDs logprobs."""
if req.logprob.token_ids_logprob is None:
return
is_multi_item_scoring = self._is_multi_item_scoring(req)
# Initialize arrays - multi-item scoring starts empty, others start with None
req.logprob.input_token_ids_logprobs_val = (
[] if is_multi_item_scoring else [None]
)
req.logprob.input_token_ids_logprobs_idx = (
[] if is_multi_item_scoring else [None]
)
# Process temp values - convert tensors to lists and extend arrays
for val, idx in zip(
req.temp_input_token_ids_logprobs_val,
req.temp_input_token_ids_logprobs_idx,
strict=True,
):
val_list = val.tolist() if isinstance(val, torch.Tensor) else val
req.logprob.input_token_ids_logprobs_val.extend(
val_list if isinstance(val_list, list) else [val_list]
)
req.logprob.input_token_ids_logprobs_idx.extend(idx)
# Remove last token (sampling token) for non multi-item scoring requests
if not is_multi_item_scoring:
req.logprob.input_token_ids_logprobs_val.pop()
req.logprob.input_token_ids_logprobs_idx.pop()
# Clean up temp storage
req.temp_input_token_ids_logprobs_idx = None
req.temp_input_token_ids_logprobs_val = None
def _calculate_relevant_tokens_len(self, req: Req) -> int:
"""Calculate the expected length of logprob arrays based on whether multi-item scoring is enabled.
For multi-item scoring, only delimiter positions have logprobs.
For regular requests, all positions from logprob_start_len onwards have logprobs.
"""
is_multi_item_scoring = self._is_multi_item_scoring(req)
if is_multi_item_scoring:
return len(req.multi_item_delimiter_indices)
else:
return len(req.origin_input_ids[req.logprob_start_len :])
def calculate_num_input_logprobs(
self,
req: Req,
extend_input_len: int,
extend_logprob_start_len: int,
) -> int:
"""Calculate the number of input logprobs based on whether multi-item scoring is enabled.
For multi-item scoring, only delimiter positions have logprobs.
For regular requests, all positions in the range have logprobs.
"""
is_multi_item_scoring = self._is_multi_item_scoring(req)
if is_multi_item_scoring:
# Count pre-computed delimiter indices within the extend range
return sum(
1
for idx in req.multi_item_delimiter_indices
if extend_logprob_start_len <= idx < extend_input_len
)
else:
# Regular request: all tokens in the range
return extend_input_len - extend_logprob_start_len
def _is_multi_item_scoring(self, req: Req) -> bool:
"""Check if request uses multi-item scoring.
Multi-item scoring applies to prefill-only requests when a delimiter
token is configured. In this mode, only positions containing the
delimiter token receive logprobs.
"""
return (
self.server_args.enable_mis
and req.is_prefill_only
and req.multi_item_delimiter_indices is not None
)
def add_input_logprob_return_values(
self,
i: int,
req: Req,
output: LogitsProcessorOutput,
logprob_pt: int,
num_input_logprobs: int,
last_prefill_chunk: bool, # If True, it means prefill is finished.
):
"""Incrementally add input logprobs to `req`.
Args:
i: The request index in a batch.
req: The request. Input logprobs inside req are modified as a
consequence of the API
logprob_pt: Pointer into the prefill ids processed.
output: Logit processor output that's used to compute input logprobs
last_prefill_chunk: True if it is the last prefill (when chunked).
Some of input logprob operation should only happen at the last
prefill (e.g., computing input token logprobs).
"""
assert output.input_token_logprobs is not None
if req.input_token_logprobs is None:
req.input_token_logprobs = []
if req.temp_input_top_logprobs_val is None:
req.temp_input_top_logprobs_val = []
if req.temp_input_top_logprobs_idx is None:
req.temp_input_top_logprobs_idx = []
if req.temp_input_token_ids_logprobs_val is None:
req.temp_input_token_ids_logprobs_val = []
if req.temp_input_token_ids_logprobs_idx is None:
req.temp_input_token_ids_logprobs_idx = []
if req.logprob.input_token_logprobs_val is not None:
# The input logprob has been already computed. It only happens
# upon retract.
if req.logprob.top_logprobs_num > 0:
assert req.logprob.input_token_logprobs_val is not None
return
# Important for the performance.
assert isinstance(output.input_token_logprobs, tuple)
input_token_logprobs: Tuple[int] = output.input_token_logprobs
input_token_logprobs = input_token_logprobs[
logprob_pt : logprob_pt + num_input_logprobs
]
req.input_token_logprobs.extend(input_token_logprobs)
if req.logprob.top_logprobs_num > 0:
req.temp_input_top_logprobs_val.append(output.input_top_logprobs_val[i])
req.temp_input_top_logprobs_idx.append(output.input_top_logprobs_idx[i])
if req.logprob.token_ids_logprob is not None:
req.temp_input_token_ids_logprobs_val.append(
output.input_token_ids_logprobs_val[i]
)
req.temp_input_token_ids_logprobs_idx.append(
output.input_token_ids_logprobs_idx[i]
)
if last_prefill_chunk:
input_token_logprobs = req.input_token_logprobs
req.input_token_logprobs = None
assert req.logprob.input_token_logprobs_val is None
assert req.logprob.input_token_logprobs_idx is None
assert req.logprob.input_top_logprobs_val is None
assert req.logprob.input_top_logprobs_idx is None
# Process all input logprob types using helper functions
self._process_input_token_logprobs(req, input_token_logprobs)
self._process_input_top_logprobs(req)
self._process_input_token_ids_logprobs(req)
if req.return_logprob:
relevant_tokens_len = self._calculate_relevant_tokens_len(req)
assert len(req.logprob.input_token_logprobs_val) == relevant_tokens_len
assert len(req.logprob.input_token_logprobs_idx) == relevant_tokens_len
if req.logprob.top_logprobs_num > 0:
assert (
len(req.logprob.input_top_logprobs_val) == relevant_tokens_len
)
assert (
len(req.logprob.input_top_logprobs_idx) == relevant_tokens_len
)
if req.logprob.token_ids_logprob is not None:
assert (
len(req.logprob.input_token_ids_logprobs_val)
== relevant_tokens_len
)
assert (
len(req.logprob.input_token_ids_logprobs_idx)
== relevant_tokens_len
)
def add_logprob_return_values(
self,
i: int,
req: Req,
pt: int,
next_token_ids: List[int],
num_input_logprobs: int,
output: LogitsProcessorOutput,
):
"""Attach logprobs to the return values."""
if output.next_token_logprobs is not None:
req.logprob.output_token_logprobs_val.append(output.next_token_logprobs[i])
req.logprob.output_token_logprobs_idx.append(next_token_ids[i])
# Only add input logprobs if there are input tokens to process
# Note: For prefill-only requests with default logprob_start_len, this will be 0,
# meaning we only compute output logprobs (which is the intended behavior)
if num_input_logprobs > 0:
self.add_input_logprob_return_values(
i,
req,
output,
pt,
num_input_logprobs,
last_prefill_chunk=True,
)
else:
self._initialize_empty_logprob_containers(req)
if req.logprob.top_logprobs_num > 0:
req.logprob.output_top_logprobs_val.append(
output.next_token_top_logprobs_val[i]
)
req.logprob.output_top_logprobs_idx.append(
output.next_token_top_logprobs_idx[i]
)
if (
req.logprob.token_ids_logprob is not None
and output.next_token_token_ids_logprobs_val is not None
):
# Convert GPU tensor to list if needed
logprobs_val = output.next_token_token_ids_logprobs_val[i]
if isinstance(logprobs_val, torch.Tensor):
logprobs_val = logprobs_val.tolist()
req.logprob.output_token_ids_logprobs_val.append(logprobs_val)
req.logprob.output_token_ids_logprobs_idx.append(
output.next_token_token_ids_logprobs_idx[i]
)
return num_input_logprobs
def _initialize_empty_logprob_containers(self, req: Req) -> None:
"""
Initialize logprob fields to empty lists if unset.
This is needed for prefill-only requests where the normal initialization
flow might be bypassed, but downstream code expects these fields to be lists.
"""
if req.logprob.input_token_logprobs_val is None:
req.logprob.input_token_logprobs_val = []
if req.logprob.input_token_logprobs_idx is None:
req.logprob.input_token_logprobs_idx = []
if req.logprob.input_top_logprobs_val is None:
req.logprob.input_top_logprobs_val = []
if req.logprob.input_top_logprobs_idx is None:
req.logprob.input_top_logprobs_idx = []
if req.logprob.input_token_ids_logprobs_val is None:
req.logprob.input_token_ids_logprobs_val = []
if req.logprob.input_token_ids_logprobs_idx is None:
req.logprob.input_token_ids_logprobs_idx = []
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,51 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import TYPE_CHECKING, Sequence
from sglang.srt.environ import envs
from sglang.srt.server_args import ServerArgs
if TYPE_CHECKING:
from sglang.srt.managers.schedule_batch import Req
@dataclass(slots=True, kw_only=True)
class NewTokenRatioTracker:
init: float
min: float
decay: float
current: float
@classmethod
def from_server_args(cls, server_args: ServerArgs) -> NewTokenRatioTracker:
init = min(
envs.SGLANG_INIT_NEW_TOKEN_RATIO.get()
* server_args.schedule_conservativeness,
1.0,
)
min_ratio = min(
init * envs.SGLANG_MIN_NEW_TOKEN_RATIO_FACTOR.get(),
1.0,
)
decay = (init - min_ratio) / envs.SGLANG_NEW_TOKEN_RATIO_DECAY_STEPS.get()
return cls(init=init, min=min_ratio, decay=decay, current=init)
def decay_step(self) -> None:
self.current = max(self.current - self.decay, self.min)
def reset(self) -> None:
self.current = self.init
@staticmethod
def estimate_new_token_ratio_after_retract(reqs: Sequence[Req]) -> float:
total_decoded_tokens = sum(len(r.output_ids) for r in reqs)
total_max_new_tokens = sum(r.sampling_params.max_new_tokens for r in reqs)
new_estimate_ratio = (
total_decoded_tokens + envs.SGLANG_RETRACT_DECODE_STEPS.get() * len(reqs)
) / (
total_max_new_tokens + 1
) # avoid zero division
new_estimate_ratio = min(1.0, new_estimate_ratio)
return new_estimate_ratio
@@ -0,0 +1,28 @@
from typing import Optional, Union
import zmq
from sglang.srt.managers.io_struct import BaseBatchReq, BaseReq, sock_send
class SenderWrapper:
def __init__(self, socket: zmq.Socket):
self.socket = socket
def send_output(
self,
output: Union[BaseReq, BaseBatchReq],
recv_obj: Optional[Union[BaseReq, BaseBatchReq]] = None,
):
if self.socket is None:
return
if (
isinstance(recv_obj, BaseReq)
and recv_obj.http_worker_ipc is not None
and output.http_worker_ipc is None
):
# handle communicator reqs for multi-http worker case
output.http_worker_ipc = recv_obj.http_worker_ipc
sock_send(self.socket, output)
@@ -0,0 +1,581 @@
from __future__ import annotations
import logging
from dataclasses import dataclass, field
from typing import (
Any,
Callable,
List,
Optional,
)
import torch
import zmq
from sglang.srt.disaggregation.utils import DisaggregationMode
from sglang.srt.distributed.parallel_state_wrapper import ParallelState
from sglang.srt.environ import envs
from sglang.srt.managers.io_struct import (
BatchEmbeddingOutput,
BatchTokenIDOutput,
CachedTokensDetails,
wrap_as_pickle,
)
from sglang.srt.managers.schedule_batch import (
BaseFinishReason,
Req,
)
from sglang.srt.mem_cache.base_prefix_cache import BasePrefixCache
from sglang.srt.server_args import ServerArgs
from sglang.srt.speculative.spec_info import SpeculativeAlgorithm
logger = logging.getLogger(__name__)
DEFAULT_FORCE_STREAM_INTERVAL = envs.SGLANG_FORCE_STREAM_INTERVAL.get()
@dataclass(kw_only=True, slots=True)
class SchedulerOutputStreamer:
send_to_detokenizer: zmq.Socket
tree_cache: BasePrefixCache
ps: ParallelState
server_args: ServerArgs
is_generation: bool
spec_algorithm: SpeculativeAlgorithm
disaggregation_mode: DisaggregationMode
enable_hicache_storage: Callable[[], bool]
_test_stream_output_count: int = 0
def _get_storage_backend_type(self) -> str:
"""Get storage backend type from tree_cache."""
storage_backend_type = "none"
cache_controller = getattr(self.tree_cache, "cache_controller", None)
if cache_controller and hasattr(cache_controller, "storage_backend"):
storage_backend = cache_controller.storage_backend
if storage_backend is not None:
storage_backend_type = type(storage_backend).__name__
return storage_backend_type
def get_cached_tokens_details(self, req: Req) -> Optional[CachedTokensDetails]:
"""Get detailed cache breakdown for a request, if available.
Returns:
- None if no cached tokens at all
- {"device": X, "host": Y} without storage breakdown
- {"device": X, "host": Y, "storage": Z} with storage breakdown
"""
if (
req.cached_tokens_device > 0
or req.cached_tokens_host > 0
or req.cached_tokens_storage > 0
):
details = {
"device": req.cached_tokens_device,
"host": req.cached_tokens_host,
}
# In PD mode the L3 hit is produced on prefill and reported on
# decode via metadata, while decode may not have a local storage backend.
if req.cached_tokens_storage > 0 or self.enable_hicache_storage():
details["storage"] = req.cached_tokens_storage
if self.enable_hicache_storage():
details["storage_backend"] = self._get_storage_backend_type()
return details
if req.cached_tokens > 0:
return {
"device": req.cached_tokens,
"host": 0,
}
return None
def stream_output(
self,
reqs: List[Req],
return_logprob: bool,
skip_req: Optional[Req] = None,
):
"""Stream the output to detokenizer."""
if self.is_generation:
self._stream_output_generation(reqs, return_logprob, skip_req)
else: # embedding or reward model
self._stream_output_embedding(reqs)
if envs.SGLANG_TEST_CRASH_AFTER_STREAM_OUTPUTS.get() > 0:
self._trigger_crash_for_tests(
envs.SGLANG_TEST_CRASH_AFTER_STREAM_OUTPUTS.get()
)
def _trigger_crash_for_tests(self, crash_threshold: int):
# Crash trigger: crash after stream_output is called N times
# This is used for testing purposes.
self._test_stream_output_count += 1
if self._test_stream_output_count >= crash_threshold:
raise RuntimeError(
f"Test crash after stream_output called {self._test_stream_output_count} times"
)
def _stream_output_generation(
self,
reqs: List[Req],
return_logprob: bool,
skip_req: Optional[Req] = None,
is_idle_batch: bool = False,
):
return_hidden_states = any(
req.return_hidden_states for req in reqs if req is not skip_req
)
return_routed_experts = any(
req.return_routed_experts for req in reqs if req is not skip_req
)
return_indexer_topk = any(
req.return_indexer_topk for req in reqs if req is not skip_req
)
acc = _GenerationStreamAccumulator(
return_logprob=return_logprob,
return_hidden_states=return_hidden_states,
return_routed_experts=return_routed_experts,
return_indexer_topk=return_indexer_topk,
spec_algorithm=self.spec_algorithm,
disaggregation_mode=self.disaggregation_mode,
default_stream_interval=self.server_args.stream_interval,
default_force_stream_interval=DEFAULT_FORCE_STREAM_INTERVAL,
get_cached_tokens_details=self.get_cached_tokens_details,
)
for req in reqs:
if req is skip_req:
continue
if req.finished() and req.finished_output:
# With the overlap schedule, a request will try to output twice and hit this line twice
# because of the one additional delayed token. This "continue" prevented the dummy output.
continue
acc.accept(req=req)
self._maybe_log_time_stats(req=req)
# Send to detokenizer
payload = acc.to_payload(
dp_rank=self.ps.dp_rank,
is_idle_batch=is_idle_batch,
)
if payload is not None:
self.send_to_detokenizer.send_output(payload)
def _maybe_log_time_stats(self, *, req: Req) -> None:
if (
req.finished()
and self.ps.attn_tp_rank == 0
and self.server_args.enable_request_time_stats_logging
):
req.log_time_stats()
def _stream_output_embedding(self, reqs: List[Req]):
rids = []
http_worker_ipcs = []
finished_reasons: List[BaseFinishReason] = []
embeddings = []
prompt_tokens = []
cached_tokens = []
cached_tokens_details = [] # Detailed breakdown by cache source
time_stats = []
retraction_counts = []
phs_list = []
has_phs = False
for req in reqs:
if req.finished():
rids.append(req.rid)
http_worker_ipcs.append(req.http_worker_ipc)
finished_reasons.append(req.finished_reason.to_json())
embeddings.append(req.embedding)
prompt_tokens.append(len(req.origin_input_ids))
cached_tokens.append(req.cached_tokens)
# Collect detailed cache breakdown if available
cached_tokens_details.append(self.get_cached_tokens_details(req))
time_stats.append(req.time_stats)
retraction_counts.append(req.retraction_count)
phs = req.pooled_hidden_state
phs_list.append(phs)
if phs is not None:
has_phs = True
# Optimize pooled hidden states (PHS) for IPC serialization.
# Two formats, disambiguated on the receiver side by length:
# Stacked: [stacked_tensor(N, ...)] — len 1, N > 1 requests
# Non-stacked: [tensor_0, tensor_1, ...] — len == N
# Stacking reduces N pickle/__reduce_ex__ calls to 1.
# Only possible when all entries are non-None and same shape.
# See paired receiver logic in tokenizer_manager.py.
stacked_phs = None
if has_phs:
all_have_phs = all(t is not None for t in phs_list)
if all_have_phs:
if len(phs_list) > 1 and all(
t.shape == phs_list[0].shape for t in phs_list
):
# Stacked: single tensor, wrapped in a list.
stacked_phs = [torch.stack(phs_list)]
else:
# Non-stacked: 1 request, mixed shapes, or mixed None.
stacked_phs = phs_list
else:
# Non-stacked: some requests don't have PHS (None entries).
stacked_phs = phs_list
self.send_to_detokenizer.send_output(
BatchEmbeddingOutput(
rids=rids,
http_worker_ipcs=http_worker_ipcs,
time_stats=wrap_as_pickle(time_stats),
finished_reasons=finished_reasons,
embeddings=embeddings,
prompt_tokens=prompt_tokens,
cached_tokens=cached_tokens,
cached_tokens_details=cached_tokens_details,
placeholder_tokens_idx=None,
placeholder_tokens_val=None,
retraction_counts=retraction_counts,
pooled_hidden_states=stacked_phs,
)
)
@dataclass(slots=True, kw_only=True)
class _GenerationStreamAccumulator:
return_logprob: bool
return_hidden_states: bool
return_routed_experts: bool
return_indexer_topk: bool
spec_algorithm: Any
disaggregation_mode: DisaggregationMode
default_stream_interval: int
default_force_stream_interval: int
get_cached_tokens_details: Callable[[Req], Optional[CachedTokensDetails]]
rids: list = field(default_factory=list)
http_worker_ipcs: list = field(default_factory=list)
finished_reasons: list = field(default_factory=list)
decoded_texts: list = field(default_factory=list)
decode_ids_list: list = field(default_factory=list)
read_offsets: list = field(default_factory=list)
output_ids: list = field(default_factory=list)
skip_special_tokens: list = field(default_factory=list)
spaces_between_special_tokens: list = field(default_factory=list)
no_stop_trim: list = field(default_factory=list)
prompt_tokens: list = field(default_factory=list)
reasoning_tokens: list = field(default_factory=list)
completion_tokens: list = field(default_factory=list)
cached_tokens: list = field(default_factory=list)
cached_tokens_details: list = field(
default_factory=list
) # Detailed breakdown by cache source
image_tokens: list = field(default_factory=list)
audio_tokens: list = field(default_factory=list)
video_tokens: list = field(default_factory=list)
spec_verify_ct: list = field(default_factory=list)
spec_num_correct_drafts: list = field(default_factory=list)
spec_num_block_accept_tokens: list = field(default_factory=list)
spec_num_cap_tokens: list = field(default_factory=list)
spec_correct_drafts_histogram: list = field(default_factory=list)
spec_cap_lens_histogram: list = field(default_factory=list)
retraction_counts: list = field(default_factory=list)
output_hidden_states: Optional[list] = None
routed_experts: Optional[list] = None
indexer_topk: Optional[list] = None
customized_info: dict = field(default_factory=dict)
time_stats: list = field(default_factory=list)
input_token_logprobs_val: Optional[list] = None
input_token_logprobs_idx: Optional[list] = None
output_token_logprobs_val: Optional[list] = None
output_token_logprobs_idx: Optional[list] = None
input_top_logprobs_val: Optional[list] = None
input_top_logprobs_idx: Optional[list] = None
output_top_logprobs_val: Optional[list] = None
output_top_logprobs_idx: Optional[list] = None
input_token_ids_logprobs_val: Optional[list] = None
input_token_ids_logprobs_idx: Optional[list] = None
output_token_ids_logprobs_val: Optional[list] = None
output_token_ids_logprobs_idx: Optional[list] = None
def __post_init__(self) -> None:
if self.return_hidden_states:
self.output_hidden_states = []
if self.return_routed_experts:
self.routed_experts = []
if self.return_indexer_topk:
self.indexer_topk = []
if self.return_logprob:
self.input_token_logprobs_val = []
self.input_token_logprobs_idx = []
self.output_token_logprobs_val = []
self.output_token_logprobs_idx = []
self.input_top_logprobs_val = []
self.input_top_logprobs_idx = []
self.output_top_logprobs_val = []
self.output_top_logprobs_idx = []
self.input_token_ids_logprobs_val = []
self.input_token_ids_logprobs_idx = []
self.output_token_ids_logprobs_val = []
self.output_token_ids_logprobs_idx = []
def accept(self, *, req: Req) -> None:
if req.finished():
assert not req.finished_output
req.finished_output = True
if req.finished_len is None:
req.finished_len = len(req.output_ids)
should_output = True
else:
if req.stream:
stream_interval = (
req.sampling_params.stream_interval or self.default_stream_interval
)
# origin stream_interval logic
should_output = (
len(req.output_ids) % stream_interval == 1
if stream_interval > 1
else len(req.output_ids) % stream_interval == 0
)
if should_output:
# check_match_stop_str_prefix if tail_str's suffix match stop_str prefix
should_output &= not req.check_match_stop_str_prefix()
else:
should_output = (
len(req.output_ids) % self.default_force_stream_interval == 0
)
if not should_output:
return
send_token_offset = req.send_token_offset
send_output_token_logprobs_offset = req.send_output_token_logprobs_offset
self.rids.append(req.rid)
self.http_worker_ipcs.append(req.http_worker_ipc)
self.finished_reasons.append(
req.finished_reason.to_json() if req.finished_reason else None
)
self.decoded_texts.append(req.decoded_text)
decode_ids, read_offset = req.init_incremental_detokenize()
self.decode_ids_list.append(decode_ids[req.send_decode_id_offset :])
# Exclude the tokens after stop condition
output_ids_ = req.output_ids_through_stop
req.send_decode_id_offset = len(decode_ids)
self.read_offsets.append(read_offset)
self.output_ids.append(output_ids_[send_token_offset:])
req.send_token_offset = len(output_ids_)
self.skip_special_tokens.append(req.sampling_params.skip_special_tokens)
self.spaces_between_special_tokens.append(
req.sampling_params.spaces_between_special_tokens
)
self.no_stop_trim.append(req.sampling_params.no_stop_trim)
self.prompt_tokens.append(len(req.origin_input_ids))
self.reasoning_tokens.append(req.reasoning_tokens)
self.completion_tokens.append(len(output_ids_))
self.cached_tokens.append(req.cached_tokens)
# Collect detailed cache breakdown if available
self.cached_tokens_details.append(self.get_cached_tokens_details(req))
# Multimodal prompt token counts. In disagg decode mode the prefill node
# already computed these and transferred them via the metadata buffer
# (req.mm_*), so prefer the pre-stored values; otherwise compute them
# from the request's multimodal items.
if req.mm_image_tokens or req.mm_audio_tokens or req.mm_video_tokens:
image_t = req.mm_image_tokens
audio_t = req.mm_audio_tokens
video_t = req.mm_video_tokens
elif req.multimodal_inputs:
image_t, audio_t, video_t = req.multimodal_inputs.compute_mm_token_counts()
else:
image_t = audio_t = video_t = 0
self.image_tokens.append(image_t)
self.audio_tokens.append(audio_t)
self.video_tokens.append(video_t)
self.retraction_counts.append(req.retraction_count)
self.time_stats.append(req.time_stats)
if not self.spec_algorithm.is_none():
self.spec_verify_ct.append(req.spec_verify_ct)
self.spec_num_correct_drafts.append(req.spec_num_correct_drafts)
self.spec_num_block_accept_tokens.append(req.spec_num_block_accept_tokens)
self.spec_num_cap_tokens.append(req.spec_num_cap_tokens)
self.spec_correct_drafts_histogram.append(req.spec_correct_drafts_histogram)
self.spec_cap_lens_histogram.append(req.spec_cap_lens_histogram)
if self.return_logprob:
if (
req.return_logprob
and not req.input_logprob_sent
# Decode server does not send input logprobs
and self.disaggregation_mode != DisaggregationMode.DECODE
# Only send when input logprobs have been computed (after prefill)
and req.logprob.input_token_logprobs_val is not None
):
self.input_token_logprobs_val.append(
req.logprob.input_token_logprobs_val
)
self.input_token_logprobs_idx.append(
req.logprob.input_token_logprobs_idx
)
self.input_top_logprobs_val.append(req.logprob.input_top_logprobs_val)
self.input_top_logprobs_idx.append(req.logprob.input_top_logprobs_idx)
self.input_token_ids_logprobs_val.append(
req.logprob.input_token_ids_logprobs_val
)
self.input_token_ids_logprobs_idx.append(
req.logprob.input_token_ids_logprobs_idx
)
req.input_logprob_sent = True
else:
self.input_token_logprobs_val.append([])
self.input_token_logprobs_idx.append([])
self.input_top_logprobs_val.append([])
self.input_top_logprobs_idx.append([])
self.input_token_ids_logprobs_val.append([])
self.input_token_ids_logprobs_idx.append([])
if req.return_logprob:
logprob_end = max(len(output_ids_), 1)
self.output_token_logprobs_val.append(
req.logprob.output_token_logprobs_val[
send_output_token_logprobs_offset:logprob_end
]
)
self.output_token_logprobs_idx.append(
req.logprob.output_token_logprobs_idx[
send_output_token_logprobs_offset:logprob_end
]
)
self.output_top_logprobs_val.append(
req.logprob.output_top_logprobs_val[
send_output_token_logprobs_offset:logprob_end
]
)
self.output_top_logprobs_idx.append(
req.logprob.output_top_logprobs_idx[
send_output_token_logprobs_offset:logprob_end
]
)
self.output_token_ids_logprobs_val.append(
req.logprob.output_token_ids_logprobs_val[
send_output_token_logprobs_offset:logprob_end
]
)
self.output_token_ids_logprobs_idx.append(
req.logprob.output_token_ids_logprobs_idx[
send_output_token_logprobs_offset:logprob_end
]
)
req.send_output_token_logprobs_offset = logprob_end
else:
self.output_token_logprobs_val.append([])
self.output_token_logprobs_idx.append([])
self.output_top_logprobs_val.append([])
self.output_top_logprobs_idx.append([])
self.output_token_ids_logprobs_val.append([])
self.output_token_ids_logprobs_idx.append([])
if self.return_hidden_states:
if req.return_hidden_states:
# Mirror output_ids_through_stop: spec verify steps can overshoot finished_len.
hs = req.hidden_states
if req.finished_len is not None:
hs = hs[: req.finished_len]
self.output_hidden_states.append(hs)
else:
self.output_hidden_states.append(None)
if self.return_routed_experts:
self.routed_experts.append(
req.routed_experts if req.return_routed_experts else None
)
if self.return_indexer_topk:
self.indexer_topk.append(
req.indexer_topk if req.return_indexer_topk else None
)
current_output_len = len(self.output_ids[-1])
if req.customized_info is not None:
for key, req_values in req.customized_info.items():
if key not in self.customized_info:
self.customized_info[key] = [
[None] * len(prev_output_ids)
for prev_output_ids in self.output_ids[:-1]
]
self.customized_info[key].append(
[None] * current_output_len
if req_values is None
else req_values[send_token_offset : len(output_ids_)]
)
for per_request_values in self.customized_info.values():
if len(per_request_values) < len(self.output_ids):
per_request_values.append([None] * current_output_len)
def to_payload(
self, *, dp_rank: int, is_idle_batch: bool
) -> Optional[BatchTokenIDOutput]:
if not (self.rids or is_idle_batch):
return None
dp_ranks = [dp_rank] * len(self.rids) if self.rids else None
return BatchTokenIDOutput(
rids=self.rids,
http_worker_ipcs=self.http_worker_ipcs,
spec_verify_ct=self.spec_verify_ct,
spec_num_correct_drafts=self.spec_num_correct_drafts,
spec_num_block_accept_tokens=self.spec_num_block_accept_tokens,
spec_num_cap_tokens=self.spec_num_cap_tokens,
spec_correct_drafts_histogram=self.spec_correct_drafts_histogram,
spec_cap_lens_histogram=self.spec_cap_lens_histogram,
time_stats=wrap_as_pickle(self.time_stats),
finished_reasons=self.finished_reasons,
decoded_texts=self.decoded_texts,
decode_ids=self.decode_ids_list,
read_offsets=self.read_offsets,
output_ids=self.output_ids,
skip_special_tokens=self.skip_special_tokens,
spaces_between_special_tokens=self.spaces_between_special_tokens,
no_stop_trim=self.no_stop_trim,
prompt_tokens=self.prompt_tokens,
reasoning_tokens=self.reasoning_tokens,
completion_tokens=self.completion_tokens,
cached_tokens=self.cached_tokens,
cached_tokens_details=self.cached_tokens_details,
image_tokens=self.image_tokens,
audio_tokens=self.audio_tokens,
video_tokens=self.video_tokens,
input_token_logprobs_val=self.input_token_logprobs_val,
input_token_logprobs_idx=self.input_token_logprobs_idx,
output_token_logprobs_val=self.output_token_logprobs_val,
output_token_logprobs_idx=self.output_token_logprobs_idx,
input_top_logprobs_val=self.input_top_logprobs_val,
input_top_logprobs_idx=self.input_top_logprobs_idx,
output_top_logprobs_val=self.output_top_logprobs_val,
output_top_logprobs_idx=self.output_top_logprobs_idx,
input_token_ids_logprobs_val=self.input_token_ids_logprobs_val,
input_token_ids_logprobs_idx=self.input_token_ids_logprobs_idx,
output_token_ids_logprobs_val=self.output_token_ids_logprobs_val,
output_token_ids_logprobs_idx=self.output_token_ids_logprobs_idx,
output_token_entropy_val=None,
output_hidden_states=self.output_hidden_states,
routed_experts=self.routed_experts,
indexer_topk=self.indexer_topk,
customized_info=(
wrap_as_pickle(self.customized_info) if self.customized_info else None
),
placeholder_tokens_idx=None,
placeholder_tokens_val=None,
retraction_counts=self.retraction_counts,
dp_ranks=dp_ranks,
)
@@ -0,0 +1,321 @@
from __future__ import annotations
import dataclasses
from dataclasses import dataclass
from typing import (
TYPE_CHECKING,
Any,
Callable,
List,
Optional,
Tuple,
)
if TYPE_CHECKING:
from sglang.srt.mem_cache.allocator import BaseTokenToKVPoolAllocator
from sglang.srt.mem_cache.base_prefix_cache import BasePrefixCache
from sglang.srt.mem_cache.memory_pool import ReqToTokenPool
class SchedulerStats: ... # type: ignore[no-redef]
@dataclasses.dataclass
class PoolStats:
# For full pools (required)
full_num_used: int
full_token_usage: float
full_available_size: int
full_evictable_size: int
is_hybrid_swa: bool = False
is_hybrid_ssm: bool = False
is_hisparse: bool = False
# For hybrid-swa pools
swa_num_used: Optional[int] = None
swa_token_usage: Optional[float] = None
swa_available_size: Optional[int] = None
swa_evictable_size: Optional[int] = None
# For mamba pools
mamba_num_used: Optional[int] = None
mamba_usage: Optional[float] = None
mamba_available_size: Optional[int] = None
mamba_evictable_size: Optional[int] = None
# HiSparse device/host breakdown for decode logs (plain KV pool only)
hisparse_device_tokens: Optional[int] = None
hisparse_device_token_usage: Optional[float] = None
hisparse_host_tokens: Optional[int] = None
hisparse_host_token_usage: Optional[float] = None
def get_kv_token_stats(self) -> Tuple[int, float]:
# NOTE: mamba pool is not included in the "token usage" calculation.
if self.is_hybrid_swa:
num_used = max(self.full_num_used, self.swa_num_used)
token_usage = max(self.full_token_usage, self.swa_token_usage)
else:
num_used = self.full_num_used
token_usage = self.full_token_usage
return num_used, token_usage
def get_max_pool_usage(self) -> float:
usage = self.full_token_usage
if self.is_hybrid_swa:
usage = max(usage, self.swa_token_usage)
if self.is_hybrid_ssm:
usage = max(usage, self.mamba_usage)
assert usage is not None and usage >= 0, f"{usage=} is not valid"
return usage
def get_prefill_usage_msg_parts(self) -> List[str]:
parts = []
if self.is_hybrid_swa:
parts += [
f"full token usage: {self.full_token_usage:.2f}",
f"swa token usage: {self.swa_token_usage:.2f}",
]
if self.is_hybrid_ssm:
if not self.is_hybrid_swa:
parts.append(f"full token usage: {self.full_token_usage:.2f}")
parts.append(f"mamba usage: {self.mamba_usage:.2f}")
if not parts:
parts.append(f"token usage: {self.full_token_usage:.2f}")
return parts
def get_decode_usage_msg_parts(self) -> List[str]:
parts = []
if self.is_hybrid_swa:
parts += [
f"#full token: {self.full_num_used}",
f"full token usage: {self.full_token_usage:.2f}",
f"#swa token: {self.swa_num_used}",
f"swa token usage: {self.swa_token_usage:.2f}",
]
if self.is_hybrid_ssm:
if not self.is_hybrid_swa:
parts += [
f"#full token: {self.full_num_used}",
f"full token usage: {self.full_token_usage:.2f}",
]
parts += [
f"mamba num: {self.mamba_num_used}",
f"mamba usage: {self.mamba_usage:.2f}",
]
if self.is_hisparse:
parts += [
f"#gpu token: {self.hisparse_device_tokens}",
f"gpu token usage: {self.hisparse_device_token_usage:.2f}",
f"#cpu token: {self.hisparse_host_tokens}",
f"cpu token usage: {self.hisparse_host_token_usage:.2f}",
]
if not parts:
parts.append(
f"#token: {self.full_num_used}, token usage: {self.full_token_usage:.2f}"
)
return parts
def update_scheduler_stats(self, stats: SchedulerStats) -> None:
"""Update pool-related fields on SchedulerStats."""
num_used, _ = self.get_kv_token_stats()
stats.num_used_tokens = num_used
stats.token_usage = round(self.get_max_pool_usage(), 2)
stats.full_token_usage = self.full_token_usage
if self.is_hybrid_swa:
stats.swa_token_usage = self.swa_token_usage
stats.swa_available_tokens = self.swa_available_size
stats.swa_evictable_tokens = self.swa_evictable_size
stats.swa_used_tokens = self.swa_num_used
if self.is_hybrid_ssm:
stats.mamba_usage = self.mamba_usage
stats.mamba_available_tokens = self.mamba_available_size
stats.mamba_evictable_tokens = self.mamba_evictable_size
stats.mamba_used_tokens = self.mamba_num_used
stats.kv_available_tokens = self.full_available_size
stats.kv_evictable_tokens = self.full_evictable_size
stats.kv_used_tokens = self.full_num_used
@dataclass(kw_only=True, slots=True, frozen=True)
class SchedulerPoolStatsObserver:
tree_cache: BasePrefixCache
token_to_kv_pool_allocator: BaseTokenToKVPoolAllocator
req_to_token_pool: ReqToTokenPool
session_controller: Any
hisparse_coordinator: Any
is_hybrid_swa: bool
is_hybrid_ssm: bool
enable_hisparse: bool
full_tokens_per_layer: Any
swa_tokens_per_layer: Any
max_total_num_tokens: int
get_last_batch: Callable
get_running_batch: Callable
def streaming_session_count(self) -> int:
return sum(
1
for session in self.session_controller.sessions.values()
if session.streaming
)
def active_pool_idxs(self) -> set:
"""Pool idxs currently owned by reqs in last_batch / running_batch.
Used to decide which session slots' KV is owned by batch reqs
(and thus counted via uncached_size, not session_held).
"""
idxs = set()
for batch in [self.get_last_batch(), self.get_running_batch()]:
if batch is None or batch.is_empty():
continue
for req in batch.reqs:
if req.req_pool_idx is not None:
idxs.add(req.req_pool_idx)
return idxs
def session_held_tokens(self) -> int:
return self.tree_cache.session_held_tokens(self.active_pool_idxs())
def session_held_full_tokens(self) -> int:
return self.tree_cache.session_held_full_tokens(self.active_pool_idxs())
def session_held_swa_tokens(self) -> int:
return self.tree_cache.session_held_swa_tokens(self.active_pool_idxs())
def session_held_req_count(self) -> int:
return self.tree_cache.session_held_req_count()
def session_held_mamba_slots(self) -> int:
return self.tree_cache.session_held_mamba_slots(self.active_pool_idxs())
def get_pool_stats(self) -> PoolStats:
if self.is_hybrid_swa:
pool_stats = self._get_swa_token_info()
elif self.is_hybrid_ssm:
pool_stats = self._get_mamba_token_info()
else:
pool_stats = self._get_token_info()
if self.enable_hisparse:
pool_stats = self._get_hisparse_token_info(pool_stats)
# swa + ssm can coexist: overlay mamba fields onto swa stats
if self.is_hybrid_ssm:
mamba_stats = self._get_mamba_token_info()
pool_stats.is_hybrid_ssm = True
pool_stats.mamba_num_used = mamba_stats.mamba_num_used
pool_stats.mamba_usage = mamba_stats.mamba_usage
pool_stats.mamba_available_size = mamba_stats.mamba_available_size
pool_stats.mamba_evictable_size = mamba_stats.mamba_evictable_size
return pool_stats
def _get_token_info(self) -> PoolStats:
available_size = self.token_to_kv_pool_allocator.available_size()
evictable_size = self.tree_cache.evictable_size()
num_used = self.max_total_num_tokens - (available_size + evictable_size)
token_usage = num_used / self.max_total_num_tokens
return PoolStats(
full_num_used=num_used,
full_token_usage=token_usage,
full_available_size=available_size,
full_evictable_size=evictable_size,
)
def _get_hisparse_token_info(self, pool_stats: PoolStats) -> PoolStats:
if self.enable_hisparse and self.hisparse_coordinator is not None:
h = self.hisparse_coordinator.get_token_stats()
return dataclasses.replace(
pool_stats,
is_hisparse=True,
hisparse_device_tokens=h.device_tokens,
hisparse_device_token_usage=h.device_token_usage,
hisparse_host_tokens=h.host_tokens,
hisparse_host_token_usage=h.host_token_usage,
)
return pool_stats
def _get_mamba_token_info(self):
is_mamba_radix_cache = (
self.tree_cache.supports_mamba() and self.tree_cache.is_tree_cache()
)
full_available_size = self.token_to_kv_pool_allocator.available_size()
full_evictable_size = (
self.tree_cache.full_evictable_size() if is_mamba_radix_cache else 0
)
mamba_available_size = self.req_to_token_pool.mamba_allocator.available_size()
# `mamba_usage`/`mamba_num_used` track the ACTIVE bf16 pool occupancy (running
# requests) -- this feeds throttle decisions (get_max_pool_usage) which asserts
# usage >= 0. With int8 checkpoints the radix-cached states live in a SEPARATE
# int8 pool, so they own ZERO active slots: report evictable=0 against the active
# pool (otherwise active.size - (available + radix_cached) goes negative). The
# int8 cache pool's own occupancy is validated separately in the invariant check.
has_int8_ckpt = (
getattr(self.req_to_token_pool, "mamba_ckpt_pool", None) is not None
)
mamba_evictable_size = (
self.tree_cache.mamba_evictable_size()
if (is_mamba_radix_cache and not has_int8_ckpt)
else 0
)
full_num_used = self.token_to_kv_pool_allocator.size - (
full_available_size + full_evictable_size
)
mamba_num_used = self.req_to_token_pool.mamba_pool.size - (
mamba_available_size + mamba_evictable_size
)
full_token_usage = full_num_used / self.token_to_kv_pool_allocator.size
mamba_usage = mamba_num_used / self.req_to_token_pool.mamba_pool.size
return PoolStats(
is_hybrid_ssm=True,
full_num_used=full_num_used,
full_token_usage=full_token_usage,
full_available_size=full_available_size,
full_evictable_size=full_evictable_size,
mamba_num_used=mamba_num_used,
mamba_usage=mamba_usage,
mamba_available_size=mamba_available_size,
mamba_evictable_size=mamba_evictable_size,
)
def _get_swa_token_info(self) -> PoolStats:
full_available_size = self.token_to_kv_pool_allocator.full_available_size()
full_evictable_size = self.tree_cache.full_evictable_size()
swa_available_size = self.token_to_kv_pool_allocator.swa_available_size()
swa_evictable_size = self.tree_cache.swa_evictable_size()
full_num_used = self.full_tokens_per_layer - (
full_available_size + full_evictable_size
)
swa_num_used = self.swa_tokens_per_layer - (
swa_available_size + swa_evictable_size
)
# FIXME(hisparse): host-backup transiently over-releases the device pool
# counter, producing negative full_num_used / swa_num_used. We clamp to 0
# to keep token_usage / leak checks sane, but the underlying accounting
# bug should be fixed so the clamp can go away.
if self.enable_hisparse:
full_num_used = max(0, full_num_used)
swa_num_used = max(0, swa_num_used)
if not self.full_tokens_per_layer:
full_num_used = 0
full_available_size = 0
full_token_usage = 0.0
else:
full_token_usage = full_num_used / self.full_tokens_per_layer
swa_token_usage = swa_num_used / self.swa_tokens_per_layer
return PoolStats(
is_hybrid_swa=True,
full_num_used=full_num_used,
full_token_usage=full_token_usage,
full_available_size=full_available_size,
full_evictable_size=full_evictable_size,
swa_num_used=swa_num_used,
swa_token_usage=swa_token_usage,
swa_available_size=swa_available_size,
swa_evictable_size=swa_evictable_size,
)
@@ -0,0 +1,445 @@
from __future__ import annotations
import logging
import os
import time
from dataclasses import dataclass
from pathlib import Path
from typing import (
TYPE_CHECKING,
Any,
Callable,
List,
Optional,
)
import torch
from sglang.srt.environ import envs
from sglang.srt.managers.io_struct import ProfileReq, ProfileReqOutput, ProfileReqType
from sglang.srt.model_executor.forward_batch_info import ForwardMode
from sglang.srt.runtime_context import get_server_args
from sglang.srt.utils import is_mps, is_npu
from sglang.srt.utils.profile_merger import ProfileMerger
from sglang.srt.utils.profile_utils import ProfileManager
from sglang.srt.utils.torch_npu_patch_utils import apply_torch_npu_patches
if TYPE_CHECKING:
from sglang.srt.managers.schedule_batch import ScheduleBatch
_is_npu = is_npu()
_is_mps = is_mps()
if _is_npu:
import torch_npu
patches = [
["profiler.profile", torch_npu.profiler.profile],
["profiler.ProfilerActivity.CUDA", torch_npu.profiler.ProfilerActivity.NPU],
["profiler.ProfilerActivity.CPU", torch_npu.profiler.ProfilerActivity.CPU],
]
apply_torch_npu_patches(torch_npu, patches)
elif _is_mps:
from sglang.srt.hardware_backend.mlx.profiler import apply_metal_profiler_patches
apply_metal_profiler_patches()
logger = logging.getLogger(__name__)
@dataclass(kw_only=True)
class SchedulerProfilerManager:
ps: Any
dp_tp_cpu_group: Any
get_forward_ct: Callable[[], int]
def __post_init__(self) -> None:
if envs.SGLANG_PROFILE_V2.get():
self._profile_manager = ProfileManager(
ps=self.ps,
cpu_group=self.dp_tp_cpu_group,
)
return
self.torch_profiler = None
self.torch_profiler_output_dir: Optional[Path] = None
self.profiler_activities: Optional[List[str]] = None
self.profile_id: Optional[str] = None
self.profiler_start_forward_ct: Optional[int] = None
self.profiler_target_forward_ct: Optional[int] = None
self.profiler_prefill_ct: Optional[int] = None
self.profiler_decode_ct: Optional[int] = None
self.profiler_target_prefill_ct: Optional[int] = None
self.profiler_target_decode_ct: Optional[int] = None
self.profile_by_stage: bool = False
self.profile_in_progress: bool = False
self.merge_profiles = False
# For ROCM
self.rpd_profiler = None
def _init_profile(
self,
output_dir: Optional[str],
start_step: Optional[int],
num_steps: Optional[int],
activities: Optional[List[str]],
with_stack: Optional[bool],
record_shapes: Optional[bool],
profile_by_stage: bool,
profile_id: str,
merge_profiles: bool = False,
profile_prefix: str = "",
profile_stages: Optional[List[str]] = None,
) -> ProfileReqOutput:
if envs.SGLANG_PROFILE_V2.get():
return self._profile_manager.configure(
output_dir=output_dir,
start_step=start_step,
num_steps=num_steps,
activities=activities,
with_stack=with_stack,
record_shapes=record_shapes,
profile_by_stage=profile_by_stage,
profile_id=profile_id,
merge_profiles=merge_profiles,
profile_prefix=profile_prefix,
profile_stages=profile_stages,
)
if self.profile_in_progress:
return ProfileReqOutput(
success=False,
message="Profiling is already in progress. Call /stop_profile first.",
)
self.profile_by_stage = profile_by_stage
self.merge_profiles = merge_profiles
if output_dir is None:
output_dir = os.getenv("SGLANG_TORCH_PROFILER_DIR", "/tmp")
if activities is None:
activities = ["CPU", "GPU"]
self.torch_profiler_output_dir = Path(output_dir).expanduser()
self.torch_profiler_with_stack = with_stack
self.torch_profiler_record_shapes = record_shapes
self.profiler_activities = activities
self.profile_id = profile_id
self.profile_prefix = profile_prefix
if start_step:
self.profiler_start_forward_ct = max(start_step, self.get_forward_ct() + 1)
if num_steps:
if self.profile_by_stage:
self.profiler_prefill_ct = 0
self.profiler_decode_ct = 0
self.profiler_target_prefill_ct = num_steps
self.profiler_target_decode_ct = num_steps
elif start_step:
self.profiler_target_forward_ct = (
self.profiler_start_forward_ct + num_steps
)
else:
self.profiler_target_forward_ct = self.get_forward_ct() + num_steps + 1
# The caller will be notified when reaching profiler_target_forward_ct
else:
self.profiler_target_forward_ct = None
return ProfileReqOutput(success=True, message="Succeeded")
def _start_profile(
self, stage: Optional[ForwardMode] = None
) -> ProfileReqOutput | None:
if envs.SGLANG_PROFILE_V2.get():
return self._profile_manager.manual_start()
stage_str = f" for {stage.name}" if stage else ""
logger.info(
f"Profiling starts{stage_str}. Traces will be saved to: {self.torch_profiler_output_dir} (with profile id: {self.profile_id})",
)
activities = self.profiler_activities
with_stack = self.torch_profiler_with_stack
record_shapes = self.torch_profiler_record_shapes
activity_map = {
"CPU": torch.profiler.ProfilerActivity.CPU,
"GPU": torch.profiler.ProfilerActivity.CUDA,
}
if hasattr(torch.profiler.ProfilerActivity, "XPU"):
activity_map["XPU"] = torch.profiler.ProfilerActivity.XPU
torchprof_activities = [
activity_map[a] for a in activities if a in activity_map
]
if "RPD" in activities: # for ROCM
from rpdTracerControl import rpdTracerControl
rpdTracerControl.skipCreate()
self.rpd_profile_path = os.path.join(
self.torch_profiler_output_dir,
"rpd-" + str(time.time()) + f"-TP-{self.ps.tp_rank}" + ".trace.json.gz",
)
if self.ps.tp_rank == 0:
import sqlite3
from rocpd.schema import RocpdSchema
if os.path.exists("trace.rpd"):
os.unlink("trace.rpd")
schema = RocpdSchema()
connection = sqlite3.connect("trace.rpd")
schema.writeSchema(connection)
connection.commit()
del connection
torch.distributed.barrier(self.dp_tp_cpu_group)
self.rpd_profiler = rpdTracerControl()
self.rpd_profiler.setPythonTrace(True)
self.rpd_profiler.start()
self.rpd_profiler.rangePush("", "rpd profile range", "")
self.profile_in_progress = True
elif torchprof_activities:
self.torch_profiler = torch.profiler.profile(
activities=torchprof_activities,
with_stack=with_stack if with_stack is not None else True,
record_shapes=record_shapes if record_shapes is not None else False,
on_trace_ready=(
None
if not _is_npu
else torch_npu.profiler.tensorboard_trace_handler(
str(self.torch_profiler_output_dir)
)
),
experimental_config=(
None
if not _is_npu
else torch_npu.profiler._ExperimentalConfig(
export_type=torch_npu.profiler.ExportType.Text,
profiler_level=torch_npu.profiler.ProfilerLevel.Level1,
msprof_tx=False,
aic_metrics=torch_npu.profiler.AiCMetrics.PipeUtilization,
l2_cache=False,
op_attr=False,
data_simplification=False,
record_op_args=False,
gc_detect_threshold=None,
)
),
)
try:
self.torch_profiler.start()
except RuntimeError as e:
self.torch_profiler = None
return ProfileReqOutput(success=False, message=str(e))
self.profile_in_progress = True
if "MEM" in activities:
torch.cuda.memory._record_memory_history(max_entries=100000)
self.profile_in_progress = True
if "CUDA_PROFILER" in activities:
if self.ps.gpu_id == get_server_args().base_gpu_id:
torch.cuda.cudart().cudaProfilerStart()
self.profile_in_progress = True
return ProfileReqOutput(success=True, message="Succeeded")
def _merge_profile_traces(self) -> str:
if not self.merge_profiles:
return ""
if self.ps.tp_rank != 0:
return ""
if self.ps.dp_size > 1 and self.ps.dp_rank != 0:
return ""
if self.ps.pp_size > 1 and self.ps.pp_rank != 0:
return ""
if self.ps.moe_ep_size > 1 and self.ps.moe_ep_rank != 0:
return ""
try:
logger.info("Starting profile merge...")
merger = ProfileMerger(self.torch_profiler_output_dir, self.profile_id)
merged_path = merger.merge_chrome_traces()
summary = merger.get_merge_summary()
merge_message = (
f" Merged trace: {merged_path} "
f"(Events: {summary.get('total_events', '?')}, "
f"Files: {summary.get('total_files', '?')})"
)
logger.info(f"Profile merge completed: {merged_path}")
except Exception as e:
logger.error(f"Failed to merge profiles: {e}", exc_info=True)
return f" Merge failed: {e!s}"
else:
return merge_message
def _stop_profile(
self, stage: Optional[ForwardMode] = None
) -> ProfileReqOutput | None:
if envs.SGLANG_PROFILE_V2.get():
return self._profile_manager.manual_stop()
if not self.profile_in_progress:
return ProfileReqOutput(
success=False,
message="Profiling is not in progress. Call /start_profile first.",
)
self.torch_profiler_output_dir.mkdir(parents=True, exist_ok=True)
if self.profile_prefix:
stage_prefix = self.profile_prefix + "-"
else:
stage_prefix = ""
stage_suffix = f"-{stage.name}" if stage else ""
logger.info("Stop profiling" + stage_suffix + "...")
if self.torch_profiler is not None:
self.torch_profiler.stop()
if not _is_npu:
# Build filename with only non-zero ranks to maintain backward compatibility
filename_parts = [self.profile_id, f"TP-{self.ps.tp_rank}"]
# Only add other ranks if parallelism is enabled (size > 1)
if self.ps.dp_size > 1:
filename_parts.append(f"DP-{self.ps.dp_rank}")
if self.ps.pp_size > 1:
filename_parts.append(f"PP-{self.ps.pp_rank}")
if self.ps.moe_ep_size > 1:
filename_parts.append(f"EP-{self.ps.moe_ep_rank}")
filename = (
stage_prefix
+ "-".join(filename_parts)
+ stage_suffix
+ ".trace.json.gz"
)
self.torch_profiler.export_chrome_trace(
os.path.join(self.torch_profiler_output_dir, filename)
)
torch.distributed.barrier(self.dp_tp_cpu_group)
if self.rpd_profiler is not None:
self.rpd_profiler.rangePop()
self.rpd_profiler.stop()
self.rpd_profiler.flush()
torch.distributed.barrier(self.dp_tp_cpu_group)
if self.ps.tp_rank == 0:
from sglang.srt.utils.rpd_utils import rpd_to_chrome_trace
rpd_to_chrome_trace("trace.rpd", self.rpd_profile_path)
self.rpd_profiler = None
self.rpd_profile_path = None
if self.profiler_activities is not None and "MEM" in self.profiler_activities:
memory_profile_path = os.path.join(
self.torch_profiler_output_dir,
str(time.time())
+ f"-TP-{self.ps.tp_rank}-memory"
+ stage_suffix
+ ".pickle",
)
torch.cuda.memory._dump_snapshot(memory_profile_path)
torch.cuda.memory._record_memory_history(enabled=None)
if "CUDA_PROFILER" in self.profiler_activities:
if self.ps.gpu_id == get_server_args().base_gpu_id:
torch.cuda.cudart().cudaProfilerStop()
merge_message = self._merge_profile_traces()
logger.info(
"Profiling done. Traces are saved to: %s%s",
self.torch_profiler_output_dir,
merge_message,
)
self.torch_profiler = None
self.profile_in_progress = False
self.profiler_start_forward_ct = None
return ProfileReqOutput(success=True, message=f"Succeeded.{merge_message}")
def _profile_batch_predicate(self, batch: ScheduleBatch):
if envs.SGLANG_PROFILE_V2.get():
self._profile_manager.step(forward_mode=batch.forward_mode)
return
if self.profile_by_stage:
if batch.forward_mode.is_prefill():
if self.profiler_prefill_ct == 0:
self._start_profile(batch.forward_mode)
self.profiler_prefill_ct += 1
if self.profiler_prefill_ct > self.profiler_target_prefill_ct:
if self.profile_in_progress:
self._stop_profile(stage=ForwardMode.EXTEND)
elif batch.forward_mode.is_decode():
if self.profiler_decode_ct == 0:
if self.profile_in_progress:
# force trace flush
self._stop_profile(stage=ForwardMode.EXTEND)
self._start_profile(batch.forward_mode)
self.profiler_decode_ct += 1
if self.profiler_decode_ct > self.profiler_target_decode_ct:
if self.profile_in_progress:
self._stop_profile(stage=ForwardMode.DECODE)
elif batch.forward_mode.is_idle():
pass
else:
raise RuntimeError(f"unsupported profile stage: {batch.forward_mode}")
else:
# Check profiler
if (
self.profiler_target_forward_ct
and self.profiler_target_forward_ct <= self.get_forward_ct()
):
self._stop_profile()
if (
self.profiler_start_forward_ct
and self.profiler_start_forward_ct == self.get_forward_ct()
):
self._start_profile()
def _profile(self, recv_req: ProfileReq):
if recv_req.req_type == ProfileReqType.START_PROFILE:
if recv_req.profile_by_stage or recv_req.start_step:
return self._init_profile(
recv_req.output_dir,
recv_req.start_step,
recv_req.num_steps,
recv_req.activities,
recv_req.with_stack,
recv_req.record_shapes,
recv_req.profile_by_stage,
recv_req.profile_id,
recv_req.merge_profiles,
recv_req.profile_prefix,
recv_req.profile_stages,
)
else:
self._init_profile(
recv_req.output_dir,
recv_req.start_step,
recv_req.num_steps,
recv_req.activities,
recv_req.with_stack,
recv_req.record_shapes,
recv_req.profile_by_stage,
recv_req.profile_id,
recv_req.merge_profiles,
recv_req.profile_prefix,
)
return self._start_profile()
else:
return self._stop_profile()
@@ -0,0 +1,282 @@
from __future__ import annotations
from dataclasses import dataclass
from http import HTTPStatus
from typing import (
TYPE_CHECKING,
Any,
Callable,
List,
Optional,
Union,
)
import zmq
from torch.distributed import barrier
from sglang.srt.disaggregation.utils import prepare_abort
from sglang.srt.managers.io_struct import (
BatchTokenizedEmbeddingReqInput,
BatchTokenizedGenerateReqInput,
TokenizedEmbeddingReqInput,
TokenizedGenerateReqInput,
sock_recv,
)
from sglang.srt.managers.mm_utils import (
has_shm_features,
unwrap_shm_features,
)
from sglang.srt.utils import (
broadcast_pyobj,
point_to_point_pyobj,
)
from sglang.srt.utils.nvtx_utils import scheduler_nvtx_method
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: Union[zmq.Socket, ScriptedTokenizerRecvProxy]
recv_from_rpc: Optional[zmq.Socket]
recv_skipper: Any
input_blocker: Any
mm_receiver: Any
ps: ParallelState
tp_group: Any
tp_cpu_group: Any
attn_tp_group: Any
attn_tp_cpu_group: Any
attn_cp_group: Any
attn_cp_cpu_group: Any
world_group: Any
server_args: ServerArgs
model_config: ModelConfig
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:
return False
return num_recv_reqs >= self.max_recv_per_poll
@scheduler_nvtx_method("scheduler.recv_requests")
def recv_requests(
self,
) -> 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 []
recv_reqs = self._pull_raw_reqs()
if self.input_blocker is not None:
recv_reqs = self.input_blocker.handle(recv_reqs)
recv_reqs = self._broadcast_reqs_across_ranks(recv_reqs)
if self.ps.pp_rank == 0:
self.unwrap_pickle_wrapper(recv_reqs)
recv_reqs = self._apply_mm_receiver(recv_reqs)
self._finalize_shm_features(recv_reqs)
return recv_reqs
def _pull_raw_reqs(self) -> Optional[List]:
if self.ps.pp_rank == 0:
if self.ps.attn_tp_rank == 0 and self.ps.attn_cp_rank == 0:
recv_reqs = []
while True:
try:
if self.recv_limit_reached(len(recv_reqs)):
break
recv_req = sock_recv(self.recv_from_tokenizer, zmq.NOBLOCK)
except zmq.ZMQError:
break
recv_reqs.append(recv_req)
while True:
try:
if self.recv_limit_reached(len(recv_reqs)):
break
recv_rpc = sock_recv(self.recv_from_rpc, zmq.NOBLOCK)
except zmq.ZMQError:
break
recv_reqs.append(recv_rpc)
else:
recv_reqs = None
else:
if self.ps.attn_tp_rank == 0 and self.ps.attn_cp_rank == 0:
dp_offset = (
self.ps.attn_dp_rank * self.ps.attn_cp_size * self.ps.attn_tp_size
)
recv_reqs = point_to_point_pyobj(
[],
self.ps.pp_rank * self.ps.tp_size + dp_offset,
self.world_group.cpu_group,
(self.ps.pp_rank - 1) * self.ps.tp_size + dp_offset,
self.ps.pp_rank * self.ps.tp_size + dp_offset,
)
else:
recv_reqs = None
return recv_reqs
def _broadcast_reqs_across_ranks(self, recv_reqs: Optional[List]) -> List:
if self.server_args.enable_dp_attention:
if self.ps.attn_tp_rank == 0 and self.ps.attn_cp_rank == 0:
work_reqs, control_reqs = self._split_work_and_control_reqs(recv_reqs)
else:
work_reqs = None
control_reqs = None
if self.ps.attn_tp_size != 1:
work_reqs = broadcast_pyobj(
work_reqs,
self.attn_tp_group.rank,
self.attn_tp_cpu_group,
src=self.attn_tp_group.ranks[0],
)
if self.ps.attn_cp_size != 1:
work_reqs = broadcast_pyobj(
work_reqs,
self.attn_cp_group.rank,
self.attn_cp_cpu_group,
src=self.attn_cp_group.ranks[0],
)
# When dp_attention_local_control_broadcast is enabled, each DP
# group leader already receives control messages from the DP
# controller, so we broadcast within attn_tp_group + attn_cp_group
# instead of the full tp_group. This avoids an expensive
# all-ranks gloo sync.
_local_ctrl = self.server_args.enable_dp_attention_local_control_broadcast
if _local_ctrl:
if self.ps.attn_tp_size != 1:
control_reqs = broadcast_pyobj(
control_reqs,
self.attn_tp_group.rank,
self.attn_tp_cpu_group,
src=self.attn_tp_group.ranks[0],
)
if self.ps.attn_cp_size != 1:
control_reqs = broadcast_pyobj(
control_reqs,
self.attn_cp_group.rank,
self.attn_cp_cpu_group,
src=self.attn_cp_group.ranks[0],
)
elif self.ps.tp_size != 1:
control_reqs = broadcast_pyobj(
control_reqs,
self.tp_group.rank,
self.tp_cpu_group,
src=self.tp_group.ranks[0],
)
recv_reqs = work_reqs + control_reqs
elif self.ps.tp_size != 1:
recv_reqs = broadcast_pyobj(
recv_reqs,
self.tp_group.rank,
self.tp_cpu_group,
src=self.tp_group.ranks[0],
)
return recv_reqs
def unwrap_pickle_wrapper(self, recv_reqs: Optional[List]) -> None:
if not recv_reqs:
return
for req in recv_reqs:
if isinstance(req, (TokenizedGenerateReqInput, TokenizedEmbeddingReqInput)):
req.unwrap_pickle_fields()
elif isinstance(
req, (BatchTokenizedGenerateReqInput, BatchTokenizedEmbeddingReqInput)
):
for sub_req in req:
sub_req.unwrap_pickle_fields()
def _apply_mm_receiver(self, recv_reqs: List) -> List:
# Process MM requests under EPD-disaggregation mode
if (
self.ps.pp_rank == 0
and self.server_args.language_only
and self.server_args.encoder_transfer_backend
in ["zmq_to_scheduler", "mooncake"]
):
recv_reqs, abort_reqs = self.mm_receiver.process_waiting_requests(recv_reqs)
for req, error_msg, error_code in abort_reqs:
status_code = (
HTTPStatus.BAD_REQUEST
if error_code == 400
else HTTPStatus.INTERNAL_SERVER_ERROR
)
prepare_abort(req, error_msg, status_code=status_code)
self.stream_output([req], req.return_logprob)
return recv_reqs
def _finalize_shm_features(self, recv_reqs: Optional[List]) -> None:
# Unwrap shared memory features AFTER all broadcasts complete,
# so that ShmPointerMMData metadata (not full tensor data) is what
# gets serialized during broadcast_pyobj.
if recv_reqs:
if self.model_config.is_multimodal and has_shm_features(recv_reqs):
# The broadcast source returns with its original objects while
# peer ranks may still be unpickling ShmPointerMMData
# (-> shm_open). Synchronize the same CPU groups that carried
# SHM-backed work requests before materialize() unlinks them.
if self.server_args.enable_dp_attention:
if self.ps.attn_tp_size > 1:
barrier(group=self.attn_tp_cpu_group)
if self.ps.attn_cp_size > 1:
barrier(group=self.attn_cp_cpu_group)
elif self.ps.tp_size > 1:
barrier(group=self.tp_cpu_group)
for req in recv_reqs:
unwrap_shm_features(req)
def _split_work_and_control_reqs(self, recv_reqs: List):
work_reqs = [
req
for req in recv_reqs
if isinstance(
req,
(
TokenizedGenerateReqInput,
TokenizedEmbeddingReqInput,
BatchTokenizedGenerateReqInput,
BatchTokenizedEmbeddingReqInput,
),
)
]
control_reqs = [
req
for req in recv_reqs
if not isinstance(
req,
(
TokenizedGenerateReqInput,
TokenizedEmbeddingReqInput,
BatchTokenizedGenerateReqInput,
BatchTokenizedEmbeddingReqInput,
),
)
]
return work_reqs, control_reqs
@@ -0,0 +1,332 @@
from __future__ import annotations
import hashlib
import logging
import time
import traceback
from contextlib import contextmanager
from dataclasses import dataclass, field
from typing import Any, Callable, Dict, Iterator, Optional, Tuple
import torch
from sglang.srt.constants import (
GPU_MEMORY_ALL_TYPES,
GPU_MEMORY_TYPE_CUDA_GRAPH,
GPU_MEMORY_TYPE_KV_CACHE,
GPU_MEMORY_TYPE_WEIGHTS,
)
from sglang.srt.disaggregation.utils import DisaggregationMode
from sglang.srt.managers.io_struct import (
CheckWeightsReqInput,
CheckWeightsReqOutput,
DestroyWeightsUpdateGroupReqInput,
DestroyWeightsUpdateGroupReqOutput,
GetWeightsByNameReqInput,
GetWeightsByNameReqOutput,
InitWeightsUpdateGroupReqInput,
InitWeightsUpdateGroupReqOutput,
ReleaseMemoryOccupationReqInput,
ReleaseMemoryOccupationReqOutput,
ResumeMemoryOccupationReqInput,
ResumeMemoryOccupationReqOutput,
UpdateWeightFromDiskReqInput,
UpdateWeightFromDiskReqOutput,
UpdateWeightsFromDistributedReqInput,
UpdateWeightsFromDistributedReqOutput,
UpdateWeightsFromIPCReqInput,
UpdateWeightsFromIPCReqOutput,
UpdateWeightsFromTensorReqInput,
UpdateWeightsFromTensorReqOutput,
)
logger = logging.getLogger(__name__)
def _get_draft_model_runner(draft_worker):
# DFlash / FrozenKVMTP workers expose draft_model_runner directly
runner = getattr(draft_worker, "draft_model_runner", None)
if runner is not None:
return runner
# EAGLEWorkerV2: _draft_worker.draft_runner
inner = getattr(draft_worker, "_draft_worker", None)
if inner is not None:
runner = getattr(inner, "draft_runner", None)
if runner is not None:
return runner
return None
def _merge_checksum_payloads(target: Dict, draft: Dict) -> Dict:
merged_checksums = dict(target["checksums"])
for name, chk in draft["checksums"].items():
merged_checksums[f"draft.{name}"] = chk
h = hashlib.sha256()
for name in sorted(merged_checksums):
h.update(name.encode())
h.update(merged_checksums[name].encode())
target["checksums"] = merged_checksums
target["per_gpu_checksum"] = h.hexdigest()
return target
@dataclass(kw_only=True, slots=True)
class SchedulerWeightUpdaterManager:
tp_worker: Any
draft_worker: Any
tp_cpu_group: Any
memory_saver_adapter: Any
flush_cache: Callable[..., bool]
is_fully_idle: Callable[..., bool]
scheduler: Optional[Any] = None
metrics_collector: Optional[Any] = None
offload_tags: set = field(default_factory=set)
stashed_model_static_state: Any = None
@contextmanager
def _observe_weight_load(self, source: str) -> Iterator[None]:
# Edge-trigger weight_load_duration_seconds at the end of each
# update_weights_from_* call. Engine is paused during the update so
# the periodic log_stats path can't carry this.
# `source` distinguishes disk vs distributed vs tensor vs ipc.
t0 = time.perf_counter()
try:
yield
finally:
if self.metrics_collector is not None:
self.metrics_collector.observe_weight_load(
time.perf_counter() - t0, source
)
def flush_cache_after_weight_update(self, recv_req) -> None:
if recv_req.flush_cache:
flush_cache_success = self.flush_cache(
empty_cache=recv_req.torch_empty_cache
)
assert flush_cache_success, "Cache flush failed after updating weights"
def update_weights_from_disk(self, recv_req: UpdateWeightFromDiskReqInput):
"""In-place update of the weights from disk."""
with self._observe_weight_load("disk"):
success, message = self.tp_worker.update_weights_from_disk(recv_req)
tp_success = success
if success and self.draft_worker is not None:
success, message = self.draft_worker.update_weights_from_disk(recv_req)
if tp_success:
self.flush_cache_after_weight_update(recv_req)
if not success:
logger.error(message)
return UpdateWeightFromDiskReqOutput(
success=success, message=message, num_paused_requests=0
)
def init_weights_update_group(self, recv_req: InitWeightsUpdateGroupReqInput):
"""Initialize the online model parameter update group."""
success, message = self.tp_worker.init_weights_update_group(recv_req)
return InitWeightsUpdateGroupReqOutput(success=success, message=message)
def destroy_weights_update_group(
self,
recv_req: DestroyWeightsUpdateGroupReqInput,
):
"""Destroy the online model parameter update group."""
success, message = self.tp_worker.destroy_weights_update_group(recv_req)
return DestroyWeightsUpdateGroupReqOutput(success=success, message=message)
def update_weights_from_distributed(
self,
recv_req: UpdateWeightsFromDistributedReqInput,
) -> Tuple[bool, str]:
"""Update the online model parameter."""
with self._observe_weight_load("distributed"):
success, message = self.tp_worker.update_weights_from_distributed(recv_req)
if success:
self.flush_cache_after_weight_update(recv_req)
else:
logger.error(message)
return UpdateWeightsFromDistributedReqOutput(
success=success, message=message
)
def update_weights_from_tensor(self, recv_req: UpdateWeightsFromTensorReqInput):
"""Update the online model parameter from tensors."""
with self._observe_weight_load("tensor"):
if recv_req.disable_draft_model:
worker = self.tp_worker
else:
worker = self.draft_worker or self.tp_worker
success, message = worker.update_weights_from_tensor(recv_req)
if success:
self.flush_cache_after_weight_update(recv_req)
else:
logger.error(message)
torch.distributed.barrier(group=self.tp_cpu_group)
return UpdateWeightsFromTensorReqOutput(success=success, message=message)
def update_weights_from_ipc(self, recv_req: UpdateWeightsFromIPCReqInput):
"""Update the online model parameter from IPC for checkpoint-engine integration."""
with self._observe_weight_load("ipc"):
success, message = self.tp_worker.update_weights_from_ipc(recv_req)
tp_success = success
if success and self.draft_worker is not None:
success, message = self.draft_worker.update_weights_from_ipc(recv_req)
if tp_success:
self.flush_cache_after_weight_update(recv_req)
if not success:
logger.error(message)
torch.distributed.barrier(group=self.tp_cpu_group)
return UpdateWeightsFromIPCReqOutput(success=success, message=message)
def get_weights_by_name(self, recv_req: GetWeightsByNameReqInput):
parameter = self.tp_worker.get_weights_by_name(recv_req)
return GetWeightsByNameReqOutput(parameter=parameter)
def release_memory_occupation(self, recv_req: ReleaseMemoryOccupationReqInput):
assert (
self.is_fully_idle()
), "release_memory_occupation should be called only when server is idle."
tags = recv_req.tags
if tags is None or len(tags) == 0:
tags = GPU_MEMORY_ALL_TYPES
for tag in tags:
self.offload_tags.add(tag)
if GPU_MEMORY_TYPE_KV_CACHE in tags:
scheduler = self.scheduler
if scheduler is not None:
if scheduler.disaggregation_mode == DisaggregationMode.DECODE:
for queue_name in (
"disagg_decode_transfer_queue",
"disagg_decode_prealloc_queue",
):
queue = getattr(scheduler, queue_name, None)
if queue is not None:
queue.release_memory_occupation()
elif scheduler.disaggregation_mode == DisaggregationMode.PREFILL:
queue = getattr(scheduler, "disagg_prefill_bootstrap_queue", None)
if queue is not None:
queue.release_memory_occupation()
self.memory_saver_adapter.pause(GPU_MEMORY_TYPE_KV_CACHE)
self.flush_cache()
if GPU_MEMORY_TYPE_WEIGHTS in tags:
self.stashed_model_static_state = _export_static_state(
self.tp_worker.model_runner.model
)
torch.distributed.barrier(self.tp_cpu_group)
self.memory_saver_adapter.pause(GPU_MEMORY_TYPE_WEIGHTS)
if GPU_MEMORY_TYPE_CUDA_GRAPH in tags:
self.memory_saver_adapter.pause(GPU_MEMORY_TYPE_CUDA_GRAPH)
torch.get_device_module().synchronize()
return ReleaseMemoryOccupationReqOutput()
def resume_memory_occupation(self, recv_req: ResumeMemoryOccupationReqInput):
tags = recv_req.tags
if tags is None or len(tags) == 0:
tags = GPU_MEMORY_ALL_TYPES
for tag in tags:
self.offload_tags.remove(tag)
if GPU_MEMORY_TYPE_CUDA_GRAPH in tags:
self.memory_saver_adapter.resume(GPU_MEMORY_TYPE_CUDA_GRAPH)
if GPU_MEMORY_TYPE_WEIGHTS in tags:
self.memory_saver_adapter.resume(GPU_MEMORY_TYPE_WEIGHTS)
torch.distributed.barrier(self.tp_cpu_group)
_import_static_state(
self.tp_worker.model_runner.model,
self.stashed_model_static_state,
)
del self.stashed_model_static_state
if GPU_MEMORY_TYPE_KV_CACHE in tags:
self.memory_saver_adapter.resume(GPU_MEMORY_TYPE_KV_CACHE)
scheduler = self.scheduler
if scheduler is not None:
if scheduler.disaggregation_mode == DisaggregationMode.DECODE:
for queue_name in (
"disagg_decode_transfer_queue",
"disagg_decode_prealloc_queue",
):
queue = getattr(scheduler, queue_name, None)
if queue is not None:
queue.resume_memory_occupation()
elif scheduler.disaggregation_mode == DisaggregationMode.PREFILL:
queue = getattr(scheduler, "disagg_prefill_bootstrap_queue", None)
if queue is not None:
queue.resume_memory_occupation()
return ResumeMemoryOccupationReqOutput()
def check_weights(self, recv_req: CheckWeightsReqInput):
try:
payload = self.tp_worker.model_runner.check_weights(
action=recv_req.action, allow_quant_error=recv_req.allow_quant_error
)
if self.draft_worker is not None:
draft_runner = _get_draft_model_runner(self.draft_worker)
if draft_runner is not None:
draft_payload = draft_runner.check_weights(
action=recv_req.action,
allow_quant_error=recv_req.allow_quant_error,
)
if payload is not None and draft_payload is not None:
payload = _merge_checksum_payloads(payload, draft_payload)
tp_size = torch.distributed.get_world_size(group=self.tp_cpu_group)
if tp_size > 1 and payload is not None:
all_payloads = [None] * tp_size
torch.distributed.all_gather_object(
all_payloads, payload, group=self.tp_cpu_group
)
payload = all_payloads
return CheckWeightsReqOutput(
success=True, message="Success.", payload=payload
)
except Exception as e:
logger.warning(f"check_weights see error: {e}")
traceback.print_exc()
return CheckWeightsReqOutput(success=False, message=f"{e}")
def save_remote_model(self, params):
url = params["url"]
self.tp_worker.model_runner.save_remote_model(url)
if self.draft_worker is not None:
draft_url = params.get("draft_url", None)
assert (
draft_url is not None
), "draft_url must be provided when draft model is enabled"
self.draft_worker.model_runner.save_remote_model(draft_url)
def save_sharded_model(self, params):
self.tp_worker.model_runner.save_sharded_model(
path=params["path"],
pattern=params["pattern"],
max_size=params["max_size"],
)
def _export_static_state(model):
return dict(
buffers=[
(name, buffer.detach().clone()) for name, buffer in model.named_buffers()
]
)
def _import_static_state(model, static_params):
with torch.inference_mode():
self_named_buffers = dict(model.named_buffers())
for name, tensor in static_params["buffers"]:
self_named_buffers[name][...] = tensor
@@ -0,0 +1,106 @@
# Copyright 2023-2024 SGLang Team
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# ==============================================================================
import logging
from contextlib import contextmanager
from enum import Enum, auto
from typing import Any, Callable, List, Optional
from sglang.srt.managers.io_struct import BlockReqInput, BlockReqType
from sglang.srt.utils.poll_based_barrier import PollBasedBarrier
logger = logging.getLogger(__name__)
class SchedulerInputBlocker:
def __init__(self, noop: bool):
self._state = _State.UNBLOCKED
self._pending_reqs = []
self._noop = noop
self._global_unblock_barrier = PollBasedBarrier(noop=noop)
def handle(self, recv_reqs: Optional[List[Any]]):
assert (recv_reqs is None) == self._noop
if not self._noop:
output_reqs = []
for recv_req in recv_reqs:
output_reqs += self._handle_recv_req(recv_req)
global_arrived_unblock_barrier = (
self._global_unblock_barrier.poll_global_arrived()
)
if (
self._state == _State.GLOBAL_UNBLOCK_BARRIER
and global_arrived_unblock_barrier
):
output_reqs += self._handle_arrive_unblock_barrier()
if not self._noop:
return output_reqs
def _handle_recv_req(self, recv_req):
if isinstance(recv_req, BlockReqInput):
if recv_req.req_type == BlockReqType.BLOCK:
self._execute_block_req()
return []
elif recv_req.req_type == BlockReqType.UNBLOCK:
self._execute_unblock_req()
return []
else:
raise NotImplementedError(f"{recv_req=}")
else:
if self._state == _State.UNBLOCKED:
return [recv_req]
else:
self._pending_reqs.append(recv_req)
return []
def _execute_block_req(self):
logger.info("Handle block req")
self._change_state(original=_State.UNBLOCKED, target=_State.BLOCKED)
def _execute_unblock_req(self):
logger.info("Handle unblock req")
self._change_state(
original=_State.BLOCKED, target=_State.GLOBAL_UNBLOCK_BARRIER
)
self._global_unblock_barrier.local_arrive()
def _handle_arrive_unblock_barrier(self):
logger.info(f"Arrived at unblock barrier ({len(self._pending_reqs)=})")
self._change_state(
original=_State.GLOBAL_UNBLOCK_BARRIER, target=_State.UNBLOCKED
)
output_reqs = [*self._pending_reqs]
self._pending_reqs.clear()
return output_reqs
def _change_state(self, original: "_State", target: "_State"):
assert self._state == original, f"{self._state=} {original=} {target=}"
self._state = target
class _State(Enum):
UNBLOCKED = auto()
BLOCKED = auto()
GLOBAL_UNBLOCK_BARRIER = auto()
@contextmanager
def input_blocker_guard_region(dispatch_to_scheduler: Callable[[BlockReqInput], None]):
dispatch_to_scheduler(BlockReqInput(req_type=BlockReqType.BLOCK))
try:
yield
finally:
dispatch_to_scheduler(BlockReqInput(req_type=BlockReqType.UNBLOCK))
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,38 @@
from sglang.srt.environ import envs
from sglang.srt.model_executor.forward_batch_info import ForwardMode
from sglang.srt.server_args import ServerArgs
class SchedulerRecvSkipper:
@staticmethod
def maybe_create(server_args: ServerArgs):
if server_args.scheduler_recv_interval <= 1:
return None
return SchedulerRecvSkipper(server_args)
def __init__(self, server_args: ServerArgs):
# Can be supported if needed, but may need e.g. `global_forward_mode`
assert not server_args.enable_dp_attention
self._counter = 0
self._threshold = server_args.scheduler_recv_interval
# All can be tuned if needed
self._default_weight = envs.SGLANG_SCHEDULER_RECV_SKIPPER_WEIGHT_DEFAULT.get()
self._weight_of_forward_mode = {
ForwardMode.DECODE: envs.SGLANG_SCHEDULER_RECV_SKIPPER_WEIGHT_DECODE.get(),
ForwardMode.TARGET_VERIFY: envs.SGLANG_SCHEDULER_RECV_SKIPPER_WEIGHT_TARGET_VERIFY.get(),
None: envs.SGLANG_SCHEDULER_RECV_SKIPPER_WEIGHT_NONE.get(),
}
def handle(self, last_forward_mode: ForwardMode):
should_recv = False
last_weight = self._weight_of_forward_mode.get(
last_forward_mode, self._default_weight
)
self._counter += last_weight
if self._counter >= self._threshold:
self._counter = 0
should_recv = True
return should_recv
@@ -0,0 +1,876 @@
from __future__ import annotations
import asyncio
import hashlib
import logging
import time
import uuid
from typing import TYPE_CHECKING, Any, Dict, List, Optional, Tuple
import fastapi
from sglang.srt.managers.communicator import FanOutCommunicator
from sglang.srt.managers.io_struct import (
AddExternalCorpusReqInput,
AddExternalCorpusReqOutput,
AttachHiCacheStorageReqInput,
AttachHiCacheStorageReqOutput,
CheckWeightsReqInput,
CheckWeightsReqOutput,
ClearHiCacheReqInput,
ClearHiCacheReqOutput,
CloseSessionReqInput,
DestroyWeightsUpdateGroupReqInput,
DestroyWeightsUpdateGroupReqOutput,
DetachHiCacheStorageReqInput,
DetachHiCacheStorageReqOutput,
DumperControlReqInput,
DumperControlReqOutput,
ExpertDistributionReq,
ExpertDistributionReqOutput,
ExpertDistributionReqType,
FlushCacheReqInput,
FlushCacheReqOutput,
GetInternalStateReq,
GetInternalStateReqOutput,
GetWeightsByNameReqInput,
GetWeightsByNameReqOutput,
InitWeightsSendGroupForRemoteInstanceReqInput,
InitWeightsSendGroupForRemoteInstanceReqOutput,
InitWeightsUpdateGroupReqInput,
InitWeightsUpdateGroupReqOutput,
ListExternalCorporaReqInput,
ListExternalCorporaReqOutput,
LoadLoRAAdapterFromTensorsReqInput,
LoadLoRAAdapterFromTensorsReqOutput,
LoadLoRAAdapterReqInput,
LoadLoRAAdapterReqOutput,
LoRAUpdateOutput,
OpenSessionReqInput,
ProfileReq,
ProfileReqOutput,
ProfileReqType,
ReleaseMemoryOccupationReqInput,
ReleaseMemoryOccupationReqOutput,
RemoveExternalCorpusReqInput,
RemoveExternalCorpusReqOutput,
ResumeMemoryOccupationReqInput,
ResumeMemoryOccupationReqOutput,
SendWeightsToRemoteInstanceReqInput,
SendWeightsToRemoteInstanceReqOutput,
SetInternalStateReq,
SetInternalStateReqOutput,
SlowDownReqInput,
SlowDownReqOutput,
UnloadLoRAAdapterReqInput,
UnloadLoRAAdapterReqOutput,
UpdateWeightsFromDistributedReqInput,
UpdateWeightsFromDistributedReqOutput,
UpdateWeightsFromIPCReqInput,
UpdateWeightsFromIPCReqOutput,
UpdateWeightsFromTensorReqInput,
UpdateWeightsFromTensorReqOutput,
)
from sglang.srt.managers.load_snapshot import LoadSnapshot
from sglang.srt.server_args import LoRARef, ServerArgs
from sglang.srt.utils import (
get_bool_env_var,
normalize_serialized_named_tensor_payloads,
)
from sglang.utils import TypeBasedDispatcher
if TYPE_CHECKING:
from sglang.srt.managers.tokenizer_manager import TokenizerManager
logger = logging.getLogger(__name__)
# Declarative spec: (attr_name_prefix, response_type[, mode])
# Each entry creates self.{prefix}_communicator and registers
# response_type -> communicator.handle_recv in the dispatch table.
_COMMUNICATOR_SPECS = [
("init_weights_update_group", InitWeightsUpdateGroupReqOutput),
("destroy_weights_update_group", DestroyWeightsUpdateGroupReqOutput),
("update_weights_from_distributed", UpdateWeightsFromDistributedReqOutput),
(
"init_weights_send_group_for_remote_instance",
InitWeightsSendGroupForRemoteInstanceReqOutput,
),
("send_weights_to_remote_instance", SendWeightsToRemoteInstanceReqOutput),
("update_weights_from_tensor", UpdateWeightsFromTensorReqOutput),
("update_weights_from_ipc", UpdateWeightsFromIPCReqOutput),
("get_weights_by_name", GetWeightsByNameReqOutput),
("release_memory_occupation", ReleaseMemoryOccupationReqOutput),
("resume_memory_occupation", ResumeMemoryOccupationReqOutput),
("check_weights", CheckWeightsReqOutput),
("slow_down", SlowDownReqOutput),
("flush_cache", FlushCacheReqOutput),
("add_external_corpus", AddExternalCorpusReqOutput),
("remove_external_corpus", RemoveExternalCorpusReqOutput),
("list_external_corpora", ListExternalCorporaReqOutput),
("clear_hicache_storage", ClearHiCacheReqOutput),
("attach_hicache_storage", AttachHiCacheStorageReqOutput),
("detach_hicache_storage", DetachHiCacheStorageReqOutput),
("profile", ProfileReqOutput),
("get_internal_state", GetInternalStateReqOutput),
("set_internal_state", SetInternalStateReqOutput),
("expert_distribution", ExpertDistributionReqOutput),
("update_lora_adapter", LoRAUpdateOutput),
("dumper_control", DumperControlReqOutput),
]
class TokenizerControlMixin:
"""Mixin for TokenizerManager's control-plane operations (weights, cache, lora,
profile, internal state, etc.) -- everything that talks to the scheduler via
FanOutCommunicator, as opposed to data-plane inference requests multiplexed by rid.
"""
def init_communicators(self: TokenizerManager, server_args: ServerArgs):
dispatch_pairs = []
for spec in _COMMUNICATOR_SPECS:
name, resp_type = spec[0], spec[1]
mode = spec[2] if len(spec) > 2 else "queueing"
comm = FanOutCommunicator(
self._dispatch_to_scheduler,
server_args.dp_size,
mode,
)
setattr(self, f"{name}_communicator", comm)
dispatch_pairs.append((resp_type, comm.handle_recv))
self._result_dispatcher += TypeBasedDispatcher(dispatch_pairs)
async def add_external_corpus(
self: TokenizerManager, obj: AddExternalCorpusReqInput
) -> AddExternalCorpusReqOutput:
self.auto_create_handle_loop()
if self.server_args.speculative_algorithm != "NGRAM":
return AddExternalCorpusReqOutput(
success=False,
message="Ngram speculative decoding is not enabled.",
)
truncated = False
try:
if not obj.corpus_id:
import uuid
obj.corpus_id = uuid.uuid4().hex
if obj.file_path is not None:
from sglang.srt.speculative.cpp_ngram.external_corpus import (
iter_external_corpus_chunks,
)
max_tokens = (
self.server_args.speculative_ngram_external_corpus_max_tokens
)
obj.token_chunks = list(
iter_external_corpus_chunks(
obj.file_path, self.tokenizer, max_tokens
)
)
elif obj.documents is not None:
from sglang.srt.speculative.cpp_ngram.external_corpus import (
SEPARATOR_TOKEN,
)
max_tokens = (
self.server_args.speculative_ngram_external_corpus_max_tokens
)
token_chunks = []
total_tokens = 0
has_prev = False
for doc in obj.documents:
if not doc:
continue
token_ids = list(
self.tokenizer.encode(doc, add_special_tokens=False)
)
if not token_ids:
continue
if has_prev:
token_ids = [SEPARATOR_TOKEN] + token_ids
if total_tokens + len(token_ids) > max_tokens:
truncated = True
break
token_chunks.append(token_ids)
total_tokens += len(token_ids)
has_prev = True
obj.token_chunks = token_chunks
else:
return AddExternalCorpusReqOutput(
success=False,
message="Either file_path or documents must be provided.",
)
obj.file_path = None
obj.documents = None
results = await self.add_external_corpus_communicator(obj)
all_success, all_message = FanOutCommunicator.merge_results(results)
if truncated and all_success:
all_message += f" (truncated: exceeded {max_tokens} token limit)"
return AddExternalCorpusReqOutput(
success=all_success,
corpus_id=results[0].corpus_id if all_success else "",
message=all_message,
loaded_token_count=results[0].loaded_token_count if all_success else 0,
)
except Exception as e:
return AddExternalCorpusReqOutput(success=False, message=str(e))
async def remove_external_corpus(
self: TokenizerManager, corpus_id: str
) -> RemoveExternalCorpusReqOutput:
self.auto_create_handle_loop()
if self.server_args.speculative_algorithm != "NGRAM":
return RemoveExternalCorpusReqOutput(
success=False,
message="Ngram speculative decoding is not enabled.",
)
results = await self.remove_external_corpus_communicator(
RemoveExternalCorpusReqInput(corpus_id=corpus_id)
)
all_success, all_message = FanOutCommunicator.merge_results(results)
return RemoveExternalCorpusReqOutput(success=all_success, message=all_message)
async def list_external_corpora(
self: TokenizerManager,
) -> ListExternalCorporaReqOutput:
self.auto_create_handle_loop()
if self.server_args.speculative_algorithm != "NGRAM":
return ListExternalCorporaReqOutput(
success=False,
message="Ngram speculative decoding is not enabled.",
)
results = await self.list_external_corpora_communicator(
ListExternalCorporaReqInput()
)
all_success, all_message = FanOutCommunicator.merge_results(results)
# Merge corpus token counts from all DP ranks (each rank loads the same set).
corpus_token_counts = results[0].corpus_token_counts if all_success else {}
return ListExternalCorporaReqOutput(
success=all_success,
corpus_token_counts=corpus_token_counts,
message=all_message,
)
async def flush_cache(
self: TokenizerManager, timeout_s: Optional[float] = None
) -> FlushCacheReqOutput:
self.auto_create_handle_loop()
return (
await self.flush_cache_communicator(FlushCacheReqInput(timeout_s=timeout_s))
)[0]
async def clear_hicache_storage(self: TokenizerManager) -> ClearHiCacheReqOutput:
"""Clear the hierarchical cache storage."""
self.auto_create_handle_loop()
# Delegate to the scheduler to handle HiCacheStorage clearing
return (await self.clear_hicache_storage_communicator(ClearHiCacheReqInput()))[
0
]
async def attach_hicache_storage(
self: TokenizerManager,
hicache_storage_backend: str,
hicache_storage_backend_extra_config_json: Optional[str] = None,
hicache_storage_prefetch_policy: Optional[str] = None,
hicache_write_policy: Optional[str] = None,
) -> AttachHiCacheStorageReqOutput:
"""Attach (enable) HiCache storage backend at runtime."""
self.auto_create_handle_loop()
results = await self.attach_hicache_storage_communicator(
AttachHiCacheStorageReqInput(
hicache_storage_backend=hicache_storage_backend,
hicache_storage_backend_extra_config_json=hicache_storage_backend_extra_config_json,
hicache_storage_prefetch_policy=hicache_storage_prefetch_policy,
hicache_write_policy=hicache_write_policy,
)
)
all_success, all_message = FanOutCommunicator.merge_results(results)
out = AttachHiCacheStorageReqOutput(success=all_success, message=all_message)
# TODO: partial rollback if failed
if all_success:
# Keep tokenizer side server_info consistent with scheduler side.
hicache_fields = {"hicache_storage_backend": hicache_storage_backend}
if hicache_storage_backend_extra_config_json is not None:
hicache_fields["hicache_storage_backend_extra_config"] = (
hicache_storage_backend_extra_config_json
)
if hicache_storage_prefetch_policy is not None:
hicache_fields["hicache_storage_prefetch_policy"] = (
hicache_storage_prefetch_policy
)
if hicache_write_policy is not None:
hicache_fields["hicache_write_policy"] = hicache_write_policy
self.server_args.override("tokenizer.attach_hicache", **hicache_fields)
return out
async def detach_hicache_storage(
self: TokenizerManager,
) -> DetachHiCacheStorageReqOutput:
"""Detach (disable) HiCache storage backend at runtime."""
self.auto_create_handle_loop()
results = await self.detach_hicache_storage_communicator(
DetachHiCacheStorageReqInput()
)
all_success, all_message = FanOutCommunicator.merge_results(results)
out = DetachHiCacheStorageReqOutput(success=all_success, message=all_message)
# TODO: partial rollback if failed
if all_success:
self.server_args.override(
"tokenizer.detach_hicache",
hicache_storage_backend=None,
hicache_storage_backend_extra_config=None,
)
return out
async def start_profile(
self: TokenizerManager,
req: Optional[ProfileReq] = None,
):
self.auto_create_handle_loop()
req = req or ProfileReq()
req.req_type = ProfileReqType.START_PROFILE
env_with_stack: bool = get_bool_env_var("SGLANG_PROFILE_WITH_STACK", "true")
req.with_stack = (
False if req.with_stack is False or env_with_stack is False else True
)
env_record_shapes: bool = get_bool_env_var(
"SGLANG_PROFILE_RECORD_SHAPES", "true"
)
req.record_shapes = (req.record_shapes is not False) and env_record_shapes
req.profile_id = req.profile_id or str(time.time())
return await self._execute_profile(req)
async def stop_profile(self: TokenizerManager):
self.auto_create_handle_loop()
req = ProfileReq(req_type=ProfileReqType.STOP_PROFILE)
return await self._execute_profile(req)
async def _execute_profile(self: TokenizerManager, req: ProfileReq):
result = (await self.profile_communicator(req))[0]
if not result.success:
raise RuntimeError(result.message)
return result
async def start_expert_distribution_record(self: TokenizerManager):
self.auto_create_handle_loop()
req = ExpertDistributionReq(action=ExpertDistributionReqType.START_RECORD)
await self.expert_distribution_communicator(req)
async def stop_expert_distribution_record(self: TokenizerManager):
self.auto_create_handle_loop()
req = ExpertDistributionReq(action=ExpertDistributionReqType.STOP_RECORD)
await self.expert_distribution_communicator(req)
async def dump_expert_distribution_record(self: TokenizerManager):
self.auto_create_handle_loop()
req = ExpertDistributionReq(action=ExpertDistributionReqType.DUMP_RECORD)
await self.expert_distribution_communicator(req)
async def init_weights_update_group(
self: TokenizerManager,
obj: InitWeightsUpdateGroupReqInput,
request: Optional[fastapi.Request] = None,
) -> Tuple[bool, str]:
self.auto_create_handle_loop()
assert (
self.server_args.dp_size == 1 or self.server_args.enable_dp_attention
), "dp_size must be 1 or dp attention must be enabled for update weights from distributed"
results = await self.init_weights_update_group_communicator(obj)
return FanOutCommunicator.merge_results(results)
async def destroy_weights_update_group(
self: TokenizerManager,
obj: DestroyWeightsUpdateGroupReqInput,
request: Optional[fastapi.Request] = None,
) -> Tuple[bool, str]:
self.auto_create_handle_loop()
assert (
self.server_args.dp_size == 1 or self.server_args.enable_dp_attention
), "dp_size must be 1 or dp attention must be enabled for destroy parameter update group"
results = await self.destroy_weights_update_group_communicator(obj)
return FanOutCommunicator.merge_results(results)
async def update_weights_from_distributed(
self: TokenizerManager,
obj: UpdateWeightsFromDistributedReqInput,
request: Optional[fastapi.Request] = None,
) -> Tuple[bool, str]:
self.auto_create_handle_loop()
assert (
self.server_args.dp_size == 1 or self.server_args.enable_dp_attention
), "dp_size must be 1 or dp attention must be enabled for update weights from distributed"
if obj.abort_all_requests:
self.abort_request(abort_all=True)
# Hold is_pause_cond while updating to prevent unpause from racing.
async with self.is_pause_cond:
is_paused = self.is_pause
if is_paused:
results = await self.update_weights_from_distributed_communicator(obj)
if not is_paused:
async with self.model_update_lock.writer_lock:
results = await self.update_weights_from_distributed_communicator(obj)
success, message = FanOutCommunicator.merge_results(results)
if success and obj.weight_version is not None:
self._update_weight_version_if_provided(obj.weight_version)
message += f" Weight version updated to {obj.weight_version}."
return success, message
async def init_weights_send_group_for_remote_instance(
self: TokenizerManager,
obj: InitWeightsSendGroupForRemoteInstanceReqInput,
request: Optional[fastapi.Request] = None,
) -> Tuple[bool, str]:
self.auto_create_handle_loop()
# TODO: support DP
assert (
self.server_args.dp_size == 1
), "dp_size must be 1 for init_weights_send_group_for_remote_instance"
result = (
await self.init_weights_send_group_for_remote_instance_communicator(obj)
)[0]
return result.success, result.message
async def send_weights_to_remote_instance(
self: TokenizerManager,
obj: SendWeightsToRemoteInstanceReqInput,
request: Optional[fastapi.Request] = None,
) -> Tuple[bool, str]:
self.auto_create_handle_loop()
# TODO: support DP
assert (
self.server_args.dp_size == 1
), "dp_size must be 1 for send_weights_to_remote_instance"
result = (await self.send_weights_to_remote_instance_communicator(obj))[0]
return result.success, result.message
async def update_weights_from_tensor(
self: TokenizerManager,
obj: UpdateWeightsFromTensorReqInput,
request: Optional[fastapi.Request] = None,
) -> Tuple[bool, str]:
self.auto_create_handle_loop()
assert (
self.server_args.dp_size == 1 or self.server_args.enable_dp_attention
), "dp_size must be 1 or dp attention must be enabled for update weights from tensor"
if obj.abort_all_requests:
self.abort_request(abort_all=True)
obj.serialized_named_tensors = normalize_serialized_named_tensor_payloads(
obj.serialized_named_tensors
)
async with self.is_pause_cond:
is_paused = self.is_pause
if is_paused:
results = await self.update_weights_from_tensor_communicator(obj)
if not is_paused:
async with self.model_update_lock.writer_lock:
results = await self.update_weights_from_tensor_communicator(obj)
success, message = FanOutCommunicator.merge_results(results)
if success and obj.weight_version is not None:
self._update_weight_version_if_provided(obj.weight_version)
message += f" Weight version updated to {obj.weight_version}."
return success, message
async def update_weights_from_ipc(
self: TokenizerManager,
obj: UpdateWeightsFromIPCReqInput,
request: Optional[fastapi.Request] = None,
) -> Tuple[bool, str]:
"""Update weights via IPC for checkpoint-engine integration."""
self.auto_create_handle_loop()
try:
# For now, we only support single data parallel instance
assert (
self.server_args.dp_size == 1 or self.server_args.enable_dp_attention
), "dp_size must be 1 or dp attention must be enabled for update weights from IPC"
logger.info("Starting IPC weight update")
async with self.is_pause_cond:
is_paused = self.is_pause
if is_paused:
result = (await self.update_weights_from_ipc_communicator(obj))[0]
success, message = result.success, result.message
if not is_paused:
async with self.model_update_lock.writer_lock:
result = (await self.update_weights_from_ipc_communicator(obj))[0]
success, message = result.success, result.message
except Exception as e:
error_msg = f"IPC weight update failed: {str(e)}"
logger.error(error_msg)
success, message = False, error_msg
if success and obj.weight_version is not None:
self._update_weight_version_if_provided(obj.weight_version)
message += f" Weight version updated to {obj.weight_version}."
return success, message
async def _unload_lora_adapter_locked(
self: TokenizerManager,
obj: UnloadLoRAAdapterReqInput,
) -> UnloadLoRAAdapterReqOutput:
assert (
self.lora_update_lock.locked()
), "self.lora_update_lock must be locked in order for self._unload_lora_adapter_locked() to be called"
# Unregister the LoRA adapter from the registry to stop new requests for this adapter
# from being started.
lora_id = await self.lora_registry.unregister(obj.lora_name)
obj.lora_id = lora_id
# Initiate the actual unloading operation at the backend processes only after all
# ongoing requests using this LoRA adapter are finished.
await self.lora_registry.wait_for_unload(lora_id)
result = (await self.update_lora_adapter_communicator(obj))[0]
return result
async def load_lora_adapter(
self: TokenizerManager,
obj: LoadLoRAAdapterReqInput,
_: Optional[fastapi.Request] = None,
) -> LoadLoRAAdapterReqOutput:
self.auto_create_handle_loop()
try:
if not self.server_args.enable_lora:
raise ValueError(
"LoRA is not enabled. Please set `--enable-lora` to enable LoRA."
)
# TODO (lifuhuang): Remove this after we verify that dynamic lora loading works
# with dp_size > 1.
assert (
self.server_args.dp_size == 1
), "dp_size must be 1 for dynamic lora loading"
logger.info(
"Start load Lora adapter. Lora name=%s, path=%s",
obj.lora_name,
obj.lora_path,
)
async with self.lora_update_lock:
# Generate new uniquely identifiable LoRARef object.
new_adapter = LoRARef(
lora_name=obj.lora_name,
lora_path=obj.lora_path,
pinned=obj.pinned,
)
# Trigger the actual loading operation at the backend processes.
obj.lora_id = new_adapter.lora_id
result = (await self.update_lora_adapter_communicator(obj))[0]
# Register the LoRA adapter only after loading is successful.
if result.success:
await self.lora_registry.register(new_adapter)
self.lora_ref_cache[obj.lora_name] = new_adapter
if self.server_args.max_loaded_loras is not None:
while (
self.lora_registry.num_registered_loras
> self.server_args.max_loaded_loras
):
lru_lora_name = await self.lora_registry.lru_lora_name(
exclude_pinned=True
)
if lru_lora_name is None:
raise ValueError(
"Didn't find any LoRA adapters when trying to evict LRU LoRA adapter. "
f"LoRA registry is: {self.lora_registry._registry}"
)
logger.info(
f"Unloading least recently used LoRA adapter '{lru_lora_name}' "
f"(current number of adapters: {self.lora_registry.num_registered_loras}, "
f"max allowed: {self.server_args.max_loaded_loras})"
)
unload_result = await self._unload_lora_adapter_locked(
UnloadLoRAAdapterReqInput(lora_name=lru_lora_name)
)
if not unload_result.success:
raise ValueError(
f"Error while unloading LRU LoRA adapter '{lru_lora_name}': "
f"{unload_result.error_message}"
)
del result.loaded_adapters[lru_lora_name]
return result
except ValueError as e:
return LoadLoRAAdapterReqOutput(
success=False,
error_message=str(e),
)
async def load_lora_adapter_from_tensors(
self: TokenizerManager,
obj: LoadLoRAAdapterFromTensorsReqInput,
_: Optional[fastapi.Request] = None,
) -> LoadLoRAAdapterFromTensorsReqOutput:
self.auto_create_handle_loop()
try:
if not self.server_args.enable_lora:
raise ValueError(
"LoRA is not enabled. Please set `--enable-lora` to enable LoRA."
)
assert (
self.server_args.dp_size == 1
), "dp_size must be 1 for dynamic lora loading"
logger.info(
"Start load Lora adapter from tensors. Lora name=%s",
obj.lora_name,
)
async with self.lora_update_lock:
new_adapter = LoRARef(
lora_name=obj.lora_name,
lora_path="__tensor__",
pinned=obj.pinned,
)
obj.lora_id = new_adapter.lora_id
result = (await self.update_lora_adapter_communicator(obj))[0]
if result.success:
await self.lora_registry.register(new_adapter)
self.lora_ref_cache[obj.lora_name] = new_adapter
if self.server_args.max_loaded_loras is not None:
while (
self.lora_registry.num_registered_loras
> self.server_args.max_loaded_loras
):
lru_lora_name = await self.lora_registry.lru_lora_name(
exclude_pinned=True
)
if lru_lora_name is None:
raise ValueError(
"Didn't find any LoRA adapters when trying to evict LRU LoRA adapter. "
f"LoRA registry is: {self.lora_registry._registry}"
)
logger.info(
f"Unloading least recently used LoRA adapter '{lru_lora_name}' "
f"(current number of adapters: {self.lora_registry.num_registered_loras}, "
f"max allowed: {self.server_args.max_loaded_loras})"
)
unload_result = await self._unload_lora_adapter_locked(
UnloadLoRAAdapterReqInput(lora_name=lru_lora_name)
)
if not unload_result.success:
raise ValueError(
f"Error while unloading LRU LoRA adapter '{lru_lora_name}': "
f"{unload_result.error_message}"
)
del result.loaded_adapters[lru_lora_name]
return result
except ValueError as e:
return LoadLoRAAdapterFromTensorsReqOutput(
success=False,
error_message=str(e),
)
async def unload_lora_adapter(
self: TokenizerManager,
obj: UnloadLoRAAdapterReqInput,
_: Optional[fastapi.Request] = None,
) -> UnloadLoRAAdapterReqOutput:
self.auto_create_handle_loop()
try:
if not self.server_args.enable_lora:
raise ValueError(
"LoRA is not enabled. Please set `--enable-lora` to enable LoRA."
)
assert (
obj.lora_name is not None
), "lora_name must be provided to unload LoRA adapter"
# TODO (lifuhuang): Remove this after we verify that dynamic lora loading works
# with dp_size > 1.
assert (
self.server_args.dp_size == 1
), "dp_size must be 1 for dynamic lora loading"
logger.info(
"Start unload Lora adapter. Lora name=%s",
obj.lora_name,
)
async with self.lora_update_lock:
return await self._unload_lora_adapter_locked(obj)
except ValueError as e:
return UnloadLoRAAdapterReqOutput(success=False, error_message=str(e))
async def get_weights_by_name(
self: TokenizerManager,
obj: GetWeightsByNameReqInput,
request: Optional[fastapi.Request] = None,
):
self.auto_create_handle_loop()
results = await self.get_weights_by_name_communicator(obj)
all_parameters = [r.parameter for r in results]
if self.server_args.dp_size == 1:
return all_parameters[0]
else:
return all_parameters
async def release_memory_occupation(
self: TokenizerManager,
obj: ReleaseMemoryOccupationReqInput,
request: Optional[fastapi.Request] = None,
):
self.auto_create_handle_loop()
await self.release_memory_occupation_communicator(obj)
async def resume_memory_occupation(
self: TokenizerManager,
obj: ResumeMemoryOccupationReqInput,
request: Optional[fastapi.Request] = None,
):
self.auto_create_handle_loop()
await self.resume_memory_occupation_communicator(obj)
async def check_weights(
self: TokenizerManager,
obj: CheckWeightsReqInput,
request: Optional[fastapi.Request] = None,
) -> Tuple[bool, str, Optional[List[Dict]], Optional[str]]:
self.auto_create_handle_loop()
results = await self.check_weights_communicator(obj)
success, message = FanOutCommunicator.merge_results(results)
ranks: Optional[List[Dict]] = None
per_engine_checksum: Optional[str] = None
if any(r.payload is not None for r in results):
ranks = []
for r in results:
if isinstance(r.payload, list):
ranks.extend(r.payload)
else:
ranks.append(r.payload)
h = hashlib.sha256()
for rank in ranks:
h.update(rank["per_gpu_checksum"].encode())
per_engine_checksum = h.hexdigest()
return success, message, ranks, per_engine_checksum
async def slow_down(
self: TokenizerManager,
obj: SlowDownReqInput,
request: Optional[fastapi.Request] = None,
):
self.auto_create_handle_loop()
await self.slow_down_communicator(obj)
async def get_internal_state(self: TokenizerManager) -> List[Dict[Any, Any]]:
self.auto_create_handle_loop()
req = GetInternalStateReq()
responses: List[GetInternalStateReqOutput] = (
await self.get_internal_state_communicator(req)
)
# Many DP ranks
return [res.internal_state for res in responses]
async def set_internal_state(
self: TokenizerManager, obj: SetInternalStateReq
) -> List[bool]:
self.auto_create_handle_loop()
responses: List[SetInternalStateReqOutput] = (
await self.set_internal_state_communicator(obj)
)
return [res.updated for res in responses]
async def dumper_control(
self: TokenizerManager, obj: DumperControlReqInput
) -> List[DumperControlReqOutput]:
self.auto_create_handle_loop()
return await self.dumper_control_communicator(obj)
async def get_loads(
self: TokenizerManager,
include: Optional[List[str]] = None,
dp_rank: Optional[int] = None,
) -> List[LoadSnapshot]:
"""
Get load snapshots for /v1/loads endpoint.
Args:
include: List of sections to include. Options: core, memory, spec, lora, disagg, queues, all
dp_rank: Optional filter for specific DP rank
Returns:
List of LoadSnapshot, one per scheduler (filtered by dp_rank if specified)
"""
self.auto_create_handle_loop()
if dp_rank is not None and (dp_rank < 0 or dp_rank >= self.server_args.dp_size):
return []
reader = self.load_snapshot_reader
if dp_rank is not None:
load = reader.read(dp_rank)
results = [load] if load is not None else []
else:
results = reader.read_all()
return results
async def open_session(
self: TokenizerManager,
obj: OpenSessionReqInput,
request: Optional[fastapi.Request] = None,
):
self.auto_create_handle_loop()
if obj.streaming:
if not self.server_args.enable_streaming_session:
raise ValueError(
"Streaming sessions are disabled. "
"Please relaunch with --enable-streaming-session."
)
if obj.session_id is None:
obj.session_id = uuid.uuid4().hex
elif obj.session_id in self.session_futures:
return None
future = asyncio.Future()
self.session_futures[obj.session_id] = future
self._dispatch_to_scheduler(obj)
try:
return await future
finally:
self.session_futures.pop(obj.session_id, None)
async def close_session(
self: TokenizerManager,
obj: CloseSessionReqInput,
request: Optional[fastapi.Request] = None,
):
await self._async_dispatch_to_scheduler(obj)
def _update_weight_version_if_provided(
self: TokenizerManager, weight_version: Optional[str]
) -> None:
"""Update weight version if provided."""
if weight_version is not None:
self.server_args.override(
"tokenizer.weight_version", weight_version=weight_version
)
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,780 @@
import logging
import math
from dataclasses import dataclass
from typing import Any, Dict, List, Optional, Tuple, Union
import torch
from sglang.srt.configs.model_config import is_cross_encoding_pooler_model
from sglang.srt.managers.embed_types import PositionalEmbeds
from sglang.srt.managers.io_struct import EmbeddingReqInput, GenerateReqInput
from sglang.srt.server_args import MIS_DELIMITER_TOKEN_ID
logger = logging.getLogger(__name__)
@dataclass(frozen=True, slots=True)
class ScoreResult:
scores: List[List[float]]
prompt_tokens: int = 0
# Per-item pooled hidden states (pre-head transformer output).
# CPU tensors when return_pooled_hidden_states=True; kept as tensors so
# in-process consumers (gRPC, engine API) avoid a .tolist() round-trip.
# The HTTP path converts to lists in serving_score.py before JSON serialization.
# Same layout as scores: one tensor per item (not a single packed 2D tensor).
pooled_hidden_states: Optional[List[Optional[torch.Tensor]]] = None
class TokenizerManagerScoreMixin:
async def score_prompts(
self,
prompts: Union[str, List[str], List[List[int]]],
label_token_ids: List[int],
apply_softmax: bool = False,
request: Optional[Any] = None,
) -> ScoreResult:
"""
Score probabilities of specified token IDs after each *full prompt*.
This is a thin wrapper over `score_request` that treats `prompts` as
already-composed inputs (i.e., no query/item concatenation needed).
Args:
prompts: A single prompt string, a list of prompt strings, or a list of
pre-tokenized prompt token ID sequences.
label_token_ids: Token IDs to compute probabilities for.
apply_softmax: Whether to normalize probabilities using softmax.
request: Optional FastAPI request object.
Returns:
ScoreResult with:
scores: List of score lists, one for each prompt, each in the order of label_token_ids.
prompt_tokens: The number of prompt tokens processed.
"""
# Text prompts
if isinstance(prompts, str) or (
isinstance(prompts, list) and (not prompts or isinstance(prompts[0], str))
):
return await self.score_request(
query="",
items=prompts, # type: ignore[arg-type]
label_token_ids=label_token_ids,
apply_softmax=apply_softmax,
item_first=False,
request=request,
)
# Tokenized prompts
if isinstance(prompts, list) and (not prompts or isinstance(prompts[0], list)):
return await self.score_request(
query=[],
items=prompts,
label_token_ids=label_token_ids,
apply_softmax=apply_softmax,
item_first=False,
request=request,
)
raise ValueError("Invalid prompts type for score_prompts.")
def _build_multi_item_token_sequence(
self, query: List[int], items: List[List[int]], delimiter_token_id: int
) -> Tuple[List[int], List[int]]:
"""
Build a single token sequence for multi-item scoring.
Format: query<delimiter>item1<delimiter>item2<delimiter>item3<delimiter>
Args:
query: Query token IDs
items: List of item token ID sequences
delimiter_token_id: Token ID to use as delimiter
Returns:
Tuple of (combined token sequence, delimiter indices)
"""
combined_sequence = query[:] # Start with query
delimiter_indices = []
for item in items:
delimiter_indices.append(len(combined_sequence))
combined_sequence.append(delimiter_token_id) # Add delimiter
combined_sequence.extend(item) # Add item tokens
# Add final delimiter after the last item for logprob extraction
delimiter_indices.append(len(combined_sequence))
combined_sequence.append(delimiter_token_id)
return combined_sequence, delimiter_indices
def _batch_tokenize_query_and_items(
self,
query: Optional[Union[str, List[int]]],
items: Optional[Union[str, List[str], List[List[int]]]],
) -> Tuple[List[int], List[List[int]]]:
"""
Tokenize query and items into token IDs.
Args:
query: The query text (str) or pre-tokenized token IDs (List[int]).
items: Item texts or pre-tokenized token IDs.
Returns:
(query_ids, items_ids): query token IDs and list of per-item token IDs.
"""
if isinstance(query, str):
query_ids = self.tokenizer.encode(query)
else:
query_ids = list(query)
items_list = [items] if isinstance(items, str) else items
items_ids = []
for item in items_list:
if isinstance(item, str):
items_ids.append(self.tokenizer.encode(item))
else:
items_ids.append(list(item))
return query_ids, items_ids
def _process_multi_item_scoring_results(
self,
results: Any,
items: List,
label_token_ids: Optional[List[int]],
apply_softmax: bool,
batch_request=None,
return_pooled_hidden_states: bool = False,
) -> ScoreResult:
"""
Process results from multi-item scoring request.
Extracts per-delimiter scores from whichever field the scheduler
populated (input_token_ids_logprobs for generation models,
embedding for classification models), then uniformly validates,
skips the query-boundary delimiter, and normalizes.
Args:
results: Results from generate_request
items: List of items being scored
label_token_ids: Token IDs to extract scores for
apply_softmax: Whether to apply softmax normalization
batch_request: The original batch request containing input sequence
return_pooled_hidden_states: Whether to extract pooled hidden states
from the result and include them in the ScoreResult.
Returns:
ScoreResult with per-item scores, prompt token count, and optional
pooled_hidden_states (when return_pooled_hidden_states=True and the
model populated the field).
"""
single_result = results[0] if isinstance(results, list) else results
meta_info = single_result.get("meta_info", {})
num_items = len(items) if isinstance(items, list) else 1
expected_count = num_items + 1
request_id = meta_info.get("id", "<unknown>")
prompt_tokens = meta_info.get("prompt_tokens", 0)
# Extract per-delimiter scores from whichever field has them
input_logprobs = meta_info.get("input_token_ids_logprobs", [])
embedding = single_result.get("embedding")
if input_logprobs:
# Generation model: extract label-token logprobs at each delimiter
per_delimiter_scores = []
for logprobs_data in input_logprobs:
logprobs = self._extract_logprobs_for_tokens(
logprobs_data, label_token_ids
)
score_list = self._convert_logprobs_to_scores(
logprobs, label_token_ids, apply_softmax
)
per_delimiter_scores.append(score_list)
elif embedding is not None:
# Classification model: scores are directly in 2D embedding.
if apply_softmax:
scores_tensor = (
torch.tensor(embedding)
if isinstance(embedding, list)
else embedding
)
scores_tensor = torch.nn.functional.softmax(scores_tensor, dim=-1)
per_delimiter_scores = scores_tensor.tolist()
else:
per_delimiter_scores = (
embedding if isinstance(embedding, list) else embedding.tolist()
)
else:
raise RuntimeError(
f"No scoring data found for multi-item scoring request {request_id}. "
"Expected either input_token_ids_logprobs or embedding."
)
# Validate delimiter count
if len(per_delimiter_scores) != expected_count:
raise RuntimeError(
f"Expected {expected_count} delimiter entries for multi-item scoring "
f"with {num_items} items, but got {len(per_delimiter_scores)}. "
f"Request ID: {request_id}"
)
# Skip the first delimiter (query-item boundary)
scores = per_delimiter_scores[1:]
phs_list = None
if return_pooled_hidden_states:
raw_phs = single_result.get("pooled_hidden_state")
if raw_phs is not None and len(raw_phs) == expected_count:
phs_list = raw_phs[1:]
return ScoreResult(
scores=scores,
prompt_tokens=prompt_tokens,
pooled_hidden_states=phs_list,
)
def _process_single_item_scoring_results(
self,
results: Any,
label_token_ids: Optional[List[int]],
apply_softmax: bool,
return_pooled_hidden_states: bool = False,
) -> ScoreResult:
"""
Process results from single-item scoring request.
For generation (CausalLM) models: reads output_token_ids_logprobs.
For non-generation (SequenceClassification) models: reads the embedding field
which contains pooled class logits from the classification head.
Args:
results: Results from generate_request
label_token_ids: Token IDs to extract scores for (generation models only)
apply_softmax: Whether to apply softmax normalization
return_pooled_hidden_states: Whether to extract pooled hidden states
Returns:
ScoreResult with per-item scores, prompt token count, and optional pooled_hidden_states.
"""
scores = []
phs_list = []
has_phs = False
prompt_tokens = 0
is_generation = self.is_generation
if is_generation:
for result in results:
# For single-item scoring, logprobs are in output_token_ids_logprobs
output_logprobs = result["meta_info"].get(
"output_token_ids_logprobs", []
)
prompt_tokens += result["meta_info"].get("prompt_tokens", 0)
if not output_logprobs or len(output_logprobs) == 0:
raise RuntimeError(
f"output_logprobs is empty for request "
f"{result['meta_info'].get('id', '<unknown>')}."
)
# Extract logprobs for the first (and only) position
logprobs = self._extract_logprobs_for_tokens(
output_logprobs[0], label_token_ids
)
score_list = self._convert_logprobs_to_scores(
logprobs, label_token_ids, apply_softmax
)
scores.append(score_list)
else:
for result in results:
embedding = result.get("embedding", None)
if embedding is None:
raise ValueError("Embedding not found in the result.")
prompt_tokens += result.get("meta_info", {}).get("prompt_tokens", 0)
if apply_softmax:
embedding = torch.softmax(
torch.as_tensor(embedding), dim=-1
).tolist()
# The classification head produces per-token logits, which the pooler reduces
# into a single vector per input. That vector is returned in the `.embeddings`
# field — not as semantic embeddings, but as pooled classification logits.
# The field name is reused for compatibility with the existing
# EmbeddingPoolerOutput API.
scores.append(embedding)
if return_pooled_hidden_states:
phs = result.get("pooled_hidden_state")
phs_list.append(phs)
if phs is not None:
has_phs = True
return ScoreResult(
scores=scores,
prompt_tokens=prompt_tokens,
pooled_hidden_states=phs_list if has_phs else None,
)
# ------------------------------------------------------------------
# Embed override position resolution
# ------------------------------------------------------------------
def _resolve_overrides_for_sequence(
self,
token_ids: List[int],
embeds: Optional[List[torch.Tensor]],
embed_override_token_id: int,
position_offset: int = 0,
label: str = "input",
) -> Tuple[List[torch.Tensor], List[int]]:
"""Scan token_ids for placeholder occurrences and pair with embeddings.
Args:
token_ids: The token sequence to scan.
embeds: Embedding tensors to place at placeholder positions (None = skip).
embed_override_token_id: The placeholder token ID.
position_offset: Added to each found position (for absolute coordinates).
label: Label for error messages (e.g. "query", "items[2]").
Returns:
(embeds, positions) lists. Empty lists if embeds is None.
"""
if embeds is None:
return [], []
positions = [
idx + position_offset
for idx, tok in enumerate(token_ids)
if tok == embed_override_token_id
]
if len(positions) != len(embeds):
raise ValueError(
f"{label} contains {len(positions)} occurrences of "
f"embed_override_token_id={embed_override_token_id}, "
f"but {len(embeds)} override embeddings were provided."
)
return embeds, positions
def _resolve_embed_overrides_for_request(
self,
query: List[int],
item: List[int],
embed_override_token_id: int,
query_embed_overrides: Optional[List[torch.Tensor]],
item_embeds: Optional[List[torch.Tensor]],
item_position_offset: int,
item_label: str,
) -> Optional[PositionalEmbeds]:
"""Resolve embed overrides for a single query+item pair.
Returns PositionalEmbeds if any overrides exist, None otherwise.
"""
q_embeds, q_positions = self._resolve_overrides_for_sequence(
query,
query_embed_overrides,
embed_override_token_id,
position_offset=0,
label="query",
)
i_embeds, i_positions = self._resolve_overrides_for_sequence(
item,
item_embeds,
embed_override_token_id,
position_offset=item_position_offset,
label=item_label,
)
all_embeds = q_embeds + i_embeds
all_positions = q_positions + i_positions
if not all_embeds:
return None
return PositionalEmbeds(embeds=all_embeds, positions=all_positions)
# ------------------------------------------------------------------
# Input preparation (tokenization + input_ids construction)
# ------------------------------------------------------------------
def _build_token_id_inputs(
self,
query: List[int],
items: List[List[int]],
item_first: bool,
use_multi_item_scoring: bool,
embed_override_token_id: Optional[int],
query_embed_overrides: Optional[List[torch.Tensor]],
item_embed_overrides: Optional[List[Optional[List[torch.Tensor]]]],
) -> Tuple[None, List[List[int]], Optional[list], Optional[List[int]]]:
"""Build input_ids and resolve embed overrides for token-ID inputs.
Works identically for multi-item-scoring and single-item modes — the only difference is
how input_ids are assembled and what position offset each item gets.
Returns:
(text_prompts, input_ids, positional_embed_overrides, delimiter_indices)
"""
# Both query and items are token IDs
has_embeds = (
query_embed_overrides is not None or item_embed_overrides is not None
)
# Query placeholder positions are invariant across items — resolve once.
# (No-op returning ([], []) if has_embeds is False or query_embed_overrides is None.)
q_embeds, q_positions = self._resolve_overrides_for_sequence(
query,
query_embed_overrides,
embed_override_token_id,
position_offset=0,
label="query",
)
if use_multi_item_scoring:
# Multi-item scoring: concatenate with placeholder delimiter token.
# Positions are derived from item lengths (delimiter_indices), not
# by scanning for this token — it exists only for FlashInfer compat.
delimiter_token_id = MIS_DELIMITER_TOKEN_ID
combined_input_ids, delimiter_indices = (
self._build_multi_item_token_sequence(query, items, delimiter_token_id)
)
input_ids = [combined_input_ids]
if not has_embeds:
return None, input_ids, None, delimiter_indices
# Resolve embed overrides across the combined multi-item-scoring sequence.
all_embeds: List[torch.Tensor] = list(q_embeds)
all_positions: List[int] = list(q_positions)
current_offset = len(query) + 1 # +1 for first delimiter
for i, item in enumerate(items):
item_embs = item_embed_overrides[i] if item_embed_overrides else None
i_embeds, i_positions = self._resolve_overrides_for_sequence(
item,
item_embs,
embed_override_token_id,
position_offset=current_offset,
label=f"items[{i}]",
)
all_embeds.extend(i_embeds)
all_positions.extend(i_positions)
current_offset += len(item) + 1 # +1 for delimiter
if all_embeds:
# PositionalEmbeds.__post_init__ does the single torch.cat stack.
positional_embed_overrides = [
PositionalEmbeds(embeds=all_embeds, positions=all_positions)
]
else:
positional_embed_overrides = None
return None, input_ids, positional_embed_overrides, delimiter_indices
else:
# Single-item scoring: process each item separately
if item_first:
input_ids = [item + query for item in items]
else:
input_ids = [query + item for item in items]
if not has_embeds:
return None, input_ids, None, None
positional_embed_overrides = []
any_overrides = False
for i, item in enumerate(items):
item_embs = item_embed_overrides[i] if item_embed_overrides else None
i_embeds, i_positions = self._resolve_overrides_for_sequence(
item,
item_embs,
embed_override_token_id,
position_offset=len(query),
label=f"items[{i}]",
)
combined_embeds = q_embeds + i_embeds
if combined_embeds:
positional_embed_overrides.append(
PositionalEmbeds(
embeds=combined_embeds,
positions=q_positions + i_positions,
)
)
any_overrides = True
else:
positional_embed_overrides.append(None)
return (
None,
input_ids,
positional_embed_overrides if any_overrides else None,
None,
)
# ------------------------------------------------------------------
# Main entry point
# ------------------------------------------------------------------
async def score_request(
self,
query: Optional[Union[str, List[int]]] = None,
items: Optional[Union[str, List[str], List[List[int]]]] = None,
label_token_ids: Optional[List[int]] = None,
apply_softmax: bool = False,
item_first: bool = False,
embed_override_token_id: Optional[int] = None,
query_embed_overrides: Optional[List[torch.Tensor]] = None,
item_embed_overrides: Optional[List[Optional[List[torch.Tensor]]]] = None,
request: Optional[Any] = None,
return_pooled_hidden_states: bool = False,
) -> ScoreResult:
"""
Score the probability of specified token IDs appearing after the given (query + item) pair.
This method supports two scoring approaches:
1. Single-Item scoring (default): Process each query+item pair independently
2. Multi-Item scoring: When --enable-mis is set, combine query and
multiple items into a single sequence using delimiter for efficient processing.
Note: item_first parameter is ignored in multi-item scoring mode since it uses
a fixed format: query<delimiter>item1<delimiter>item2<delimiter>item3<delimiter>
Multi-item scoring works with both text and pre-tokenized inputs:
- Text: query<delimiter_text>item1<delimiter_text>item2<delimiter_text>item3<delimiter_text>
- Tokens: query<delimiter_token_id>item1<delimiter_token_id>item2<delimiter_token_id>item3<delimiter_token_id>
Supports two model types:
- Generation (CausalLM): Requires label_token_ids; returns logprob-based scores.
- SequenceClassification: label_token_ids is optional; returns pooled class logits.
Args:
query: The query text or pre-tokenized query token IDs
items: The item text(s) or pre-tokenized item token IDs
label_token_ids: List of token IDs to compute probabilities for
apply_softmax: Whether to normalize probabilities using softmax
item_first: If True, prepend items to query. Ignored for multi-item scoring.
embed_override_token_id: Placeholder token ID for embedding override positions.
query_embed_overrides: Embedding vectors replacing placeholder tokens in query.
item_embed_overrides: Per-item embedding vectors replacing placeholder tokens in items.
request: Optional FastAPI request object
return_pooled_hidden_states: Whether to include the raw pooled transformer
hidden states (before the task-specific head) in the result. Only
supported for non-generation models (SequenceClassification,
RewardModel). Raises ValueError for CausalLM models.
Returns:
ScoreResult with:
scores: List of score lists, one per item.
prompt_tokens: The number of prompt tokens processed.
pooled_hidden_states: Per-item CPU tensors when
return_pooled_hidden_states=True and the model supports it;
None otherwise.
"""
is_generation = self.is_generation
if is_generation and label_token_ids is None:
raise ValueError(
"label_token_ids is required for generation (CausalLM) models."
)
if items is None:
raise ValueError("items must be provided")
if not items:
return ScoreResult(scores=[], prompt_tokens=0)
has_embeds = (
query_embed_overrides is not None or item_embed_overrides is not None
)
if has_embeds and embed_override_token_id is None:
raise ValueError(
"embed_override_token_id is required when query_embed_overrides "
"or item_embed_overrides are supplied."
)
if item_first and has_embeds:
raise ValueError("item_first is not supported when embeddings are supplied")
if item_embed_overrides is not None and len(item_embed_overrides) != len(items):
raise ValueError(
f"item_embed_overrides length ({len(item_embed_overrides)}) "
f"must match items length ({len(items)})."
)
if self.tokenizer is not None and label_token_ids is not None:
vocab_size = self.tokenizer.vocab_size
for token_id in label_token_ids:
if token_id >= vocab_size:
raise ValueError(
f"Token ID {token_id} is out of vocabulary (vocab size: {vocab_size})"
)
# Check if multi-item scoring is enabled
use_multi_item_scoring = self.server_args.enable_mis
input_ids = None
text_prompts = None
positional_embed_overrides = None
delimiter_indices = None
use_text_prompts = isinstance(query, str) and not has_embeds
if use_text_prompts:
# Both query and items are text
items_list = [items] if isinstance(items, str) else items
if use_multi_item_scoring:
# Tokenize separately, then combine at token level with placeholder
# delimiter. Positions come from item lengths (delimiter_indices),
# not from scanning for this token — it's for FlashInfer compat only.
delimiter_token_id = MIS_DELIMITER_TOKEN_ID
query_ids, items_ids = self._batch_tokenize_query_and_items(
query, items_list
)
combined_input_ids, delimiter_indices = (
self._build_multi_item_token_sequence(
query_ids, items_ids, delimiter_token_id
)
)
input_ids = [combined_input_ids]
else:
# Single-item scoring: create separate prompts for each item
if item_first:
text_prompts = [f"{item}{query}" for item in items_list]
else:
text_prompts = [f"{query}{item}" for item in items_list]
elif (
isinstance(query, list)
and isinstance(items, list)
and items
and isinstance(items[0], list)
):
# Both query and items are token IDs — tokenize text inputs if needed for embed overrides
query_ids, items_ids = query, items
_, input_ids, positional_embed_overrides, delimiter_indices = (
self._build_token_id_inputs(
query_ids,
items_ids,
item_first,
use_multi_item_scoring,
embed_override_token_id,
query_embed_overrides,
item_embed_overrides,
)
)
elif has_embeds:
# Text inputs with embed overrides — need to tokenize first to resolve positions
query_ids, items_ids = self._batch_tokenize_query_and_items(query, items)
_, input_ids, positional_embed_overrides, delimiter_indices = (
self._build_token_id_inputs(
query_ids,
items_ids,
item_first,
use_multi_item_scoring,
embed_override_token_id,
query_embed_overrides,
item_embed_overrides,
)
)
else:
raise ValueError(
"Invalid combination of query/items types for score_request."
)
if return_pooled_hidden_states:
if is_generation:
raise ValueError(
"return_pooled_hidden_states is not supported for CausalLM models. "
"It requires a model with a task-specific head "
"(e.g. SequenceClassification or RewardModel)."
)
model_config = self.model_config
if model_config is not None:
archs = getattr(model_config.hf_config, "architectures", []) or []
if is_cross_encoding_pooler_model(archs):
raise ValueError(
f"return_pooled_hidden_states is not supported for "
f"{archs[0]}. This model uses CrossEncodingPooler which "
f"does not expose pre-head hidden states."
)
# Create the appropriate request type
mis_delimiter_indices = [delimiter_indices] if use_multi_item_scoring else None
if is_generation:
batch_request = GenerateReqInput(
text=text_prompts,
input_ids=input_ids,
token_ids_logprob=label_token_ids,
return_logprob=True,
# Set logprob_start_len=0 for multi-item scoring since we want logprobs at all delimiter positions
logprob_start_len=0 if use_multi_item_scoring else -1,
stream=False,
sampling_params={"max_new_tokens": 0},
positional_embed_overrides=positional_embed_overrides,
multi_item_delimiter_indices=mis_delimiter_indices,
)
else:
batch_request = EmbeddingReqInput(
text=text_prompts,
input_ids=input_ids,
positional_embed_overrides=positional_embed_overrides,
return_pooled_hidden_states=return_pooled_hidden_states,
multi_item_delimiter_indices=mis_delimiter_indices,
)
results = await self.generate_request(batch_request, request).__anext__()
if use_multi_item_scoring:
# Multi-item scoring: extract scores from input_token_ids_logprobs or embedding
return self._process_multi_item_scoring_results(
results,
items,
label_token_ids,
apply_softmax,
batch_request,
return_pooled_hidden_states,
)
else:
# Single-item scoring: process each result separately
return self._process_single_item_scoring_results(
results, label_token_ids, apply_softmax, return_pooled_hidden_states
)
def _convert_logprobs_to_scores(
self,
logprobs: Dict[int, float],
label_token_ids: List[int],
apply_softmax: bool,
) -> List[float]:
"""
Convert logprobs dictionary to ordered score list.
Args:
logprobs: Dictionary mapping token_id to logprob
label_token_ids: Token IDs in desired order
apply_softmax: Whether to apply softmax normalization
Returns:
List of scores in the same order as label_token_ids
"""
score_list = [
logprobs.get(token_id, float("-inf")) for token_id in label_token_ids
]
if apply_softmax:
score_list = torch.softmax(torch.tensor(score_list), dim=0).tolist()
else:
# Convert logprobs to probabilities if not using softmax
score_list = [
math.exp(x) if x != float("-inf") else 0.0 for x in score_list
]
return score_list
def _extract_logprobs_for_tokens(
self, logprobs_data: List, label_token_ids: List[int]
) -> Dict[int, float]:
"""
Extract logprobs for specified token IDs from logprobs data.
Args:
logprobs_data: List of (logprob, token_id, text) tuples
label_token_ids: Token IDs to extract logprobs for
Returns:
Dictionary mapping token_id to logprob
"""
logprobs = {}
if logprobs_data:
for logprob, token_id, _ in logprobs_data:
if token_id in label_token_ids:
logprobs[token_id] = logprob
return logprobs
+615
View File
@@ -0,0 +1,615 @@
# Copyright 2023-2024 SGLang Team
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# ==============================================================================
"""A tensor parallel worker."""
from __future__ import annotations
import logging
from abc import ABC, abstractmethod
from typing import TYPE_CHECKING, List, Optional, Tuple
import torch
from sglang.srt.distributed import get_pp_group, get_world_group
from sglang.srt.managers.io_struct import (
DestroyWeightsUpdateGroupReqInput,
GetWeightsByNameReqInput,
InitWeightsSendGroupForRemoteInstanceReqInput,
InitWeightsUpdateGroupReqInput,
LoadLoRAAdapterFromTensorsReqInput,
LoadLoRAAdapterReqInput,
SendWeightsToRemoteInstanceReqInput,
UnloadLoRAAdapterReqInput,
UpdateWeightFromDiskReqInput,
UpdateWeightsFromDistributedReqInput,
UpdateWeightsFromIPCReqInput,
UpdateWeightsFromTensorReqInput,
)
from sglang.srt.managers.schedule_batch import ScheduleBatch
from sglang.srt.managers.scheduler import GenerationBatchResult
from sglang.srt.mem_cache.allocator import BaseTokenToKVPoolAllocator
from sglang.srt.mem_cache.memory_pool import ReqToTokenPool
from sglang.srt.model_executor.forward_batch_info import ForwardBatch, PPProxyTensors
from sglang.srt.model_executor.pool_configurator import MemoryPoolConfig
from sglang.srt.server_args import ServerArgs
from sglang.srt.utils import MultiprocessingSerializer, broadcast_pyobj, set_random_seed
from sglang.srt.utils.hf_transformers_utils import (
get_processor,
get_tokenizer,
get_tokenizer_from_processor,
)
from sglang.srt.utils.patch_torch import monkey_patch_torch_reductions
from sglang.srt.weight_sync.tensor_bucket import FlattenedTensorBucket
if TYPE_CHECKING:
from sglang.srt.managers.cache_controller import LayerDoneCounter
from sglang.srt.model_executor.model_runner import ModelRunner
from sglang.srt.model_executor.pool_configurator import MemoryPoolConfig
logger = logging.getLogger(__name__)
class BaseTpWorker(ABC):
@abstractmethod
def forward_batch_generation(self, forward_batch: ForwardBatch):
pass
@property
@abstractmethod
def model_runner(self) -> ModelRunner:
pass
@property
def war_fastpath_runner(self):
# The runner that runs the step's LAST shared-buffer-reading phase --
# it owns the read-done event the scheduler's WAR barrier waits on.
# For a plain worker that's its own runner.
return self.model_runner
@property
def sliding_window_size(self) -> Optional[int]:
return self.model_runner.sliding_window_size
@property
def is_hybrid_swa(self) -> bool:
return self.model_runner.is_hybrid_swa
def get_tokens_per_layer_info(self):
return (
self.model_runner.full_max_total_num_tokens,
self.model_runner.swa_max_total_num_tokens,
)
def get_pad_input_ids_func(self):
return getattr(self.model_runner.model, "pad_input_ids", None)
def get_memory_pool(self) -> Tuple[ReqToTokenPool, BaseTokenToKVPoolAllocator]:
return (
self.model_runner.req_to_token_pool,
self.model_runner.token_to_kv_pool_allocator,
)
def update_weights_from_disk(self, recv_req: UpdateWeightFromDiskReqInput):
success, message = self.model_runner.update_weights_from_disk(
recv_req.model_path,
recv_req.load_format,
recapture_cuda_graph=recv_req.recapture_cuda_graph,
)
return success, message
def init_weights_update_group(self, recv_req: InitWeightsUpdateGroupReqInput):
success, message = self.model_runner.init_weights_update_group(
recv_req.master_address,
recv_req.master_port,
recv_req.rank_offset,
recv_req.world_size,
recv_req.group_name,
recv_req.backend,
)
return success, message
def destroy_weights_update_group(self, recv_req: DestroyWeightsUpdateGroupReqInput):
success, message = self.model_runner.destroy_weights_update_group(
recv_req.group_name,
)
return success, message
def init_weights_send_group_for_remote_instance(
self, recv_req: InitWeightsSendGroupForRemoteInstanceReqInput
):
success, message = (
self.model_runner.init_weights_send_group_for_remote_instance(
recv_req.master_address,
recv_req.ports,
recv_req.group_rank,
recv_req.world_size,
recv_req.group_name,
recv_req.backend,
)
)
return success, message
def send_weights_to_remote_instance(
self, recv_req: SendWeightsToRemoteInstanceReqInput
):
success, message = self.model_runner.send_weights_to_remote_instance(
recv_req.master_address,
recv_req.ports,
recv_req.group_name,
)
return success, message
def update_weights_from_distributed(
self, recv_req: UpdateWeightsFromDistributedReqInput
):
success, message = self.model_runner.update_weights_from_distributed(
recv_req.names,
recv_req.dtypes,
recv_req.shapes,
recv_req.group_name,
recv_req.load_format,
)
return success, message
def update_weights_from_tensor(self, recv_req: UpdateWeightsFromTensorReqInput):
monkey_patch_torch_reductions()
success, message = self.model_runner.update_weights_from_tensor(
named_tensors=MultiprocessingSerializer.deserialize(
recv_req.serialized_named_tensors[self.tp_rank]
),
load_format=recv_req.load_format,
)
return success, message
def update_weights_from_ipc(self, recv_req: UpdateWeightsFromIPCReqInput):
"""Update weights from IPC for checkpoint-engine integration."""
success, message = self.model_runner.update_weights_from_ipc(recv_req)
return success, message
def get_weights_by_name(self, recv_req: GetWeightsByNameReqInput):
parameter = self.model_runner.get_weights_by_name(
recv_req.name, recv_req.truncate_size
)
return parameter
def load_lora_adapter(self, recv_req: LoadLoRAAdapterReqInput):
result = self.model_runner.load_lora_adapter(recv_req.to_ref())
return result
def unload_lora_adapter(self, recv_req: UnloadLoRAAdapterReqInput):
result = self.model_runner.unload_lora_adapter(recv_req.to_ref())
return result
def load_lora_adapter_from_tensors(
self, recv_req: LoadLoRAAdapterFromTensorsReqInput
):
# The LoRA code handles TP sharding internally using slice_lora_a_weights
# and slice_lora_b_weights methods (see lora/layers.py:46-49, mem_pool.py:437-440).
if recv_req.load_format == "flattened_bucket":
flattened_data = MultiprocessingSerializer.deserialize(
recv_req.serialized_tensors
)
bucket = FlattenedTensorBucket(
flattened_tensor=flattened_data["flattened_tensor"],
metadata=flattened_data["metadata"],
)
tensors = dict(bucket.reconstruct_tensors())
else:
tensors = MultiprocessingSerializer.deserialize(recv_req.serialized_tensors)
result = self.model_runner.load_lora_adapter_from_tensors(
recv_req.to_ref(),
tensors,
recv_req.config_dict,
recv_req.added_tokens_config,
)
return result
def forward_batch_embedding(self, batch: ScheduleBatch):
forward_batch = ForwardBatch.init_new(batch, self.model_runner)
output = self.model_runner.forward(forward_batch).logits_output
return output # Returns EmbeddingPoolerOutput
class TpModelWorker(BaseTpWorker):
"""A tensor parallel model worker."""
def __init__(
self,
server_args: ServerArgs,
gpu_id: int,
tp_rank: int,
moe_ep_rank: int,
pp_rank: int,
attn_cp_rank: int,
moe_dp_rank: int,
dp_rank: Optional[int],
nccl_port: int,
is_draft_worker: bool = False,
req_to_token_pool: Optional[ReqToTokenPool] = None,
token_to_kv_pool_allocator: Optional[BaseTokenToKVPoolAllocator] = None,
memory_pool_config: Optional[MemoryPoolConfig] = None,
is_multi_layer_eagle: bool = False,
context_length: Optional[int] = None,
):
# Parse args
self.server_args = server_args
self.tp_size = server_args.tp_size
self.ep_size = server_args.ep_size
self.pp_size = server_args.pp_size
self.tp_rank = tp_rank
self.moe_ep_rank = moe_ep_rank
self.pp_rank = pp_rank
self.dp_rank = dp_rank
self.gpu_id = gpu_id
self.nccl_port = nccl_port
self.is_draft_worker = is_draft_worker
self.is_multi_layer_eagle = is_multi_layer_eagle
self.req_to_token_pool = req_to_token_pool
self.token_to_kv_pool_allocator = token_to_kv_pool_allocator
self.attn_cp_rank = attn_cp_rank
self.moe_dp_rank = moe_dp_rank
# Draft worker: target's resolved MemoryPoolConfig (forwarded to ModelRunner).
self.memory_pool_config = memory_pool_config
# Draft worker: target's effective context length; the draft runs at
# absolute target positions. None keeps server_args.context_length.
self.context_length = context_length
# MTP model runners
self.model_runner_list: List[ModelRunner] = []
self._init_model_config()
self._init_model_runner()
if is_multi_layer_eagle:
self._init_multi_layer_eagle_model_runners()
self._init_dllm_algorithm()
if server_args.skip_tokenizer_init or self.is_draft_worker:
# A draft worker's tokenizer would only duplicate the target's:
# tokenizer_path always points at the target model.
self.tokenizer = self.processor = None
else:
if self.model_config.is_multimodal:
self.processor = get_processor(
server_args.tokenizer_path,
tokenizer_mode=server_args.tokenizer_mode,
trust_remote_code=server_args.trust_remote_code,
revision=server_args.revision,
tokenizer_backend=server_args.tokenizer_backend,
model_name=server_args.model_path,
)
self.tokenizer = get_tokenizer_from_processor(self.processor)
else:
self.tokenizer = get_tokenizer(
server_args.tokenizer_path,
tokenizer_mode=server_args.tokenizer_mode,
trust_remote_code=server_args.trust_remote_code,
revision=server_args.revision,
tokenizer_backend=server_args.tokenizer_backend,
)
self.device = self.model_runner.device
# Init nccl groups
self.pp_group = get_pp_group()
self.world_group = get_world_group()
# Sync random seed across TP workers
self.random_seed = broadcast_pyobj(
[server_args.random_seed],
self.tp_size * self.pp_rank + tp_rank,
self.world_group.cpu_group,
src=self.world_group.ranks[0],
)[0]
set_random_seed(self.random_seed)
self.enable_overlap = not server_args.disable_overlap_schedule
self.enable_spec = server_args.speculative_algorithm is not None
self.hicache_layer_transfer_counter = None
def alloc_memory_pool(
self,
memory_pool_config: Optional[MemoryPoolConfig] = None,
req_to_token_pool: Optional[ReqToTokenPool] = None,
token_to_kv_pool_allocator: Optional[BaseTokenToKVPoolAllocator] = None,
):
"""Allocate KV cache pools only (no backends or cuda graphs)."""
if req_to_token_pool is not None:
self.req_to_token_pool = req_to_token_pool
self.model_runner.req_to_token_pool = req_to_token_pool
if token_to_kv_pool_allocator is not None:
self.token_to_kv_pool_allocator = token_to_kv_pool_allocator
self.model_runner.token_to_kv_pool_allocator = token_to_kv_pool_allocator
self.model_runner.alloc_memory_pool(memory_pool_config)
for mr in self.model_runner_list[1:]:
mr.req_to_token_pool = self.req_to_token_pool
mr.token_to_kv_pool_allocator = self.token_to_kv_pool_allocator
mr.alloc_memory_pool(memory_pool_config)
# Validation
assert self.model_runner.max_running_requests > 0, "max_running_request is zero"
max_req_len = min(
self.model_config.context_len - 1,
self.model_runner.max_token_pool_size - 1,
)
assert max_req_len > 0, "Memory pool size is too small"
def init_attention_backends(self):
"""Initialize attention backends for all model runners."""
self.model_runner.init_attention_backends()
for mr in self.model_runner_list[1:]:
mr.init_attention_backends()
def init_cuda_graphs(self, capture_decode_cuda_graph: bool = True):
"""Capture cuda graphs for all model runners."""
self.model_runner.init_cuda_graphs(
capture_decode_cuda_graph=capture_decode_cuda_graph
)
for mr in self.model_runner_list[1:]:
mr.init_cuda_graphs(capture_decode_cuda_graph=capture_decode_cuda_graph)
def _init_model_config(self):
from sglang.srt.configs.model_config import ModelConfig
self.model_config = ModelConfig.from_server_args(
self.server_args,
model_path=(
self.server_args.model_path
if not self.is_draft_worker
else self.server_args.speculative_draft_model_path
),
model_revision=(
self.server_args.revision
if not self.is_draft_worker
else self.server_args.speculative_draft_model_revision
),
is_draft_model=self.is_draft_worker,
context_length=self.context_length,
)
def _init_model_runner(self):
from sglang.srt.model_executor.model_runner import ModelRunner
self._model_runner = ModelRunner(
model_config=self.model_config,
mem_fraction_static=self.server_args.mem_fraction_static,
gpu_id=self.gpu_id,
tp_rank=self.tp_rank,
tp_size=self.tp_size,
moe_ep_rank=self.moe_ep_rank,
moe_ep_size=self.ep_size,
pp_rank=self.pp_rank,
pp_size=self.pp_size,
nccl_port=self.nccl_port,
dp_rank=self.dp_rank,
server_args=self.server_args,
is_draft_worker=self.is_draft_worker,
req_to_token_pool=self.req_to_token_pool,
token_to_kv_pool_allocator=self.token_to_kv_pool_allocator,
memory_pool_config=self.memory_pool_config,
draft_model_idx=0 if self.is_multi_layer_eagle else None,
)
def _init_multi_layer_eagle_model_runners(self):
from sglang.srt.model_executor.model_runner import ModelRunner
self.model_runner_list.append(self.model_runner)
for i in range(1, self.server_args.speculative_num_steps):
self.model_runner_list.append(
ModelRunner(
model_config=self.model_config,
mem_fraction_static=self.server_args.mem_fraction_static,
gpu_id=self.gpu_id,
tp_rank=self.tp_rank,
tp_size=self.tp_size,
moe_ep_rank=self.moe_ep_rank,
moe_ep_size=self.ep_size,
pp_rank=self.pp_rank,
pp_size=self.pp_size,
nccl_port=self.nccl_port,
dp_rank=self.dp_rank,
server_args=self.server_args,
is_draft_worker=self.is_draft_worker,
req_to_token_pool=self.req_to_token_pool,
token_to_kv_pool_allocator=self.token_to_kv_pool_allocator,
memory_pool_config=self.memory_pool_config,
draft_model_idx=i,
)
)
def _init_dllm_algorithm(self):
from sglang.srt.dllm.algorithm.base import DllmAlgorithm
if self.server_args.dllm_algorithm is not None:
self.dllm_algorithm = DllmAlgorithm.from_server_args(self.server_args)
else:
self.dllm_algorithm = None
@property
def model_runner(self) -> ModelRunner:
return self._model_runner
def register_hicache_layer_transfer_counter(self, counter: LayerDoneCounter):
self.hicache_layer_transfer_counter = counter
def set_hicache_consumer(self, consumer_index: int):
if self.hicache_layer_transfer_counter is not None:
self.hicache_layer_transfer_counter.set_consumer(consumer_index)
def register_hisparse_coordinator(self, coordinator):
self.model_runner.hisparse_coordinator = coordinator
def get_worker_info(self):
max_req_len = min(
self.model_config.context_len - 1,
self.model_runner.max_token_pool_size - 1,
)
return (
self.model_runner.max_total_num_tokens,
self.server_args.max_prefill_tokens,
self.model_runner.max_running_requests,
self.server_args.max_queued_requests,
max_req_len,
max_req_len - 5,
self.random_seed,
self.device,
self.model_runner.forward_stream,
self.model_runner.req_to_token_pool.size,
self.model_runner.req_to_token_pool.max_context_len,
self.model_runner.token_to_kv_pool.size,
)
def is_dllm(self):
return self.dllm_algorithm is not None
def _forward_batch_generation_dllm(
self,
forward_batch: ForwardBatch,
batch: Optional[ScheduleBatch] = None,
) -> GenerationBatchResult:
algo_states = None
if self.dllm_algorithm.fdfo and batch is not None:
algo_states = [req.dllm_algo_state for req in batch.reqs]
(
logits_output,
next_token_ids,
accept_length_per_req_cpu,
dllm_algo_state,
can_run_cuda_graph,
) = self.dllm_algorithm.run(self.model_runner, forward_batch, algo_states)
return GenerationBatchResult(
logits_output=logits_output,
next_token_ids=next_token_ids,
accept_length_per_req_cpu=accept_length_per_req_cpu,
dllm_algo_state=dllm_algo_state,
can_run_cuda_graph=can_run_cuda_graph,
)
def forward_batch_generation(
self,
batch: Optional[ScheduleBatch],
forward_batch: Optional[ForwardBatch] = None,
pp_proxy_tensors: Optional[PPProxyTensors] = None,
is_verify: bool = False,
skip_attn_backend_init: Optional[bool] = None, # deprecated
) -> GenerationBatchResult:
# Get forward batch from schedule batch
if batch is not None:
# update the consumer index of hicache to the running batch
self.set_hicache_consumer(batch.hicache_consumer_index)
forward_batch = ForwardBatch.init_new(batch, self.model_runner)
else:
# FIXME(lsyin): unify the interface of forward_batch
assert forward_batch is not None
# Deprecated kwarg: pre-planners mark the batch themselves now.
forward_batch.apply_deprecated_skip_attn_backend_init(skip_attn_backend_init)
if self.is_dllm():
return self._forward_batch_generation_dllm(forward_batch, batch)
if self.pp_group.is_last_rank:
out = self.model_runner.forward(
forward_batch,
pp_proxy_tensors=pp_proxy_tensors,
)
logits_output, can_run_cuda_graph = out.logits_output, out.can_run_graph
batch_result = GenerationBatchResult(
logits_output=logits_output,
can_run_cuda_graph=can_run_cuda_graph,
expert_distribution_metrics=out.expert_distribution_metrics,
routed_experts_output=out.routed_experts_output,
indexer_topk_output=out.indexer_topk_output,
)
if is_verify:
# Skip sampling; spec_v2 worker fires its own publish post-verify.
return batch_result
if (
self.enable_overlap
and not self.enable_spec
and forward_batch.sampling_info.grammars is not None
):
def sample_batch_func():
batch_result.next_token_ids = self.model_runner.sample(
logits_output, forward_batch
)
return batch_result
batch_result.delay_sample_func = sample_batch_func
return batch_result
if not forward_batch.is_prefill_only:
# For normal requests, sample the next token ids.
batch_result.next_token_ids = self.model_runner.sample(
logits_output, forward_batch
)
else:
# For prefill-only requests, create dummy token IDs on CPU
# The size should match the batch size (number of sequences), not total tokens
batch_result.next_token_ids = torch.zeros(
len(forward_batch.seq_lens),
dtype=torch.long,
device=forward_batch.input_ids.device,
)
if (
forward_batch.return_logprob
and logits_output.next_token_logits is not None
):
# NOTE: Compute logprobs without full sampling
self.model_runner.compute_logprobs_only(
logits_output, forward_batch
)
return batch_result
else:
out = self.model_runner.forward(
forward_batch,
pp_proxy_tensors=pp_proxy_tensors,
)
pp_proxy_tensors, can_run_cuda_graph = out.logits_output, out.can_run_graph
return GenerationBatchResult(
pp_hidden_states_proxy_tensors=pp_proxy_tensors,
can_run_cuda_graph=can_run_cuda_graph,
expert_distribution_metrics=out.expert_distribution_metrics,
)
def forward_batch_split_prefill(self, batch: ScheduleBatch):
if batch.split_index == 0:
forward_batch = ForwardBatch.init_new(batch, self.model_runner)
batch.split_forward_batch = forward_batch
out = self.model_runner.forward(
batch.split_forward_batch, split_forward_count=batch.split_forward_count
)
logits_output, can_run_cuda_graph = out.logits_output, out.can_run_graph
if logits_output:
next_token_ids = self.model_runner.sample(
logits_output, batch.split_forward_batch
)
else:
next_token_ids = None
batch_result = GenerationBatchResult(
logits_output=logits_output,
can_run_cuda_graph=can_run_cuda_graph,
expert_distribution_metrics=out.expert_distribution_metrics,
)
batch_result.next_token_ids = next_token_ids
return batch_result
+312
View File
@@ -0,0 +1,312 @@
from __future__ import annotations
import dataclasses
import logging
from dataclasses import dataclass
from typing import TYPE_CHECKING, Any, List, Optional, Union
import torch
from sglang.srt.constants import HEALTH_CHECK_RID_PREFIX
from sglang.srt.eplb.expert_distribution import ExpertDistributionMetrics
from sglang.srt.layers.logits_processor import LogitsProcessorOutput
from sglang.srt.managers.schedule_batch import Req
from sglang.srt.model_executor.forward_batch_info import PPProxyTensors
from sglang.srt.state_capturer.base import TopkCaptureOutput
if TYPE_CHECKING:
from sglang.srt.managers.scheduler import GenerationBatchResult
from sglang.srt.speculative.eagle_info import EagleDraftInput
logger = logging.getLogger(__name__)
def _async_d2h(t: torch.Tensor) -> torch.Tensor:
"""Async D2H copy for overlap scheduling. On CUDA the dest is pinned (a D2H
to pageable host memory blocks the caller until done) and record_stream keeps
the source alive until the copy stream drains, so the caching allocator can't
recycle it early. Non-CUDA falls back to a plain copy."""
if not t.is_cuda:
return t.to("cpu", non_blocking=True)
cpu_t = torch.empty(t.shape, dtype=t.dtype, pin_memory=True)
cpu_t.copy_(t, non_blocking=True)
t.record_stream(torch.cuda.current_stream(t.device))
return cpu_t
@dataclasses.dataclass
class GenerationBatchResult:
logits_output: Optional[LogitsProcessorOutput] = None
pp_hidden_states_proxy_tensors: Optional[PPProxyTensors] = None
next_token_ids: Optional[
Union[torch.Tensor, List[torch.Tensor], List[List[int]]]
] = None
num_correct_drafts: int = 0 # no bonus included
num_correct_drafts_per_req_cpu: Optional[List[int]] = None
num_block_accept_tokens: int = 0
num_cap_tokens: int = 0
# FDFO dLLM batching: per-request accepted block length and carried algo state.
accept_length_per_req_cpu: Optional[List[int]] = None
dllm_algo_state: Optional[List[Any]] = None
can_run_cuda_graph: bool = False
# PP skip output comm: True when output send/recv was skipped and
# next_token_ids are placeholder zeros. Used by process_batch_result_prefill
# to validate that skipped output is never consumed.
skipped_output_comm: bool = False
# For output processing
extend_input_len_per_req: Optional[List[int]] = None
extend_logprob_start_len_per_req: Optional[List[int]] = None
# For overlap scheduling
copy_done: Optional[torch.cuda.Event] = None
delay_sample_func: Optional[callable] = None
future_indices: Optional[torch.Tensor] = None
speculative_num_draft_tokens: Optional[int] = None
# FIXME(lsyin): maybe move to a better place?
# sync path: forward stream -> output processor
accept_lens: Optional[torch.Tensor] = None
block_accept_lens: Optional[torch.Tensor] = None
cap_lens: Optional[torch.Tensor] = None
# Next-iter seq_lens; published via on_publish.
new_seq_lens: Optional[torch.Tensor] = None
# relay path: forward stream -> next step forward
next_draft_input: Optional[EagleDraftInput] = None
# Refs the worker wants scheduler to keep alive for the same 2-iter window
# as batch_record_buf. Used for cross-stream tensor lifetime (e.g. a spec
# V2 verify ForwardBatch whose tensors must outlive mid-iter SB rebinds).
extra_keep_alive_refs: Optional[List[Any]] = None
# Routed experts: pending async D2H for overlap scheduling
routed_experts_output: Optional[TopkCaptureOutput] = None
indexer_topk_output: Optional[TopkCaptureOutput] = None
# metrics
expert_distribution_metrics: Optional[ExpertDistributionMetrics] = None
# Forward pass metrics (FPM) — GPU-accurate timing via CUDA events
fpm_start_event: Optional[torch.cuda.Event] = None
fpm_end_event: Optional[torch.cuda.Event] = None
@property
def has_sampled_token_ids(self) -> bool:
"""True when this iter sampled token ids; False when none were produced
this rank/split (a non-last PP rank or a non-final prefill split)."""
return isinstance(self.next_token_ids, torch.Tensor)
@torch.profiler.record_function("copy_result_to_cpu")
def copy_to_cpu(self, return_logprob: bool, return_hidden_states: bool = True):
"""Copy tensors to CPU in overlap scheduling.
Only the tensors which are needed for processing results are copied,
e.g., next_token_ids, logits outputs
"""
if return_logprob:
if self.logits_output.next_token_logprobs is not None:
self.logits_output.next_token_logprobs = _async_d2h(
self.logits_output.next_token_logprobs
)
if self.logits_output.input_token_logprobs is not None:
self.logits_output.input_token_logprobs = _async_d2h(
self.logits_output.input_token_logprobs
)
if self.logits_output.next_token_top_logprobs_val is not None:
self.logits_output.next_token_top_logprobs_val = [
_async_d2h(v) if torch.is_tensor(v) else v
for v in self.logits_output.next_token_top_logprobs_val
]
if self.logits_output.next_token_top_logprobs_idx is not None:
self.logits_output.next_token_top_logprobs_idx = [
_async_d2h(x) if torch.is_tensor(x) else x
for x in self.logits_output.next_token_top_logprobs_idx
]
if self.logits_output.next_token_token_ids_logprobs_val is not None:
self.logits_output.next_token_token_ids_logprobs_val = [
_async_d2h(v) if torch.is_tensor(v) else v
for v in self.logits_output.next_token_token_ids_logprobs_val
]
if return_hidden_states and self.logits_output.hidden_states is not None:
self.logits_output.hidden_states = _async_d2h(
self.logits_output.hidden_states
)
self.next_token_ids = _async_d2h(self.next_token_ids)
if self.accept_lens is not None:
self.accept_lens = _async_d2h(self.accept_lens)
if self.block_accept_lens is not None:
self.block_accept_lens = _async_d2h(self.block_accept_lens)
if self.cap_lens is not None:
self.cap_lens = _async_d2h(self.cap_lens)
# Sub-objects only declare their device fields; the single copy+safety
# primitive (_async_d2h: pinned D2H + record_stream) is injected here so
# all device->host copying and lifetime safety lives in one place.
for holder in (
self.routed_experts_output,
self.indexer_topk_output,
self.expert_distribution_metrics,
):
if holder is not None:
holder.map_device_tensors(_async_d2h)
self.copy_done.record()
@classmethod
def from_pp_proxy(
cls, logits_output, next_pp_outputs: PPProxyTensors, can_run_cuda_graph
):
# TODO(lsyin): refactor PP and avoid using dict
proxy_dict = next_pp_outputs.tensors
return cls(
logits_output=logits_output,
pp_hidden_states_proxy_tensors=None,
next_token_ids=next_pp_outputs["next_token_ids"],
extend_input_len_per_req=proxy_dict.get("extend_input_len_per_req", None),
extend_logprob_start_len_per_req=proxy_dict.get(
"extend_logprob_start_len_per_req", None
),
can_run_cuda_graph=can_run_cuda_graph,
)
def validate_input_length(
req: Req, max_req_input_len: int, allow_auto_truncate: bool
) -> Optional[str]:
"""Validate and potentially truncate input length.
Args:
req: The request containing input_ids to validate
max_req_input_len: Maximum allowed input length
allow_auto_truncate: Whether to truncate long inputs
Returns:
Error message if validation fails, None if successful
"""
if len(req.origin_input_ids) >= max_req_input_len:
if allow_auto_truncate:
logger.warning(
"Request length is longer than the KV cache pool size or "
"the max context length. Truncated. "
f"{len(req.origin_input_ids)=}, {max_req_input_len=}."
)
req.origin_input_ids = req.origin_input_ids[:max_req_input_len]
return None
else:
error_msg = (
f"Input length ({len(req.origin_input_ids)} tokens) exceeds "
f"the maximum allowed length ({max_req_input_len} tokens). "
f"Use a shorter input or enable --allow-auto-truncate."
)
return error_msg
return None
def get_logprob_dict_from_result(result: GenerationBatchResult) -> dict:
logits_output = result.logits_output
assert logits_output is not None
return {
"extend_input_len_per_req": result.extend_input_len_per_req,
"extend_logprob_start_len_per_req": result.extend_logprob_start_len_per_req,
"next_token_logprobs": result.logits_output.next_token_logprobs,
"next_token_top_logprobs_val": result.logits_output.next_token_top_logprobs_val,
"next_token_top_logprobs_idx": result.logits_output.next_token_top_logprobs_idx,
"next_token_token_ids_logprobs_val": result.logits_output.next_token_token_ids_logprobs_val,
"next_token_token_ids_logprobs_idx": result.logits_output.next_token_token_ids_logprobs_idx,
"input_token_logprobs": result.logits_output.input_token_logprobs,
"input_top_logprobs_val": result.logits_output.input_top_logprobs_val,
"input_top_logprobs_idx": result.logits_output.input_top_logprobs_idx,
"input_token_ids_logprobs_val": result.logits_output.input_token_ids_logprobs_val,
"input_token_ids_logprobs_idx": result.logits_output.input_token_ids_logprobs_idx,
}
def get_logprob_from_pp_outputs(
next_pp_outputs: PPProxyTensors,
) -> tuple[LogitsProcessorOutput, list[int], list[int]]:
logits_output = LogitsProcessorOutput(
# Do not send logits and hidden states because they are large
next_token_logits=None,
hidden_states=None,
next_token_logprobs=next_pp_outputs["next_token_logprobs"],
next_token_top_logprobs_val=next_pp_outputs["next_token_top_logprobs_val"],
next_token_top_logprobs_idx=next_pp_outputs["next_token_top_logprobs_idx"],
next_token_token_ids_logprobs_val=next_pp_outputs[
"next_token_token_ids_logprobs_val"
],
next_token_token_ids_logprobs_idx=next_pp_outputs[
"next_token_token_ids_logprobs_idx"
],
input_token_logprobs=next_pp_outputs["input_token_logprobs"],
input_top_logprobs_val=next_pp_outputs["input_top_logprobs_val"],
input_top_logprobs_idx=next_pp_outputs["input_top_logprobs_idx"],
input_token_ids_logprobs_val=next_pp_outputs["input_token_ids_logprobs_val"],
input_token_ids_logprobs_idx=next_pp_outputs["input_token_ids_logprobs_idx"],
)
extend_input_len_per_req = next_pp_outputs["extend_input_len_per_req"]
extend_logprob_start_len_per_req = next_pp_outputs[
"extend_logprob_start_len_per_req"
]
return logits_output, extend_input_len_per_req, extend_logprob_start_len_per_req
@dataclass
class EmbeddingBatchResult:
"""Result from an embedding/classification forward pass.
Attributes:
embeddings: Model output — pooled embeddings or classification logits.
pooled_hidden_states: Raw hidden states before the task head. Present
only when the batch contained ``return_pooled_hidden_states=True``
requests. Tensor (uniform shapes) or list of tensors (MIS).
copy_done: CUDA event recorded after the async CPU copy completes.
"""
embeddings: torch.Tensor
pooled_hidden_states: Optional[torch.Tensor] = None
copy_done: Optional[torch.cuda.Event] = None
@property
def can_run_cuda_graph(self) -> bool:
return False
@torch.profiler.record_function("copy_embedding_to_cpu")
def copy_to_cpu(self):
"""Copy embeddings and pooled hidden states to CPU for overlap scheduling."""
if isinstance(self.embeddings, torch.Tensor):
self.copy_done = torch.get_device_module(self.embeddings.device).Event()
self.embeddings = _async_d2h(self.embeddings)
else:
assert isinstance(self.embeddings, list)
if len(self.embeddings) == 0:
return
self.copy_done = torch.get_device_module(self.embeddings[0].device).Event()
self.embeddings = [_async_d2h(emb) for emb in self.embeddings]
if self.pooled_hidden_states is not None:
if isinstance(self.pooled_hidden_states, list):
self.pooled_hidden_states = [
_async_d2h(t) for t in self.pooled_hidden_states
]
else:
self.pooled_hidden_states = _async_d2h(self.pooled_hidden_states)
self.copy_done.record()
def is_health_check_generate_req(recv_req):
rid = getattr(recv_req, "rid", None)
return rid is not None and rid.startswith(HEALTH_CHECK_RID_PREFIX)