59a0a3844c
PR Test AMD / cancel-on-close (push) Has been skipped
PR Test NVIDIA ARM / scan (push) Has been skipped
PR Test NVIDIA / cancel-on-close (push) Has been skipped
PR Test AMD / scan (push) Has been skipped
PR Test NVIDIA ARM / cancel-on-close (push) Has been skipped
PR Test NVIDIA / scan (push) Has been skipped
Release Docker Images / build (cu129-torch-2.11.0) (push) Has been skipped
Release Docker Images / build (cu130-torch-2.11.0) (push) Has been skipped
Release PyPI / publish (push) Has been skipped
Scheduler Python Test / test (push) Successful in 27m19s
Docs / build (push) Successful in 28m8s
Scheduler C++ Test / test (push) Successful in 28m19s
Scheduler C++ Test / test-flat (push) Successful in 28m18s
Docs / deploy (push) Has been cancelled
PR Test AMD / finish (push) Has been cancelled
PR Test NVIDIA / finish (push) Has been cancelled
PR Test NVIDIA ARM / finish (push) Has been cancelled
PR Test NVIDIA ARM / ${{ matrix.name }} (${{ matrix.runner }}) (push) Has been cancelled
PR Test AMD / ${{ matrix.name }} (${{ matrix.runner }}) (push) Has been cancelled
PR Test NVIDIA / ${{ matrix.name }} (${{ matrix.runner }}) (push) Has been cancelled
723 lines
33 KiB
Python
723 lines
33 KiB
Python
# Copyright (c) 2026 LightSeek Foundation
|
|
#
|
|
# Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
# of this software and associated documentation files (the "Software"), to deal
|
|
# in the Software without restriction, including without limitation the rights
|
|
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
# copies of the Software, and to permit persons to whom the Software is
|
|
# furnished to do so, subject to the following conditions:
|
|
#
|
|
# The above copyright notice and this permission notice shall be included in
|
|
# all copies or substantial portions of the Software.
|
|
#
|
|
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
# SOFTWARE.
|
|
|
|
"""Mooncake transport for EPD encode->prefill image-embedding transfer.
|
|
|
|
The encode (vision-tower-only) server is the data source: it calls
|
|
``batch_transfer_sync`` to ship a contiguous ``[num_tokens, hidden]`` tensor
|
|
per item over the same Mooncake RDMA engine the KV path uses. The prefill side
|
|
is the receiver that pre-registers buffers and registers with the bootstrap
|
|
server (direction reversed relative to prefill->decode).
|
|
|
|
Wire-frame dataclasses live in :mod:`tokenspeed.runtime.pd.epd.entities`: they
|
|
are pure data plus codecs and import no torch, so the protocol contract stays
|
|
importable and unit-testable on CPU.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import concurrent.futures
|
|
import os
|
|
import threading
|
|
import time
|
|
from typing import TYPE_CHECKING
|
|
|
|
import requests
|
|
import zmq
|
|
|
|
from tokenspeed.runtime.pd.base.status import TransferPoll
|
|
from tokenspeed.runtime.pd.epd.conn import (
|
|
MooncakeEmbeddingManagerBase,
|
|
)
|
|
from tokenspeed.runtime.pd.epd.entities import (
|
|
REGISTER_ROOM_SENTINEL,
|
|
EmbeddingArgs,
|
|
EmbeddingArgsRegisterInfo,
|
|
EmbeddingChunk,
|
|
EmbeddingManagerArgs,
|
|
EmbeddingTransferError,
|
|
EmbeddingTransferInfo,
|
|
)
|
|
from tokenspeed.runtime.pd.utils import DisaggregationMode, FastQueue
|
|
from tokenspeed.runtime.utils import get_colorful_logger
|
|
from tokenspeed.runtime.utils.env import envs
|
|
from tokenspeed.runtime.utils.network import get_free_port, get_local_ip_by_remote
|
|
|
|
logger = get_colorful_logger(__name__)
|
|
|
|
if TYPE_CHECKING:
|
|
import torch
|
|
|
|
|
|
def _route_get(bootstrap_addr: str, engine_rank: int, target_dp_group: int):
|
|
"""GET the bootstrap server's /route endpoint; ``None`` on any failure.
|
|
|
|
Must never raise: callers in the receiver ``__init__`` treat ``None`` as a
|
|
per-room failure, but an uncaught exception would escape the prefill
|
|
scheduler thread and take the whole engine down via SIGUSR1.
|
|
"""
|
|
url = (
|
|
f"http://{bootstrap_addr}/route?"
|
|
f"engine_rank={engine_rank}&target_dp_group={target_dp_group}"
|
|
)
|
|
try:
|
|
resp = requests.get(url, timeout=5)
|
|
except Exception as e: # noqa: BLE001 -- any transport failure -> per-room fail
|
|
logger.error("EPD bootstrap /route fetch failed (%s): %s", url, e)
|
|
return None
|
|
if resp.status_code == 200:
|
|
return resp.json()
|
|
return None
|
|
|
|
|
|
def _b(value: object) -> bytes:
|
|
return str(value).encode("ascii")
|
|
|
|
|
|
def validate_fanout_frames(
|
|
infos: list[EmbeddingTransferInfo], chunk: "EmbeddingChunk"
|
|
) -> str | None:
|
|
"""Pre-send contract check over a room's full fanout set; None when valid.
|
|
|
|
Per frame: hidden/dtype must match the chunk; ``span`` (every frame carries
|
|
the image's full row count) must equal the chunk's token count (the
|
|
unchecked RDMA write silently truncates on divergence); the shard must stay
|
|
inside the chunk's rows; and deepstack presence must agree on both sides.
|
|
The set must then be either ALL identity (each frame covers the full span --
|
|
full-copy broadcast) or a proper shard set whose non-empty shards, sorted by
|
|
``row_start``, tile a contiguous range disjointly (an encode rank under
|
|
encode_tp>1 serves a contiguous BLOCK of the global shards). A gap/overlap
|
|
means the two sides' shard math diverged, so the room must fail loud.
|
|
"""
|
|
for info in infos:
|
|
if not (info.hidden == chunk.hidden and info.dtype == chunk.dtype):
|
|
return "embedding shape/dtype contract violated"
|
|
if info.span != chunk.n_tokens:
|
|
return (
|
|
f"image token-count contract violated: receiver expects "
|
|
f"{info.span} rows, encode has {chunk.n_tokens} (G2)"
|
|
)
|
|
if (
|
|
info.row_start < 0
|
|
or info.n_tokens < 0
|
|
or info.row_start + info.n_tokens > chunk.n_tokens
|
|
):
|
|
return (
|
|
f"embedding shard out of range: rows [{info.row_start}, "
|
|
f"{info.row_start + info.n_tokens}) of {chunk.n_tokens}"
|
|
)
|
|
if info.has_deepstack and not chunk.deepstack_width:
|
|
return (
|
|
"receiver expects deepstack but the chunk carries none "
|
|
"(encode-side cache hit without its deepstack half?)"
|
|
)
|
|
if chunk.deepstack_width and not info.has_deepstack:
|
|
return "chunk carries deepstack but the receiver did not allocate for it"
|
|
if all(i.row_start == 0 and i.n_tokens == chunk.n_tokens for i in infos):
|
|
return None # identity (full-copy) mode
|
|
shards = sorted((i.row_start, i.n_tokens) for i in infos if i.n_tokens > 0)
|
|
for (start, count), (next_start, _next_count) in zip(shards, shards[1:]):
|
|
if start + count != next_start:
|
|
return (
|
|
"embedding shard frames do not tile contiguously: "
|
|
f"[{start}, {start + count}) then [{next_start}, ...)"
|
|
)
|
|
return None
|
|
|
|
|
|
def shard_payload(
|
|
chunk: "EmbeddingChunk", info: EmbeddingTransferInfo
|
|
) -> tuple[int, int, int, int]:
|
|
"""Source pointer/length math for one receiver's row shard of a chunk.
|
|
|
|
Returns ``(src_ptr, nbytes, src_deepstack_ptr, deepstack_nbytes)`` for the
|
|
rows ``[info.row_start, info.row_start + info.n_tokens)``. Identity frames
|
|
reproduce the whole-chunk payload exactly. Row strides are derived from the
|
|
chunk's own byte counts so dtype size never needs decoding here.
|
|
"""
|
|
row_bytes = chunk.nbytes // chunk.n_tokens if chunk.n_tokens else 0
|
|
src = chunk.src_embedding_ptr + info.row_start * row_bytes
|
|
nbytes = info.n_tokens * row_bytes
|
|
deep_src = deep_nbytes = 0
|
|
if chunk.deepstack_width and chunk.deepstack_nbytes:
|
|
deep_row_bytes = (
|
|
chunk.deepstack_nbytes // chunk.n_tokens if chunk.n_tokens else 0
|
|
)
|
|
deep_src = chunk.src_deepstack_ptr + info.row_start * deep_row_bytes
|
|
deep_nbytes = info.n_tokens * deep_row_bytes
|
|
return src, nbytes, deep_src, deep_nbytes
|
|
|
|
|
|
class MooncakeEmbeddingSender:
|
|
"""Encode-side per-request sender for one bootstrap room.
|
|
|
|
``poll`` / ``clear`` / ``failure_exception`` are status-only (they touch
|
|
only the manager's status maps). ``send`` queues one contiguous embedding
|
|
tensor described by scalar fields, keeping this class free of any torch
|
|
dependency.
|
|
"""
|
|
|
|
def __init__(self, mgr, bootstrap_addr: str, bootstrap_room: int):
|
|
self.mgr = mgr # MooncakeEmbeddingManagerEncode
|
|
self.bootstrap_server_url = bootstrap_addr
|
|
self.bootstrap_room = bootstrap_room
|
|
self.mgr.update_status(bootstrap_room, TransferPoll.Bootstrapping)
|
|
self.conclude_state = None
|
|
|
|
def send(
|
|
self,
|
|
*,
|
|
src_embedding_ptr: int,
|
|
n_tokens: int,
|
|
hidden: int,
|
|
dtype: str,
|
|
nbytes: int,
|
|
src_deepstack_ptr: int = 0,
|
|
deepstack_width: int = 0,
|
|
deepstack_nbytes: int = 0,
|
|
copy_event: torch.cuda.Event | None = None,
|
|
) -> None:
|
|
chunk = EmbeddingChunk(
|
|
room=self.bootstrap_room,
|
|
src_embedding_ptr=src_embedding_ptr,
|
|
n_tokens=n_tokens,
|
|
hidden=hidden,
|
|
dtype=dtype,
|
|
nbytes=nbytes,
|
|
src_deepstack_ptr=src_deepstack_ptr,
|
|
deepstack_width=deepstack_width,
|
|
deepstack_nbytes=deepstack_nbytes,
|
|
copy_event=copy_event,
|
|
)
|
|
self.mgr.add_transfer_request(self.bootstrap_room, chunk)
|
|
|
|
def poll(self) -> int:
|
|
# No Bootstrapping timeout here: the never-registered-receiver case is
|
|
# handled by the manager's _park_reaper (it fails rooms whose parked chunk
|
|
# outlives bootstrap_time_out), so this only reports terminal status.
|
|
if self.conclude_state is None:
|
|
status = self.mgr.check_status(self.bootstrap_room)
|
|
if status in (TransferPoll.Success, TransferPoll.Failed):
|
|
self.conclude_state = status
|
|
return status
|
|
return self.conclude_state
|
|
|
|
def clear(self) -> None:
|
|
if self.bootstrap_room in self.mgr.request_status:
|
|
self.mgr.request_status.pop(self.bootstrap_room)
|
|
|
|
def failure_exception(self):
|
|
if self.conclude_state is None:
|
|
self.conclude_state = TransferPoll.Failed
|
|
self.clear()
|
|
with self.mgr.failure_lock:
|
|
failure_reason = self.mgr.failure_records.pop(
|
|
self.bootstrap_room, "Failed due to an unknown reason from another rank"
|
|
)
|
|
raise EmbeddingTransferError(
|
|
self.bootstrap_room, failure_reason, self.bootstrap_server_url
|
|
)
|
|
|
|
|
|
class MooncakeEmbeddingManagerEncode(MooncakeEmbeddingManagerBase):
|
|
"""Encode-side (data source) manager: registers itself to the bootstrap
|
|
server so the prefill receiver can find it, listens for the receiver's
|
|
registration + per-request pre-alloc frames, and a worker thread issues the
|
|
one-sided Mooncake write of the embedding.
|
|
"""
|
|
|
|
def __init__(self, args: EmbeddingManagerArgs, embedding_args: EmbeddingArgs):
|
|
super().__init__(args, embedding_args, DisaggregationMode.ENCODE)
|
|
# room -> {receiver_session -> EmbeddingTransferInfo}
|
|
self.transfer_infos: dict[int, dict[str, EmbeddingTransferInfo]] = {}
|
|
# Bootstrap registration-wait timeout (default 120s).
|
|
self.bootstrap_time_out = envs.TOKENSPEED_DISAGGREGATION_BOOTSTRAP_TIMEOUT.get()
|
|
# room -> [(deadline, chunk), ...] chunks popped before the receiver
|
|
# registered; flushed by the bootstrap thread once its info arrives.
|
|
self._pending: dict[int, list[tuple]] = {}
|
|
self._pending_lock = threading.Lock()
|
|
# zmq sockets are not thread-safe and status pushes come from any
|
|
# fanout-pool thread: keep one PUSH socket per (thread, endpoint).
|
|
self._status_tls = threading.local()
|
|
self._start_bootstrap_thread()
|
|
self._register_to_bootstrap()
|
|
# K queues sharded by ROOM: a single consumer per room preserves the
|
|
# park/flush/straggler-drop ordering the parking machinery relies on.
|
|
# Each queue's worker uses a pool to issue one room's per-receiver writes
|
|
# concurrently (each prefill rank is a distinct Mooncake session, so a
|
|
# single batch call cannot span them).
|
|
cpu_count = os.cpu_count() or 8
|
|
pool_size = envs.TOKENSPEED_DISAGGREGATION_THREAD_POOL_SIZE.get_set_value_or(
|
|
min(max(4, int(0.75 * cpu_count) // 8), 12)
|
|
)
|
|
queue_count = envs.TOKENSPEED_DISAGGREGATION_QUEUE_SIZE.get()
|
|
assert pool_size >= queue_count, (
|
|
f"TOKENSPEED_DISAGGREGATION_THREAD_POOL_SIZE={pool_size} must be >= "
|
|
f"TOKENSPEED_DISAGGREGATION_QUEUE_SIZE={queue_count}"
|
|
)
|
|
self._queues: list[FastQueue] = [FastQueue() for _ in range(queue_count)]
|
|
self._executors = [
|
|
concurrent.futures.ThreadPoolExecutor(max(1, pool_size // queue_count))
|
|
for _ in range(queue_count)
|
|
]
|
|
for queue, executor in zip(self._queues, self._executors):
|
|
threading.Thread(
|
|
target=self._transfer_worker, args=(queue, executor), daemon=True
|
|
).start()
|
|
threading.Thread(target=self._park_reaper, daemon=True).start()
|
|
|
|
def _start_bootstrap_thread(self):
|
|
self.rank_port = get_free_port()
|
|
self.server_socket.bind(f"tcp://{get_local_ip_by_remote()}:{self.rank_port}")
|
|
|
|
def loop():
|
|
while True:
|
|
msg = self.server_socket.recv_multipart()
|
|
# A malformed frame must not kill this daemon thread: a dead
|
|
# listener drops every later registration, parking all chunks
|
|
# until the reaper fails them. Log and continue.
|
|
try:
|
|
if msg[0].decode("ascii") == REGISTER_ROOM_SENTINEL:
|
|
# registration frame: consumed, no per-session state needed
|
|
pass
|
|
else:
|
|
info = EmbeddingTransferInfo.from_zmq(msg)
|
|
self.transfer_infos.setdefault(info.room, {})[
|
|
info.mooncake_session_id
|
|
] = info
|
|
if (
|
|
len(self.transfer_infos[info.room])
|
|
>= info.required_dst_info_num
|
|
):
|
|
self.update_status(info.room, TransferPoll.Bootstrapped)
|
|
# Re-drive any chunk parked before this room registered.
|
|
self._flush_pending(info.room)
|
|
except Exception as exc: # noqa: BLE001
|
|
logger.error(
|
|
"dropping malformed embedding bootstrap frame "
|
|
"(%d parts): %s",
|
|
len(msg),
|
|
exc,
|
|
)
|
|
|
|
threading.Thread(target=loop, daemon=True).start()
|
|
|
|
def _register_to_bootstrap(self):
|
|
ip = get_local_ip_by_remote()
|
|
bootstrap_host = self.args.bootstrap_host or ip
|
|
payload = {
|
|
"role": "Prefill", # bootstrap-server role string for "discoverable data source"
|
|
"world_size": self.world_size,
|
|
"dp_size": self.dp_size,
|
|
"rank_ip": ip,
|
|
"rank_port": self.rank_port,
|
|
"engine_rank": self.embedding_args.engine_rank,
|
|
}
|
|
url = f"http://{bootstrap_host}:{self.bootstrap_port}/route"
|
|
# The bootstrap HTTP server starts concurrently with this call, so the
|
|
# first PUTs can race it and hit connection-refused. A dropped
|
|
# registration leaves /route's parallel-info null and the encode silently
|
|
# serves no embeddings, so retry until the server accepts it.
|
|
last_err = None
|
|
for _ in range(60):
|
|
try:
|
|
resp = requests.put(url, json=payload, timeout=5)
|
|
if resp.ok:
|
|
return
|
|
last_err = f"status {resp.status_code}"
|
|
except Exception as e: # noqa: BLE001
|
|
last_err = e
|
|
time.sleep(0.5)
|
|
logger.error(
|
|
"encode failed to register to bootstrap server after retries: %s", last_err
|
|
)
|
|
|
|
def add_transfer_request(self, room: int, chunk: EmbeddingChunk) -> None:
|
|
if (
|
|
room not in self.request_status
|
|
or self.check_status(room) == TransferPoll.Failed
|
|
):
|
|
return
|
|
self._queue_for(room).put(chunk)
|
|
|
|
def _queue_for(self, room: int) -> FastQueue:
|
|
# Room-affinity sharding: one consumer per room keeps the park /
|
|
# _flush_pending re-enqueue / Success-straggler-drop sequence
|
|
# single-threaded per room.
|
|
return self._queues[room % len(self._queues)]
|
|
|
|
def _transfer_worker(
|
|
self, queue: FastQueue, executor: concurrent.futures.ThreadPoolExecutor
|
|
):
|
|
while True:
|
|
chunk: EmbeddingChunk = queue.get()
|
|
# Already concluded: a re-enqueued straggler copy from _flush_pending
|
|
# (which re-puts a parked chunk on each receiver registration). The
|
|
# room was already sent + popped, so drop it.
|
|
if self.request_status.get(chunk.room) == TransferPoll.Success:
|
|
continue
|
|
# Snapshot via list(): the bootstrap listener inserts into this inner
|
|
# dict without _pending_lock, so next(iter(...)) could raise "dict changed
|
|
# size during iteration". list() is GIL-atomic.
|
|
info_vals = list(self.transfer_infos.get(chunk.room, {}).values())
|
|
# required_dst_info_num (= fanout) is identical across the N frames for a
|
|
# room, so read it off any registered frame; None until one registers.
|
|
need = info_vals[0].required_dst_info_num if info_vals else None
|
|
if (need is None or len(info_vals) < need) and self.request_status.get(
|
|
chunk.room
|
|
) != TransferPoll.Failed:
|
|
# Fewer than N receivers registered (1->N broadcast not yet complete):
|
|
# park (don't drop) until the full set arrives. _flush_pending re-drives
|
|
# this chunk on every new registration; the reaper fails it on timeout.
|
|
parked = True
|
|
with self._pending_lock:
|
|
info_vals = list(self.transfer_infos.get(chunk.room, {}).values())
|
|
need = info_vals[0].required_dst_info_num if info_vals else None
|
|
if info_vals and need is not None and len(info_vals) >= need:
|
|
# Full set landed inside the park window: send below instead.
|
|
parked = False
|
|
else:
|
|
self._pending.setdefault(chunk.room, []).append(
|
|
(time.time() + self.bootstrap_time_out, chunk)
|
|
)
|
|
if parked:
|
|
continue
|
|
# All N receivers registered: serve every one concurrently (full copy
|
|
# in identity mode, its row shard otherwise) -- each is a distinct
|
|
# Mooncake session, so concurrency must come from threads.
|
|
infos = list(self.transfer_infos.get(chunk.room, {}).values())
|
|
err = validate_fanout_frames(infos, chunk)
|
|
if err is not None:
|
|
self._fail_room(chunk.room, err, infos)
|
|
continue
|
|
# Wait the ring copy's completion HERE on the daemon (not the
|
|
# encode-loop thread): the one-sided RDMA reads in _send touch GPU
|
|
# memory off any CUDA stream, so they must not precede the device copy
|
|
# that filled the slot (ViT->send corruption hazard). One wait per
|
|
# chunk covers all N receivers (same slot); event.synchronize()
|
|
# releases the GIL while waiting.
|
|
if chunk.copy_event is not None:
|
|
chunk.copy_event.synchronize()
|
|
futures = [executor.submit(self._send, info, chunk) for info in infos]
|
|
failed = False
|
|
for info, future in zip(infos, futures):
|
|
ret = future.result() # _send returns <0 on error, never raises
|
|
if ret != 0:
|
|
self.record_failure(chunk.room, f"mooncake transfer ret={ret}")
|
|
failed = True
|
|
else:
|
|
# Per-receiver completion sync (each of the N prefill ranks gets
|
|
# exactly one, matching its required_response_num == 1).
|
|
self._sync_status(info, TransferPoll.Success)
|
|
if failed:
|
|
# Push Failed to EVERY receiver (idempotent on ones already
|
|
# Success'd: the rank-synced admission MIN aborts the request
|
|
# everywhere anyway).
|
|
self._fail_room(chunk.room, None, infos)
|
|
continue
|
|
# All N receivers served: mark the room Success ONCE and pop only now,
|
|
# so a partial fanout can never conclude/pop early.
|
|
self.update_status(chunk.room, TransferPoll.Success)
|
|
self.transfer_infos.pop(chunk.room, None)
|
|
|
|
def _fail_room(
|
|
self,
|
|
room: int,
|
|
reason: str | None,
|
|
infos: list[EmbeddingTransferInfo],
|
|
) -> None:
|
|
if reason is not None:
|
|
self.record_failure(room, reason)
|
|
self.update_status(room, TransferPoll.Failed)
|
|
for info in infos:
|
|
self._sync_status(info, TransferPoll.Failed)
|
|
|
|
def is_parked(self, room: int) -> bool:
|
|
"""Whether ``room`` still has a chunk parked awaiting receiver
|
|
registration. Public probe for the executor's ring-slot lease: a parked
|
|
chunk holds its slot's pointer for re-send, so the slot is not reusable
|
|
until the room unparks."""
|
|
with self._pending_lock:
|
|
return room in self._pending
|
|
|
|
def fail_room(self, room: int, reason: str | None) -> None:
|
|
"""Conclude ``room`` Failed and push Failed to all of its registered
|
|
receivers. Public seam for the encode executor, which must not reach into
|
|
``transfer_infos`` or the status FSM directly."""
|
|
infos = list(self.transfer_infos.get(room, {}).values())
|
|
self._fail_room(room, reason, infos)
|
|
|
|
def _flush_pending(self, room: int) -> None:
|
|
# Re-enqueue this room's parked chunks (in order) now that it registered.
|
|
# Same room -> same queue, so the re-driven copy is consumed by the same
|
|
# single worker that parked it.
|
|
with self._pending_lock:
|
|
parked = self._pending.pop(room, [])
|
|
for _deadline, chunk in parked:
|
|
self._queue_for(room).put(chunk)
|
|
|
|
def _park_reaper(self):
|
|
# Fail rooms whose parked chunk outlived bootstrap_time_out (the
|
|
# receiver never registered), so an aborted request never hangs.
|
|
while True:
|
|
time.sleep(1.0)
|
|
now = time.time()
|
|
with self._pending_lock:
|
|
expired = [
|
|
room
|
|
for room, parked in self._pending.items()
|
|
if parked and now >= parked[0][0]
|
|
]
|
|
for room in expired:
|
|
self._pending.pop(room, None)
|
|
for room in expired:
|
|
self.record_failure(
|
|
room, f"receiver never registered within {self.bootstrap_time_out}s"
|
|
)
|
|
self.update_status(room, TransferPoll.Failed)
|
|
|
|
def _send(self, info: EmbeddingTransferInfo, chunk: EmbeddingChunk) -> int:
|
|
src, nbytes, deep_src, deep_nbytes = shard_payload(chunk, info)
|
|
if nbytes == 0:
|
|
# Zero-row shard (image span < shard count): nothing to write, but
|
|
# the frame already served as this receiver's registration and it
|
|
# still gets its Success push so its poll() completes.
|
|
return 0
|
|
bufs = [src]
|
|
dsts = [info.dst_embedding_ptr]
|
|
lens = [nbytes]
|
|
if deep_nbytes and info.dst_deepstack_ptr:
|
|
bufs.append(deep_src)
|
|
dsts.append(info.dst_deepstack_ptr)
|
|
lens.append(deep_nbytes)
|
|
# Always go through the BATCH transfer API, even for a single buffer: its
|
|
# binding releases the GIL for the one-sided RDMA write
|
|
# (gil_scoped_release), while singular transfer_sync_write does not
|
|
# reliably, pinning the GIL for the whole write and freezing this daemon's
|
|
# loop + other daemons. A 1-element batch is byte-for-byte the same
|
|
# transfer.
|
|
return self.engine.batch_transfer_sync(
|
|
info.mooncake_session_id, bufs, dsts, lens
|
|
)
|
|
|
|
def _sync_status(self, info: EmbeddingTransferInfo, status: int) -> None:
|
|
# One socket per (thread, endpoint): pushes come from fanout-pool threads
|
|
# and zmq sockets are not thread-safe.
|
|
socks = getattr(self._status_tls, "socks", None)
|
|
if socks is None:
|
|
socks = self._status_tls.socks = {}
|
|
endpoint = f"tcp://{info.endpoint}:{info.dst_port}"
|
|
sock = socks.get(endpoint)
|
|
if sock is None:
|
|
sock = zmq.Context.instance().socket(zmq.PUSH)
|
|
sock.connect(endpoint)
|
|
socks[endpoint] = sock
|
|
sock.send_multipart(
|
|
[_b(info.room), _b(int(status)), _b(self.embedding_args.engine_rank)]
|
|
)
|
|
|
|
|
|
class MooncakeEmbeddingManagerPrefill(MooncakeEmbeddingManagerBase):
|
|
"""Prefill-side (data sink) manager: holds the discovery caches and a thread
|
|
that consumes the encode side's per-request completion-status frames, marking
|
|
the room Success once all expected responses arrive.
|
|
"""
|
|
|
|
def __init__(self, args: EmbeddingManagerArgs, embedding_args: EmbeddingArgs):
|
|
super().__init__(args, embedding_args, DisaggregationMode.PREFILL)
|
|
self.required_response_num: dict[int, int] = {}
|
|
self.response_tracker: dict[int, set] = {}
|
|
self.connection_pool: dict[str, list] = {}
|
|
self.prefill_parallel_info: dict[str, dict] = {}
|
|
self._start_status_thread()
|
|
|
|
def _start_status_thread(self):
|
|
self.rank_port = get_free_port()
|
|
self.server_socket.bind(f"tcp://{get_local_ip_by_remote()}:{self.rank_port}")
|
|
|
|
def loop():
|
|
while True:
|
|
parts = self.server_socket.recv_multipart()
|
|
room = int(parts[0].decode("ascii"))
|
|
status = int(parts[1].decode("ascii"))
|
|
rank = int(parts[2].decode("ascii"))
|
|
if status == TransferPoll.Success and room in self.request_status:
|
|
self.response_tracker.setdefault(room, set()).add(rank)
|
|
if len(
|
|
self.response_tracker[room]
|
|
) >= self.required_response_num.get(room, 1):
|
|
self.update_status(room, TransferPoll.Success)
|
|
elif status == TransferPoll.Failed:
|
|
self.record_failure(room, "encode failed to send embedding")
|
|
self.update_status(room, TransferPoll.Failed)
|
|
|
|
threading.Thread(target=loop, daemon=True).start()
|
|
|
|
|
|
class MooncakeEmbeddingReceiver:
|
|
"""Prefill-side per-request receiver: discovers the encode endpoint via the
|
|
bootstrap server, registers its receive buffer, and on ``pre_alloc`` tells
|
|
the encode side where/how big to write this request's embedding. 1->N
|
|
broadcast (prefill_tp a multiple of encode_tp): contiguous blocks of prefill
|
|
ranks pair one encode rank; encode_tp=1 -> all prefill ranks pair encode
|
|
rank 0 and receive the same TP-gathered embedding.
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
mgr: MooncakeEmbeddingManagerPrefill,
|
|
bootstrap_addr: str,
|
|
bootstrap_room: int,
|
|
):
|
|
self.mgr = mgr
|
|
self.bootstrap_addr = bootstrap_addr
|
|
self.bootstrap_room = bootstrap_room
|
|
self.session_id = mgr.get_session_id()
|
|
mgr.update_status(bootstrap_room, TransferPoll.Bootstrapping)
|
|
|
|
pinfo = mgr.prefill_parallel_info.get(bootstrap_addr)
|
|
if pinfo is None or pinfo.get("prefill_tp_size") is None:
|
|
# SINGLE-attempt fail-fast: this runs in the prefill scheduler thread,
|
|
# so a retry-loop would stall the whole TP group on a slow/unreachable
|
|
# encode bootstrap. /route can answer 200 with un-populated
|
|
# parallel-info while the encode worker is still registering (startup
|
|
# race); fail this room fast and let the client retry once the encode
|
|
# has registered. A partial dict is never cached (it would crash later
|
|
# requests on int(None)). _route_get bounds its own GET at timeout=5,
|
|
# so this attempt cannot block beyond that.
|
|
cand = _route_get(bootstrap_addr, -1, -1)
|
|
if cand is None or cand.get("prefill_tp_size") is None:
|
|
mgr.record_failure(
|
|
bootstrap_room, "no (complete) parallel info from bootstrap"
|
|
)
|
|
mgr.update_status(bootstrap_room, TransferPoll.Failed)
|
|
return
|
|
pinfo = cand
|
|
mgr.prefill_parallel_info[bootstrap_addr] = pinfo
|
|
|
|
encode_tp = int(pinfo["prefill_tp_size"])
|
|
encode_dp = int(pinfo["prefill_dp_size"])
|
|
local_tp = mgr.world_size // mgr.dp_size
|
|
# prefill_tp must be a whole multiple of encode_tp. The vision tower
|
|
# output is TP-gathered (identical on every encode rank) and every prefill
|
|
# rank needs the full embedding, so encode_tp=1 -> prefill_tp=N is a 1->N
|
|
# broadcast. Contiguous blocks of `fanout` prefill ranks share one encode
|
|
# rank.
|
|
assert local_tp % encode_tp == 0, (
|
|
f"EPD requires prefill_tp to be a multiple of encode_tp "
|
|
f"(encode_tp={encode_tp}, prefill_tp={local_tp})"
|
|
)
|
|
fanout = local_tp // encode_tp # prefill ranks served by one encode rank
|
|
# Drives the ENCODE-side gate: how many prefill ranks register with this
|
|
# request's encode rank before it may send + conclude (1->N broadcast).
|
|
self.required_dst_info_num = fanout
|
|
# This prefill rank still pulls from exactly ONE encode rank, so it expects
|
|
# a single completion sync. The N-way fan-out is purely the encode side's
|
|
# concern; bumping this to N would hang every prefill rank.
|
|
mgr.required_response_num[bootstrap_room] = 1
|
|
|
|
# Which encode rank this prefill rank pulls from: contiguous grouping.
|
|
# encode_tp=1 -> fanout=local_tp -> my_encode_rank == 0 for all prefill
|
|
# ranks. Use `// fanout`, NOT `% encode_tp`: both are 0 for encode_tp=1,
|
|
# but only `// fanout` groups ranks correctly for encode_tp>1.
|
|
my_tp_rank = mgr.embedding_args.engine_rank % local_tp
|
|
my_encode_rank = my_tp_rank // fanout
|
|
target_dp_group = bootstrap_room % encode_dp
|
|
key = f"{bootstrap_addr}_{target_dp_group}_{my_encode_rank}"
|
|
if key not in mgr.connection_pool:
|
|
info = _route_get(bootstrap_addr, my_encode_rank, target_dp_group)
|
|
if info is None:
|
|
mgr.record_failure(bootstrap_room, "no encode rank info from bootstrap")
|
|
mgr.update_status(bootstrap_room, TransferPoll.Failed)
|
|
return
|
|
self.encode_infos = [info]
|
|
mgr.connection_pool[key] = self.encode_infos
|
|
self._register_args()
|
|
else:
|
|
self.encode_infos = mgr.connection_pool[key]
|
|
mgr.update_status(bootstrap_room, TransferPoll.Bootstrapped)
|
|
|
|
def _register_args(self):
|
|
ea = self.mgr.embedding_args
|
|
reg = EmbeddingArgsRegisterInfo(
|
|
room=REGISTER_ROOM_SENTINEL,
|
|
endpoint=get_local_ip_by_remote(),
|
|
dst_port=self.mgr.rank_port,
|
|
mooncake_session_id=self.session_id,
|
|
dst_embedding_ptr=ea.embedding_data_ptr,
|
|
dst_deepstack_ptr=ea.deepstack_data_ptr,
|
|
)
|
|
for info in self.encode_infos:
|
|
sock = self.mgr._connect(f"tcp://{info['rank_ip']}:{info['rank_port']}")
|
|
sock.send_multipart(reg.to_zmq())
|
|
|
|
def pre_alloc(
|
|
self,
|
|
*,
|
|
dst_embedding_ptr: int,
|
|
n_tokens: int,
|
|
hidden: int,
|
|
dtype: str,
|
|
dst_deepstack_ptr: int = 0,
|
|
has_deepstack: bool = False,
|
|
row_start: int = 0,
|
|
span: int = 0,
|
|
) -> None:
|
|
"""Tell the encode side where/how big to write. In shard mode the caller
|
|
passes this rank's row sub-range: ``row_start`` within the image and
|
|
``n_tokens`` = the SHARD's row count, both dst pointers already offset to
|
|
the shard's first row. ``span`` is the image's FULL row count (pass it in
|
|
identity mode too: it is the encode side's token-count tripwire). A
|
|
``n_tokens == 0`` frame is still sent: it doubles as this receiver's
|
|
registration heartbeat (the encode-side fanout gate counts frames, not
|
|
bytes)."""
|
|
for info in self.encode_infos:
|
|
ti = EmbeddingTransferInfo(
|
|
room=self.bootstrap_room,
|
|
endpoint=get_local_ip_by_remote(),
|
|
dst_port=self.mgr.rank_port,
|
|
mooncake_session_id=self.session_id,
|
|
dst_embedding_ptr=dst_embedding_ptr,
|
|
dst_deepstack_ptr=dst_deepstack_ptr,
|
|
n_tokens=n_tokens,
|
|
hidden=hidden,
|
|
dtype=dtype,
|
|
has_deepstack=has_deepstack,
|
|
required_dst_info_num=self.required_dst_info_num,
|
|
row_start=row_start,
|
|
span=span,
|
|
)
|
|
sock = self.mgr._connect(f"tcp://{info['rank_ip']}:{info['rank_port']}")
|
|
sock.send_multipart(ti.to_zmq())
|
|
|
|
def poll(self) -> int:
|
|
return self.mgr.check_status(self.bootstrap_room)
|
|
|
|
def clear(self) -> None:
|
|
"""Drop this room's bookkeeping from the (singleton) prefill manager on a
|
|
terminal receive, else every request leaks its status/tracker entries.
|
|
Mirrors the KV receiver; called post-terminal so it can't race check_status."""
|
|
room = self.bootstrap_room
|
|
self.mgr.request_status.pop(room, None)
|
|
self.mgr.required_response_num.pop(room, None)
|
|
self.mgr.response_tracker.pop(room, None)
|
|
with self.mgr.failure_lock:
|
|
self.mgr.failure_records.pop(room, None)
|