Files
wehub-resource-sync 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
chore: import upstream snapshot with attribution
2026-07-13 12:32:31 +08:00

1093 lines
48 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.
"""EPD prefill-side receive glue: fill each multimodal item's ``encoded``
tensor from the Mooncake transfer instead of running the vision tower.
Inverse of ``encode_executor.assign_encoded_embeddings``: the encode worker
row-splits the tower output per item and column-splits the deepstack half; here
the prefill allocates a receive buffer sized to each item's post-merge token
count (and deepstack width), registers it with that item's encode worker via
:class:`MooncakeEmbeddingReceiver`, waits for the push, and assigns the result
onto ``item.encoded`` / ``item.encoded_deepstack`` -- the ``skip-ViT`` form the
prefill ``VisionEmbedder`` consumes (it never re-encodes an item whose
``encoded`` is already set). A wrong token count or deepstack width mis-sizes the
RDMA target (silent corruption), so buffer-sizing + assignment is isolated and
CPU-unit-testable with a fake receiver.
Poll-driven state machine (:class:`EmbeddingReceiveJob`): ``start()`` does only
the non-blocking Phase-1 allocation + receiver construction (it must NEVER block
the prefill event loop); ``poll()`` advances every receiver a little each cycle
and publishes ``item.encoded`` only once EVERY handshaked item has landed. The
scheduler holds the job and does not admit the request to a prefill forward until
``poll()`` returns ``DONE``.
``receive_encoded_embeddings`` is a thin BLOCKING wrapper (start + spin poll) for
synchronous callers and the CPU tests.
"""
from __future__ import annotations
import logging
import time
from collections import deque
from collections.abc import Callable, Mapping, Sequence
from typing import Any
import torch
import torch.distributed as dist
logger = logging.getLogger(__name__)
from tokenspeed.runtime.multimodal.embedder import _item_token_count
from tokenspeed.runtime.multimodal.inputs import MultimodalDataItem
from tokenspeed.runtime.pd.base.status import TransferPoll
from tokenspeed.runtime.pd.epd.embedding_transfer import (
MooncakeEmbeddingReceiver,
)
from tokenspeed.runtime.utils.env import envs
# (manager, bootstrap_addr, bootstrap_room) -> receiver. Defaults to the real
# MooncakeEmbeddingReceiver; overridden in tests with a fake.
ReceiverFactory = Callable[[Any, str, int], Any]
# poll() return values (the per-job lifecycle status, distinct from the
# per-receiver Poll status it aggregates).
PENDING = "pending"
DONE = "done"
FAILED = "failed"
# Deregistering a receive MR the instant its Success notif arrives races the
# NIC's DMA placement tail (Success is the transport ACK, not proof the last
# bytes cleared HCA->PCIe), tripping `local access violation work queue error`.
# So deregistration is DEFERRED: entries hold the tensor ref (the allocator
# cannot reuse a still-registered address -- the no-double-register invariant)
# and are swept after a grace period, on the scheduler loop (no thread, no lock).
_DEREG_DELAY_S = 0.5
_RECV_POOL_QUARANTINE_S = 10.0
# (due_monotonic, engine, [(tensor, ptr), ...]) in due order (delay is constant).
_pending_dereg: deque = deque()
def _record_current_stream_event(tensor: torch.Tensor) -> torch.cuda.Event | None:
if not tensor.is_cuda:
return None
event = torch.cuda.Event()
torch.cuda.current_stream(tensor.device).record_event(event)
return event
def _lazy_deregister(engine: Any, tensors: list[tuple[torch.Tensor, int]]) -> None:
_pending_dereg.append((time.monotonic() + _DEREG_DELAY_S, engine, tensors))
_sweep_deregister()
def _sweep_deregister() -> None:
now = time.monotonic()
while _pending_dereg and _pending_dereg[0][0] <= now:
_, engine, tensors = _pending_dereg.popleft()
for _tensor, ptr in tensors:
try:
engine.deregister(ptr)
except Exception: # noqa: BLE001 -- best-effort; worst case the MR leaks
pass
class _RecvBufferPool:
"""Pre-registered, lifetime-stable receive slots for E->P embedding lands.
With per-request buffers, deregistering then re-registering a recycled
allocator address mints a NEW rkey for the same range while the encode side's
Mooncake segment cache still resolves it to the OLD rkey -- every reuse risks
a `local access violation work queue error` that kills the QP. The pool
registers ONE region for the engine's lifetime, so the sender's cached
mapping can never go stale; requests lease slots, the publish path clones the
landed rows out, and CUDA-backed slots return only after that clone completes.
Failure path: a FAILED job may still have an in-flight remote write targeting
its slot, which under a single lifetime MR would land SILENTLY in the next
tenant's data, so failed slots sit in quarantine until the transfer layer's
timeouts have expired.
Single-threaded by design: all callers run on the scheduler loop (no locks).
"""
def __init__(self, engine: Any, device: Any, slot_bytes: int, n_slots: int):
self.engine = engine
self.slot_bytes = slot_bytes
self.buf = torch.empty(n_slots * slot_bytes, dtype=torch.uint8, device=device)
engine.register(self.buf.data_ptr(), self.buf.numel())
self._free = list(range(n_slots))
self._quarantine: deque = deque() # (release_due_monotonic, slot)
self._pending_release: deque = deque() # (cuda_event, slot)
def _sweep_pending_release(self) -> None:
kept = deque()
while self._pending_release:
event, slot = self._pending_release.popleft()
try:
ready = event.query()
except Exception: # noqa: BLE001 -- avoid permanently losing a slot
logger.warning(
"EPD recv pool: CUDA event query failed; releasing slot anyway",
exc_info=True,
)
ready = True
if ready:
self._free.append(slot)
else:
kept.append((event, slot))
self._pending_release = kept
def _sweep_quarantine(self) -> None:
now = time.monotonic()
while self._quarantine and self._quarantine[0][0] <= now:
self._free.append(self._quarantine.popleft()[1])
def sweep(self) -> None:
self._sweep_pending_release()
self._sweep_quarantine()
def lease(self, nbytes: int) -> int | None:
self.sweep()
if nbytes > self.slot_bytes or not self._free:
return None
return self._free.pop()
def view(self, slot: int, nbytes: int, dtype: torch.dtype, shape) -> torch.Tensor:
off = slot * self.slot_bytes
return self.buf[off : off + nbytes].view(dtype).reshape(shape)
def release(self, slot: int) -> None:
self._free.append(slot)
def release_after_copy(self, slot: int, copied_tensor: torch.Tensor) -> None:
event = _record_current_stream_event(copied_tensor)
if event is None:
self.release(slot)
else:
self._pending_release.append((event, slot))
def quarantine(self, slot: int, delay_s: float) -> None:
self._quarantine.append((time.monotonic() + delay_s, slot))
# (id(engine), str(device)) -> _RecvBufferPool | False (False = disabled).
_POOLS: dict = {}
def _get_pool(engine: Any, device: Any) -> _RecvBufferPool | None:
key = (id(engine), str(device))
pool = _POOLS.get(key)
if pool is None:
n_slots = envs.TOKENSPEED_EPD_RECV_POOL_SLOTS.get()
slot_mb = envs.TOKENSPEED_EPD_RECV_POOL_SLOT_MB.get()
if n_slots <= 0 or slot_mb <= 0:
pool = False
else:
pool = _RecvBufferPool(engine, device, slot_mb << 20, n_slots)
logger.info(
"EPD recv pool up: %d slots x %d MB (lifetime MR)", n_slots, slot_mb
)
_POOLS[key] = pool
return pool or None
def shard_rows(span: int, shard_rank: int, shard_size: int) -> tuple[int, int]:
"""Balanced contiguous row shard of ``span`` rows across ``shard_size``
ranks: returns this rank's ``(row_start, row_count)``. The first
``span % shard_size`` ranks get one extra row; shards tile ``[0, span)``
disjointly and in rank order, which BOTH sides of the transfer (the
receiver's pre_alloc and the post-receive reassembly) must derive from this
one function so their geometry can never diverge. ``row_count`` may be 0
when ``span < shard_size`` (tiny images)."""
base, rem = divmod(span, shard_size)
start = shard_rank * base + min(shard_rank, rem)
count = base + (1 if shard_rank < rem else 0)
return start, count
class _ItemReceive:
"""Per-item receive bookkeeping for one :class:`EmbeddingReceiveJob`.
Holds the single ``[n_tokens, hidden]`` receive buffer (+ optional deepstack
column buffer) and one receiver per owned image, each targeting a contiguous
row sub-range of that buffer. The buffers are filled in place by the encode
side's RDMA writes; this object publishes them onto the item only once every
one of its receivers reaches ``Success`` (handled by the owning job).
"""
__slots__ = (
"item",
"recv_main",
"recv_deepstack",
"receivers",
"pre_alloced",
"main_slice_ptrs",
"deepstack_slice_ptrs",
"spans",
"row_starts",
"row_counts",
"sharded",
"n_tokens",
"hidden",
"pool",
"pool_slot",
)
def __init__(
self,
item: MultimodalDataItem,
recv_main: torch.Tensor,
recv_deepstack: torch.Tensor | None,
receivers: list[Any],
main_slice_ptrs: list[int],
deepstack_slice_ptrs: list[int],
spans: list[int],
row_starts: list[int],
row_counts: list[int],
sharded: list[bool],
n_tokens: int,
hidden: int,
pool: _RecvBufferPool | None = None,
pool_slot: int | None = None,
):
self.item = item
self.recv_main = recv_main
self.recv_deepstack = recv_deepstack
self.receivers = receivers
self.pool = pool
self.pool_slot = pool_slot
# Lazy-pre_alloc latch, one per receiver (index-aligned): each receiver's
# pre_alloc must be issued exactly once, AFTER it reports Bootstrapped, and
# never again (a second pre_alloc would double-register the transfer).
self.pre_alloced = [False] * len(receivers)
self.main_slice_ptrs = main_slice_ptrs
self.deepstack_slice_ptrs = deepstack_slice_ptrs
self.spans = spans
# Per-image shard geometry (index-aligned with receivers/spans): this
# rank's row sub-range WITHIN the image, and whether the image was
# sharded at all (identity images need no reassembly broadcast).
self.row_starts = row_starts
self.row_counts = row_counts
self.sharded = sharded
self.n_tokens = n_tokens
self.hidden = hidden
class EmbeddingReceiveJob:
"""Poll-driven, non-blocking EPD embedding receive for ONE request.
Usage (driven by the scheduler/event loop):
job = start_embedding_receive(items, manager, ...)
# ... each cycle, until DONE/FAILED:
status = job.poll() # PENDING | DONE | FAILED
``start()`` (the constructor) runs Phase-1 only: it sizes + allocates each
handshaked item's receive buffer, registers it with the Mooncake engine, and
constructs one :class:`MooncakeEmbeddingReceiver` on the item's room (the
receiver's own ``__init__`` performs the bootstrap handshake). It does NOT
wait for any transfer and does NOT call ``pre_alloc`` -- that is deferred to
``poll()``, which issues each ``pre_alloc`` lazily once its receiver reports
Bootstrapped and then waits (across cycles, never blocking) for Success.
HARD CONSTRAINT: one room/receiver PER ITEM. A single request's
items may be served by DIFFERENT encode workers, so collapsing to one
receiver per request is not possible. ``poll()`` returns DONE only when every
item's receiver is Success.
Buffer lifetime: by default the receive target is a leased slot from the
lifetime-registered :class:`_RecvBufferPool`; on DONE the landed rows are
cloned onto ``item.encoded`` and the slot is reused after that copy completes
(no MR churn, see the pool docstring). Deepstack models, oversized items, pool
exhaustion, or ``TOKENSPEED_EPD_RECV_POOL_SLOTS=0`` fall back to a per-request
buffer registered on start and lazily deregistered after publish. Pooled
slots stay leased until the CUDA copy into ``item.encoded`` has completed,
so a following request cannot overwrite rows still being cloned. The GPU cost
per request is roughly ``n_tokens * hidden * dtype.itemsize`` (plus ``* (1 +
num_deepstack)`` with deepstack); the caller should cap the number of
in-flight jobs accordingly.
Idempotent re-`start`/poll: items whose ``item.encoded`` is already set
(chunked prefill re-runs the receive per Path-4 forward on the same item) are
SKIPPED at start time -- no receiver is constructed for them.
"""
def __init__(
self,
items: Sequence[MultimodalDataItem],
manager: Any,
*,
hidden: int,
num_deepstack: int,
dtype: torch.dtype,
device: torch.device | str,
receiver_factory: ReceiverFactory | None = None,
shard_rank: int = 0,
shard_size: int = 1,
):
self.manager = manager
self.hidden = hidden
self.num_deepstack = num_deepstack
# Row sharding across the attn-TP group: with shard_size > 1 this rank
# registers only its shard_rows() sub-range of each image; after the
# rank-agreed DONE the caller MUST run reassemble() (rank-lockstep) to
# rebuild the full rows before any forward consumes item.encoded.
# shard_size <= 1 is a plain full copy (no reassemble).
self._shard_rank = shard_rank
self._shard_size = shard_size
# torch.dtype is used for buffer allocation (no dtype lost in a string
# round-trip); the string is only the encode-side wire contract in pre_alloc.
self.dtype = dtype
self.dtype_str = str(dtype)
self._factory: ReceiverFactory = receiver_factory or MooncakeEmbeddingReceiver
# Terminal latch: once DONE/FAILED, poll() is a cheap no-op returning it
# (the receivers/buffers have been torn down).
self._status: str = PENDING
# Phase 1 (non-blocking): for each item carrying an encode handshake
# (``item.encode_handshake``), allocate ONE [n_tokens, hidden] buffer
# (+ deepstack columns), register it with the Mooncake engine, and build a
# single receiver on the item's room. The handshake lives ON the item: the
# gateway mints one room per item and the encode worker row-splits the
# concatenated-subgrid embedding per item -- so one item == one room == one
# embedding.
self._items: list[_ItemReceive] = []
for item in items:
handshake = getattr(item, "encode_handshake", None)
if handshake is None:
# No EPD-routed embedding on this item; leave it for the tower.
continue
# Chunked prefill re-runs the receive per forward on the SAME item;
# ``encoded`` is set only after Success, so ``encoded is not None``
# means fully received -- skip it (re-bootstrapping a Success room
# would never re-report Bootstrapped and would stick). Mirrors
# ``VisionEmbedder``, which never re-encodes an encoded item.
if item.encoded is not None:
continue
self._items.append(self._start_item(item, handshake, device))
# Nothing to receive (text-only / all-already-encoded / no EPD item):
# the job is immediately DONE so the scheduler admits the request at once.
if not self._items:
self._status = DONE
def _start_item(
self,
item: MultimodalDataItem,
handshake: Mapping[str, Any],
device: torch.device | str,
) -> _ItemReceive:
"""Allocate + register one item's receive buffer and construct its single
receiver on the item's room, recording the (possibly sharded) destination
row range so ``poll()`` can lazily issue ``pre_alloc`` once the receiver
bootstraps. Does NOT block on any transfer (no ``_wait``, no ``pre_alloc``
here).
One room per item: the encode worker concatenates the item's subgrid
tokens into a single ``[n_tokens, hidden]`` embedding and row-splits it per
prefill TP rank, so the receive geometry spans the item's FULL token count
(sum of its offset spans), not per offset.
"""
elt = torch.empty(0, dtype=self.dtype).element_size()
# The item's image spans all its concatenated subgrid tokens.
span = _item_token_count(item)
addr = f"{handshake['bootstrap_host']}:{handshake['bootstrap_port']}"
# The receiver __init__ performs the bootstrap handshake (HTTP /route fetch
# + endpoint registration); on return its poll() is already Bootstrapped or
# Failed. We do NOT pre_alloc here -- poll() does it once Bootstrapped.
receiver = self._factory(self.manager, addr, int(handshake["bootstrap_room"]))
# Row sharding across the attn-TP group: this rank registers only its
# shard_rows() sub-range, or the full image when sharding is off.
is_sharded = self._shard_size > 1
if is_sharded:
row_start, row_count = shard_rows(span, self._shard_rank, self._shard_size)
else:
row_start, row_count = 0, span
# Length-1 per-image lists so poll()/_packed_to_full()/reassemble() iterate
# unchanged (one item == one image under per-item rooms).
receivers: list[Any] = [receiver]
spans: list[int] = [span]
row_starts: list[int] = [row_start]
row_counts: list[int] = [row_count]
sharded: list[bool] = [is_sharded]
# The RECEIVE buffer holds only THIS rank's shard rows, packed contiguously
# (image i at rows [packed_cursor, packed_cursor + row_count)); the FULL
# embedding is rebuilt later in item.encoded (publish scatter + reassemble),
# which needs no MR. Registering just the shard rows -- not the full image
# -- keeps a big multi-image item from overflowing a recv pool slot onto
# the GIL-held per-request register fallback. With nothing sharded,
# packed_tokens == full_tokens.
full_tokens = _item_token_count(item)
packed_tokens = sum(row_counts)
nbytes = packed_tokens * self.hidden * elt
pool = None
pool_slot = None
recv_main = None
if self.num_deepstack == 0:
# Preferred path: lease a lifetime-registered pool slot (see
# _RecvBufferPool for why per-request register/deregister kills QPs).
# Deepstack models keep the legacy path (a second buffer per item,
# out of the pool's scope).
pool = _get_pool(self.manager.engine, device)
if pool is not None:
pool_slot = pool.lease(nbytes)
if pool_slot is not None:
recv_main = pool.view(
pool_slot, nbytes, self.dtype, (packed_tokens, self.hidden)
)
else:
logger.info(
"EPD recv pool: no slot for %d B (free=%d); falling back "
"to per-request registration",
nbytes,
len(pool._free),
)
pool = None
if recv_main is None:
recv_main = torch.empty(
(packed_tokens, self.hidden), dtype=self.dtype, device=device
)
# Legacy fallback (pool disabled/exhausted, oversized item, or
# deepstack present): register per request; the deregister is DEFERRED
# on the publish/fail paths (lazy queue) to soften -- not eliminate --
# the stale-rkey window. A 0-row packed buffer has nothing to register.
if packed_tokens > 0:
self.manager.engine.register(
recv_main.data_ptr(),
recv_main.numel() * recv_main.element_size(),
)
recv_deepstack: torch.Tensor | None = None
if self.num_deepstack > 0:
recv_deepstack = torch.empty(
(packed_tokens, self.hidden * self.num_deepstack),
dtype=self.dtype,
device=device,
)
if packed_tokens > 0:
self.manager.engine.register(
recv_deepstack.data_ptr(),
recv_deepstack.numel() * recv_deepstack.element_size(),
)
# Destination pointers into the PACKED buffer: the encode side writes its
# n_tokens(=row_count) source rows CONTIGUOUSLY at this pointer (it does
# not re-apply row_start -- that only selects which SOURCE rows to read).
# A 0-row shard still records its (empty) pointer; its pre_alloc is the
# registration heartbeat the encode fanout gate counts.
main_slice_ptrs: list[int] = []
deepstack_slice_ptrs: list[int] = []
packed_cursor = 0
for row_count in row_counts:
main_slice_ptrs.append(
recv_main[packed_cursor : packed_cursor + row_count].data_ptr()
)
if recv_deepstack is not None:
deepstack_slice_ptrs.append(
recv_deepstack[packed_cursor : packed_cursor + row_count].data_ptr()
)
else:
deepstack_slice_ptrs.append(0)
packed_cursor += row_count
return _ItemReceive(
item=item,
recv_main=recv_main,
recv_deepstack=recv_deepstack,
receivers=receivers,
main_slice_ptrs=main_slice_ptrs,
deepstack_slice_ptrs=deepstack_slice_ptrs,
spans=spans,
row_starts=row_starts,
row_counts=row_counts,
sharded=sharded,
n_tokens=full_tokens,
hidden=self.hidden,
pool=pool,
pool_slot=pool_slot,
)
def poll(self) -> str:
"""Advance the receive state machine one cheap step.
For every still-pending receiver: if it just reached Bootstrapped, issue
its (single) ``pre_alloc`` so the encode side learns where to write; if it
reached Failed -> the whole job is FAILED; otherwise leave it. When EVERY
receiver of EVERY item is Success, deregister the buffers, publish
``item.encoded`` / ``item.encoded_deepstack``, and return DONE.
Returns ``PENDING`` | ``DONE`` | ``FAILED``. Idempotent after a terminal
result (the buffers/receivers are gone, so it just returns the latch).
"""
_sweep_deregister()
if self._status is not PENDING:
return self._status
all_success = True
for it in self._items:
for idx, receiver in enumerate(it.receivers):
status = receiver.poll()
if status == TransferPoll.Failed:
self._fail()
return FAILED
if not it.pre_alloced[idx] and status in (
TransferPoll.Bootstrapped,
TransferPoll.Transferring,
TransferPoll.Success,
):
# Bootstrapped (or already further along on a fast/in-process
# transport): issue the one-shot pre_alloc and latch it -- a
# second would double-register the transfer on the encode side.
# In shard mode n_tokens carries this rank's row COUNT and the
# dst pointers are already offset to the shard's first row; a
# row_count of 0 is still sent (it doubles as the encode-side
# fanout-gate heartbeat).
receiver.pre_alloc(
dst_embedding_ptr=it.main_slice_ptrs[idx],
n_tokens=it.row_counts[idx],
hidden=it.hidden,
dtype=self.dtype_str,
dst_deepstack_ptr=it.deepstack_slice_ptrs[idx],
has_deepstack=self.num_deepstack > 0,
row_start=it.row_starts[idx],
span=it.spans[idx],
)
it.pre_alloced[idx] = True
# Re-poll once after pre_alloc: an in-process/fake transport
# may flip straight to Success on pre_alloc.
status = receiver.poll()
if status == TransferPoll.Failed:
self._fail()
return FAILED
if status != TransferPoll.Success:
all_success = False
if not all_success:
return PENDING
# Every image of every item has landed; publish and reclaim. Pooled
# path: clone the landed rows OUT of the slot (item.encoded must outlive
# the lease) and release the slot only after that copy completes. Legacy
# path: hand the buffer itself to item.encoded and queue the registration
# for DEFERRED drop (the lazy entry holds the tensor ref, so the allocator
# cannot recycle a still-registered address).
for it in self._items:
any_sharded = any(it.sharded)
if it.pool is not None:
# Pooled path is deepstack-free. Copy the rows OUT of the slot
# before releasing it. When sharded the buffer is packed (this
# rank's rows only), so scatter into a full-layout tensor at each
# image's absolute offset (reassemble fills the rest); when not
# sharded the buffer is already full -> clone it.
it.item.encoded = (
self._packed_to_full(it, it.recv_main, self.hidden)
if any_sharded
else it.recv_main.clone()
)
it.item.encoded_deepstack = None
it.pool.release_after_copy(it.pool_slot, it.item.encoded)
else:
# Legacy path. When sharded, the packed buffers are smaller than
# the image and cannot alias item.encoded, so build separate full
# tensors; when not sharded, hand the (full) buffers over directly.
if any_sharded:
it.item.encoded = self._packed_to_full(
it, it.recv_main, self.hidden
)
it.item.encoded_deepstack = (
self._packed_to_full(
it, it.recv_deepstack, self.hidden * self.num_deepstack
)
if it.recv_deepstack is not None
else None
)
else:
it.item.encoded = it.recv_main
it.item.encoded_deepstack = it.recv_deepstack
pairs = [(it.recv_main, it.recv_main.data_ptr())]
if it.recv_deepstack is not None:
pairs.append((it.recv_deepstack, it.recv_deepstack.data_ptr()))
_lazy_deregister(self.manager.engine, pairs)
# Receive concluded: release each room's bookkeeping from the prefill manager.
self._clear_receivers()
self._status = DONE
return DONE
def _packed_to_full(
self, it: "_ItemReceive", packed: torch.Tensor, width: int
) -> torch.Tensor:
"""Scatter a PACKED shard buffer (this rank's rows, image-contiguous) into
a full-layout ``[n_tokens, width]`` tensor, placing each image's shard at
its absolute row offset and leaving the non-owned rows for ``reassemble``
to fill. The packed buffer holds ``sum(row_counts)`` rows; the full tensor
holds ``sum(spans)`` rows (== n_tokens). Identity images (row_count==span,
row_start==0) copy whole, so a buffer with no real sharding round-trips
unchanged."""
full = torch.empty(
(it.n_tokens, width), dtype=packed.dtype, device=packed.device
)
packed_cursor = 0
full_cursor = 0
for span, row_start, row_count in zip(it.spans, it.row_starts, it.row_counts):
if row_count > 0:
full[full_cursor + row_start : full_cursor + row_start + row_count] = (
packed[packed_cursor : packed_cursor + row_count]
)
packed_cursor += row_count
full_cursor += span
return full
def reassemble(self, nccl_group: Any, group_ranks: Sequence[int]) -> None:
"""Rebuild full embeddings from row shards via per-image broadcasts.
Must be called RANK-LOCKSTEP on every attn-TP rank, only after the
rank-agreed DONE (the drain's MIN all-reduce) and BEFORE any forward
consumes ``item.encoded``: until then each rank's buffer holds only its
own shard rows, the rest is uninitialized memory. All ranks iterate the
identical items/images in identical order issuing identical collectives,
which also requires the caller to run on the NON-overlap event loop (a
second thread launching forward collectives concurrently would break the
cross-rank launch-order guarantee NCCL needs across communicators).
``group_ranks`` is the attn-TP group as GLOBAL ranks
(``mapping.attn.tp_group``): ``dist.broadcast`` takes a global src rank,
and group rank p == global rank p only in the no-DP single-engine case.
Per-image sub-range broadcasts (2 x shard_size x n_images launches per
request).
No-op for identity images and when sharding is off; safe after _fail
(items were dropped). Idempotence is NOT required: called exactly once
per admitted request by the drain.
Broadcasts target ``item.encoded`` (the PUBLISHED tensor), never
``recv_main``: on the pooled path the publish step cloned the rows out
and queued the slot for reuse after the clone completes, so ``recv_main``
may later belong to the next tenant; on the legacy path ``item.encoded``
IS ``recv_main``, so the two are equivalent there.
"""
if self._shard_size <= 1 or self._status is not DONE:
return
for it in self._items:
main = it.item.encoded
deep = it.item.encoded_deepstack
cursor = 0
for idx, span in enumerate(it.spans):
if not it.sharded[idx]:
cursor += span
continue
for p in range(self._shard_size):
start, count = shard_rows(span, p, self._shard_size)
if count == 0:
continue
src = group_ranks[p]
dist.broadcast(
main[cursor + start : cursor + start + count],
src=src,
group=nccl_group,
)
if deep is not None:
dist.broadcast(
deep[cursor + start : cursor + start + count],
src=src,
group=nccl_group,
)
cursor += span
def _clear_receivers(self) -> None:
"""Release each receiver's per-room manager bookkeeping (terminal paths only;
no-op for fake receivers without clear())."""
for it in self._items:
for receiver in it.receivers:
clear = getattr(receiver, "clear", None)
if clear is not None:
clear()
def _fail(self) -> None:
"""Tear down on failure and drop our references. A SIBLING image's
write may still be in flight into the item buffer, so reclamation is
deferred on both paths: pooled slots go to quarantine (under the
lifetime MR a late write would land SILENTLY in the next tenant's
data), legacy buffers go to the lazy-deregistration queue.
``item.encoded`` is left unset -- the request is being failed, not
served.
"""
for it in self._items:
if it.pool is not None:
it.pool.quarantine(it.pool_slot, _RECV_POOL_QUARANTINE_S)
else:
pairs = [(it.recv_main, it.recv_main.data_ptr())]
if it.recv_deepstack is not None:
pairs.append((it.recv_deepstack, it.recv_deepstack.data_ptr()))
_lazy_deregister(self.manager.engine, pairs)
self._clear_receivers()
self._items = []
self._status = FAILED
@property
def status(self) -> str:
return self._status
def release(self) -> None:
"""Best-effort teardown for an externally-driven abort.
Used when a rank-agreed FAILED is reached but THIS rank had not yet seen a
local Failed poll (so its buffers are still registered): free/deregister
them so a reused allocator address is never left double-registered.
Idempotent and a no-op once terminal (poll() already tore a DONE/FAILED job
down). After release() the job is FAILED.
"""
if self._status is not PENDING:
return
self._fail()
def start_embedding_receive(
items: Sequence[MultimodalDataItem],
manager: Any,
*,
hidden: int,
num_deepstack: int,
dtype: torch.dtype,
device: torch.device | str,
receiver_factory: ReceiverFactory | None = None,
shard_rank: int = 0,
shard_size: int = 1,
) -> EmbeddingReceiveJob:
"""Begin (non-blocking) the per-item EPD embedding receive for one request.
See :class:`EmbeddingReceiveJob`. The handshake for each EPD-routed item
rides on ``item.encode_handshake`` (a dict ``{bootstrap_room, bootstrap_host,
bootstrap_port}``); items without one are left to the vision tower. Returns a
job whose ``poll()`` the caller drives every cycle until DONE/FAILED; the
request must not be admitted to a prefill forward before then. With
``shard_size > 1`` the caller must also run ``job.reassemble()`` rank-
lockstep after the rank-agreed DONE, before the request's first forward.
"""
return EmbeddingReceiveJob(
items,
manager,
hidden=hidden,
num_deepstack=num_deepstack,
dtype=dtype,
device=device,
receiver_factory=receiver_factory,
shard_rank=shard_rank,
shard_size=shard_size,
)
def receive_encoded_embeddings(
items: Sequence[MultimodalDataItem],
manager: Any,
*,
hidden: int,
num_deepstack: int,
dtype: torch.dtype,
device: torch.device | str,
timeout: float = 60.0,
receiver_factory: ReceiverFactory | None = None,
) -> None:
"""BLOCKING wrapper: fill ``item.encoded`` from the per-item transfers.
For synchronous callers and the CPU unit tests: the poll-driven
:class:`EmbeddingReceiveJob` spun to completion in place (start, then
busy-poll until DONE, raising on FAILED or timeout). Event-loop code should
drive ``poll()`` once per cycle instead.
The handshake for each EPD-routed item rides on ``item.encode_handshake``
(``{bootstrap_room, bootstrap_host, bootstrap_port}``). ``hidden`` is the main
embedding width, ``num_deepstack`` the deepstack level count (0 if absent),
and ``dtype`` must match the encode worker's tower output dtype (asserted on
the encode side before the unchecked RDMA write).
"""
job = start_embedding_receive(
items,
manager,
hidden=hidden,
num_deepstack=num_deepstack,
dtype=dtype,
device=device,
receiver_factory=receiver_factory,
)
deadline = time.monotonic() + timeout
while True:
status = job.poll()
if status == DONE:
return
if status == FAILED:
raise RuntimeError("encode->prefill embedding transfer failed")
if time.monotonic() > deadline:
raise TimeoutError(
f"embedding receive timed out after {timeout:.1f}s "
f"(job still {status})"
)
time.sleep(0.0005)
def build_prefill_embedding_manager(server_args, global_rank, is_multimodal_active):
"""Stand up the EPD encode->prefill embedding sink, if applicable.
Only a multimodal *prefill* node receives embeddings from encode workers. Built
as one TP group (the embedding transport allows prefill_tp = any multiple of
encode_tp) with no fixed base buffer (receive buffers are allocated per image
at receive time). Construction spawns a daemon status thread, so build it
exactly once per rank. Returns None for decode/encode/text-only nodes.
"""
if server_args.disaggregation_mode != "prefill" or not is_multimodal_active:
return None
from tokenspeed.runtime.pd.epd.embedding_transfer import (
MooncakeEmbeddingManagerPrefill,
)
from tokenspeed.runtime.pd.epd.entities import EmbeddingArgs, EmbeddingManagerArgs
emb_mgr_args = EmbeddingManagerArgs(
bootstrap_port=server_args.disaggregation_bootstrap_port,
tp_size=server_args.mapping.attn.tp_size,
)
emb_args = EmbeddingArgs(
engine_rank=global_rank,
gpu_id=global_rank,
ib_device=server_args.disaggregation_ib_device,
embedding_data_ptr=0,
embedding_data_len=0,
)
return MooncakeEmbeddingManagerPrefill(emb_mgr_args, emb_args)
class EpdPrefillAdmission:
"""EPD prefill-side embedding receive + rank-synced admission.
Owns the encode->prefill embedding sink (MooncakeEmbeddingManagerPrefill), the
set of requests whose per-image embeddings are still arriving (``_pending``),
and the optional NCCL row-shard reassembly. Exists ONLY on a multimodal prefill
node; the EventLoop holds one (or None) and drives it each non-overlap cycle.
DECIDE/ACT split: ``drain()`` polls the staged receives, applies the rank-
lockstep MIN all-reduce + reassembly, and RETURNS ``(admitted, failed)``
decisions. The EventLoop performs the acts those imply (P->D sender
register/abort, scheduler submit, output-processor finish) -- they touch
EventLoop collaborators, so they stay there.
"""
def __init__(
self,
*,
manager,
device,
hidden,
num_deepstack,
dtype,
attn_tp_rank,
attn_tp_size,
attn_tp_cpu_group,
attn_tp_group,
pg_manager,
):
self._manager = manager
self._device = device
self._hidden = hidden
self._num_deepstack = num_deepstack
self._dtype = dtype
self._attn_tp_rank = attn_tp_rank
self._attn_tp_size = attn_tp_size
self._attn_tp_cpu_group = attn_tp_cpu_group
# Requests whose per-image encode->prefill embeddings are still arriving:
# registered but NOT yet submitted to the scheduler; drain() polls them each
# cycle and admits (rank-synced) only once ready. Rank-identical in
# length+order across attn-TP ranks (recv_reqs broadcasts the new-request
# set), which drain()'s MIN all-reduce relies on.
self._pending: list = []
# Deadline for an EPD request's per-image embeddings to all arrive; past it
# the request is aborted (not waited on forever). Reuse the PD KV-receive
# wait knob (default 300s): the prefill waiting on the encode->prefill
# embedding transfer is the direct analog of the decode waiting on the
# prefill->decode KV transfer, so one operator knob covers both.
self._embed_timeout: float = float(
envs.TOKENSPEED_DISAGGREGATION_WAITING_TIMEOUT.get()
)
# EPD embedding row-sharding: each attn-TP rank receives only 1/N of every
# image's rows over the wire; the full embedding is rebuilt by an NCCL
# all-gather in drain() (job.reassemble), which runs on the prefill's
# NON-overlap loop so the drain and the forward launch from one thread in
# the same order on every rank -- the cross-rank launch-order consistency
# NCCL requires across communicators.
self._shard_embeddings = False
self._nccl_group = None
self._group_ranks = tuple(attn_tp_group)
shard_flag = False
if attn_tp_size > 1:
shard_flag = bool(envs.TOKENSPEED_EPD_EMBEDDING_SHARD.get())
# The flag is a per-process env read but gates a GROUP collective:
# torn across ranks (e.g. set on one node of a multi-node prefill),
# flag-on ranks would join the warmup broadcast below while flag-off
# ranks never do -- a silent boot hang. Agree first, loud.
flag_t = torch.tensor(
[int(shard_flag), -int(shard_flag)], dtype=torch.int32
)
dist.all_reduce(flag_t, op=dist.ReduceOp.MIN, group=attn_tp_cpu_group)
if flag_t[0].item() != -flag_t[1].item():
raise RuntimeError(
"TOKENSPEED_EPD_EMBEDDING_SHARD differs across attn-TP ranks; "
"set it identically on every node of the prefill engine"
)
if shard_flag:
self._nccl_group = pg_manager.get_process_group("nccl", attn_tp_group)
self._shard_embeddings = True
# Warm the communicator at startup: NCCL initializes lazily on the
# first collective, and nothing else issues torch.distributed NCCL ops
# on this group in the scheduler process -- without this the FIRST
# admitted EPD request pays communicator init (hundreds of ms, all
# ranks) inside the drain, and a misconfigured group would surface on a
# customer request instead of at boot.
warmup = torch.zeros(1, device=device)
dist.broadcast(warmup, src=self._group_ranks[0], group=self._nccl_group)
torch.cuda.current_stream().synchronize()
logger.info(
"EPD embedding row-sharding enabled (attn_tp=%d, NCCL group warm)",
attn_tp_size,
)
def stage(self, request_id, mm_items) -> None:
"""Begin the non-blocking per-image embedding receive and stage it by
request_id until its embeddings land (polled in drain()). The request payload
(spec/state/bootstrap) stays with the caller, keyed by request_id; this
controller tracks only the receive job (mirrors kv_transfer)."""
job = start_embedding_receive(
items=mm_items,
manager=self._manager,
hidden=self._hidden,
num_deepstack=self._num_deepstack,
dtype=self._dtype,
device=self._device,
shard_rank=self._attn_tp_rank,
shard_size=self._attn_tp_size if self._shard_embeddings else 1,
)
self._pending.append((request_id, job, time.time()))
def has_pending(self) -> bool:
return bool(self._pending)
def drain(self):
"""Poll staged EPD embedding receives; return ``(admitted_ids, failed_ids)``.
poll/timeout/MIN-all-reduce/reassemble/release + ``_pending`` bookkeeping
happen here, rank-lockstep, keyed by request_id. The caller maps the ids back
to its staged request payloads and performs the acts (kv_transfer
register/abort, scheduler submit, output-processor finish).
- admitted_ids: DONE on every rank, reassembled.
- failed_ids: FAILED/timed-out, job released.
"""
if not self._pending:
return [], [] # rank-identical emptiness -> all ranks skip the collective
code_of = {FAILED: 0, PENDING: 1, DONE: 2}
codes = [code_of[job.poll()] for (_rid, job, _ts) in self._pending]
# Timeout: a still-PENDING request whose per-image embeddings have not all
# arrived within the deadline is marked FAILED (-> rank-agreed abort below).
# Without this the prefill waits FOREVER if an embedding is ever lost (a
# degraded/dead encode worker, a network drop). Folded into the SAME MIN
# all-reduce: a timed-out job -> code 0 -> all ranks abort it together;
# union-of-timeout is rank-safe even if ranks cross the deadline a cycle
# apart (any rank's 0 propagates via MIN).
_now = time.time()
for _i in range(len(self._pending)):
if codes[_i] == 1 and (_now - self._pending[_i][2]) > self._embed_timeout:
codes[_i] = 0
logger.warning(
"EPD embedding receive timed out after %.0fs for rid=%s; aborting",
self._embed_timeout,
self._pending[_i][0],
)
if self._attn_tp_size > 1:
t = torch.tensor(codes, dtype=torch.uint8, device="cpu")
dist.all_reduce(t, op=dist.ReduceOp.MIN, group=self._attn_tp_cpu_group)
codes = t.tolist()
admitted = []
failed = []
leftover = []
for (request_id, job, start_ts), code in zip(self._pending, codes):
if code == 2: # DONE on every rank
# Sharded receive: rebuild the full rows from the per-rank shards
# FIRST -- item.encoded is shard-only until this runs. Rank-lockstep-
# safe: codes are identical post-MIN and _pending is rank-identical
# in length/order, so every rank issues identical collectives in
# identical order this cycle.
if self._shard_embeddings:
job.reassemble(self._nccl_group, self._group_ranks)
admitted.append(request_id)
elif code == 0: # FAILED/timed-out on some rank -> abort everywhere
job.release()
failed.append(request_id)
else: # still pending on some rank
leftover.append((request_id, job, start_ts))
self._pending = leftover
return admitted, failed
def make_epd_prefill_admission(
server_args,
global_rank,
*,
model_config,
model_executor,
mapping,
attn_tp_rank,
attn_tp_size,
attn_tp_cpu_group,
pg_manager,
):
"""Build the EPD-prefill admission controller, or None for non-EPD nodes.
Returns None unless this is a multimodal *prefill* node (the only node that
receives encode->prefill embeddings)."""
manager = build_prefill_embedding_manager(
server_args, global_rank, model_config.is_multimodal_active
)
if manager is None:
return None
# Extract the narrow model facts the controller needs (vision dtype, hidden
# width, deepstack width, device) here -- the controller holds these, not the
# whole model_executor (mirrors how create_kv_transfer takes a kv_args struct).
model = model_executor.model_runner.model
return EpdPrefillAdmission(
manager=manager,
device=model_executor.device,
hidden=model.config.hidden_size,
num_deepstack=getattr(model, "num_deepstack_embeddings", 0),
dtype=(getattr(model, "visual", None) or model.vision_tower).dtype,
attn_tp_rank=attn_tp_rank,
attn_tp_size=attn_tp_size,
attn_tp_cpu_group=attn_tp_cpu_group,
attn_tp_group=mapping.attn.tp_group,
pg_manager=pg_manager,
)