[PD] Introduce runtime role switching between prefill and decode (#28403)
Signed-off-by: huanglong <huanglong@linux.alibaba.com> Signed-off-by: inkcherry <mingzhi.liu@amd.com> Co-authored-by: huanglong <huanglong@linux.alibaba.com> Co-authored-by: Shangming Cai <csmthu@gmail.com> Co-authored-by: Huang Long <121648372+LLLL114@users.noreply.github.com>
This commit is contained in:
co-authored by
huanglong
Shangming Cai
Huang Long
parent
a98d921658
commit
1f60ddef5d
@@ -114,6 +114,10 @@ class Disagg(msgspec.Struct):
|
||||
int,
|
||||
"The interval to poll requests in decode server. Can be set to >1 to reduce the overhead of this.",
|
||||
] = 1
|
||||
enable_pd_role_switch: A[
|
||||
bool,
|
||||
"Allow runtime prefill<->decode role switch via /pd_role_switch (PD mode).",
|
||||
] = False
|
||||
optimistic_prefill_attempts: A[
|
||||
int, "Number of optimistic prefill forward passes that skip the bootstrap wait."
|
||||
] = 0
|
||||
|
||||
@@ -147,6 +147,39 @@ def handle_pd_disaggregation(server_args: ServerArgs) -> None:
|
||||
f"got '{cfg.disaggregation_transfer_backend}'."
|
||||
)
|
||||
|
||||
# Reject features whose role-specific state is not rebuilt on a flip.
|
||||
if cfg.enable_pd_role_switch:
|
||||
view = resolved_view(server_args)
|
||||
unsupported = []
|
||||
if view.enable_dp_attention:
|
||||
unsupported.append("DP attention (--enable-dp-attention)")
|
||||
if view.ep_size > 1:
|
||||
unsupported.append(f"expert parallelism (--ep-size {view.ep_size})")
|
||||
if view.moe_a2a_backend != "none":
|
||||
unsupported.append(
|
||||
f"MoE all-to-all (--moe-a2a-backend {view.moe_a2a_backend})"
|
||||
)
|
||||
if view.pp_size > 1:
|
||||
unsupported.append(f"pipeline parallelism (--pp-size {view.pp_size})")
|
||||
if view.dp_size > 1:
|
||||
unsupported.append(f"data parallelism (--dp-size {view.dp_size})")
|
||||
if view.dcp_size > 1:
|
||||
unsupported.append(
|
||||
f"decode context parallelism (--dcp-size {view.dcp_size})"
|
||||
)
|
||||
if view.speculative_algorithm is not None:
|
||||
unsupported.append(
|
||||
"speculative decoding "
|
||||
f"(--speculative-algorithm {view.speculative_algorithm})"
|
||||
)
|
||||
if unsupported:
|
||||
raise ValueError(
|
||||
"--enable-pd-role-switch does not rebuild role-specific "
|
||||
"state for the following features: "
|
||||
+ ", ".join(unsupported)
|
||||
+ ". Remove these options or drop --enable-pd-role-switch."
|
||||
)
|
||||
|
||||
|
||||
def _alias_bootstrap_port_to_api_port(server_args: ServerArgs) -> None:
|
||||
"""Rust-server prefill serves the KV bootstrap registry on the api listener
|
||||
|
||||
@@ -127,6 +127,15 @@ class BaseKVManager(ABC):
|
||||
"""Register prefill server info to the bootstrap server."""
|
||||
...
|
||||
|
||||
# Opt-in per backend: set True and implement teardown() to support runtime PD
|
||||
# role switch (release transfer resources; the scheduler owns the KV pool).
|
||||
supports_role_switch: bool = False
|
||||
|
||||
def teardown(self) -> None:
|
||||
raise NotImplementedError(
|
||||
f"{type(self).__name__} does not support PD role switch teardown"
|
||||
)
|
||||
|
||||
|
||||
class BaseKVSender(ABC):
|
||||
@abstractmethod
|
||||
|
||||
@@ -287,6 +287,9 @@ class CommonKVManager(BaseKVManager):
|
||||
self.max_failures = max(
|
||||
envs.SGLANG_DISAGGREGATION_HEARTBEAT_MAX_FAILURE.get(), 1
|
||||
)
|
||||
# Event used to signal the heartbeat checker thread to exit
|
||||
# during teardown (e.g. runtime P<->D role switch).
|
||||
self._heartbeat_shutdown = threading.Event()
|
||||
# If a timeout happens on the decode side, it means decode instances
|
||||
# fail to receive the KV Cache transfer done signal after bootstrapping.
|
||||
# These timeout requests should be aborted to release the tree cache.
|
||||
@@ -707,6 +710,25 @@ class CommonKVManager(BaseKVManager):
|
||||
return
|
||||
self._kv_replica_factor = info.required_dst_info_num
|
||||
|
||||
def _make_worker_recv(self, socket, timeout_ms: int = 500):
|
||||
"""Build the blocking multipart recv used by a worker thread.
|
||||
|
||||
Plain blocking recv unless role switching is enabled: teardown flips a
|
||||
stop flag that a blocked recv can never observe, so in that mode poll
|
||||
with a timeout and return None when it expires. Deployments without
|
||||
--enable-pd-role-switch keep the original blocking recv and pay nothing.
|
||||
"""
|
||||
if not self.server_args.enable_pd_role_switch:
|
||||
return socket.recv_multipart
|
||||
|
||||
poller = zmq.Poller()
|
||||
poller.register(socket, zmq.POLLIN)
|
||||
|
||||
def recv():
|
||||
return socket.recv_multipart() if poller.poll(timeout_ms) else None
|
||||
|
||||
return recv
|
||||
|
||||
def _ensure_prefill_recompute_executor(
|
||||
self,
|
||||
) -> concurrent.futures.ThreadPoolExecutor:
|
||||
@@ -1308,12 +1330,18 @@ class CommonKVManager(BaseKVManager):
|
||||
|
||||
return src_kv_ptrs, sliced_dst
|
||||
|
||||
def _start_heartbeat_checker_thread(self):
|
||||
"""Start the heartbeat checker thread for Decode worker."""
|
||||
def _start_heartbeat_checker_thread(self) -> threading.Thread:
|
||||
"""Start the heartbeat checker thread for Decode worker.
|
||||
|
||||
Returns the thread object so callers can track/join it during teardown.
|
||||
"""
|
||||
|
||||
def heartbeat_checker():
|
||||
while True:
|
||||
time.sleep(self.heartbeat_interval)
|
||||
while not self._heartbeat_shutdown.is_set():
|
||||
# Use Event.wait() instead of time.sleep() so teardown can
|
||||
# wake this thread immediately by setting the event.
|
||||
if self._heartbeat_shutdown.wait(self.heartbeat_interval):
|
||||
break
|
||||
with self.connection_lock:
|
||||
addresses = list(self.prefill_info_table.keys())
|
||||
|
||||
@@ -1352,7 +1380,13 @@ class CommonKVManager(BaseKVManager):
|
||||
if bootstrap_addr in self.session_pool:
|
||||
del self.session_pool[bootstrap_addr]
|
||||
|
||||
threading.Thread(target=heartbeat_checker, daemon=True).start()
|
||||
t = threading.Thread(
|
||||
target=heartbeat_checker,
|
||||
name="HeartbeatChecker",
|
||||
daemon=True,
|
||||
)
|
||||
t.start()
|
||||
return t
|
||||
|
||||
def _on_heartbeat_success(self, bootstrap_addr: str):
|
||||
"""Hook called on successful heartbeat. Override for backend-specific cleanup."""
|
||||
@@ -1800,6 +1834,31 @@ class CommonKVReceiver(BaseKVReceiver):
|
||||
sock.close()
|
||||
logger.debug(f"Disconnected stale ZMQ PUSH socket (receiver): {endpoint}")
|
||||
|
||||
@classmethod
|
||||
def close_all_sockets(cls):
|
||||
"""Close all cached PUSH sockets on role switch, keeping ``_ctx`` reusable."""
|
||||
with cls._global_lock:
|
||||
entries = list(cls._socket_cache.items())
|
||||
locks = cls._socket_locks.copy()
|
||||
cls._socket_cache.clear()
|
||||
cls._socket_locks.clear()
|
||||
|
||||
# Close outside _global_lock: _connect drops it before the per-endpoint lock.
|
||||
for endpoint, sock in entries:
|
||||
lock = locks.get(endpoint)
|
||||
try:
|
||||
if lock:
|
||||
with lock:
|
||||
sock.close(linger=0)
|
||||
else:
|
||||
sock.close(linger=0)
|
||||
except Exception:
|
||||
logger.exception(
|
||||
f"Failed to close ZMQ PUSH socket (receiver): {endpoint}"
|
||||
)
|
||||
if entries:
|
||||
logger.debug(f"Closed {len(entries)} receiver ZMQ PUSH socket(s)")
|
||||
|
||||
@classmethod
|
||||
def _connect_to_bootstrap_server(cls, bootstrap_info: dict):
|
||||
ip_address = bootstrap_info["rank_ip"]
|
||||
|
||||
@@ -208,6 +208,8 @@ class KVArgsRegisterInfo:
|
||||
|
||||
class MooncakeKVManager(StagingManagerMixin, CommonKVManager):
|
||||
AUX_DATA_HEADER = b"AUX_DATA"
|
||||
# Implements teardown() below, so runtime PD role switching is supported.
|
||||
supports_role_switch = True
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@@ -224,6 +226,9 @@ class MooncakeKVManager(StagingManagerMixin, CommonKVManager):
|
||||
envs.SGLANG_MOONCAKE_MAX_TRANSFER_BATCH_INDICES.get()
|
||||
)
|
||||
self.enable_trace = get_observability().enable_trace
|
||||
# Set by teardown() to make worker threads exit (P<->D role switch).
|
||||
self._stopped = False
|
||||
self._worker_threads: List[threading.Thread] = []
|
||||
if self.disaggregation_mode == DisaggregationMode.PREFILL:
|
||||
self.session_failures = defaultdict(int)
|
||||
self.failed_sessions = set()
|
||||
@@ -262,7 +267,10 @@ class MooncakeKVManager(StagingManagerMixin, CommonKVManager):
|
||||
for i, (queue, executor) in enumerate(
|
||||
zip(self.transfer_queues, self.executors)
|
||||
):
|
||||
threading.Thread(
|
||||
# Track the thread so teardown() can join it: otherwise every
|
||||
# P->D->P flip that re-enters PREFILL leaks threads
|
||||
# (each parked forever in FastQueue.get()).
|
||||
t = threading.Thread(
|
||||
target=self.transfer_worker,
|
||||
args=(
|
||||
queue,
|
||||
@@ -275,7 +283,9 @@ class MooncakeKVManager(StagingManagerMixin, CommonKVManager):
|
||||
i,
|
||||
),
|
||||
daemon=True,
|
||||
).start()
|
||||
)
|
||||
t.start()
|
||||
self._worker_threads.append(t)
|
||||
self.enable_failed_session_probe = (
|
||||
envs.SGLANG_ENABLE_FAILED_SESSION_PROBE.get()
|
||||
)
|
||||
@@ -284,11 +294,13 @@ class MooncakeKVManager(StagingManagerMixin, CommonKVManager):
|
||||
envs.SGLANG_FAILED_SESSION_PROBE_INTERVAL_S.get()
|
||||
)
|
||||
self._failed_session_probe_shutdown = threading.Event()
|
||||
threading.Thread(
|
||||
t = threading.Thread(
|
||||
target=self._failed_session_probe_loop,
|
||||
name="MooncakeFailedSessionProbe",
|
||||
daemon=True,
|
||||
).start()
|
||||
)
|
||||
t.start()
|
||||
self._worker_threads.append(t)
|
||||
elif self.disaggregation_mode == DisaggregationMode.DECODE:
|
||||
self._staging_ctx = DecodeStagingContext() if self.enable_staging else None
|
||||
if self.enable_staging:
|
||||
@@ -338,6 +350,94 @@ class MooncakeKVManager(StagingManagerMixin, CommonKVManager):
|
||||
with self.connection_lock:
|
||||
self.connection_pool.clear()
|
||||
|
||||
def teardown(self) -> None:
|
||||
"""Stop worker threads and release transport resources so this
|
||||
KVManager can be discarded during a P<->D role switch.
|
||||
|
||||
The KV cache pool memory is owned by the scheduler and is NOT freed
|
||||
here; only mooncake-side registrations / sockets are released.
|
||||
"""
|
||||
self._stopped = True
|
||||
|
||||
# Stop the failed-session probe loop (PREFILL role) if running.
|
||||
probe_shutdown = getattr(self, "_failed_session_probe_shutdown", None)
|
||||
if probe_shutdown is not None:
|
||||
probe_shutdown.set()
|
||||
|
||||
# Stop the heartbeat checker thread (DECODE role) if running.
|
||||
heartbeat_shutdown = getattr(self, "_heartbeat_shutdown", None)
|
||||
if heartbeat_shutdown is not None:
|
||||
heartbeat_shutdown.set()
|
||||
|
||||
# Transfer workers (PREFILL role) park in FastQueue.get(), which has no
|
||||
# timeout; push a None sentinel per shard to wake and stop them so the
|
||||
# join below returns instead of leaking the thread.
|
||||
for queue in getattr(self, "transfer_queues", []):
|
||||
try:
|
||||
queue.put(None)
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"Failed to signal mooncake transfer worker on teardown"
|
||||
)
|
||||
|
||||
# Shutdown thread pool executors (PREFILL role).
|
||||
for executor in getattr(self, "executors", []):
|
||||
try:
|
||||
executor.shutdown(wait=False, cancel_futures=True)
|
||||
except TypeError:
|
||||
# Python < 3.9 does not support cancel_futures
|
||||
executor.shutdown(wait=False)
|
||||
except Exception:
|
||||
logger.exception("Failed to shutdown executor on teardown")
|
||||
self.executors = []
|
||||
|
||||
# Join workers before touching their sockets: ZMQ sockets aren't
|
||||
# thread-safe, so don't close server_socket while a worker may poll it.
|
||||
for t in self._worker_threads:
|
||||
t.join(timeout=3.0)
|
||||
self._worker_threads = []
|
||||
|
||||
# Drop the queues so their buffered tasks/senders are released too.
|
||||
self.transfer_queues = []
|
||||
|
||||
# Close cached PUSH sockets (used by _connect for status sync).
|
||||
with self._socket_lock:
|
||||
for sock in self._socket_cache.values():
|
||||
try:
|
||||
sock.close(linger=0)
|
||||
except Exception:
|
||||
pass
|
||||
for monitor in self._monitor_cache.values():
|
||||
try:
|
||||
monitor.close()
|
||||
except Exception:
|
||||
pass
|
||||
self._socket_cache.clear()
|
||||
self._monitor_cache.clear()
|
||||
|
||||
try:
|
||||
self.server_socket.close(linger=0)
|
||||
except Exception:
|
||||
logger.exception("Failed to close mooncake server_socket during teardown")
|
||||
|
||||
# destroy() force-closes every socket in the context; plain term()
|
||||
# would block waiting on them.
|
||||
try:
|
||||
self._zmq_ctx.destroy(linger=0)
|
||||
except Exception:
|
||||
logger.exception("Failed to destroy mooncake zmq context during teardown")
|
||||
|
||||
# Deregister memory from the transfer engine.
|
||||
try:
|
||||
self.deregister_buffer_to_engine()
|
||||
except Exception:
|
||||
logger.exception("Failed to deregister buffers during teardown")
|
||||
|
||||
logger.info(
|
||||
"MooncakeKVManager torn down (was role=%s)",
|
||||
self.disaggregation_mode.value,
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Staging buffer methods (all delegate to staging_handler.py)
|
||||
# ------------------------------------------------------------------
|
||||
@@ -1862,6 +1962,11 @@ class MooncakeKVManager(StagingManagerMixin, CommonKVManager):
|
||||
while True:
|
||||
try:
|
||||
kv_chunk: TransferKVChunk = queue.get()
|
||||
# teardown() pushes a None sentinel to unblock get() and stop
|
||||
# the worker: FastQueue.get() blocks indefinitely, so checking
|
||||
# _stopped alone can never wake a parked worker during a role switch.
|
||||
if kv_chunk is None:
|
||||
break
|
||||
if self.enable_trace:
|
||||
kv_chunk.trace_ctx.rebuild_thread_context()
|
||||
kv_chunk.trace_ctx.trace_slice_start(
|
||||
@@ -2186,11 +2291,15 @@ class MooncakeKVManager(StagingManagerMixin, CommonKVManager):
|
||||
)
|
||||
|
||||
def start_prefill_thread(self):
|
||||
recv = self._make_worker_recv(self.server_socket)
|
||||
|
||||
def bootstrap_thread():
|
||||
"""This thread recvs pre-alloc notification from the decode engine"""
|
||||
# KVPoll.Bootstrapping -> KVPoll.WaitingForInput
|
||||
while True:
|
||||
waiting_req_bytes = self.server_socket.recv_multipart()
|
||||
while not self._stopped:
|
||||
waiting_req_bytes = recv()
|
||||
if waiting_req_bytes is None:
|
||||
continue
|
||||
room = waiting_req_bytes[0].decode("ascii")
|
||||
# Staging: decode reports consumption watermark back to prefill
|
||||
if room == "WATERMARK":
|
||||
@@ -2321,12 +2430,18 @@ class MooncakeKVManager(StagingManagerMixin, CommonKVManager):
|
||||
)
|
||||
self.update_status(room, KVPoll.WaitingForInput)
|
||||
|
||||
threading.Thread(target=bootstrap_thread).start()
|
||||
t = threading.Thread(target=bootstrap_thread, daemon=True)
|
||||
t.start()
|
||||
self._worker_threads.append(t)
|
||||
|
||||
def start_decode_thread(self):
|
||||
recv = self._make_worker_recv(self.server_socket)
|
||||
|
||||
def decode_thread():
|
||||
while True:
|
||||
msg = self.server_socket.recv_multipart()
|
||||
while not self._stopped:
|
||||
msg = recv()
|
||||
if msg is None:
|
||||
continue
|
||||
if msg[0] == MooncakeKVManager.AUX_DATA_HEADER:
|
||||
self._handle_aux_data(msg)
|
||||
continue
|
||||
@@ -2379,8 +2494,11 @@ class MooncakeKVManager(StagingManagerMixin, CommonKVManager):
|
||||
failure_reason=reason,
|
||||
)
|
||||
|
||||
threading.Thread(target=decode_thread).start()
|
||||
self._start_heartbeat_checker_thread()
|
||||
t = threading.Thread(target=decode_thread, daemon=True)
|
||||
t.start()
|
||||
self._worker_threads.append(t)
|
||||
t = self._start_heartbeat_checker_thread()
|
||||
self._worker_threads.append(t)
|
||||
|
||||
def add_transfer_request(
|
||||
self,
|
||||
|
||||
@@ -293,6 +293,8 @@ class TransferTarget:
|
||||
|
||||
class MoriKVManager(CommonKVManager):
|
||||
AUX_DATA_HEADER = b"AUX_DATA"
|
||||
# Implements teardown() below, so runtime PD role switching is supported.
|
||||
supports_role_switch = True
|
||||
|
||||
# The bootstrap socket carries several message kinds, so the status message
|
||||
# is tagged. Mori has always shipped the failure reason with it.
|
||||
@@ -315,6 +317,9 @@ class MoriKVManager(CommonKVManager):
|
||||
self.transfer_lock = threading.Lock()
|
||||
self._zmq_ctx = zmq.Context()
|
||||
self._socket_local = threading.local()
|
||||
# Set by teardown() to make worker threads exit (PoC: P<->D role switch).
|
||||
self._stopped = False
|
||||
self._worker_threads: List[threading.Thread] = []
|
||||
self._send_aux_rdma = envs.SGLANG_MORI_SEND_AUX_RDMA.get()
|
||||
self._register_local_buffers()
|
||||
if self.disaggregation_mode == DisaggregationMode.PREFILL:
|
||||
@@ -325,7 +330,10 @@ class MoriKVManager(CommonKVManager):
|
||||
self._wait_poll_ms = envs.SGLANG_MORI_WAIT_POLL_MS.get()
|
||||
self._transfer_timeout_ms = envs.SGLANG_MORI_TRANSFER_TIMEOUT_MS.get()
|
||||
for shard, queue in enumerate(self._transfer_queues):
|
||||
threading.Thread(
|
||||
# Track the thread so teardown() can join it: otherwise every
|
||||
# P->D->P flip that re-enters PREFILL leaks _num_shards threads
|
||||
# (each parked forever in FastQueue.get()).
|
||||
t = threading.Thread(
|
||||
target=self._transfer_worker,
|
||||
args=(queue,),
|
||||
daemon=True,
|
||||
@@ -333,7 +341,9 @@ class MoriKVManager(CommonKVManager):
|
||||
f"mori-xfer-dp{self.system_dp_rank}-"
|
||||
f"tp{self.attn_tp_rank}-s{shard}"
|
||||
),
|
||||
).start()
|
||||
)
|
||||
t.start()
|
||||
self._worker_threads.append(t)
|
||||
self._start_bootstrap_thread()
|
||||
elif self.disaggregation_mode == DisaggregationMode.DECODE:
|
||||
self._start_decode_thread()
|
||||
@@ -420,6 +430,11 @@ class MoriKVManager(CommonKVManager):
|
||||
def _transfer_worker(self, queue: FastQueue) -> None:
|
||||
while True:
|
||||
kv_chunk = queue.get()
|
||||
# teardown() pushes a None sentinel to unblock get() and stop the
|
||||
# worker: FastQueue.get() blocks indefinitely, so checking _stopped
|
||||
# alone can never wake a parked worker during a role switch.
|
||||
if kv_chunk is None:
|
||||
break
|
||||
try:
|
||||
self._process_transfer_chunk(kv_chunk)
|
||||
except Exception as exc:
|
||||
@@ -683,11 +698,13 @@ class MoriKVManager(CommonKVManager):
|
||||
logger.debug("Room %s marked Failed via ABORT from decode", bootstrap_room)
|
||||
|
||||
def _start_bootstrap_thread(self) -> None:
|
||||
recv = self._make_worker_recv(self.server_socket)
|
||||
|
||||
def bootstrap_worker():
|
||||
while True:
|
||||
while not self._stopped:
|
||||
try:
|
||||
msg = self.server_socket.recv_multipart()
|
||||
if not msg:
|
||||
msg = recv()
|
||||
if msg is None:
|
||||
continue
|
||||
|
||||
tag = msg[0]
|
||||
@@ -705,15 +722,23 @@ class MoriKVManager(CommonKVManager):
|
||||
else:
|
||||
self._handle_transfer_message(payload)
|
||||
except Exception:
|
||||
if self._stopped:
|
||||
break
|
||||
logger.exception("Bootstrap worker failed")
|
||||
|
||||
threading.Thread(target=bootstrap_worker, daemon=True).start()
|
||||
t = threading.Thread(target=bootstrap_worker, daemon=True)
|
||||
t.start()
|
||||
self._worker_threads.append(t)
|
||||
|
||||
def _start_decode_thread(self) -> None:
|
||||
recv = self._make_worker_recv(self.server_socket)
|
||||
|
||||
def decode_worker():
|
||||
while True:
|
||||
while not self._stopped:
|
||||
try:
|
||||
msg = self.server_socket.recv_multipart()
|
||||
msg = recv()
|
||||
if msg is None:
|
||||
continue
|
||||
if msg and msg[0] == MoriKVManager.AUX_DATA_HEADER:
|
||||
self._handle_aux_data(msg)
|
||||
continue
|
||||
@@ -732,9 +757,69 @@ class MoriKVManager(CommonKVManager):
|
||||
failure_reason=reason,
|
||||
)
|
||||
except Exception:
|
||||
if self._stopped:
|
||||
break
|
||||
logger.exception("Decode status worker failed")
|
||||
|
||||
threading.Thread(target=decode_worker, daemon=True).start()
|
||||
t = threading.Thread(target=decode_worker, daemon=True)
|
||||
t.start()
|
||||
self._worker_threads.append(t)
|
||||
|
||||
def teardown(self) -> None:
|
||||
"""Stop worker threads and release transport resources so this
|
||||
KVManager can be discarded during a P<->D role switch.
|
||||
|
||||
The KV cache pool memory is owned by the scheduler and is NOT freed
|
||||
here; only mori-side registrations / sockets / engine are released.
|
||||
"""
|
||||
self._stopped = True
|
||||
# Transfer workers (PREFILL role) park in FastQueue.get(), which has no
|
||||
# timeout; push a None sentinel per shard to wake and stop them so the
|
||||
# join below returns instead of leaking the thread.
|
||||
for queue in getattr(self, "_transfer_queues", []):
|
||||
try:
|
||||
queue.put(None)
|
||||
except Exception:
|
||||
logger.exception("Failed to signal mori transfer worker on teardown")
|
||||
# Join workers before touching their sockets: ZMQ sockets aren't
|
||||
# thread-safe, so don't close server_socket while a worker may poll it.
|
||||
for t in self._worker_threads:
|
||||
t.join(timeout=3.0)
|
||||
self._worker_threads = []
|
||||
# Drop the queues so their buffered tasks/senders are released too.
|
||||
self._transfer_queues = []
|
||||
try:
|
||||
self.server_socket.close(linger=0)
|
||||
except Exception:
|
||||
logger.exception("Failed to close mori server_socket during teardown")
|
||||
# destroy() force-closes every socket in the context (incl. per-thread
|
||||
# cached PUSH sockets); plain term() would block waiting on them.
|
||||
try:
|
||||
self._zmq_ctx.destroy(linger=0)
|
||||
except Exception:
|
||||
logger.exception("Failed to destroy mori zmq context during teardown")
|
||||
# Deregister RDMA memory and drop the IOEngine reference.
|
||||
try:
|
||||
for descs in (self.kv_mem_descs, self.aux_mem_descs):
|
||||
for desc in descs:
|
||||
try:
|
||||
self.engine.deregister_memory(desc)
|
||||
except Exception:
|
||||
pass
|
||||
for component_descs in self.state_mem_descs:
|
||||
for desc in component_descs:
|
||||
try:
|
||||
self.engine.deregister_memory(desc)
|
||||
except Exception:
|
||||
pass
|
||||
finally:
|
||||
self.kv_mem_descs = []
|
||||
self.aux_mem_descs = []
|
||||
self.state_mem_descs = []
|
||||
self.engine = None
|
||||
logger.info(
|
||||
"MoriKVManager torn down (was role=%s)", self.disaggregation_mode.value
|
||||
)
|
||||
|
||||
def _add_remote_peer(self, register_info: KVArgsRegisterInfo) -> None:
|
||||
engine_key = register_info.engine_key
|
||||
|
||||
@@ -0,0 +1,284 @@
|
||||
"""Runtime prefill<->decode role switching for PD disaggregation.
|
||||
|
||||
The token KV pool is role-independent and never reallocated; only the
|
||||
role-specific disaggregation structures are torn down and rebuilt on a flip.
|
||||
Kept out of scheduler.py to avoid growing it further.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import TYPE_CHECKING, Callable, Optional, Tuple
|
||||
|
||||
from sglang.srt.disaggregation.common.conn import CommonKVReceiver
|
||||
from sglang.srt.disaggregation.utils import DisaggregationMode
|
||||
from sglang.srt.managers.io_struct import PdRoleSwitchReqInput, PdRoleSwitchReqOutput
|
||||
from sglang.srt.runtime_context import get_context, get_disagg
|
||||
from sglang.srt.utils import get_available_gpu_memory
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from sglang.srt.managers.scheduler import Scheduler
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class PdRoleSwitchRestart(Exception):
|
||||
"""Break out of the current role's event loop after a successful switch."""
|
||||
|
||||
|
||||
def run_event_loop_supervisor(
|
||||
scheduler: Scheduler, dispatch_once: Callable[[Scheduler], None]
|
||||
) -> None:
|
||||
"""Re-dispatch the scheduler event loop after each runtime role switch."""
|
||||
while True:
|
||||
try:
|
||||
return dispatch_once(scheduler)
|
||||
except PdRoleSwitchRestart:
|
||||
logger.info(
|
||||
"Re-dispatching event loop after PD role switch -> %s",
|
||||
scheduler.disaggregation_mode.value,
|
||||
)
|
||||
|
||||
|
||||
def handle_pd_role_switch(
|
||||
scheduler: Scheduler, recv_req: PdRoleSwitchReqInput
|
||||
) -> PdRoleSwitchReqOutput:
|
||||
"""Flip the scheduler's disaggregation role at runtime. The instance must be
|
||||
idle; rebuild failure is fatal to the instance (no in-place rollback)."""
|
||||
old_role = scheduler.disaggregation_mode.value
|
||||
new_role = (recv_req.new_role or "").lower()
|
||||
|
||||
def _fail(msg: str, safe_to_restore: bool = False) -> PdRoleSwitchReqOutput:
|
||||
logger.warning(
|
||||
"PD role switch rejected (%s -> %s): %s", old_role, new_role, msg
|
||||
)
|
||||
return PdRoleSwitchReqOutput(
|
||||
success=False,
|
||||
message=msg,
|
||||
old_role=old_role,
|
||||
new_role=new_role,
|
||||
safe_to_restore=safe_to_restore,
|
||||
)
|
||||
|
||||
rejection = _reject_reason(scheduler, new_role)
|
||||
if rejection is not None:
|
||||
return _fail(*rejection)
|
||||
if new_role == old_role:
|
||||
return PdRoleSwitchReqOutput(
|
||||
success=True,
|
||||
message="already in target role",
|
||||
old_role=old_role,
|
||||
new_role=new_role,
|
||||
)
|
||||
if not scheduler.is_fully_idle():
|
||||
return _fail(
|
||||
"instance is not idle; drain all requests before switching",
|
||||
safe_to_restore=True,
|
||||
)
|
||||
|
||||
required_graph_gb = recv_req.decode_cuda_graph_memory_gb
|
||||
# Same condition ensure_decode_cuda_graphs skips on, so the check cannot be
|
||||
# bypassed while the capture still runs.
|
||||
will_capture_graphs = (
|
||||
new_role == "decode" and not scheduler.tp_worker.get_decode_cuda_graph_bs()
|
||||
)
|
||||
if will_capture_graphs and required_graph_gb is None:
|
||||
return _fail(
|
||||
"decode_cuda_graph_memory_gb is required before capturing decode graphs",
|
||||
safe_to_restore=True,
|
||||
)
|
||||
if will_capture_graphs and required_graph_gb is not None:
|
||||
if required_graph_gb < 0:
|
||||
return _fail(
|
||||
"decode_cuda_graph_memory_gb must be non-negative",
|
||||
safe_to_restore=True,
|
||||
)
|
||||
try:
|
||||
available_graph_gb = get_available_gpu_memory(
|
||||
scheduler.device, scheduler.ps.gpu_id
|
||||
)
|
||||
except Exception as e:
|
||||
return _fail(
|
||||
f"failed to check decode CUDA graph headroom: {e}",
|
||||
safe_to_restore=True,
|
||||
)
|
||||
if available_graph_gb < required_graph_gb:
|
||||
return _fail(
|
||||
"insufficient decode CUDA graph headroom: "
|
||||
f"required={required_graph_gb:.3f} GB, "
|
||||
f"available={available_graph_gb:.3f} GB",
|
||||
safe_to_restore=True,
|
||||
)
|
||||
|
||||
scheduler._pd_role_switch_in_progress = True
|
||||
try:
|
||||
# Teardown + role flip + rebuild are one logical atomic step. If any of
|
||||
# them raises, the instance is left half-torn-down (old role released,
|
||||
# new role not up) and isn't safe to serve, so mark it unhealthy. There
|
||||
# is no in-place rollback.
|
||||
try:
|
||||
teardown_disaggregation(scheduler)
|
||||
get_context().override("role_switch.flip", disaggregation_mode=new_role)
|
||||
scheduler.init_disaggregation()
|
||||
scheduler._sync_disaggregation_mode_to_subcomponents()
|
||||
except Exception as e:
|
||||
scheduler._pd_role_switch_unhealthy = True
|
||||
logger.critical(
|
||||
"PD role switch (%s -> %s) failed during teardown/rebuild; "
|
||||
"instance unhealthy: %s",
|
||||
old_role,
|
||||
new_role,
|
||||
e,
|
||||
)
|
||||
return _fail(
|
||||
f"role switch failed; instance unhealthy, restart required: {e}"
|
||||
)
|
||||
|
||||
if new_role == "decode":
|
||||
# Best-effort deferred capture; a failure only degrades to eager.
|
||||
try:
|
||||
scheduler.tp_worker.ensure_decode_cuda_graphs(
|
||||
recv_req.decode_cuda_graph_bs
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("Decode CUDA graph capture on role switch failed")
|
||||
|
||||
# Break out of the old-role event loop so the supervisor re-dispatches.
|
||||
scheduler._event_loop_should_restart = True
|
||||
logger.info("PD role switch succeeded: %s -> %s", old_role, new_role)
|
||||
return PdRoleSwitchReqOutput(
|
||||
success=True, message="ok", old_role=old_role, new_role=new_role
|
||||
)
|
||||
except Exception as e:
|
||||
logger.exception("PD role switch failed")
|
||||
return _fail(f"role switch raised: {e}")
|
||||
finally:
|
||||
scheduler._pd_role_switch_in_progress = False
|
||||
|
||||
|
||||
def _reject_reason(scheduler: Scheduler, new_role: str) -> Optional[Tuple[str, bool]]:
|
||||
"""Why the switch must be rejected before draining, or None to proceed.
|
||||
|
||||
Table-driven: the first failing precondition's message is returned.
|
||||
"""
|
||||
sa = scheduler.server_args
|
||||
km = _current_kv_manager(scheduler)
|
||||
# (failed?, safe to restore routing?, lazy message)
|
||||
checks = (
|
||||
(
|
||||
not sa.enable_pd_role_switch,
|
||||
True,
|
||||
lambda: "--enable-pd-role-switch is not set on this instance",
|
||||
),
|
||||
(
|
||||
scheduler._pd_role_switch_unhealthy,
|
||||
False,
|
||||
lambda: (
|
||||
"instance is unhealthy after a failed role switch; restart required"
|
||||
),
|
||||
),
|
||||
(
|
||||
scheduler._pd_role_switch_in_progress,
|
||||
False,
|
||||
lambda: "another role switch is already in progress",
|
||||
),
|
||||
(
|
||||
new_role not in ("prefill", "decode"),
|
||||
True,
|
||||
lambda: f"invalid new_role={new_role!r}",
|
||||
),
|
||||
(
|
||||
scheduler.disaggregation_mode == DisaggregationMode.NULL,
|
||||
True,
|
||||
lambda: "instance is not running in PD disaggregation mode",
|
||||
),
|
||||
(
|
||||
km is not None and not km.supports_role_switch,
|
||||
True,
|
||||
lambda: (
|
||||
f"transfer backend {get_disagg().disaggregation_transfer_backend!r} "
|
||||
"does not support runtime role switch"
|
||||
),
|
||||
),
|
||||
(
|
||||
getattr(km, "enable_staging", False),
|
||||
True,
|
||||
lambda: (
|
||||
"staging buffer (SGLANG_DISAGG_STAGING_BUFFER) is not "
|
||||
"supported with runtime role switch"
|
||||
),
|
||||
),
|
||||
)
|
||||
return next(
|
||||
((msg(), safe_to_restore) for failed, safe_to_restore, msg in checks if failed),
|
||||
None,
|
||||
)
|
||||
|
||||
|
||||
def _current_kv_manager(scheduler: Scheduler):
|
||||
"""The KV manager of the current role's disaggregation queue, or None."""
|
||||
if scheduler.disaggregation_mode == DisaggregationMode.PREFILL:
|
||||
q = getattr(scheduler, "disagg_prefill_bootstrap_queue", None)
|
||||
elif scheduler.disaggregation_mode == DisaggregationMode.DECODE:
|
||||
q = getattr(scheduler, "disagg_decode_prealloc_queue", None)
|
||||
else:
|
||||
q = None
|
||||
return getattr(q, "kv_manager", None) if q is not None else None
|
||||
|
||||
|
||||
def teardown_disaggregation(scheduler: Scheduler) -> None:
|
||||
"""Release the current role's disaggregation structures (queues, metadata
|
||||
buffers, KV transfer manager) so the other role can be rebuilt."""
|
||||
mode = scheduler.disaggregation_mode
|
||||
if mode == DisaggregationMode.PREFILL:
|
||||
q = getattr(scheduler, "disagg_prefill_bootstrap_queue", None)
|
||||
if q is not None:
|
||||
km = getattr(q, "kv_manager", None)
|
||||
if km is not None:
|
||||
km.teardown()
|
||||
scheduler.disagg_prefill_bootstrap_queue = None
|
||||
scheduler.disagg_prefill_inflight_queue = []
|
||||
elif mode == DisaggregationMode.DECODE:
|
||||
q = getattr(scheduler, "disagg_decode_prealloc_queue", None)
|
||||
if q is not None:
|
||||
km = getattr(q, "kv_manager", None)
|
||||
if km is not None:
|
||||
km.teardown()
|
||||
scheduler.disagg_decode_prealloc_queue = None
|
||||
scheduler.disagg_decode_transfer_queue = None
|
||||
# clear socket ctx in CommonKVReceiver
|
||||
CommonKVReceiver.close_all_sockets()
|
||||
scheduler.disagg_metadata_buffers = None
|
||||
scheduler.req_to_metadata_buffer_idx_allocator = None
|
||||
_release_prefix_cache_for_role_switch(scheduler)
|
||||
|
||||
|
||||
def _release_prefix_cache_for_role_switch(scheduler: Scheduler) -> None:
|
||||
"""Release the prefix (radix/hicache) cache so a flip works with radix ON.
|
||||
|
||||
With radix disabled (ChunkCache) the flip needs nothing here: ChunkCache
|
||||
keeps no persistent prefixes and, since the instance is idle before the
|
||||
switch, the allocator is already empty. This is the historical
|
||||
``--disable-radix-cache`` path, left untouched by the guard below.
|
||||
|
||||
With radix (or hicache) enabled, finished prefixes stay in the tree and keep
|
||||
their KV-pool slots *locked* even while idle. Carried across a role switch
|
||||
that means (a) the new role would match against stale prefixes whose KV no
|
||||
longer means what it did (corruption) and (b) those locked slots would leak
|
||||
on every flip. Reset mirrors ``Scheduler.flush_cache``'s cache-release block
|
||||
(the instance is already fully idle, checked before teardown) and, for
|
||||
hicache, best-effort clears the storage backend so it is released completely.
|
||||
"""
|
||||
if scheduler.disable_radix_cache:
|
||||
return
|
||||
tree_cache = scheduler.tree_cache
|
||||
if tree_cache is not None:
|
||||
clear_storage = getattr(tree_cache, "clear_storage_backend", None)
|
||||
if callable(clear_storage):
|
||||
try:
|
||||
clear_storage()
|
||||
except Exception:
|
||||
logger.exception("hicache storage release on role switch failed")
|
||||
tree_cache.reset()
|
||||
scheduler.req_to_token_pool.clear()
|
||||
scheduler.token_to_kv_pool_allocator.clear()
|
||||
@@ -1407,6 +1407,7 @@ class Engine(EngineScoreMixin, EngineBase):
|
||||
"load_format": tm.config_value("load_format"),
|
||||
"reasoning_parser": tm.config_value("reasoning_parser"),
|
||||
"tool_call_parser": tm.config_value("tool_call_parser"),
|
||||
"disaggregation_mode": tm.config_value("disaggregation_mode"),
|
||||
}
|
||||
|
||||
def init_weights_update_group(
|
||||
|
||||
@@ -411,6 +411,9 @@ class RuntimeHandle:
|
||||
"load_format": self.tokenizer_manager.config_value("load_format"),
|
||||
"reasoning_parser": self.tokenizer_manager.config_value("reasoning_parser"),
|
||||
"tool_call_parser": self.tokenizer_manager.config_value("tool_call_parser"),
|
||||
"disaggregation_mode": self.tokenizer_manager.config_value(
|
||||
"disaggregation_mode"
|
||||
),
|
||||
"model_type": getattr(model_config.hf_config, "model_type", None),
|
||||
"architectures": getattr(model_config.hf_config, "architectures", None),
|
||||
}
|
||||
|
||||
@@ -133,6 +133,7 @@ from sglang.srt.managers.io_struct import (
|
||||
OpenSessionReqInput,
|
||||
ParseFunctionCallReq,
|
||||
PauseGenerationReqInput,
|
||||
PdRoleSwitchReqInput,
|
||||
ProfileReq,
|
||||
ReleaseMemoryOccupationReqInput,
|
||||
ResumeMemoryOccupationReqInput,
|
||||
@@ -779,6 +780,9 @@ async def model_info():
|
||||
"tool_call_parser": _global_state.tokenizer_manager.config_value(
|
||||
"tool_call_parser"
|
||||
),
|
||||
"disaggregation_mode": _global_state.tokenizer_manager.config_value(
|
||||
"disaggregation_mode"
|
||||
),
|
||||
"has_image_understanding": model_config.is_image_understandable_model,
|
||||
"has_audio_understanding": model_config.is_audio_understandable_model,
|
||||
"model_type": getattr(model_config.hf_config, "model_type", None),
|
||||
@@ -1561,6 +1565,23 @@ async def slow_down(obj: Annotated[SlowDownReqInput, Body()], request: Request):
|
||||
return _create_error_response(e)
|
||||
|
||||
|
||||
@app.api_route("/pd_role_switch", methods=["POST"])
|
||||
@auth_level(AuthLevel.ADMIN_OPTIONAL)
|
||||
async def pd_role_switch(
|
||||
obj: Annotated[PdRoleSwitchReqInput, Body()], request: Request
|
||||
):
|
||||
"""Switch this instance's PD disaggregation role (prefill<->decode) at runtime.
|
||||
Requires --enable-pd-role-switch; the instance must be idle."""
|
||||
try:
|
||||
result = await _global_state.tokenizer_manager.pd_role_switch(obj, request)
|
||||
except Exception as e:
|
||||
return _create_error_response(e)
|
||||
return ORJSONResponse(
|
||||
msgspec_to_builtins(result),
|
||||
status_code=HTTPStatus.OK if result.success else HTTPStatus.BAD_REQUEST,
|
||||
)
|
||||
|
||||
|
||||
@app.api_route("/load_lora_adapter", methods=["POST"])
|
||||
@auth_level(AuthLevel.ADMIN_OPTIONAL)
|
||||
async def load_lora_adapter(
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"""Start bootstrap/kv-store-related server"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
|
||||
from sglang.srt.disaggregation.utils import (
|
||||
@@ -14,14 +15,30 @@ from sglang.srt.runtime_context import (
|
||||
get_serving,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def start_disagg_service():
|
||||
# Start kv bootstrap server on prefill
|
||||
disagg_mode = DisaggregationMode(get_disagg().disaggregation_mode)
|
||||
transfer_backend = TransferBackend(get_disagg().disaggregation_transfer_backend)
|
||||
|
||||
if disagg_mode == DisaggregationMode.PREFILL:
|
||||
# only start bootstrap server on prefill tm
|
||||
# With role switching, run bootstrap on every instance (not just prefill) so
|
||||
# one flipped to prefill already has it; it isn't rebuilt on flip.
|
||||
start_bootstrap = disagg_mode == DisaggregationMode.PREFILL or (
|
||||
get_disagg().enable_pd_role_switch and disagg_mode != DisaggregationMode.NULL
|
||||
)
|
||||
|
||||
if start_bootstrap and get_disagg().enable_pd_role_switch:
|
||||
logger.warning(
|
||||
"Role switch starts a bootstrap server on this instance at %s:%d. "
|
||||
"If another PD instance runs on the same host, give each one a "
|
||||
"distinct --disaggregation-bootstrap-port or the bind will conflict.",
|
||||
get_serving().host,
|
||||
get_disagg().disaggregation_bootstrap_port,
|
||||
)
|
||||
|
||||
if start_bootstrap:
|
||||
kv_bootstrap_server_class = get_kv_class(
|
||||
transfer_backend, KVClassType.BOOTSTRAP_SERVER
|
||||
)
|
||||
|
||||
@@ -2049,6 +2049,24 @@ class SlowDownReqOutput(BaseReq, kw_only=True):
|
||||
pass
|
||||
|
||||
|
||||
class PdRoleSwitchReqInput(BaseReq, kw_only=True):
|
||||
# Target role; "" is an invalid sentinel rejected by the handler.
|
||||
new_role: Literal["prefill", "decode", ""] = ""
|
||||
# Optional decode bs to capture on a flip to decode (capture-to-fit);
|
||||
# None uses the server's configured decode bs list.
|
||||
decode_cuda_graph_bs: Optional[List[int]] = None
|
||||
# Measured graph footprint from a matching decode peer.
|
||||
decode_cuda_graph_memory_gb: Optional[float] = None
|
||||
|
||||
|
||||
class PdRoleSwitchReqOutput(BaseReq, kw_only=True):
|
||||
success: bool = False
|
||||
message: str = ""
|
||||
old_role: str = ""
|
||||
new_role: str = ""
|
||||
safe_to_restore: bool = False
|
||||
|
||||
|
||||
class AbortReq(BaseReq, kw_only=True):
|
||||
# Whether to abort all requests
|
||||
abort_all: bool = False
|
||||
|
||||
@@ -2225,6 +2225,7 @@ def release_req(
|
||||
# Callers that will recompute the KV instead (PD true-retraction rebootstrap)
|
||||
# pass offload_kv=False to skip the wasteful device->host copy.
|
||||
backup_saved = True
|
||||
# The config bag reflects role flips; server_args keeps the launch role.
|
||||
if get_disagg().disaggregation_mode == "decode" and offload_kv:
|
||||
backup_saved = retraction_backup(
|
||||
req,
|
||||
|
||||
@@ -74,6 +74,7 @@ from sglang.srt.configs.model_config import (
|
||||
)
|
||||
from sglang.srt.constrained.grammar_manager import GrammarManager
|
||||
from sglang.srt.debug_utils.pr_fix_toggle import maybe_revert_pr_fix
|
||||
from sglang.srt.disaggregation import role_switch
|
||||
from sglang.srt.disaggregation.checksum import KvChecksumComputer
|
||||
from sglang.srt.disaggregation.decode import (
|
||||
DecodePreallocQueue,
|
||||
@@ -159,6 +160,7 @@ from sglang.srt.managers.io_struct import (
|
||||
MMInputsProcessError,
|
||||
OpenSessionReqInput,
|
||||
PauseGenerationReqInput,
|
||||
PdRoleSwitchReqInput,
|
||||
ProfileReq,
|
||||
ReleaseMemoryOccupationReqInput,
|
||||
RemoveExternalCorpusReqInput,
|
||||
@@ -1284,6 +1286,13 @@ class Scheduler(
|
||||
self.hisparse_coordinator.set_decode_producer_stream(self.forward_stream)
|
||||
|
||||
def init_running_status(self):
|
||||
# Set by a runtime PD role switch to break out of the current event loop.
|
||||
self._event_loop_should_restart = False
|
||||
# Guards against concurrent/re-entrant PD role switches.
|
||||
self._pd_role_switch_in_progress = False
|
||||
# Set if a role switch tore down the old role but failed to rebuild
|
||||
# either the new or the old role; the instance can no longer serve.
|
||||
self._pd_role_switch_unhealthy = False
|
||||
# Set by the ShutdownReq handler to break the event loop for graceful shutdown.
|
||||
self.gracefully_exit = False
|
||||
self.waiting_queue: List[Req] = []
|
||||
@@ -1804,6 +1813,7 @@ class Scheduler(
|
||||
self.weight_updater.check_weights,
|
||||
),
|
||||
(SlowDownReqInput, self.slow_down),
|
||||
(PdRoleSwitchReqInput, self.handle_pd_role_switch),
|
||||
(
|
||||
ProfileReq,
|
||||
lambda req: self.profiler_manager._profile(req),
|
||||
@@ -2133,6 +2143,13 @@ class Scheduler(
|
||||
if self.external_corpus_manager is not None:
|
||||
self.external_corpus_manager.check_pending_load()
|
||||
|
||||
# A runtime PD role switch rebuilt the disaggregation structures for a new
|
||||
# role. The response has already been sent above; now break out of the
|
||||
# current (old-role) event loop so the supervisor can re-dispatch.
|
||||
if get_disagg().enable_pd_role_switch and self._event_loop_should_restart:
|
||||
self._event_loop_should_restart = False
|
||||
raise role_switch.PdRoleSwitchRestart()
|
||||
|
||||
@staticmethod
|
||||
def _tokenized_requests(recv_req):
|
||||
if isinstance(
|
||||
@@ -5151,7 +5168,7 @@ class Scheduler(
|
||||
draft_graph_memory_usage = (
|
||||
None if self.draft_worker is None else self.draft_worker.graph_memory_usage
|
||||
)
|
||||
ret["memory_usage"] = build_memory_usage(
|
||||
memory_usage = build_memory_usage(
|
||||
weight_gb=self.tp_worker.model_runner.weight_load_mem_usage,
|
||||
kv_cache_gb=self.token_to_kv_pool_allocator.get_kvcache().mem_usage,
|
||||
startup_available_gb=self.startup_available_gpu_memory_gb,
|
||||
@@ -5160,8 +5177,29 @@ class Scheduler(
|
||||
target_graph_memory_usage=self.tp_worker.graph_memory_usage,
|
||||
draft_graph_memory_usage=draft_graph_memory_usage,
|
||||
)
|
||||
ret["memory_usage"] = memory_usage
|
||||
ret["startup_time"] = self.startup_time
|
||||
ret["effective_max_running_requests_per_dp"] = self.max_running_requests
|
||||
# PD role switch: report this instance's role and the decode CUDA graph
|
||||
# batch sizes it captured, which a router feeds back as
|
||||
# PdRoleSwitchReqInput.decode_cuda_graph_bs. Unset until
|
||||
# init_disaggregation runs, which also re-derives it on every flip.
|
||||
disaggregation_mode = getattr(self, "disaggregation_mode", None)
|
||||
if disaggregation_mode is not None:
|
||||
ret["disaggregation_mode"] = disaggregation_mode.value
|
||||
ret["decode_cuda_graph_bs"] = self.tp_worker.get_decode_cuda_graph_bs()
|
||||
ret["decode_cuda_graph_memory_gb"] = round(
|
||||
sum(
|
||||
memory_usage["graph"][phase]
|
||||
for phase in (
|
||||
"decode",
|
||||
"target_verify",
|
||||
"draft_decode",
|
||||
"draft_extend",
|
||||
)
|
||||
),
|
||||
3,
|
||||
)
|
||||
|
||||
if get_exec().moe.elastic_ep_backend is not None:
|
||||
from sglang.srt.elastic_ep.elastic_ep import ElasticEPStateManager
|
||||
@@ -5739,6 +5777,24 @@ class Scheduler(
|
||||
self.forward_sleep_time = t
|
||||
return SlowDownReqOutput()
|
||||
|
||||
def handle_pd_role_switch(self, recv_req: PdRoleSwitchReqInput):
|
||||
return role_switch.handle_pd_role_switch(self, recv_req)
|
||||
|
||||
def _sync_disaggregation_mode_to_subcomponents(self):
|
||||
# Push the (possibly flipped) mode into sub-components that cache it.
|
||||
# object.__setattr__ because some are frozen dataclasses.
|
||||
for name in (
|
||||
"invariant_checker",
|
||||
"load_inquirer",
|
||||
"output_streamer",
|
||||
"batch_result_processor",
|
||||
):
|
||||
comp = getattr(self, name, None)
|
||||
if comp is not None and hasattr(comp, "disaggregation_mode"):
|
||||
object.__setattr__(
|
||||
comp, "disaggregation_mode", self.disaggregation_mode
|
||||
)
|
||||
|
||||
def expert_distribution_handle(self, recv_req: ExpertDistributionReq):
|
||||
action = recv_req.action
|
||||
if action == ExpertDistributionReqType.START_RECORD:
|
||||
@@ -5818,6 +5874,15 @@ class Scheduler(
|
||||
|
||||
|
||||
def dispatch_event_loop(scheduler: Scheduler):
|
||||
if scheduler.server_args.enable_pd_role_switch:
|
||||
return role_switch.run_event_loop_supervisor(
|
||||
scheduler,
|
||||
_dispatch_event_loop_once,
|
||||
)
|
||||
return _dispatch_event_loop_once(scheduler)
|
||||
|
||||
|
||||
def _dispatch_event_loop_once(scheduler: Scheduler):
|
||||
# The live PP property asserts before torch.distributed init (MLX stub).
|
||||
disaggregation_mode: DisaggregationMode = scheduler.disaggregation_mode
|
||||
if disaggregation_mode == DisaggregationMode.NULL:
|
||||
|
||||
@@ -424,12 +424,13 @@ class SchedulerInvariantChecker:
|
||||
)
|
||||
|
||||
def _check_req_pool(self):
|
||||
if self.disaggregation_mode == DisaggregationMode.DECODE:
|
||||
# Account for pre-alloc headroom whenever the pool has it. The decode
|
||||
# pool always does; with runtime P<->D role switching a prefill instance
|
||||
# may also hold a decode-flavored (pre-alloc) pool, so key off the pool
|
||||
# itself rather than the current role.
|
||||
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:
|
||||
|
||||
@@ -9,6 +9,7 @@ from typing import TYPE_CHECKING, Any, Dict, List, Optional, Tuple
|
||||
|
||||
import fastapi
|
||||
|
||||
from sglang.srt.disaggregation.utils import DisaggregationMode
|
||||
from sglang.srt.managers.communicator import FanOutCommunicator
|
||||
from sglang.srt.managers.io_struct import (
|
||||
AddExternalCorpusReqInput,
|
||||
@@ -48,6 +49,8 @@ from sglang.srt.managers.io_struct import (
|
||||
LoadLoRAAdapterReqOutput,
|
||||
LoRAUpdateOutput,
|
||||
OpenSessionReqInput,
|
||||
PdRoleSwitchReqInput,
|
||||
PdRoleSwitchReqOutput,
|
||||
ProfileReq,
|
||||
ProfileReqOutput,
|
||||
ProfileReqType,
|
||||
@@ -77,6 +80,7 @@ from sglang.srt.managers.io_struct import (
|
||||
)
|
||||
from sglang.srt.managers.load_snapshot import LoadSnapshot
|
||||
from sglang.srt.runtime_context import (
|
||||
get_disagg,
|
||||
get_lora,
|
||||
get_parallel,
|
||||
get_serving,
|
||||
@@ -115,6 +119,7 @@ _COMMUNICATOR_SPECS = [
|
||||
("resume_memory_occupation", ResumeMemoryOccupationReqOutput),
|
||||
("check_weights", CheckWeightsReqOutput),
|
||||
("slow_down", SlowDownReqOutput),
|
||||
("pd_role_switch", PdRoleSwitchReqOutput),
|
||||
("flush_cache", FlushCacheReqOutput),
|
||||
("add_external_corpus", AddExternalCorpusReqOutput),
|
||||
("remove_external_corpus", RemoveExternalCorpusReqOutput),
|
||||
@@ -840,6 +845,42 @@ class TokenizerControlMixin:
|
||||
self.auto_create_handle_loop()
|
||||
await self.slow_down_communicator(obj)
|
||||
|
||||
async def pd_role_switch(
|
||||
self: TokenizerManager,
|
||||
obj: PdRoleSwitchReqInput,
|
||||
request: Optional[fastapi.Request] = None,
|
||||
) -> PdRoleSwitchReqOutput:
|
||||
self.auto_create_handle_loop()
|
||||
if not self.server_args.enable_pd_role_switch:
|
||||
return PdRoleSwitchReqOutput(
|
||||
success=False,
|
||||
message="--enable-pd-role-switch is not set on this server",
|
||||
old_role=get_disagg().disaggregation_mode,
|
||||
new_role=obj.new_role,
|
||||
safe_to_restore=True,
|
||||
)
|
||||
results = await self.pd_role_switch_communicator(obj)
|
||||
all_success = all(r.success for r in results)
|
||||
safe_to_restore = bool(results) and all(r.safe_to_restore for r in results)
|
||||
if all_success:
|
||||
# Keep the tokenizer-manager's view of the role in sync so future
|
||||
# control ops and bootstrap routing behave consistently.
|
||||
self.record_config_updates(
|
||||
"tokenizer.pd_role_switch", disaggregation_mode=obj.new_role
|
||||
)
|
||||
self.disaggregation_mode = DisaggregationMode(obj.new_role)
|
||||
msg = "ok"
|
||||
else:
|
||||
# Surface only the failing workers' messages.
|
||||
msg = "; ".join(r.message for r in results if not r.success)
|
||||
return PdRoleSwitchReqOutput(
|
||||
success=all_success,
|
||||
message=msg,
|
||||
old_role=results[0].old_role if results else "",
|
||||
new_role=obj.new_role,
|
||||
safe_to_restore=safe_to_restore,
|
||||
)
|
||||
|
||||
async def get_internal_state(self: TokenizerManager) -> List[Dict[Any, Any]]:
|
||||
self.auto_create_handle_loop()
|
||||
req = GetInternalStateReq()
|
||||
|
||||
@@ -471,6 +471,18 @@ class TpModelWorker(BaseTpWorker):
|
||||
for mr in self.model_runner_list[1:]:
|
||||
mr.init_cuda_graphs(capture_decode_cuda_graph=capture_decode_cuda_graph)
|
||||
|
||||
def ensure_decode_cuda_graphs(self, capture_bs: Optional[List[int]] = None):
|
||||
"""Idempotently capture decode cuda graphs for all model runners (used
|
||||
for the on-flip capture during a runtime PD role switch)."""
|
||||
self.model_runner.ensure_decode_cuda_graphs(capture_bs)
|
||||
for mr in self.model_runner_list[1:]:
|
||||
mr.ensure_decode_cuda_graphs(capture_bs)
|
||||
|
||||
def get_decode_cuda_graph_bs(self) -> List[int]:
|
||||
"""Decode bs captured as CUDA graphs (empty on a not-yet-flipped prefill,
|
||||
or on a runner that never allocates a KV pool, e.g. the MLX stub)."""
|
||||
return list(getattr(self.model_runner, "decode_cuda_graph_capture_bs", []))
|
||||
|
||||
def start_startup_weight_load(self) -> None:
|
||||
"""Start deferred checkpoint prefetching for all model runners."""
|
||||
self.model_runner.start_startup_weight_load()
|
||||
|
||||
@@ -1009,32 +1009,34 @@ class KVCacheConfigurator:
|
||||
def _build_req_to_token_pool(self, *, max_num_reqs: int) -> ReqToTokenPool:
|
||||
extra_max_context_len = get_req_to_token_extra_context_len()
|
||||
|
||||
if get_disagg().disaggregation_mode == "decode":
|
||||
# Extra slots for pre-allocated requests
|
||||
pre_alloc_size = get_disagg().disaggregation_decode_extra_slots
|
||||
disagg = get_disagg()
|
||||
if disagg.disaggregation_mode == "decode" or disagg.enable_pd_role_switch:
|
||||
# A flip-capable prefill needs the decode pool shape, and the extra-slot
|
||||
# default is only computed for a decode launch.
|
||||
pre_alloc_size = disagg.disaggregation_decode_extra_slots
|
||||
if disagg.enable_pd_role_switch:
|
||||
pre_alloc_size = pre_alloc_size or 0
|
||||
if self.mambaish_config:
|
||||
req_to_token_pool = self._build_hybrid_mamba_decode_req_pool(
|
||||
return self._build_hybrid_mamba_decode_req_pool(
|
||||
max_num_reqs=max_num_reqs,
|
||||
extra_max_context_len=extra_max_context_len,
|
||||
pre_alloc_size=pre_alloc_size,
|
||||
)
|
||||
else:
|
||||
req_to_token_pool = self._build_decode_req_pool(
|
||||
return self._build_decode_req_pool(
|
||||
max_num_reqs=max_num_reqs,
|
||||
extra_max_context_len=extra_max_context_len,
|
||||
pre_alloc_size=pre_alloc_size,
|
||||
)
|
||||
elif self.mambaish_config:
|
||||
req_to_token_pool = self._build_hybrid_req_pool(
|
||||
|
||||
if self.mambaish_config:
|
||||
return self._build_hybrid_req_pool(
|
||||
max_num_reqs=max_num_reqs,
|
||||
extra_max_context_len=extra_max_context_len,
|
||||
)
|
||||
else:
|
||||
req_to_token_pool = self._build_default_req_pool(
|
||||
return self._build_default_req_pool(
|
||||
max_num_reqs=max_num_reqs,
|
||||
extra_max_context_len=extra_max_context_len,
|
||||
)
|
||||
return req_to_token_pool
|
||||
|
||||
def _get_mamba_layer_ids_for_req_pool(self) -> list:
|
||||
mamba_layer_ids = [
|
||||
|
||||
@@ -273,6 +273,10 @@ class ReqToTokenPool:
|
||||
"""A memory pool that maps a request to its token locations."""
|
||||
|
||||
enable_mamba_extra_buffer_lazy: bool = False
|
||||
# Extra pre-allocation headroom (reserved for in-transfer decode requests).
|
||||
# 0 for a plain pool; the decode-flavored pool (DecodeReqToTokenPool) sets a
|
||||
# positive value. Declared here so callers can read it without getattr.
|
||||
pre_alloc_size: int = 0
|
||||
# Class default: some decode pools borrow another __init__ (see
|
||||
# DecodeReqToTokenPool) but inherit alloc_rows.
|
||||
_on_alloc_rows: Optional[Callable[[List[int]], None]] = None
|
||||
|
||||
@@ -92,6 +92,7 @@ from sglang.srt.mem_cache.kv_cache_configurator import (
|
||||
from sglang.srt.mem_cache.kv_index_translator import KVIndexTranslator
|
||||
from sglang.srt.mem_cache.memory_pool import HybridReqToTokenPool, ReqToTokenPool
|
||||
from sglang.srt.model_executor.cuda_graph_config import (
|
||||
Backend,
|
||||
cuda_graph_fully_disabled,
|
||||
)
|
||||
from sglang.srt.model_executor.forward_batch_info import (
|
||||
@@ -107,6 +108,7 @@ from sglang.srt.model_executor.graph_memory_usage import (
|
||||
replace_graph_memory_usage,
|
||||
replace_graph_time_usage,
|
||||
)
|
||||
from sglang.srt.model_executor.graph_shared_output import GraphSharedOutput
|
||||
from sglang.srt.model_executor.model_runner_components import misc_utils
|
||||
from sglang.srt.model_executor.model_runner_components.attention_backend_setup import (
|
||||
build_attention_backends,
|
||||
@@ -936,6 +938,11 @@ class ModelRunner:
|
||||
self.init_indexer_capturer()
|
||||
|
||||
self.graph_shared_output = None
|
||||
# Set once real decode CUDA graphs are captured (makes on-flip role-switch
|
||||
# capture idempotent).
|
||||
self.decode_cuda_graph_captured = False
|
||||
# Captured decode bs; exposed via /get_server_info for role-switch queries.
|
||||
self.decode_cuda_graph_capture_bs: list[int] = []
|
||||
|
||||
def maybe_init_hisparse_coordinator(self):
|
||||
if not self.enable_hisparse:
|
||||
@@ -1492,6 +1499,51 @@ class ModelRunner:
|
||||
capture.time_usage,
|
||||
phases=("decode", "target_verify", "draft_decode"),
|
||||
)
|
||||
# Bookkeeping for the PD role switch: mark the graphs as captured (makes
|
||||
# the on-flip capture idempotent) and record the captured bs so it can be
|
||||
# queried via /get_server_info.
|
||||
self.decode_cuda_graph_captured = self.decode_cuda_graph_runner is not None
|
||||
self.decode_cuda_graph_capture_bs = list(
|
||||
getattr(self.decode_cuda_graph_runner, "capture_bs", []) or []
|
||||
)
|
||||
|
||||
def ensure_decode_cuda_graphs(self, capture_bs: Optional[list[int]] = None):
|
||||
"""Idempotently capture decode CUDA graphs after startup.
|
||||
|
||||
Used by the PD role switch: an instance launched as prefill runs fully
|
||||
eager (decode CUDA graph disabled). On the first flip to decode we
|
||||
enable the decode CUDA graph and capture it here, so the flipped
|
||||
instance replays decode graphs instead of running eager.
|
||||
"""
|
||||
if self.decode_cuda_graph_captured:
|
||||
logger.info("Decode CUDA graphs already captured; skipping re-capture.")
|
||||
return
|
||||
|
||||
cfg = get_exec().graph.cuda_graph_config
|
||||
was_disabled = cfg is not None and cfg.decode.backend == Backend.DISABLED
|
||||
if was_disabled:
|
||||
# Prefill was launched with the decode CUDA graph disabled; enable it
|
||||
# for the decode role.
|
||||
logger.info(
|
||||
"Enabling decode CUDA graph on role switch (was disabled at startup)."
|
||||
)
|
||||
cfg.decode.backend = Backend.FULL
|
||||
get_context().override(
|
||||
"model_runner.ensure_decode_cuda_graphs", disable_cuda_graph=False
|
||||
)
|
||||
|
||||
if capture_bs:
|
||||
# Capture-to-fit: only the requested (router-sized) batch sizes.
|
||||
filtered_bs = sorted({int(b) for b in capture_bs if int(b) > 0})
|
||||
if filtered_bs:
|
||||
cfg.decode.bs = filtered_bs
|
||||
|
||||
if was_disabled:
|
||||
# graph_shared_output is skipped at startup when decode is disabled,
|
||||
# so build it now (before the decode runner reads its logits buffer).
|
||||
self.graph_shared_output = GraphSharedOutput.create_for_model_runner(self)
|
||||
|
||||
self.init_decode_cuda_graph()
|
||||
|
||||
def init_prefill_cuda_graph(self, force_for_draft_worker: bool = False):
|
||||
self.prefill_cuda_graph_runner = None
|
||||
|
||||
@@ -1124,8 +1124,11 @@ class SchedulerMetricsCollector(_StatLoggerDIMixin):
|
||||
)
|
||||
collector: Optional[SchedulerMetricsCollector] = None
|
||||
if enable_metrics:
|
||||
engine_type = DisaggregationMode.to_engine_type(
|
||||
get_disagg().disaggregation_mode
|
||||
# Keep one metric series across role flips.
|
||||
engine_type = (
|
||||
"dynamic"
|
||||
if get_disagg().enable_pd_role_switch
|
||||
else DisaggregationMode.to_engine_type(get_disagg().disaggregation_mode)
|
||||
)
|
||||
labels = {
|
||||
"model_name": get_serving().served_model_name,
|
||||
|
||||
@@ -94,6 +94,7 @@ async fn model_info(State(state): State<Arc<AppState>>) -> Response {
|
||||
// selected parser into `server_args` before the scheduler forks.
|
||||
"reasoning_parser": sa.reasoning_parser,
|
||||
"tool_call_parser": sa.tool_call_parser,
|
||||
"disaggregation_mode": sa.disaggregation_mode,
|
||||
});
|
||||
(
|
||||
StatusCode::OK,
|
||||
|
||||
@@ -317,7 +317,9 @@ impl<'py> pyo3::FromPyObject<'_, 'py> for PreferredSamplingParams {
|
||||
from_py_object,
|
||||
module = "sglang.srt.rust_extensions._server"
|
||||
)]
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
// Lowercase to match the values Python reports for the same field.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum DisaggregationMode {
|
||||
/// Unified prefill + decode.
|
||||
Null,
|
||||
@@ -619,6 +621,14 @@ mod tests {
|
||||
assert_eq!(ServerArgs::default().bind(), "127.0.0.1:30000");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn disaggregation_mode_wire_values_match_python() {
|
||||
let json = |m| serde_json::to_string(&m).unwrap();
|
||||
assert_eq!(json(DisaggregationMode::Null), "\"null\"");
|
||||
assert_eq!(json(DisaggregationMode::Prefill), "\"prefill\"");
|
||||
assert_eq!(json(DisaggregationMode::Decode), "\"decode\"");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pd_role_derivations() {
|
||||
let prefill = ServerArgs {
|
||||
|
||||
@@ -111,6 +111,42 @@ class MiniLoadBalancer:
|
||||
self.decode_urls[didx],
|
||||
)
|
||||
|
||||
def current_role_and_port(self, worker_url):
|
||||
"""Return (role, bootstrap_port) of a registered server, or (None, None)
|
||||
if it is not in either routing list."""
|
||||
if worker_url in self.prefill_urls:
|
||||
return (
|
||||
"prefill",
|
||||
self.prefill_bootstrap_ports[self.prefill_urls.index(worker_url)],
|
||||
)
|
||||
if worker_url in self.decode_urls:
|
||||
return "decode", None
|
||||
return None, None
|
||||
|
||||
def remove_worker(self, worker_url):
|
||||
"""Drop a server from both routing lists so no new requests are sent to
|
||||
it (used to quiesce it before a role switch)."""
|
||||
if worker_url in self.decode_urls:
|
||||
self.decode_urls.remove(worker_url)
|
||||
if worker_url in self.prefill_urls:
|
||||
idx = self.prefill_urls.index(worker_url)
|
||||
self.prefill_urls.pop(idx)
|
||||
self.prefill_bootstrap_ports.pop(idx)
|
||||
|
||||
def add_worker(self, worker_url, role, bootstrap_port=None):
|
||||
"""Register a server under a role in the routing lists."""
|
||||
if role == "prefill":
|
||||
self.prefill_urls.append(worker_url)
|
||||
self.prefill_bootstrap_ports.append(bootstrap_port or 8998)
|
||||
elif role == "decode":
|
||||
self.decode_urls.append(worker_url)
|
||||
|
||||
def apply_role_switch(self, worker_url, new_role, bootstrap_port=None):
|
||||
"""Move a server between the prefill and decode routing lists after its
|
||||
role has been switched on the backend. Idempotent."""
|
||||
self.remove_worker(worker_url)
|
||||
self.add_worker(worker_url, new_role, bootstrap_port)
|
||||
|
||||
async def generate(
|
||||
self, modified_request, prefill_server, decode_server, endpoint
|
||||
) -> ORJSONResponse:
|
||||
@@ -255,6 +291,69 @@ async def health_generate():
|
||||
return Response(status_code=200)
|
||||
|
||||
|
||||
async def _post_role_switch(worker_url, body):
|
||||
"""POST the role switch to a backend server; return (status, json)."""
|
||||
try:
|
||||
async with aiohttp.ClientSession(
|
||||
timeout=aiohttp.ClientTimeout(total=lb.timeout)
|
||||
) as session:
|
||||
async with session.post(f"{worker_url}/pd_role_switch", json=body) as resp:
|
||||
return resp.status, await resp.json()
|
||||
except Exception as e: # transport error -> report as a failure
|
||||
return 502, {"success": False, "message": str(e)}
|
||||
|
||||
|
||||
@app.post("/pd_role_switch")
|
||||
async def pd_role_switch(request_data: dict):
|
||||
"""Switch a running server's PD role (prefill<->decode) at runtime and
|
||||
update the LB's routing lists. Body: {"worker_url", "new_role":
|
||||
"prefill"|"decode", "bootstrap_port"?, "decode_cuda_graph_bs"?,
|
||||
"decode_cuda_graph_memory_gb"?, "drain"?, "drain_timeout_secs"?}.
|
||||
|
||||
The backend rejects a switch unless the instance is idle. To make this
|
||||
safe while serving, by default the LB first removes the server from its
|
||||
routing lists (so no new requests arrive), then retries the switch while
|
||||
the server drains its in-flight requests, and only then registers it
|
||||
under the new role. A failed server is restored only when the backend
|
||||
confirms that no role state changed."""
|
||||
worker_url = request_data.get("worker_url")
|
||||
new_role = request_data.get("new_role")
|
||||
if worker_url is None:
|
||||
raise HTTPException(status_code=400, detail="worker_url is required")
|
||||
if new_role not in ("prefill", "decode"):
|
||||
raise HTTPException(status_code=400, detail=f"invalid new_role={new_role!r}")
|
||||
|
||||
drain = request_data.get("drain", True)
|
||||
drain_timeout = request_data.get("drain_timeout_secs", 300)
|
||||
old_role, old_port = lb.current_role_and_port(worker_url)
|
||||
|
||||
body = {"new_role": new_role}
|
||||
for field in ("decode_cuda_graph_bs", "decode_cuda_graph_memory_gb"):
|
||||
if request_data.get(field) is not None:
|
||||
body[field] = request_data[field]
|
||||
|
||||
# Stop routing new requests to this server so it can drain to idle.
|
||||
if drain and old_role is not None:
|
||||
lb.remove_worker(worker_url)
|
||||
|
||||
deadline = asyncio.get_event_loop().time() + drain_timeout
|
||||
while True:
|
||||
status, result = await _post_role_switch(worker_url, body)
|
||||
if status == 200 and result.get("success", False):
|
||||
break
|
||||
# The backend rejects while not idle; keep retrying as it drains.
|
||||
not_idle = "not idle" in (result.get("message", "") or "").lower()
|
||||
if drain and not_idle and asyncio.get_event_loop().time() < deadline:
|
||||
await asyncio.sleep(1.0)
|
||||
continue
|
||||
if drain and old_role is not None and result.get("safe_to_restore", False):
|
||||
lb.add_worker(worker_url, old_role, old_port)
|
||||
return ORJSONResponse(content=result, status_code=status)
|
||||
|
||||
lb.apply_role_switch(worker_url, new_role, request_data.get("bootstrap_port"))
|
||||
return ORJSONResponse(content=result, status_code=200)
|
||||
|
||||
|
||||
@app.post("/flush_cache")
|
||||
async def flush_cache(timeout: Optional[float] = None):
|
||||
# `timeout` must reach the workers. The scheduler treats a missing or
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import asyncio
|
||||
|
||||
import pytest
|
||||
from sglang_router import mini_lb
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("result", "restored"),
|
||||
[
|
||||
(
|
||||
{
|
||||
"success": False,
|
||||
"message": "instance is not idle",
|
||||
"safe_to_restore": True,
|
||||
},
|
||||
True,
|
||||
),
|
||||
(
|
||||
{
|
||||
"success": False,
|
||||
"message": "instance unhealthy, restart required",
|
||||
"safe_to_restore": False,
|
||||
},
|
||||
False,
|
||||
),
|
||||
({"success": False, "message": "connection lost"}, False),
|
||||
],
|
||||
)
|
||||
def test_failed_role_switch_restores_only_healthy_worker(monkeypatch, result, restored):
|
||||
worker_url = "http://prefill:8000"
|
||||
load_balancer = mini_lb.MiniLoadBalancer.__new__(mini_lb.MiniLoadBalancer)
|
||||
load_balancer.timeout = 1
|
||||
load_balancer.prefill_urls = [worker_url]
|
||||
load_balancer.prefill_bootstrap_ports = [8998]
|
||||
load_balancer.decode_urls = ["http://decode:8000"]
|
||||
monkeypatch.setattr(mini_lb, "lb", load_balancer)
|
||||
|
||||
async def post_role_switch(*_args, **_kwargs):
|
||||
return 400, result
|
||||
|
||||
monkeypatch.setattr(mini_lb, "_post_role_switch", post_role_switch)
|
||||
response = asyncio.run(
|
||||
mini_lb.pd_role_switch(
|
||||
{
|
||||
"worker_url": worker_url,
|
||||
"new_role": "decode",
|
||||
"drain_timeout_secs": 0,
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert (worker_url in load_balancer.prefill_urls) is restored
|
||||
|
||||
|
||||
def test_role_switch_forwards_decode_graph_requirements(monkeypatch):
|
||||
worker_url = "http://prefill:8000"
|
||||
load_balancer = mini_lb.MiniLoadBalancer.__new__(mini_lb.MiniLoadBalancer)
|
||||
load_balancer.timeout = 1
|
||||
load_balancer.prefill_urls = [worker_url]
|
||||
load_balancer.prefill_bootstrap_ports = [8998]
|
||||
load_balancer.decode_urls = ["http://decode:8000"]
|
||||
monkeypatch.setattr(mini_lb, "lb", load_balancer)
|
||||
sent_body = {}
|
||||
|
||||
async def post_role_switch(_worker_url, body):
|
||||
sent_body.update(body)
|
||||
return 200, {"success": True, "message": "ok"}
|
||||
|
||||
monkeypatch.setattr(mini_lb, "_post_role_switch", post_role_switch)
|
||||
response = asyncio.run(
|
||||
mini_lb.pd_role_switch(
|
||||
{
|
||||
"worker_url": worker_url,
|
||||
"new_role": "decode",
|
||||
"decode_cuda_graph_bs": [1, 2, 4],
|
||||
"decode_cuda_graph_memory_gb": 1.25,
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert sent_body["decode_cuda_graph_bs"] == [1, 2, 4]
|
||||
assert sent_body["decode_cuda_graph_memory_gb"] == 1.25
|
||||
@@ -0,0 +1,653 @@
|
||||
import argparse
|
||||
import concurrent.futures
|
||||
import unittest
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from sglang.srt import runtime_context as rc # noqa: E402
|
||||
from sglang.srt.disaggregation import role_switch # noqa: E402
|
||||
from sglang.srt.disaggregation.utils import DisaggregationMode # noqa: E402
|
||||
from sglang.srt.managers.io_struct import ( # noqa: E402
|
||||
PdRoleSwitchReqInput,
|
||||
PdRoleSwitchReqOutput,
|
||||
)
|
||||
from sglang.srt.managers.scheduler import Scheduler # noqa: E402
|
||||
from sglang.srt.server_args import ServerArgs # noqa: E402
|
||||
from sglang.test.ci.ci_register import register_cpu_ci
|
||||
|
||||
register_cpu_ci(est_time=5, suite="base-a-test-cpu")
|
||||
|
||||
|
||||
class TestPdRoleSwitchServerArg(unittest.TestCase):
|
||||
def test_cli_flag_parses(self):
|
||||
parser = argparse.ArgumentParser()
|
||||
ServerArgs.add_cli_args(parser)
|
||||
|
||||
off = parser.parse_args(["--model-path", "dummy"])
|
||||
self.assertFalse(off.enable_pd_role_switch)
|
||||
|
||||
on = parser.parse_args(["--model-path", "dummy", "--enable-pd-role-switch"])
|
||||
self.assertTrue(on.enable_pd_role_switch)
|
||||
|
||||
|
||||
class TestHandlePdRoleSwitch(unittest.TestCase):
|
||||
"""Cover the control-plane contract of Scheduler.handle_pd_role_switch.
|
||||
|
||||
Only the role-flip *decision* logic is exercised here (no GPU): the heavy
|
||||
teardown/rebuild is mocked, so this asserts the guard branches and the
|
||||
orchestration order without standing up a model.
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
rc.reset_context()
|
||||
|
||||
def tearDown(self):
|
||||
rc.reset_context()
|
||||
|
||||
def _scheduler(self, mode, *, enable=True, idle=True):
|
||||
s = Scheduler.__new__(Scheduler)
|
||||
s.disaggregation_mode = mode
|
||||
sa = ServerArgs(
|
||||
model_path="dummy",
|
||||
disaggregation_mode=mode.value,
|
||||
enable_pd_role_switch=enable,
|
||||
)
|
||||
rc.get_context().set_server_args(sa)
|
||||
s.server_args = sa
|
||||
s.is_fully_idle = MagicMock(return_value=idle)
|
||||
teardown_patcher = patch.object(role_switch, "teardown_disaggregation")
|
||||
s.teardown_disaggregation = teardown_patcher.start()
|
||||
self.addCleanup(teardown_patcher.stop)
|
||||
s.init_disaggregation = MagicMock()
|
||||
s._sync_disaggregation_mode_to_subcomponents = MagicMock()
|
||||
s._event_loop_should_restart = False
|
||||
s._pd_role_switch_in_progress = False
|
||||
s._pd_role_switch_unhealthy = False
|
||||
s.tp_worker = MagicMock()
|
||||
return s
|
||||
|
||||
def test_rejected_when_flag_disabled(self):
|
||||
s = self._scheduler(DisaggregationMode.PREFILL, enable=False)
|
||||
out = Scheduler.handle_pd_role_switch(
|
||||
s, PdRoleSwitchReqInput(new_role="decode")
|
||||
)
|
||||
self.assertIsInstance(out, PdRoleSwitchReqOutput)
|
||||
self.assertFalse(out.success)
|
||||
self.assertTrue(out.safe_to_restore)
|
||||
self.assertIn("enable-pd-role-switch", out.message)
|
||||
s.teardown_disaggregation.assert_not_called()
|
||||
|
||||
def test_rejected_on_invalid_role(self):
|
||||
s = self._scheduler(DisaggregationMode.PREFILL)
|
||||
out = Scheduler.handle_pd_role_switch(s, PdRoleSwitchReqInput(new_role="both"))
|
||||
self.assertFalse(out.success)
|
||||
self.assertTrue(out.safe_to_restore)
|
||||
self.assertIn("invalid new_role", out.message)
|
||||
s.teardown_disaggregation.assert_not_called()
|
||||
|
||||
def test_rejected_when_not_in_pd_mode(self):
|
||||
s = self._scheduler(DisaggregationMode.NULL)
|
||||
out = Scheduler.handle_pd_role_switch(
|
||||
s, PdRoleSwitchReqInput(new_role="decode")
|
||||
)
|
||||
self.assertFalse(out.success)
|
||||
self.assertTrue(out.safe_to_restore)
|
||||
self.assertIn("not running in PD", out.message)
|
||||
s.teardown_disaggregation.assert_not_called()
|
||||
|
||||
def test_same_role_is_noop(self):
|
||||
s = self._scheduler(DisaggregationMode.PREFILL)
|
||||
out = Scheduler.handle_pd_role_switch(
|
||||
s, PdRoleSwitchReqInput(new_role="prefill")
|
||||
)
|
||||
self.assertTrue(out.success)
|
||||
self.assertEqual(out.message, "already in target role")
|
||||
s.teardown_disaggregation.assert_not_called()
|
||||
s.init_disaggregation.assert_not_called()
|
||||
self.assertFalse(s._event_loop_should_restart)
|
||||
|
||||
def test_rejected_when_not_idle(self):
|
||||
s = self._scheduler(DisaggregationMode.PREFILL, idle=False)
|
||||
out = Scheduler.handle_pd_role_switch(
|
||||
s, PdRoleSwitchReqInput(new_role="decode")
|
||||
)
|
||||
self.assertFalse(out.success)
|
||||
self.assertTrue(out.safe_to_restore)
|
||||
self.assertIn("not idle", out.message)
|
||||
s.teardown_disaggregation.assert_not_called()
|
||||
|
||||
def test_rejected_when_decode_graph_headroom_is_missing(self):
|
||||
s = self._scheduler(DisaggregationMode.PREFILL)
|
||||
s.tp_worker.get_decode_cuda_graph_bs.return_value = []
|
||||
out = Scheduler.handle_pd_role_switch(
|
||||
s, PdRoleSwitchReqInput(new_role="decode")
|
||||
)
|
||||
|
||||
self.assertFalse(out.success)
|
||||
self.assertTrue(out.safe_to_restore)
|
||||
self.assertIn("decode_cuda_graph_memory_gb is required", out.message)
|
||||
s.teardown_disaggregation.assert_not_called()
|
||||
|
||||
def test_rejected_when_decode_graph_headroom_is_insufficient(self):
|
||||
s = self._scheduler(DisaggregationMode.PREFILL)
|
||||
s.device = "cuda"
|
||||
s.ps = SimpleNamespace(gpu_id=0)
|
||||
s.tp_worker.get_decode_cuda_graph_bs.return_value = []
|
||||
with patch.object(role_switch, "get_available_gpu_memory", return_value=0.5):
|
||||
out = Scheduler.handle_pd_role_switch(
|
||||
s,
|
||||
PdRoleSwitchReqInput(
|
||||
new_role="decode",
|
||||
decode_cuda_graph_memory_gb=1.0,
|
||||
),
|
||||
)
|
||||
|
||||
self.assertFalse(out.success)
|
||||
self.assertTrue(out.safe_to_restore)
|
||||
self.assertIn("insufficient decode CUDA graph headroom", out.message)
|
||||
s.teardown_disaggregation.assert_not_called()
|
||||
self.assertEqual(rc.get_disagg().disaggregation_mode, "prefill")
|
||||
|
||||
def test_decode_graph_headroom_allows_flip(self):
|
||||
s = self._scheduler(DisaggregationMode.PREFILL)
|
||||
s.device = "cuda"
|
||||
s.ps = SimpleNamespace(gpu_id=0)
|
||||
s.tp_worker.get_decode_cuda_graph_bs.return_value = []
|
||||
with patch.object(role_switch, "get_available_gpu_memory", return_value=1.0):
|
||||
out = Scheduler.handle_pd_role_switch(
|
||||
s,
|
||||
PdRoleSwitchReqInput(
|
||||
new_role="decode",
|
||||
decode_cuda_graph_memory_gb=1.0,
|
||||
),
|
||||
)
|
||||
|
||||
self.assertTrue(out.success)
|
||||
s.teardown_disaggregation.assert_called_once_with(s)
|
||||
|
||||
def test_successful_flip_orchestration(self):
|
||||
s = self._scheduler(DisaggregationMode.PREFILL)
|
||||
out = Scheduler.handle_pd_role_switch(
|
||||
s, PdRoleSwitchReqInput(new_role="decode")
|
||||
)
|
||||
|
||||
self.assertTrue(out.success)
|
||||
self.assertEqual(out.old_role, "prefill")
|
||||
self.assertEqual(out.new_role, "decode")
|
||||
# Orchestration: drain -> teardown -> flip config bag -> rebuild -> signal.
|
||||
s.teardown_disaggregation.assert_called_once_with(s)
|
||||
self.assertEqual(rc.get_disagg().disaggregation_mode, "decode")
|
||||
# The pristine startup record is never mutated.
|
||||
self.assertEqual(s.server_args.disaggregation_mode, "prefill")
|
||||
s.init_disaggregation.assert_called_once()
|
||||
s._sync_disaggregation_mode_to_subcomponents.assert_called_once()
|
||||
self.assertTrue(s._event_loop_should_restart)
|
||||
# Flip to decode ensures decode CUDA graphs exist (idempotent capture).
|
||||
s.tp_worker.ensure_decode_cuda_graphs.assert_called_once()
|
||||
# The in-progress guard is released after a successful flip.
|
||||
self.assertFalse(s._pd_role_switch_in_progress)
|
||||
|
||||
def test_flip_to_prefill_skips_decode_graph_capture(self):
|
||||
s = self._scheduler(DisaggregationMode.DECODE)
|
||||
out = Scheduler.handle_pd_role_switch(
|
||||
s, PdRoleSwitchReqInput(new_role="prefill")
|
||||
)
|
||||
self.assertTrue(out.success)
|
||||
self.assertEqual(out.new_role, "prefill")
|
||||
s.init_disaggregation.assert_called_once()
|
||||
# Flipping to prefill must not capture decode graphs.
|
||||
s.tp_worker.ensure_decode_cuda_graphs.assert_not_called()
|
||||
self.assertTrue(s._event_loop_should_restart)
|
||||
|
||||
def test_rejected_when_switch_in_progress(self):
|
||||
s = self._scheduler(DisaggregationMode.PREFILL)
|
||||
s._pd_role_switch_in_progress = True
|
||||
out = Scheduler.handle_pd_role_switch(
|
||||
s, PdRoleSwitchReqInput(new_role="decode")
|
||||
)
|
||||
self.assertFalse(out.success)
|
||||
self.assertFalse(out.safe_to_restore)
|
||||
self.assertIn("in progress", out.message)
|
||||
s.teardown_disaggregation.assert_not_called()
|
||||
|
||||
def test_rejected_when_unhealthy(self):
|
||||
s = self._scheduler(DisaggregationMode.PREFILL)
|
||||
s._pd_role_switch_unhealthy = True
|
||||
out = Scheduler.handle_pd_role_switch(
|
||||
s, PdRoleSwitchReqInput(new_role="decode")
|
||||
)
|
||||
self.assertFalse(out.success)
|
||||
self.assertFalse(out.safe_to_restore)
|
||||
self.assertIn("unhealthy", out.message)
|
||||
s.teardown_disaggregation.assert_not_called()
|
||||
|
||||
def test_rebuild_failure_marks_unhealthy_and_notifies(self):
|
||||
s = self._scheduler(DisaggregationMode.PREFILL)
|
||||
# Rebuild of the new role fails after the old role was torn down.
|
||||
s.init_disaggregation = MagicMock(side_effect=RuntimeError("boom"))
|
||||
|
||||
out = Scheduler.handle_pd_role_switch(
|
||||
s, PdRoleSwitchReqInput(new_role="decode")
|
||||
)
|
||||
|
||||
# Fail loud (notify), mark unhealthy, no in-place rollback attempt.
|
||||
self.assertFalse(out.success)
|
||||
self.assertFalse(out.safe_to_restore)
|
||||
self.assertIn("unhealthy", out.message)
|
||||
self.assertIn("restart", out.message)
|
||||
self.assertTrue(s._pd_role_switch_unhealthy)
|
||||
self.assertFalse(s._event_loop_should_restart)
|
||||
self.assertFalse(s._pd_role_switch_in_progress)
|
||||
# Teardown + rebuild attempted exactly once (no rollback).
|
||||
self.assertEqual(s.teardown_disaggregation.call_count, 1)
|
||||
self.assertEqual(s.init_disaggregation.call_count, 1)
|
||||
s._sync_disaggregation_mode_to_subcomponents.assert_not_called()
|
||||
# A subsequent switch is rejected because the instance is unhealthy.
|
||||
out2 = Scheduler.handle_pd_role_switch(
|
||||
s, PdRoleSwitchReqInput(new_role="prefill")
|
||||
)
|
||||
self.assertFalse(out2.success)
|
||||
self.assertIn("unhealthy", out2.message)
|
||||
|
||||
def test_teardown_failure_marks_unhealthy(self):
|
||||
"""Teardown, the role flip and rebuild are one atomic step: a failure
|
||||
during teardown (not only rebuild) must also mark the instance unhealthy
|
||||
and must not proceed to rebuild."""
|
||||
s = self._scheduler(DisaggregationMode.PREFILL)
|
||||
s.teardown_disaggregation.side_effect = RuntimeError("boom")
|
||||
|
||||
out = Scheduler.handle_pd_role_switch(
|
||||
s, PdRoleSwitchReqInput(new_role="decode")
|
||||
)
|
||||
|
||||
self.assertFalse(out.success)
|
||||
self.assertFalse(out.safe_to_restore)
|
||||
self.assertIn("unhealthy", out.message)
|
||||
self.assertIn("restart", out.message)
|
||||
self.assertTrue(s._pd_role_switch_unhealthy)
|
||||
self.assertFalse(s._event_loop_should_restart)
|
||||
self.assertFalse(s._pd_role_switch_in_progress)
|
||||
# Teardown raised, so rebuild is never attempted.
|
||||
self.assertEqual(s.teardown_disaggregation.call_count, 1)
|
||||
s.init_disaggregation.assert_not_called()
|
||||
s._sync_disaggregation_mode_to_subcomponents.assert_not_called()
|
||||
|
||||
|
||||
class TestPdRoleSwitchReqSerialization(unittest.TestCase):
|
||||
"""Guard the wire contract of the /pd_role_switch req/resp structs.
|
||||
|
||||
These caught real breakages when upstream moved BaseReq to msgspec: the
|
||||
request must accept an optional decode_cuda_graph_bs body field, and the
|
||||
response must be encodable for the HTTP layer (msgspec_to_builtins).
|
||||
"""
|
||||
|
||||
def test_req_accepts_optional_decode_cuda_graph_bs(self):
|
||||
req = PdRoleSwitchReqInput(
|
||||
new_role="decode",
|
||||
decode_cuda_graph_bs=[1, 2, 4],
|
||||
decode_cuda_graph_memory_gb=1.25,
|
||||
)
|
||||
self.assertEqual(req.new_role, "decode")
|
||||
self.assertEqual(req.decode_cuda_graph_bs, [1, 2, 4])
|
||||
self.assertEqual(req.decode_cuda_graph_memory_gb, 1.25)
|
||||
# Field is optional and defaults to None.
|
||||
default_req = PdRoleSwitchReqInput(new_role="prefill")
|
||||
self.assertIsNone(default_req.decode_cuda_graph_bs)
|
||||
self.assertIsNone(default_req.decode_cuda_graph_memory_gb)
|
||||
|
||||
def test_resp_is_json_encodable(self):
|
||||
from sglang.srt.utils.msgspec_utils import msgspec_to_builtins
|
||||
|
||||
out = PdRoleSwitchReqOutput(
|
||||
success=True, message="ok", old_role="prefill", new_role="decode"
|
||||
)
|
||||
d = msgspec_to_builtins(out)
|
||||
self.assertEqual(d["success"], True)
|
||||
self.assertEqual(d["old_role"], "prefill")
|
||||
self.assertEqual(d["new_role"], "decode")
|
||||
self.assertEqual(d["message"], "ok")
|
||||
self.assertEqual(d["safe_to_restore"], False)
|
||||
|
||||
|
||||
class TestPdRoleSwitchStartupValidation(unittest.TestCase):
|
||||
"""--enable-pd-role-switch only rebuilds the small role-specific disagg
|
||||
structures on a flip; the per-role buffers of DP attention / EP / MoE
|
||||
all-to-all / pipeline parallelism are sized at startup and not rebuilt, so
|
||||
a flip with those on would silently deadlock. The PD arg hook must reject
|
||||
the combination up-front instead of failing at flip time."""
|
||||
|
||||
def _sa(self, **kw):
|
||||
base = dict(
|
||||
disaggregation_transfer_backend="mori",
|
||||
disaggregation_mode="prefill",
|
||||
enable_pd_role_switch=True,
|
||||
enable_dp_attention=False,
|
||||
ep_size=1,
|
||||
moe_a2a_backend="none",
|
||||
pp_size=1,
|
||||
dp_size=1,
|
||||
dcp_size=1,
|
||||
speculative_algorithm=None,
|
||||
)
|
||||
base.update(kw)
|
||||
return SimpleNamespace(**base)
|
||||
|
||||
def _run(self, sa):
|
||||
from sglang.srt.arg_groups.pd_disaggregation_hook import (
|
||||
handle_pd_disaggregation,
|
||||
)
|
||||
|
||||
handle_pd_disaggregation(sa)
|
||||
|
||||
def test_pure_tp_role_switch_accepted(self):
|
||||
# No raise for the validated pure-TP configuration.
|
||||
self._run(self._sa())
|
||||
|
||||
def test_reject_dp_attention(self):
|
||||
with self.assertRaises(ValueError) as ctx:
|
||||
self._run(self._sa(enable_dp_attention=True))
|
||||
self.assertIn("DP attention", str(ctx.exception))
|
||||
|
||||
def test_reject_expert_parallelism(self):
|
||||
with self.assertRaises(ValueError) as ctx:
|
||||
self._run(self._sa(ep_size=8))
|
||||
self.assertIn("expert parallelism", str(ctx.exception))
|
||||
|
||||
def test_reject_moe_a2a(self):
|
||||
with self.assertRaises(ValueError) as ctx:
|
||||
self._run(self._sa(moe_a2a_backend="mori"))
|
||||
self.assertIn("MoE all-to-all", str(ctx.exception))
|
||||
|
||||
def test_reject_pipeline_parallelism(self):
|
||||
with self.assertRaises(ValueError) as ctx:
|
||||
self._run(self._sa(pp_size=2))
|
||||
self.assertIn("pipeline parallelism", str(ctx.exception))
|
||||
|
||||
def test_reject_data_parallelism(self):
|
||||
with self.assertRaises(ValueError) as ctx:
|
||||
self._run(self._sa(dp_size=2))
|
||||
self.assertIn("data parallelism", str(ctx.exception))
|
||||
|
||||
def test_reject_decode_context_parallelism(self):
|
||||
with self.assertRaises(ValueError) as ctx:
|
||||
self._run(self._sa(dcp_size=2))
|
||||
self.assertIn("decode context parallelism", str(ctx.exception))
|
||||
|
||||
def test_reject_speculative_decoding(self):
|
||||
with self.assertRaises(ValueError) as ctx:
|
||||
self._run(self._sa(speculative_algorithm="EAGLE"))
|
||||
self.assertIn("speculative decoding", str(ctx.exception))
|
||||
|
||||
def test_no_role_switch_is_unaffected(self):
|
||||
# The same unsupported feature is fine when role switch is off.
|
||||
self._run(self._sa(enable_pd_role_switch=False, moe_a2a_backend="mori"))
|
||||
|
||||
|
||||
# --- teardown: transfer-worker thread-leak fix + prefix-cache release (radix ON) ---
|
||||
import threading # noqa: E402
|
||||
import time # noqa: E402
|
||||
|
||||
import zmq # noqa: E402
|
||||
|
||||
try:
|
||||
from sglang.srt.disaggregation.common.utils import FastQueue # noqa: E402
|
||||
from sglang.srt.disaggregation.mori.conn import MoriKVManager # noqa: E402
|
||||
|
||||
_HAS_MORI = True
|
||||
except Exception: # pragma: no cover - environment dependent
|
||||
_HAS_MORI = False
|
||||
|
||||
try:
|
||||
from sglang.srt.disaggregation.common.utils import ( # noqa: E402,F811
|
||||
FastQueue as _FQ,
|
||||
)
|
||||
from sglang.srt.disaggregation.mooncake.conn import MooncakeKVManager # noqa: E402
|
||||
|
||||
_HAS_MOONCAKE = True
|
||||
except Exception: # pragma: no cover - environment dependent
|
||||
_HAS_MOONCAKE = False
|
||||
|
||||
try:
|
||||
from sglang.srt.disaggregation.role_switch import ( # noqa: E402
|
||||
_release_prefix_cache_for_role_switch,
|
||||
teardown_disaggregation,
|
||||
)
|
||||
|
||||
_HAS_ROLE_SWITCH = True
|
||||
except Exception: # pragma: no cover - environment dependent
|
||||
_HAS_ROLE_SWITCH = False
|
||||
|
||||
|
||||
@unittest.skipUnless(_HAS_MORI, "mori not importable in this environment")
|
||||
class TestMoriTeardownNoThreadLeak(unittest.TestCase):
|
||||
"""teardown() must stop+join the transfer workers it started, so a P->D->P
|
||||
flip loop does not leak _num_shards transfer threads per cycle."""
|
||||
|
||||
def test_teardown_joins_transfer_workers(self):
|
||||
m = MoriKVManager.__new__(MoriKVManager)
|
||||
m.disaggregation_mode = DisaggregationMode.PREFILL
|
||||
m._stopped = False
|
||||
m._worker_threads = []
|
||||
m._transfer_queues = [FastQueue() for _ in range(3)]
|
||||
m.server_socket = MagicMock()
|
||||
m._zmq_ctx = MagicMock()
|
||||
m.engine = MagicMock()
|
||||
m.kv_mem_descs = m.aux_mem_descs = m.state_mem_descs = []
|
||||
for q in m._transfer_queues:
|
||||
t = threading.Thread(target=m._transfer_worker, args=(q,), daemon=True)
|
||||
t.start()
|
||||
m._worker_threads.append(t)
|
||||
started = list(m._worker_threads)
|
||||
time.sleep(0.05) # let workers park in FastQueue.get()
|
||||
for t in started:
|
||||
self.assertTrue(t.is_alive())
|
||||
|
||||
MoriKVManager.teardown(m)
|
||||
|
||||
for t in started:
|
||||
self.assertFalse(t.is_alive(), "transfer worker survived teardown (leak)")
|
||||
self.assertEqual(m._worker_threads, [])
|
||||
self.assertEqual(m._transfer_queues, [])
|
||||
|
||||
|
||||
@unittest.skipUnless(_HAS_MOONCAKE, "mooncake not importable in this environment")
|
||||
class TestMooncakeTeardownNoThreadLeak(unittest.TestCase):
|
||||
"""teardown() must stop+join the transfer workers it started, so a P->D->P
|
||||
flip loop does not leak transfer threads per cycle."""
|
||||
|
||||
def test_teardown_joins_transfer_workers(self):
|
||||
m = MooncakeKVManager.__new__(MooncakeKVManager)
|
||||
m.disaggregation_mode = DisaggregationMode.PREFILL
|
||||
m._stopped = False
|
||||
m.enable_trace = False
|
||||
m._worker_threads = []
|
||||
m.transfer_queues = [_FQ() for _ in range(3)]
|
||||
m.executors = [concurrent.futures.ThreadPoolExecutor(1) for _ in range(3)]
|
||||
m.server_socket = MagicMock()
|
||||
m._zmq_ctx = MagicMock()
|
||||
m._socket_lock = threading.Lock()
|
||||
m._socket_cache = {}
|
||||
m._monitor_cache = {}
|
||||
m.engine = MagicMock()
|
||||
# Built from KVArgs' own annotations: teardown walks several ptr/len
|
||||
# pairs, and hardcoding them here goes stale every time one is added.
|
||||
from sglang.srt.disaggregation.base.conn import KVArgs
|
||||
|
||||
m.kv_args = SimpleNamespace(**{name: [] for name in KVArgs.__annotations__})
|
||||
for i, (q, ex) in enumerate(zip(m.transfer_queues, m.executors)):
|
||||
t = threading.Thread(
|
||||
target=m.transfer_worker, args=(q, ex, None, i), daemon=True
|
||||
)
|
||||
t.start()
|
||||
m._worker_threads.append(t)
|
||||
started = list(m._worker_threads)
|
||||
time.sleep(0.05) # let workers park in FastQueue.get()
|
||||
for t in started:
|
||||
self.assertTrue(t.is_alive())
|
||||
|
||||
MooncakeKVManager.teardown(m)
|
||||
|
||||
for t in started:
|
||||
self.assertFalse(t.is_alive(), "transfer worker survived teardown (leak)")
|
||||
self.assertEqual(m._worker_threads, [])
|
||||
self.assertEqual(m.transfer_queues, [])
|
||||
self.assertEqual(m.executors, [])
|
||||
|
||||
|
||||
@unittest.skipUnless(_HAS_MOONCAKE, "mooncake not importable in this environment")
|
||||
class TestMooncakeBootstrapThreadRobustness(unittest.TestCase):
|
||||
"""The prefill bootstrap loop moved from a blocking recv_multipart() to a
|
||||
500ms poll + _stopped check (so teardown, i.e. a runtime role switch, can
|
||||
stop it). That loop runs on every mooncake PD instance, so pin the
|
||||
contract with real ZMQ traffic driven through the ABORT -> ABORT_ACK
|
||||
path: no message loss while idle or bursting, and prompt exit once
|
||||
_stopped is set. Unlike mori, the loop has no try/except around recv: a
|
||||
recv error terminates the thread (see test_recv_error_kills_thread).
|
||||
"""
|
||||
|
||||
class _FlakySocket(zmq.Socket):
|
||||
"""Real PULL socket whose next recv can be forced to fail once,
|
||||
emulating a transient ZMQ error between poll() and recv()."""
|
||||
|
||||
fail_next_recv = False
|
||||
|
||||
def recv_multipart(self, *args, **kwargs):
|
||||
if type(self).fail_next_recv:
|
||||
type(self).fail_next_recv = False
|
||||
raise RuntimeError("transient recv failure")
|
||||
return super().recv_multipart(*args, **kwargs)
|
||||
|
||||
def setUp(self):
|
||||
self._FlakySocket.fail_next_recv = False
|
||||
self._ctx = zmq.Context()
|
||||
sock = self._FlakySocket(self._ctx, zmq.PULL)
|
||||
port = sock.bind_to_random_port("tcp://127.0.0.1")
|
||||
m = MooncakeKVManager.__new__(MooncakeKVManager)
|
||||
m._stopped = False
|
||||
m._worker_threads = []
|
||||
m.server_socket = sock
|
||||
# The receive path is gated on this flag: role switch must be on for
|
||||
# the poll-with-timeout loop these tests exercise.
|
||||
m.server_args = SimpleNamespace(enable_pd_role_switch=True)
|
||||
# Read by the receive loop; off keeps these tests on the plain ACK path.
|
||||
m.enable_deferred_decode_kv_release = False
|
||||
# ABORT for an unknown room takes the "ignoring" branch and still
|
||||
# ACKs, giving a side-effect-free probe of the receive loop.
|
||||
m.request_status = {}
|
||||
m._socket_send_locks = {}
|
||||
|
||||
def _connect(endpoint, is_ipv6=False):
|
||||
m._socket_send_locks.setdefault(endpoint, threading.Lock())
|
||||
return m._connect.return_value
|
||||
|
||||
m._connect = MagicMock(side_effect=_connect)
|
||||
self.m = m
|
||||
self._push = self._ctx.socket(zmq.PUSH)
|
||||
self._push.connect(f"tcp://127.0.0.1:{port}")
|
||||
|
||||
def tearDown(self):
|
||||
self.m._stopped = True
|
||||
for t in self.m._worker_threads:
|
||||
t.join(timeout=3.0)
|
||||
self._push.close(linger=0)
|
||||
self.m.server_socket.close(linger=0)
|
||||
self._ctx.destroy(linger=0)
|
||||
|
||||
def _start(self):
|
||||
MooncakeKVManager.start_prefill_thread(self.m)
|
||||
(thread,) = self.m._worker_threads
|
||||
return thread
|
||||
|
||||
def _send_abort(self, room):
|
||||
self._push.send_multipart(
|
||||
[b"ABORT", str(room).encode("ascii"), b"127.0.0.1", b"9999"]
|
||||
)
|
||||
|
||||
def _wait_acks(self, n, timeout=10.0):
|
||||
send = self.m._connect.return_value.send_multipart
|
||||
deadline = time.monotonic() + timeout
|
||||
while time.monotonic() < deadline:
|
||||
if send.call_count >= n:
|
||||
return
|
||||
time.sleep(0.02)
|
||||
self.fail(f"expected {n} ABORT_ACKs, got {send.call_count}")
|
||||
|
||||
def test_messages_processed_across_idle_poll_timeouts(self):
|
||||
self._start()
|
||||
self._send_abort(1)
|
||||
self._wait_acks(1)
|
||||
# Idle past a full poll timeout, then traffic must still flow: the
|
||||
# empty-poll -> continue path must not disturb the socket.
|
||||
time.sleep(0.8)
|
||||
self._send_abort(2)
|
||||
self._wait_acks(2)
|
||||
|
||||
def test_no_message_loss_under_burst(self):
|
||||
self._start()
|
||||
n = 200
|
||||
for i in range(n):
|
||||
self._send_abort(i)
|
||||
# Two-step poll+recv must consume every queued message exactly once.
|
||||
self._wait_acks(n)
|
||||
|
||||
def test_recv_error_kills_thread(self):
|
||||
# No try/except guards recv() in the mooncake loop (unlike mori): a
|
||||
# recv error terminates the thread and the loop stops processing.
|
||||
# Pin that contract so adding error handling stays a deliberate,
|
||||
# reviewed change rather than a silent behavior shift.
|
||||
thread = self._start()
|
||||
self._FlakySocket.fail_next_recv = True
|
||||
self._send_abort(3)
|
||||
thread.join(timeout=2.0)
|
||||
self.assertFalse(thread.is_alive(), "bootstrap thread survived recv error")
|
||||
self.assertFalse(self._FlakySocket.fail_next_recv) # fault consumed
|
||||
|
||||
def test_exits_promptly_when_stopped_while_idle(self):
|
||||
thread = self._start()
|
||||
self.m._stopped = True
|
||||
# Poll timeout is 500ms, so the flag must be observed within ~1 cycle
|
||||
# (this is what keeps teardown / role switch from hanging).
|
||||
thread.join(timeout=2.0)
|
||||
self.assertFalse(thread.is_alive(), "bootstrap thread leaked past stop")
|
||||
|
||||
|
||||
def _radix_scheduler(disable_radix_cache):
|
||||
s = MagicMock()
|
||||
s.disable_radix_cache = disable_radix_cache
|
||||
tree = MagicMock()
|
||||
del tree.clear_storage_backend # plain RadixCache has none
|
||||
s.tree_cache = tree
|
||||
s.req_to_token_pool = MagicMock()
|
||||
s.token_to_kv_pool_allocator = MagicMock()
|
||||
return s
|
||||
|
||||
|
||||
@unittest.skipUnless(_HAS_ROLE_SWITCH, "role_switch not importable in this env")
|
||||
class TestReleasePrefixCacheOnRoleSwitch(unittest.TestCase):
|
||||
"""The flip may run with radix cache ENABLED: teardown resets the tree cache
|
||||
+ KV pools when radix is on, and is a no-op on the historical chunk-cache path."""
|
||||
|
||||
def test_noop_when_radix_disabled(self):
|
||||
s = _radix_scheduler(disable_radix_cache=True)
|
||||
_release_prefix_cache_for_role_switch(s)
|
||||
s.tree_cache.reset.assert_not_called()
|
||||
s.token_to_kv_pool_allocator.clear.assert_not_called()
|
||||
|
||||
def test_releases_when_radix_enabled(self):
|
||||
s = _radix_scheduler(disable_radix_cache=False)
|
||||
_release_prefix_cache_for_role_switch(s)
|
||||
s.tree_cache.reset.assert_called_once_with()
|
||||
s.req_to_token_pool.clear.assert_called_once_with()
|
||||
s.token_to_kv_pool_allocator.clear.assert_called_once_with()
|
||||
|
||||
def test_teardown_invokes_release(self):
|
||||
s = _radix_scheduler(disable_radix_cache=False)
|
||||
s.disaggregation_mode = DisaggregationMode.PREFILL
|
||||
s.disagg_prefill_bootstrap_queue = None # no queue -> skip km.teardown()
|
||||
teardown_disaggregation(s)
|
||||
self.assertIsNone(s.disagg_metadata_buffers)
|
||||
s.tree_cache.reset.assert_called_once_with()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -69,6 +69,7 @@ class TestModelInfoSerialization(CustomTestCase):
|
||||
"load_format": _CustomModelLoader,
|
||||
"reasoning_parser": None,
|
||||
"tool_call_parser": None,
|
||||
"disaggregation_mode": "null",
|
||||
}
|
||||
tokenizer_manager = SimpleNamespace(
|
||||
model_config=SimpleNamespace(
|
||||
@@ -97,6 +98,7 @@ class TestModelInfoSerialization(CustomTestCase):
|
||||
reset_context()
|
||||
|
||||
self.assertEqual(payload["load_format"], f"{__name__}._CustomModelLoader")
|
||||
self.assertEqual(payload["disaggregation_mode"], "null")
|
||||
json.dumps(payload)
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user