[PD] Share the prefill->decode failure notification across backends (#36612)

Co-authored-by: inkcherry <mingzhi.liu@amd.com>
Co-authored-by: Shangming Cai <csmthu@gmail.com>
This commit is contained in:
jambow0320
2026-09-11 23:16:17 +08:00
committed by GitHub
co-authored by inkcherry Shangming Cai
parent e016de462c
commit 2a46cf2ca0
6 changed files with 453 additions and 368 deletions
+239 -16
View File
@@ -144,6 +144,19 @@ class PrefillRankInfo:
class CommonKVManager(BaseKVManager):
# Wire layout of the prefill->decode terminal status message. The legacy
# layout (mooncake, and ascend which inherits it) is three untagged frames
# ``[room, status, prefill_rank]``; backends whose control socket also
# carries tagged messages prefix a tag frame and may append a reason:
# ``[tag, room, status, prefill_rank, reason]``.
kv_status_msg_tag: Optional[bytes] = None
kv_status_msg_carries_reason: bool = False
# Used by decode when the prefill reported Failed without a reason frame.
DEFAULT_PREFILL_FAILURE_REASON = (
"Failed to get kvcache from prefill instance, it might be dead"
)
def __init__(
self,
args: KVArgs,
@@ -223,7 +236,6 @@ class CommonKVManager(BaseKVManager):
self._socket_lock = threading.Lock()
self.failure_records: Dict[int, str] = {}
self.failure_lock = threading.Lock()
if self.disaggregation_mode == DisaggregationMode.PREFILL:
# When SGLANG_DISAGGREGATION_ALL_CP_RANKS_TRANSFER is True, all CP ranks
# participate in KV transfer; Otherwise only CP rank 0 sends.
@@ -253,6 +265,7 @@ class CommonKVManager(BaseKVManager):
self.bootstrap_timeout = envs.SGLANG_DISAGGREGATION_BOOTSTRAP_TIMEOUT.get()
elif self.disaggregation_mode == DisaggregationMode.DECODE:
self.enable_staging: bool = False
self._staging_handler = None
self.connection_pool: Dict[str, Dict[str, Union[str, int]]] = {}
self.connection_lock = threading.Lock()
self.required_prefill_response_num_table: Dict[int, int] = {}
@@ -363,26 +376,233 @@ class CommonKVManager(BaseKVManager):
return self.request_status[bootstrap_room]
def update_status(self, bootstrap_room: int, status: KVPoll):
if bootstrap_room not in self.request_status:
# Do not resurrect a cleared entry with Failed: once clear() has
# popped the room from request_status, any late update_status(Failed)
# (e.g. from abort()) must be a no-op. Otherwise a Failed entry could
# pollute a future request that reuses the same bootstrap_room.
if status == KVPoll.Failed:
return
self.request_status[bootstrap_room] = status
else:
if status == KVPoll.Failed:
self.request_status[bootstrap_room] = KVPoll.Failed
else:
self.request_status[bootstrap_room] = max(
self.request_status[bootstrap_room], status
)
current = self.request_status.get(bootstrap_room)
if current is None:
# The room does not exist yet, or clear() already popped it. Only a
# request's opening status may create it: Bootstrapping normally, or
# WaitingForInput for a dummy CP rank (see CommonKVSender.__init__).
# Anything else would resurrect a concluded room and pollute a later
# request that reuses the same bootstrap_room.
if status in (KVPoll.Bootstrapping, KVPoll.WaitingForInput):
self.request_status[bootstrap_room] = status
return
if status == KVPoll.Failed:
self.request_status[bootstrap_room] = KVPoll.Failed
return
if current == KVPoll.Failed:
# Failed is terminal. It also sorts lowest, so the max() below would
# happily promote it back to Transferring or Success.
return
self.request_status[bootstrap_room] = max(current, status)
def record_failure(self, bootstrap_room: int, failure_reason: str):
with self.failure_lock:
self.failure_records[bootstrap_room] = failure_reason
def _room_notify_targets(self, bootstrap_room: int) -> List[Tuple[str, int]]:
infos = self.transfer_infos.get(bootstrap_room)
if not infos:
return []
# Every non-dummy endpoint, not just the one a caller failed on: the
# others never receive the room's remaining chunks either.
targets: List[Tuple[str, int]] = []
# Snapshot: the control thread can register a late peer for this room
# while we walk it, and iterating the live view would then raise.
for info in list(infos.values()):
if info.is_dummy:
continue
target = (info.endpoint, info.dst_port)
if target not in targets:
targets.append(target)
return targets
def _encode_kv_status_message(
self,
*,
bootstrap_room: int,
status: KVPoll,
failure_reason: Optional[str],
) -> List[bytes]:
parts = [
str(bootstrap_room).encode("ascii"),
str(int(status)).encode("ascii"),
str(self._prefill_unique_rank()).encode("ascii"),
]
if self.kv_status_msg_carries_reason:
parts.append((failure_reason or "").encode("utf-8"))
if self.kv_status_msg_tag is not None:
parts.insert(0, self.kv_status_msg_tag)
return parts
def parse_kv_status_message(
self, msg: List[bytes]
) -> Optional[Tuple[int, int, int, Optional[str]]]:
"""Decode a prefill status message, or None when it is not one."""
if self.kv_status_msg_tag is not None:
if not msg or msg[0] != self.kv_status_msg_tag:
return None
msg = msg[1:]
if len(msg) < 3:
logger.warning(
"Dropping malformed prefill status message with %d frames", len(msg)
)
return None
try:
bootstrap_room = int(msg[0].decode("ascii"))
status = int(msg[1].decode("ascii"))
prefill_rank = int(msg[2].decode("ascii"))
except (UnicodeDecodeError, ValueError):
logger.warning("Dropping unparsable prefill status message")
return None
failure_reason = (
msg[3].decode("utf-8", errors="replace")
if len(msg) > 3 and msg[3]
else None
)
return bootstrap_room, status, prefill_rank, failure_reason
def send_kv_status_message(
self,
*,
targets: List[Tuple[str, int]],
bootstrap_room: int,
status: KVPoll,
failure_reason: Optional[str] = None,
) -> None:
"""Push of a terminal transfer status to decode endpoints."""
if not targets:
return
parts = self._encode_kv_status_message(
bootstrap_room=bootstrap_room,
status=status,
failure_reason=failure_reason,
)
for endpoint, dst_port in targets:
na = NetworkAddress(endpoint, dst_port)
try:
self._send_multipart_locked(na.to_tcp(), parts, is_ipv6=na.is_ipv6)
except Exception as e:
logger.warning(
f"Failed to sync status {status} of room {bootstrap_room} to "
f"{na.to_host_port_str()}: {e}"
)
def conclude_transfer(
self,
*,
bootstrap_room: int,
status: KVPoll,
targets: Optional[List[Tuple[str, int]]] = None,
failure_reason: Optional[str] = None,
) -> Optional[KVPoll]:
"""Returns the status emitted, or None for a cleared room.
Runs more than once for a room when a staging chunk is deferred past the
last one. ``targets`` defaults to the room's non-dummy decode endpoints.
"""
if bootstrap_room not in self.request_status:
# The sender already cleared this room. Concluding now would
# re-create it in request_status and leave a failure record that a
# request reusing this bootstrap_room would adopt as its own.
return None
if status == KVPoll.Success:
with self.failure_lock:
recorded = self.failure_records.get(bootstrap_room)
if recorded is not None:
status = KVPoll.Failed
failure_reason = recorded
elif self.request_status.get(bootstrap_room) == KVPoll.Failed:
status = KVPoll.Failed
failure_reason = (
failure_reason or "Room marked Failed before the transfer ended"
)
if status == KVPoll.Failed:
with self.failure_lock:
# Keep the first root cause; later callers see the symptom.
failure_reason = self.failure_records.setdefault(
bootstrap_room, failure_reason or "KV transfer failed"
)
if targets is None:
targets = self._room_notify_targets(bootstrap_room)
self.update_status(bootstrap_room, status)
self.send_kv_status_message(
targets=targets,
bootstrap_room=bootstrap_room,
status=status,
failure_reason=failure_reason,
)
return status
def conclude_failure(
self,
*,
bootstrap_room: int,
failure_reason: str,
targets: Optional[List[Tuple[str, int]]] = None,
) -> Optional[KVPoll]:
"""Record the reason, mark the room Failed and tell decode."""
return self.conclude_transfer(
bootstrap_room=bootstrap_room,
status=KVPoll.Failed,
targets=targets,
failure_reason=failure_reason,
)
def apply_prefill_status(
self,
*,
bootstrap_room: int,
status: int,
prefill_rank: int,
failure_reason: Optional[str] = None,
) -> None:
"""Decode-side handling of one prefill rank's terminal status."""
if bootstrap_room not in self.request_status:
# The room concluded and was cleared. Recording a failure now would
# leave an entry that a later request reusing this bootstrap_room
# would pick up as its own root cause.
logger.debug("Dropping late status for cleared room %s", bootstrap_room)
return
if status == KVPoll.Success:
self.prefill_response_tracker[bootstrap_room].add(prefill_rank)
expected_response_num = self.required_prefill_response_num_table.get(
bootstrap_room
)
if expected_response_num is None:
logger.warning(
"No expected prefill response count for room %s, prefill rank %s",
bootstrap_room,
prefill_rank,
)
return
if (
len(self.prefill_response_tracker[bootstrap_room])
< expected_response_num
):
return
# Tell the staging handler no more chunks are coming, before any
# poller can see Success. Only mooncake gets here: NIXL arms the
# handler from its own notifications, mori has no staging.
if self.enable_staging and self._staging_handler is not None:
handler = self._staging_handler
if handler.is_staging_room(bootstrap_room):
handler.submit_last_scatter_async(bootstrap_room)
self.update_status(bootstrap_room, KVPoll.Success)
return
if status == KVPoll.Failed:
self.record_failure(
bootstrap_room, failure_reason or self.DEFAULT_PREFILL_FAILURE_REASON
)
self.update_status(bootstrap_room, KVPoll.Failed)
return
logger.warning(
"Ignoring non-terminal status %s for room %s from prefill rank %s",
status,
bootstrap_room,
prefill_rank,
)
def register_deferred_abort_room(self, bootstrap_room: int) -> None:
"""Arm drain-ack accounting for a held room; a fresh set wipes stale acks
from a prior request that reused this bootstrap_room."""
@@ -1613,6 +1833,9 @@ class CommonKVReceiver(BaseKVReceiver):
self.kv_mgr.request_status.pop(self.bootstrap_room, None)
self.kv_mgr.required_prefill_response_num_table.pop(self.bootstrap_room, None)
self.kv_mgr.prefill_response_tracker.pop(self.bootstrap_room, None)
self.kv_mgr.addr_to_rooms_tracker[self.bootstrap_addr].discard(
self.bootstrap_room
)
def abort(self):
self.kv_mgr.record_failure(
@@ -951,13 +951,7 @@ def prefetch_staging_reqs(
full_chunk_pages = staging_grid_tokens(chunked_prefill_size, page_size) // page_size
for session_id, tinfo in transfer_infos[room].items():
# mooncake exposes is_dummy as a dataclass bool field, NIXL exposes it
# as a method (it consults decode_prefix_len). Normalize via callable()
# so this shared helper works for either backend; treating a bound
# method as truthy (the previous behavior) silently dropped every
# STAGING_REQ on NIXL and deadlocked the prefill transfer worker.
is_dummy_attr = tinfo.is_dummy
if is_dummy_attr() if callable(is_dummy_attr) else is_dummy_attr:
if tinfo.is_dummy:
continue
total_pages = len(tinfo.dst_kv_indices)
if total_pages == 0:
+42 -100
View File
@@ -292,7 +292,6 @@ class MooncakeKVManager(StagingManagerMixin, CommonKVManager):
self._staging_ctx = DecodeStagingContext() if self.enable_staging else None
if self.enable_staging:
self._init_staging_allocator()
self._staging_handler = None
self.start_decode_thread()
def init_engine(self):
@@ -1742,20 +1741,6 @@ class MooncakeKVManager(StagingManagerMixin, CommonKVManager):
return self._transfer_data(req.mooncake_session_id, transfer_blocks)
def sync_status_to_decode_endpoint(
self, remote: str, dst_port: int, room: int, status: int, prefill_rank: int
):
na = NetworkAddress(remote, dst_port)
self._send_multipart_locked(
na.to_tcp(),
[
str(room).encode("ascii"),
str(status).encode("ascii"),
str(prefill_rank).encode("ascii"),
],
is_ipv6=na.is_ipv6,
)
def transfer_worker(
self,
queue: FastQueue,
@@ -1822,11 +1807,7 @@ class MooncakeKVManager(StagingManagerMixin, CommonKVManager):
polls = []
dst_ranks_infos = []
# Unique id per prefill sender so decode's response set size matches expected_response_num.
prefill_unique_rank = (
self.attn_tp_rank * (self.pp_size * self.attn_cp_size)
+ self.pp_rank * self.attn_cp_size
+ self.attn_cp_rank
)
prefill_unique_rank = self._prefill_unique_rank()
# When staging transfer is not yet ready (watermark/allocation pending),
# the chunk is re-enqueued and we break out of the req loop to retry later.
staging_deferred = False
@@ -1836,17 +1817,13 @@ class MooncakeKVManager(StagingManagerMixin, CommonKVManager):
# Early exit if the request has failed
with self.session_lock:
if req.mooncake_session_id in self.failed_sessions:
self.record_failure(
kv_chunk.room,
f"Decode instance could be dead, remote mooncake session {req.mooncake_session_id} is not alive",
)
self.update_status(kv_chunk.room, KVPoll.Failed)
self.sync_status_to_decode_endpoint(
req.endpoint,
req.dst_port,
req.room,
KVPoll.Failed,
prefill_unique_rank,
self.conclude_failure(
bootstrap_room=kv_chunk.room,
failure_reason=(
"Decode instance could be dead, remote "
f"mooncake session {req.mooncake_session_id} "
"is not alive"
),
)
break
@@ -1989,18 +1966,12 @@ class MooncakeKVManager(StagingManagerMixin, CommonKVManager):
logger.error(
f"Session {req.mooncake_session_id} failed."
)
self.record_failure(
kv_chunk.room,
f"Failed to send kv chunk of {kv_chunk.room} to "
f"{NetworkAddress(req.endpoint, req.dst_port).to_host_port_str()}",
)
self.update_status(kv_chunk.room, KVPoll.Failed)
self.sync_status_to_decode_endpoint(
req.endpoint,
req.dst_port,
req.room,
KVPoll.Failed,
prefill_unique_rank,
self.conclude_failure(
bootstrap_room=kv_chunk.room,
failure_reason=(
f"Failed to send kv chunk of {kv_chunk.room} to "
f"{NetworkAddress(req.endpoint, req.dst_port).to_host_port_str()}"
),
)
break
@@ -2020,18 +1991,13 @@ class MooncakeKVManager(StagingManagerMixin, CommonKVManager):
self.failed_sessions.add(
req.mooncake_session_id
)
self.record_failure(
kv_chunk.room,
f"Failed to send state components of {kv_chunk.room} to "
f"{NetworkAddress(req.endpoint, req.dst_port).to_host_port_str()}",
)
self.update_status(kv_chunk.room, KVPoll.Failed)
self.sync_status_to_decode_endpoint(
req.endpoint,
req.dst_port,
req.room,
KVPoll.Failed,
prefill_unique_rank,
self.conclude_failure(
bootstrap_room=kv_chunk.room,
failure_reason=(
"Failed to send state components of "
f"{kv_chunk.room} to "
f"{NetworkAddress(req.endpoint, req.dst_port).to_host_port_str()}"
),
)
break
@@ -2042,22 +2008,21 @@ class MooncakeKVManager(StagingManagerMixin, CommonKVManager):
target_rank_registration_info.dst_aux_ptrs,
)
polls.append(True if ret == 0 else False)
dst_ranks_infos.append(
(req.endpoint, req.dst_port, req.room)
)
dst_ranks_infos.append((req.endpoint, req.dst_port))
# Only sync status when all the dst ranks have received the kvcache
if len(polls) == req.required_dst_info_num:
status = KVPoll.Success if all(polls) else KVPoll.Failed
self.update_status(req.room, status)
for endpoint, dst_port, room in dst_ranks_infos:
self.sync_status_to_decode_endpoint(
endpoint,
dst_port,
room,
status,
prefill_unique_rank,
)
self.conclude_transfer(
bootstrap_room=req.room,
status=status,
targets=dst_ranks_infos,
failure_reason=(
None
if status == KVPoll.Success
else f"Failed to send aux data of {req.room}"
),
)
else:
# Dummy request means the decode instance is not used, so its status can be marked as success directly
# Dummy request does not need to sync status to decode endpoint
@@ -2295,32 +2260,16 @@ class MooncakeKVManager(StagingManagerMixin, CommonKVManager):
)
continue
bootstrap_room, status, prefill_rank = msg
status = int(status.decode("ascii"))
bootstrap_room = int(bootstrap_room.decode("ascii"))
prefill_rank = int(prefill_rank.decode("ascii"))
if status == KVPoll.Success:
if bootstrap_room in self.request_status:
self.prefill_response_tracker[bootstrap_room].add(prefill_rank)
expected_response_num = (
self.required_prefill_response_num_table[bootstrap_room]
)
arrived_response_num = len(
self.prefill_response_tracker[bootstrap_room]
)
if arrived_response_num == expected_response_num:
if self.enable_staging:
handler = self._staging_handler
if handler.is_staging_room(bootstrap_room):
handler.submit_last_scatter_async(bootstrap_room)
self.update_status(bootstrap_room, KVPoll.Success)
elif status == KVPoll.Failed:
self.record_failure(
bootstrap_room,
"Failed to get kvcache from prefill instance, it might be dead",
)
self.update_status(bootstrap_room, status)
parsed = self.parse_kv_status_message(msg)
if parsed is None:
continue
room, status, prefill_rank, reason = parsed
self.apply_prefill_status(
bootstrap_room=room,
status=status,
prefill_rank=prefill_rank,
failure_reason=reason,
)
threading.Thread(target=decode_thread).start()
self._start_heartbeat_checker_thread()
@@ -2380,13 +2329,6 @@ class MooncakeKVManager(StagingManagerMixin, CommonKVManager):
def get_session_id(self):
return self.engine.get_session_id()
def _on_heartbeat_success(self, bootstrap_addr: str):
current_rooms = self.addr_to_rooms_tracker[bootstrap_addr].copy()
for bootstrap_room in current_rooms:
# Remove KVPoll.Success requests from the tracker
if bootstrap_room not in self.request_status:
self.addr_to_rooms_tracker[bootstrap_addr].discard(bootstrap_room)
def _run_one_probe_pass(self) -> None:
with self.session_lock:
snapshot = list(self.failed_sessions)
+27 -184
View File
@@ -7,7 +7,7 @@ import struct
import threading
import time
import uuid
from typing import Dict, List, Optional, Tuple
from typing import List, Optional
import msgspec
import numpy as np
@@ -294,6 +294,11 @@ class TransferTarget:
class MoriKVManager(CommonKVManager):
AUX_DATA_HEADER = b"AUX_DATA"
# The bootstrap socket carries several message kinds, so the status message
# is tagged. Mori has always shipped the failure reason with it.
kv_status_msg_tag = MORI_GUARD
kv_status_msg_carries_reason = True
def __init__(
self,
args: KVArgs,
@@ -319,8 +324,6 @@ 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()
self._room_status_notified: Dict[int, bool] = {}
self._room_notify_lock = threading.Lock()
for shard, queue in enumerate(self._transfer_queues):
threading.Thread(
target=self._transfer_worker,
@@ -333,7 +336,6 @@ class MoriKVManager(CommonKVManager):
).start()
self._start_bootstrap_thread()
elif self.disaggregation_mode == DisaggregationMode.DECODE:
self.room_to_bootstrap_addr: Dict[int, str] = {}
self._start_decode_thread()
self._start_heartbeat_checker_thread()
@@ -415,19 +417,6 @@ class MoriKVManager(CommonKVManager):
component_descs.append(desc)
self.state_mem_descs.append(component_descs)
def update_status(self, bootstrap_room: int, status: KVPoll):
current = self.request_status.get(bootstrap_room)
if current is None:
# Room not yet created or already cleared.
# Only allow initial creation: Bootstrapping (normal) or
# WaitingForInput (dummy CP rank, see CommonKVSender.__init__).
if status not in (KVPoll.Bootstrapping, KVPoll.WaitingForInput):
return
elif current == KVPoll.Failed and status != KVPoll.Failed:
# Failed is terminal — never overwrite with non-Failed.
return
super().update_status(bootstrap_room, status)
def _transfer_worker(self, queue: FastQueue) -> None:
while True:
kv_chunk = queue.get()
@@ -443,7 +432,9 @@ class MoriKVManager(CommonKVManager):
except Exception:
pass
try:
self._conclude_room_failure(kv_chunk.room, failure_reason)
self.conclude_failure(
bootstrap_room=kv_chunk.room, failure_reason=failure_reason
)
except Exception:
try:
logger.exception(
@@ -464,7 +455,7 @@ class MoriKVManager(CommonKVManager):
if self._should_skip_transfer(room):
return
statuses, target_infos = self._submit_kv_transfer(
statuses = self._submit_kv_transfer(
room,
kv_chunk.prefill_kv_indices,
kv_chunk.index_slice,
@@ -480,14 +471,14 @@ class MoriKVManager(CommonKVManager):
if self._should_skip_transfer(room):
return
if failure_reason is not None:
self._conclude_room_failure(room, failure_reason)
self.conclude_failure(bootstrap_room=room, failure_reason=failure_reason)
return
if kv_chunk.is_last_chunk:
self._notify_decode_for_room(
room, KVPoll.Success, target_infos=target_infos
)
self.update_status(room, KVPoll.Success)
# conclude_transfer downgrades to Failed when a failure was recorded
# while this chunk was in flight, and applies the same status locally
# and on the wire.
self.conclude_transfer(bootstrap_room=room, status=KVPoll.Success)
def _should_skip_transfer(self, room: int) -> bool:
if room not in self.request_status or self.check_status(room) == KVPoll.Failed:
@@ -523,60 +514,6 @@ class MoriKVManager(CommonKVManager):
return f"KV transfer failed: {status.Message()}"
return "KV transfer failed due to unknown reason"
def _notify_decode_for_room(
self,
room: int,
status: KVPoll,
failure_reason: Optional[str] = None,
target_infos: Optional[List[TransferInfo]] = None,
) -> None:
with self._room_notify_lock:
if room not in self.request_status or self._room_status_notified.get(room):
return
emitted_status = status
emitted_reason = failure_reason
if emitted_status == KVPoll.Success:
with self.failure_lock:
recorded = self.failure_records.get(room)
if recorded is not None:
emitted_status = KVPoll.Failed
emitted_reason = recorded
elif self.request_status.get(room) == KVPoll.Failed:
emitted_status = KVPoll.Failed
emitted_reason = (
emitted_reason or "request marked Failed before notify"
)
if emitted_status == KVPoll.Failed:
with self.failure_lock:
self.failure_records.setdefault(
room, emitted_reason or "KV transfer failed"
)
self.update_status(room, KVPoll.Failed)
infos = target_infos
if infos is None:
with self.transfer_lock:
room_infos = self.transfer_infos.get(room)
infos = (
list(room_infos.values()) if room_infos is not None else None
)
self._room_status_notified[room] = True
if infos:
self.notify_decode_status(infos, room, emitted_status, emitted_reason)
def _conclude_room_failure(
self, room: int, failure_reason: Optional[str] = None
) -> None:
if failure_reason is None:
with self.failure_lock:
failure_reason = self.failure_records.get(room, "KV transfer failed")
self._notify_decode_for_room(room, KVPoll.Failed, failure_reason)
def add_transfer_request(
self,
bootstrap_room: int,
@@ -772,15 +709,6 @@ class MoriKVManager(CommonKVManager):
threading.Thread(target=bootstrap_worker, daemon=True).start()
def _cleanup_room_tracking(self, bootstrap_room: int) -> None:
bootstrap_addr = self.room_to_bootstrap_addr.pop(bootstrap_room, None)
if bootstrap_addr is not None:
rooms = self.addr_to_rooms_tracker.get(bootstrap_addr)
if rooms is not None:
rooms.discard(bootstrap_room)
if not rooms:
self.addr_to_rooms_tracker.pop(bootstrap_addr, None)
def _start_decode_thread(self) -> None:
def decode_worker():
while True:
@@ -790,97 +718,24 @@ class MoriKVManager(CommonKVManager):
self._handle_aux_data(msg)
continue
if not msg or msg[0] != MORI_GUARD:
parsed = self.parse_kv_status_message(msg)
if parsed is None:
logger.warning(
"Received malformed status message on decode worker"
)
continue
payload = msg[1:]
if len(payload) < 3:
logger.warning("Incomplete status payload received")
continue
bootstrap_room = int(payload[0].decode("ascii"))
if bootstrap_room not in self.request_status:
logger.debug(
"Dropping late status for cleared room %s",
bootstrap_room,
)
continue
status_code = int(payload[1].decode("ascii"))
prefill_rank = int(payload[2].decode("ascii"))
failure_reason = (
payload[3].decode("utf-8")
if len(payload) > 3 and payload[3]
else None
room, status, prefill_rank, reason = parsed
self.apply_prefill_status(
bootstrap_room=room,
status=status,
prefill_rank=prefill_rank,
failure_reason=reason,
)
if status_code == KVPoll.Success:
tracker = self.prefill_response_tracker[bootstrap_room]
tracker.add(prefill_rank)
expected = self.required_prefill_response_num_table.get(
bootstrap_room, 1
)
if len(tracker) >= expected:
self.prefill_response_tracker.pop(bootstrap_room, None)
self.update_status(bootstrap_room, KVPoll.Success)
self._cleanup_room_tracking(bootstrap_room)
elif status_code == KVPoll.Failed:
if failure_reason:
self.record_failure(bootstrap_room, failure_reason)
self.prefill_response_tracker.pop(bootstrap_room, None)
self.update_status(bootstrap_room, KVPoll.Failed)
self._cleanup_room_tracking(bootstrap_room)
else:
logger.warning(
"Unknown status code %s received for room %s",
status_code,
bootstrap_room,
)
except Exception:
logger.exception("Decode status worker failed")
threading.Thread(target=decode_worker, daemon=True).start()
def _compute_prefill_unique_rank(self) -> int:
"""Unique id per prefill sender, encoding TP/PP/CP ranks.
Must match Mooncake's formula so decode's response set size matches
expected_response_num when multiple CP ranks participate."""
return (
self.attn_tp_rank * (self.pp_size * self.attn_cp_size)
+ self.pp_rank * self.attn_cp_size
+ self.attn_cp_rank
)
def notify_decode_status(
self,
infos: List[TransferInfo],
bootstrap_room: int,
status: KVPoll,
failure_reason: Optional[str] = None,
) -> None:
if not infos:
return
payload = [
MORI_GUARD,
str(bootstrap_room).encode("ascii"),
str(int(status)).encode("ascii"),
str(self._compute_prefill_unique_rank()).encode("ascii"),
failure_reason.encode("utf-8") if failure_reason else b"",
]
for info in infos:
try:
na = NetworkAddress(info.endpoint, info.dst_port)
socket = self._connect_threadsafe(na.to_tcp(), is_ipv6=na.is_ipv6)
socket.send_multipart(payload)
except Exception:
logger.exception(
"Failed to sync status %s to decode endpoint %s:%s for room %s",
status,
info.endpoint,
info.dst_port,
bootstrap_room,
)
def _add_remote_peer(self, register_info: KVArgsRegisterInfo) -> None:
engine_key = register_info.engine_key
if engine_key in self.decode_kv_args_table:
@@ -1541,21 +1396,20 @@ class MoriKVManager(CommonKVManager):
is_last_chunk: bool,
aux_index: Optional[int] = None,
state_indices: Optional[List[npt.NDArray[np.int32]]] = None,
) -> Tuple[List[TransferStatus], Optional[List[TransferInfo]]]:
) -> List[TransferStatus]:
assert self.disaggregation_mode == DisaggregationMode.PREFILL
if (
bootstrap_room not in self.request_status
or self.request_status.get(bootstrap_room) == KVPoll.Failed
):
return [], None
return []
targets: List[TransferTarget] = []
target_infos_snapshot: Optional[List[TransferInfo]] = None
with self.transfer_lock:
current = self.request_status.get(bootstrap_room)
if current is None or current == KVPoll.Failed:
return [], None
return []
transfer_infos = self.transfer_infos.get(bootstrap_room)
if not transfer_infos:
@@ -1571,8 +1425,6 @@ class MoriKVManager(CommonKVManager):
f"Peer info missing for engine {info.engine_key}"
)
targets.append(TransferTarget(info=info, peer_info=peer_info))
if is_last_chunk:
target_infos_snapshot = list(transfer_infos.values())
result_statuses: List[TransferStatus] = []
try:
@@ -1616,7 +1468,7 @@ class MoriKVManager(CommonKVManager):
)
raise RuntimeError(f"Transfer submission failed: {e}") from e
return result_statuses, target_infos_snapshot
return result_statuses
class MoriKVSender(CommonKVSender):
@@ -1705,11 +1557,6 @@ class MoriKVSender(CommonKVSender):
self.conclude_state = status
return status
def clear(self) -> None:
super().clear()
with self.kv_mgr._room_notify_lock:
self.kv_mgr._room_status_notified.pop(self.bootstrap_room, None)
def failure_exception(self):
if self.conclude_state is None:
self.conclude_state = KVPoll.Failed
@@ -1741,9 +1588,6 @@ class MoriKVReceiver(CommonKVReceiver):
prefill_dp_rank: int,
):
super().init(prefill_dp_rank)
if self.bootstrap_room is None:
return
self.kv_mgr.room_to_bootstrap_addr[self.bootstrap_room] = self.bootstrap_addr
def _register_kv_args(self) -> bool:
if self.bootstrap_infos is None:
@@ -1871,7 +1715,6 @@ class MoriKVReceiver(CommonKVReceiver):
if self.bootstrap_room is None:
return
super().clear()
self.kv_mgr._cleanup_room_tracking(self.bootstrap_room)
def failure_exception(self):
if self.conclude_state is None:
+133 -56
View File
@@ -72,6 +72,12 @@ logger = logging.getLogger(__name__)
GUARD = "NixlMsgGuard".encode("ascii")
KV_MEM_KINDS = {"VRAM", "DRAM"}
# Once one handle of a batch reports ERR, its siblings settle only when NIXL
# notices their peer is gone, which for UCX means waiting out peer keepalive.
# This worker serves other rooms, so bound that wait.
NIXL_ERR_SETTLE_TIMEOUT_S = 5.0
NIXL_ERR_SETTLE_POLL_S = 0.001
def _normalize_kv_mem_kinds(kinds: Optional[List[str]], expected_len: int) -> List[str]:
if kinds is None:
@@ -166,45 +172,47 @@ class TransferInfo:
required_dst_info_num: int
dst_state_indices: List[List[int]]
decode_prefix_len: Optional[int] = None # for decode radix cache
is_dummy_rank: Optional[bool] = None
is_dummy: bool = False
# NOTE: optional staging field; populated via STAGING_RSP. Keep at the
# end so positional construction in from_zmq() continues to work.
staging: Optional[StagingTransferInfo] = None
def is_dummy(self):
# A transfer is "dummy" only for CP non-authoritative ranks.
# When dst_kv_indices is empty due to a decode-side radix cache
# full hit (decode_prefix_len > 0), the transfer is NOT dummy --
# aux/state data still needs to be sent.
if self.is_dummy_rank is not None:
return self.is_dummy_rank
if self.dst_kv_indices.size == 0 and self.decode_prefix_len:
return False
return self.dst_kv_indices.size == 0
@classmethod
def from_zmq(cls, msg: List[bytes]):
dst_state_indices = (
unpack_int_lists(msg[7], "i") if len(msg) > 7 and msg[7] != b"" else []
)
dst_kv_indices = np.frombuffer(msg[4], dtype=np.int32)
decode_prefix_len = (
int(msg[8].decode("ascii")) if len(msg) > 8 and msg[8] != b"" else None
) # hacky just add it into the message that will be sent
dummy_rank = (
bool(int(msg[9].decode("ascii")))
if len(msg) > 9 and msg[9] != b""
else None
)
# A transfer is "dummy" only for CP non-authoritative ranks. When
# dst_kv_indices is empty due to a decode-side radix cache full hit
# (decode_prefix_len > 0), the transfer is NOT dummy -- aux/state data
# still needs to be sent.
if dummy_rank is not None:
is_dummy = dummy_rank
elif dst_kv_indices.size == 0 and decode_prefix_len:
is_dummy = False
else:
is_dummy = dst_kv_indices.size == 0
return cls(
room=int(msg[0].decode("ascii")),
endpoint=msg[1].decode("ascii"),
dst_port=int(msg[2].decode("ascii")),
agent_name=msg[3].decode("ascii"),
dst_kv_indices=np.frombuffer(msg[4], dtype=np.int32),
dst_kv_indices=dst_kv_indices,
dst_aux_index=int(msg[5].decode("ascii")),
required_dst_info_num=int(msg[6].decode("ascii")),
dst_state_indices=dst_state_indices,
decode_prefix_len=(
int(msg[8].decode("ascii")) if len(msg) > 8 and msg[8] != b"" else None
), # hacky just add it into the message that will be sent
is_dummy_rank=(
bool(int(msg[9].decode("ascii")))
if len(msg) > 9 and msg[9] != b""
else None
),
decode_prefix_len=decode_prefix_len,
is_dummy=is_dummy,
)
@@ -399,6 +407,11 @@ class TransferStatus:
class NixlKVManager(StagingManagerMixin, CommonKVManager):
# The decode control socket multiplexes tagged messages, so the status
# message is tagged too. It is new to NIXL, hence free to carry the reason.
kv_status_msg_tag = b"KV_STATUS"
kv_status_msg_carries_reason = True
def __init__(
self,
args: KVArgs,
@@ -523,9 +536,7 @@ class NixlKVManager(StagingManagerMixin, CommonKVManager):
)
if self.enable_staging:
self._init_staging_decode_ctx()
self._staging_handler = None
if self.enable_staging or self.enable_deferred_decode_kv_release:
self._start_decode_listener_thread()
self._start_decode_listener_thread()
self._start_heartbeat_checker_thread()
else:
raise ValueError(
@@ -595,15 +606,19 @@ class NixlKVManager(StagingManagerMixin, CommonKVManager):
self._staging_ctx.room_receivers[room] = receiver
def _start_decode_listener_thread(self):
"""Decode-side ZMQ listener for STAGING_REQ and ABORT_ACK. A thread, not
NIXL notifs: the decode agent has no progress thread, so notifs only drain
inside a live receiver's poll() and would be missed while idle."""
"""Decode-side ZMQ listener for KV_STATUS, STAGING_REQ and ABORT_ACK. A
thread, not NIXL notifs: the decode agent has no progress thread, so notifs
only drain inside a live receiver's poll() and would be missed while idle.
Started unconditionally: KV_STATUS carries prefill-side transfer failures,
which are independent of staging and deferred KV release."""
def decode_listener_thread():
while True:
msg = self.server_socket.recv_multipart()
if msg[0] == b"STAGING_REQ":
self._handle_staging_req(msg)
if self.enable_staging:
self._handle_staging_req(msg)
continue
if msg[0] == b"ABORT_ACK":
# Drain ack for an aborted room; aggregate per prefill rank.
@@ -612,6 +627,16 @@ class NixlKVManager(StagingManagerMixin, CommonKVManager):
int(msg[1].decode("ascii")), int(msg[2].decode("ascii"))
)
continue
parsed = self.parse_kv_status_message(msg)
if parsed is not None:
room, status, prefill_rank, reason = parsed
self.apply_prefill_status(
bootstrap_room=room,
status=status,
prefill_rank=prefill_rank,
failure_reason=reason,
)
continue
logger.warning(
"decode_listener_thread: unexpected message tag %s",
msg[0][:20],
@@ -634,7 +659,7 @@ class NixlKVManager(StagingManagerMixin, CommonKVManager):
room_infos = self.transfer_infos.get(room, {})
needs_staging = any(
not tinfo.is_dummy()
not tinfo.is_dummy
and tinfo.agent_name in self.decode_kv_args_table
and self.decode_kv_args_table[tinfo.agent_name].decode_tp_size
!= self.attn_tp_size
@@ -662,11 +687,44 @@ class NixlKVManager(StagingManagerMixin, CommonKVManager):
def check_status(self, bootstrap_room: int):
return self.request_status.get(bootstrap_room, KVPoll.WaitingForInput)
def update_status(self, bootstrap_room: int, status: KVPoll):
# Keep Failed sticky until the sender clears the room.
if self.request_status.get(bootstrap_room) == KVPoll.Failed:
return
super().update_status(bootstrap_room, status)
def _await_handles(
self, handles: List[Any], *, failure_seen: bool
) -> Tuple[bool, bool]:
"""Poll until every handle settled. Returns ``(settled, any_failed)``.
The wait is unbounded while every handle is still healthy, and bounded
to NIXL_ERR_SETTLE_TIMEOUT_S from the moment the batch is known broken.
``failure_seen`` arms that deadline up front, for a batch that raised
before the barrier ran. A state that cannot be read counts as running,
since it does not prove the write into the decode's pages is over.
"""
deadline = time.time() + NIXL_ERR_SETTLE_TIMEOUT_S if failure_seen else None
while True:
all_settled = True
any_failed = failure_seen
try:
for handle in handles:
state = self.agent.check_xfer_state(handle)
if state == "ERR":
any_failed = True
elif state != "DONE":
all_settled = False
except Exception as e:
logger.warning(f"Failed to read NIXL transfer state: {e}")
return False, True
if all_settled:
return True, any_failed
if not any_failed:
time.sleep(0)
continue
# This room is already lost, so trade its notification for the
# worker's other rooms: back off, and give up waiting for the
# siblings once the deadline passes.
if deadline is None:
deadline = time.time() + NIXL_ERR_SETTLE_TIMEOUT_S
elif time.time() >= deadline:
return False, True
time.sleep(NIXL_ERR_SETTLE_POLL_S)
def _prep_equal_tp_dlist(
self,
@@ -1095,6 +1153,7 @@ class NixlKVManager(StagingManagerMixin, CommonKVManager):
kv_chunk: TransferKVChunk = queue.get()
room = kv_chunk.room
handles: List[Any] = []
settle_timed_out = False
try:
if room not in self.request_status:
logger.debug(
@@ -1154,7 +1213,7 @@ class NixlKVManager(StagingManagerMixin, CommonKVManager):
for req in reqs_to_be_processed:
assert room == req.room
if req.is_dummy():
if req.is_dummy:
continue
assert req.agent_name in self.decode_kv_args_table
@@ -1353,19 +1412,19 @@ class NixlKVManager(StagingManagerMixin, CommonKVManager):
# Chunk has been re-enqueued; do not advance status.
continue
while handles:
all_done = True
for handle in handles:
state = self.agent.check_xfer_state(handle)
if state == "ERR":
raise RuntimeError(
f"NIXL transfer encountered ERR room={room}"
)
if state != "DONE":
all_done = False
if all_done:
break
time.sleep(0)
# Raise only once every handle of this batch settled, not on the
# first ERR: a sibling still in PROC keeps writing into the
# decode's KV pages, and the failure path below tells the decode
# those pages are free.
settled, any_failed = self._await_handles(handles, failure_seen=False)
if not settled:
settle_timed_out = True
raise RuntimeError(
f"NIXL transfer for room {room} left a handle running "
f"{NIXL_ERR_SETTLE_TIMEOUT_S}s after a peer handle failed"
)
if any_failed:
raise RuntimeError(f"NIXL transfer encountered ERR room={room}")
self._staging_outstanding[room] -= 1
if self.enable_deferred_decode_kv_release:
@@ -1409,10 +1468,20 @@ class NixlKVManager(StagingManagerMixin, CommonKVManager):
f"Unexpected transfer worker error for room {room}"
)
self.exceptions[room] = e
self.record_failure(room, str(e))
self.update_status(room, KVPoll.Failed)
# No ack here on purpose: the DONE barrier bails on the first
# ERR, so siblings may still be writing; fall back to the timeout.
# An exception raised while the batch was still being built
# leaves the handles posted so far running, so settle here too
# rather than only after the barrier.
notify = False
if not settle_timed_out:
notify, _ = self._await_handles(handles, failure_seen=True)
if notify:
self.conclude_failure(bootstrap_room=room, failure_reason=str(e))
else:
# A handle can still write into the decode's KV pages, so
# leave the room to the decode's waiting timeout rather
# than telling it those pages are free.
self.record_failure(room, str(e))
self.update_status(room, KVPoll.Failed)
def register_buffer_to_engine(self):
self.kv_descs = []
@@ -3002,6 +3071,13 @@ class NixlKVReceiver(CommonKVReceiver):
super().__init__(mgr, bootstrap_addr, bootstrap_room)
self.init_time = None
def clear(self) -> None:
super().clear()
# transfer_statuses is NIXL's own per-room bookkeeping -- the other
# backends track completion through prefill_response_tracker, which
# CommonKVReceiver.clear() already drops -- so it needs its own pop.
self.kv_mgr.transfer_statuses.pop(self.bootstrap_room, None)
def send_metadata(
self,
kv_indices: npt.NDArray[np.int32],
@@ -3096,11 +3172,7 @@ class NixlKVReceiver(CommonKVReceiver):
# deadline would otherwise lose to the timeout purely by poll ordering.
self.kv_mgr.update_transfer_status()
if self.kv_mgr.check_transfer_done(self.bootstrap_room): # type: ignore
self.kv_mgr.addr_to_rooms_tracker[self.bootstrap_addr].discard(
self.bootstrap_room
)
self.conclude_state = KVPoll.Success
del self.kv_mgr.transfer_statuses[self.bootstrap_room]
return self.conclude_state # type: ignore
timeout_result = self._check_waiting_timeout()
@@ -3201,6 +3273,11 @@ class NixlKVReceiver(CommonKVReceiver):
return True
def failure_exception(self):
if self.conclude_state is None:
self.conclude_state = KVPoll.Failed
self.clear()
with self.kv_mgr.failure_lock:
failure_reason = self.kv_mgr.failure_records.pop(self.bootstrap_room, None)
is_propagated = failure_reason is None