94057c3d3e
PR Test (NPU) / check-changes (push) Has been cancelled
PR Test (NPU) / pr-gate (push) Has been cancelled
PR Test (NPU) / set-image-config (push) Has been cancelled
PR Test (NPU) / stage-b-test-1-npu-a2 (0) (push) Has been cancelled
PR Test (NPU) / stage-b-test-1-npu-a2 (1) (push) Has been cancelled
PR Test (NPU) / stage-b-test-2-npu-a2 (0) (push) Has been cancelled
PR Test (NPU) / stage-b-test-2-npu-a2 (1) (push) Has been cancelled
PR Test (NPU) / stage-b-test-4-npu-a3 (push) Has been cancelled
PR Test (NPU) / stage-b-test-16-npu-a3 (push) Has been cancelled
PR Test (NPU) / multimodal-gen-test-1-npu-a3 (push) Has been cancelled
PR Test (NPU) / multimodal-gen-test-2-npu-a3 (push) Has been cancelled
PR Test (Arm64) / pr-gate (push) Has been cancelled
PR Test (Arm64) / check-changes (push) Has been cancelled
PR Test (Arm64) / build-test (push) Has been cancelled
PR Test (sgl-router) / gate (push) Has been cancelled
PR Test (sgl-router) / tier-1 — lint (push) Has been cancelled
PR Test (sgl-router) / tier-2 — build + test (push) Has been cancelled
PR Test (sgl-router) / tier-3 — docker (placeholder) (push) Has been cancelled
PR Test (sgl-router) / tier-3 — k8s integration (push) Has been cancelled
PR Test (sgl-router) / tier-3 — e2e (push) Has been cancelled
PR Test (sgl-router) / finish (push) Has been cancelled
PR Test (NPU) / single-node-poc (map[name:qwen3_6_27b_w8a8_1p_in64k_out1k_50ms runner:linux-aarch64-a3-2 test_case:test/registered/ascend/performance/qwen3_6_27b/test_npu_qwen3_6_27b_w8a8_1p_in64k_out1k_50ms.py test_type:perf]) (push) Has been cancelled
PR Test (NPU) / pr-test-npu-finish (push) Has been cancelled
PR Test (Xeon) / pr-gate (push) Has been cancelled
PR Test (Xeon) / check-changes (push) Has been cancelled
PR Test (Xeon) / build-test (, xeon-gnr, base-b-test-cpu) (push) Has been cancelled
PR Test (XPU) / check-changes (push) Has been cancelled
PR Test (XPU) / pr-gate (push) Has been cancelled
PR Test (XPU) / stage-a-test-1-gpu-xpu (push) Has been cancelled
PR Test (XPU) / wait-for-stage-a (push) Has been cancelled
PR Test (XPU) / stage-b-test-1-gpu-xpu (push) Has been cancelled
PR Test (XPU) / finish (push) Has been cancelled
CI Model Inventory / build-inventory (push) Has been cancelled
Lint / lint (push) Has been cancelled
PR Benchmark (SMG Components) / Benchmark Compilation Check (push) Has been cancelled
PR Benchmark (SMG Components) / Benchmark - Manual Policy (push) Has been cancelled
PR Benchmark (SMG Components) / Benchmark - Request Processing (push) Has been cancelled
PR Benchmark (SMG Components) / Benchmark Summary (push) Has been cancelled
PR Test (SMG) / build-wheel (push) Has been cancelled
Release SGLang Model Gateway to PyPI / build on windows (x86_64 - auto) (push) Has been cancelled
Release SGLang Model Gateway to PyPI / build on macos (x86_64 - auto) (push) Has been cancelled
PR Test (SMG) / python-unit-tests (push) Has been cancelled
PR Test (SMG) / unit-tests (push) Has been cancelled
PR Test (SMG) / benchmarks (push) Has been cancelled
PR Test (SMG) / chat-completions (push) Has been cancelled
PR Test (SMG) / chat-completions-4gpu (push) Has been cancelled
PR Test (SMG) / e2e (push) Has been cancelled
PR Test (SMG) / docker-build-test (push) Has been cancelled
PR Test (SMG) / k8s-integration (push) Has been cancelled
PR Test (SMG) / finish (push) Has been cancelled
PR Test (SMG) / summarize-benchmarks (push) Has been cancelled
Release SGLang Model Gateway Docker Image / publish (push) Has been cancelled
Release SGLang Model Gateway to PyPI / build on macos (aarch64 - auto) (push) Has been cancelled
Release SGLang Model Gateway to PyPI / build on linux (aarch64 - auto) (push) Has been cancelled
Release SGLang Model Gateway to PyPI / build on linux (x86_64 - auto) (push) Has been cancelled
Release SGLang Model Gateway to PyPI / build on linux (aarch64 - musllinux_1_1) (push) Has been cancelled
Release SGLang Model Gateway to PyPI / build on linux (x86_64 - musllinux_1_1) (push) Has been cancelled
Release SGLang Model Gateway to PyPI / Build SDist (push) Has been cancelled
Release SGLang Model Gateway to PyPI / Upload to PyPI (push) Has been cancelled
Release SGLang Kernels / build-cu129-matrix (aarch64, 12.9, 3.10, arm-kernel-build-node) (push) Has been cancelled
Release SGLang Kernels / build-cu129-matrix (x86_64, 12.9, 3.10, x64-kernel-build-node) (push) Has been cancelled
Release SGLang Kernels / release-cu129 (push) Has been cancelled
Release SGLang Kernels / build-cu130-matrix (aarch64, 13.0, 3.10, arm-kernel-build-node) (push) Has been cancelled
Release SGLang Kernels / build-cu130-matrix (x86_64, 13.0, 3.10, x64-kernel-build-node) (push) Has been cancelled
Release SGLang Kernels / release-cu130 (push) Has been cancelled
Release SGLang Kernels / build-rocm-matrix (3.10, 700) (push) Has been cancelled
Release SGLang Kernels / build-rocm-matrix (3.10, 720) (push) Has been cancelled
Release SGLang Kernels / release-rocm700 (push) Has been cancelled
Release SGLang Kernels / release-rocm720 (push) Has been cancelled
Release SGLang Kernels / build-musa43 (43, 3.10) (push) Has been cancelled
Release SGLang Kernels / release-musa43 (push) Has been cancelled
1611 lines
65 KiB
Python
1611 lines
65 KiB
Python
# SPDX-License-Identifier: Apache-2.0
|
|
"""Mixin that adds disaggregated diffusion scheduling to the Scheduler.
|
|
|
|
Extracted from scheduler.py to keep the core scheduler lean.
|
|
All transfer, compute, and event-loop logic for disaggregated roles
|
|
(encoder / denoiser / decoder) lives here.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import contextlib
|
|
import dataclasses
|
|
import inspect
|
|
import json
|
|
import logging
|
|
import pickle
|
|
import queue
|
|
import threading
|
|
import time
|
|
from typing import TYPE_CHECKING, Any
|
|
|
|
import torch
|
|
import zmq
|
|
|
|
from sglang.multimodal_gen.configs.sample.sampling_params import SamplingParams
|
|
from sglang.multimodal_gen.runtime.disaggregation.roles import RoleType
|
|
from sglang.multimodal_gen.runtime.disaggregation.transport.buffer import (
|
|
TransferTensorBuffer,
|
|
)
|
|
from sglang.multimodal_gen.runtime.disaggregation.transport.codec import (
|
|
send_tensors,
|
|
)
|
|
from sglang.multimodal_gen.runtime.disaggregation.transport.engine import (
|
|
create_transfer_engine,
|
|
)
|
|
from sglang.multimodal_gen.runtime.disaggregation.transport.manager import (
|
|
DiffusionTransferManager,
|
|
)
|
|
from sglang.multimodal_gen.runtime.disaggregation.transport.protocol import (
|
|
TRANSFER_MAGIC,
|
|
TransferAllocatedMsg,
|
|
TransferDoneMsg,
|
|
TransferMsgType,
|
|
TransferPushedMsg,
|
|
TransferRegisterMsg,
|
|
decode_transfer_msg,
|
|
encode_transfer_msg,
|
|
is_transfer_message,
|
|
)
|
|
from sglang.multimodal_gen.runtime.pipelines_core import Req
|
|
from sglang.multimodal_gen.runtime.pipelines_core.diffusion_scheduler_utils import (
|
|
clone_scheduler_runtime,
|
|
)
|
|
from sglang.multimodal_gen.runtime.platforms import current_platform
|
|
from sglang.multimodal_gen.runtime.utils.common import get_zmq_socket
|
|
from sglang.multimodal_gen.runtime.utils.distributed import broadcast_pyobj
|
|
from sglang.multimodal_gen.runtime.utils.logging_utils import init_logger
|
|
from sglang.multimodal_gen.runtime.utils.trace_wrapper import DiffStage, trace_slice
|
|
from sglang.srt.observability.trace import TraceReqContext
|
|
|
|
if TYPE_CHECKING:
|
|
from sglang.multimodal_gen.runtime.managers.scheduler import Scheduler
|
|
|
|
logger = init_logger(__name__)
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Field extraction: split Req into tensors (transfer buffer) and scalars (JSON)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
# Fields that should never be transferred (non-serializable, internal, or receiver rebuilds)
|
|
_EXCLUDE_FIELDS = frozenset(
|
|
{
|
|
"sampling_params",
|
|
"generator",
|
|
"modules",
|
|
"metrics",
|
|
"extra_step_kwargs",
|
|
"extra",
|
|
"condition_image",
|
|
"vae_image",
|
|
"pixel_values",
|
|
"preprocessed_image",
|
|
"image_embeds",
|
|
"original_condition_image_size",
|
|
"vae_image_sizes",
|
|
"output",
|
|
"audio",
|
|
"audio_sample_rate",
|
|
"trajectory_timesteps",
|
|
"trajectory_latents",
|
|
"trajectory_audio_latents",
|
|
"timestep",
|
|
"step_index",
|
|
# Request scheduler is a local runtime object cloned from the pipeline
|
|
# scheduler template. It may hold live mutable state and is not JSON-safe.
|
|
"scheduler",
|
|
"prompt_template",
|
|
"max_sequence_length",
|
|
# trace_ctx holds live OTel SDK objects that aren't JSON-serializable.
|
|
# We propagate tracing across the JSON hop via a separate, JSON-safe
|
|
# ``_trace_state`` scalar field built from ``TraceReqContext.__getstate__``
|
|
# (same W3C carrier SRT relies on for pickle transport) and rebuild it
|
|
# on the receiver in ``_build_disagg_req``.
|
|
"trace_ctx",
|
|
}
|
|
)
|
|
|
|
# Sampling-params fields that should never be transferred across roles:
|
|
# - data_type / supported_resolutions: enums / non-JSON classvars reconstructed on the receiver
|
|
# - teacache_params: model-specific object, not JSON-safe
|
|
# - output_* / save_output / return_*: output-side concerns owned by the decoder role
|
|
#
|
|
# Everything else on SamplingParams is forwarded automatically via a field-walk
|
|
# below; this keeps new request-level features (e.g. Qwen-Image's
|
|
# true_cfg_scale, guidance_rescale, cfg_normalization, ...) from silently
|
|
# getting dropped just because nobody remembered to add them to a whitelist.
|
|
_SAMPLING_PARAMS_EXCLUDE_FIELDS = frozenset(
|
|
{
|
|
"data_type",
|
|
"supported_resolutions",
|
|
"teacache_params",
|
|
}
|
|
)
|
|
|
|
_BASE_SP_DEFAULTS: dict[str, Any] = {}
|
|
for _f in dataclasses.fields(SamplingParams):
|
|
if _f.default is not dataclasses.MISSING:
|
|
_BASE_SP_DEFAULTS[_f.name] = _f.default
|
|
|
|
|
|
def _is_tensor_like(value) -> bool:
|
|
if isinstance(value, torch.Tensor):
|
|
return True
|
|
if isinstance(value, list) and value and isinstance(value[0], torch.Tensor):
|
|
return True
|
|
return False
|
|
|
|
|
|
def _to_json_serializable(value):
|
|
if isinstance(value, torch.Tensor):
|
|
return value.tolist()
|
|
if isinstance(value, (list, tuple)):
|
|
converted = []
|
|
for item in value:
|
|
if isinstance(item, torch.Tensor):
|
|
converted.append(item.tolist())
|
|
else:
|
|
converted.append(item)
|
|
return converted
|
|
return value
|
|
|
|
|
|
def _is_default(value, field_info) -> bool:
|
|
if field_info.default is not dataclasses.MISSING:
|
|
return value == field_info.default
|
|
if field_info.default_factory is not dataclasses.MISSING:
|
|
if isinstance(value, (list, dict)) and len(value) == 0:
|
|
return True
|
|
return False
|
|
|
|
|
|
def _extract_extra_fields(extra: dict, scalar_fields: dict) -> None:
|
|
"""Extract JSON-serializable entries from Req.extra into scalar_fields."""
|
|
for key, value in extra.items():
|
|
if key.startswith("_"):
|
|
continue
|
|
try:
|
|
json.dumps(value)
|
|
scalar_fields[f"_extra_{key}"] = value
|
|
except (TypeError, ValueError, OverflowError):
|
|
pass
|
|
|
|
|
|
def _init_request_scheduler_from_template(
|
|
scheduler_template: Any, req: Req, device: torch.device
|
|
) -> None:
|
|
scheduler = clone_scheduler_runtime(scheduler_template)
|
|
extra_kwargs = {}
|
|
mu = req.extra.get("mu") if hasattr(req, "extra") else None
|
|
if mu is not None:
|
|
extra_kwargs["mu"] = mu
|
|
|
|
set_timesteps_params = inspect.signature(scheduler.set_timesteps).parameters
|
|
timesteps = getattr(req, "timesteps", None)
|
|
sigmas = getattr(req, "sigmas", None)
|
|
num_steps = getattr(req, "num_inference_steps", None)
|
|
|
|
if sigmas is not None and "sigmas" in set_timesteps_params:
|
|
if isinstance(sigmas, torch.Tensor):
|
|
sigmas = sigmas.detach().cpu()
|
|
scheduler.set_timesteps(sigmas=sigmas, device=device, **extra_kwargs)
|
|
elif timesteps is not None and "timesteps" in set_timesteps_params:
|
|
if isinstance(timesteps, torch.Tensor):
|
|
timesteps = timesteps.detach().cpu()
|
|
scheduler.set_timesteps(timesteps=timesteps, device=device, **extra_kwargs)
|
|
elif num_steps is not None:
|
|
scheduler.set_timesteps(num_steps, device=device, **extra_kwargs)
|
|
else:
|
|
return
|
|
|
|
req.scheduler = scheduler
|
|
req.timesteps = scheduler.timesteps
|
|
|
|
|
|
def _init_disagg_request_scheduler(self: Scheduler, req: Req) -> None:
|
|
scheduler_template = self.worker.pipeline.get_module("scheduler")
|
|
if scheduler_template is None:
|
|
return
|
|
device = torch.device(f"{current_platform.device_type}:{self.worker.local_rank}")
|
|
_init_request_scheduler_from_template(scheduler_template, req, device)
|
|
|
|
|
|
def extract_transfer_fields(req) -> tuple[dict, dict]:
|
|
"""Extract all transferable fields from a Req, split into tensors and scalars."""
|
|
tensor_fields = {}
|
|
scalar_fields = {}
|
|
_debug_transfer = logger.isEnabledFor(logging.DEBUG)
|
|
|
|
for f in dataclasses.fields(req):
|
|
if f.name in _EXCLUDE_FIELDS:
|
|
continue
|
|
|
|
value = getattr(req, f.name, None)
|
|
if value is None:
|
|
continue
|
|
if _is_default(value, f):
|
|
continue
|
|
|
|
if _is_tensor_like(value):
|
|
tensor_fields[f.name] = value
|
|
else:
|
|
try:
|
|
scalar_fields[f.name] = _to_json_serializable(value)
|
|
except (TypeError, ValueError):
|
|
pass
|
|
|
|
extra = getattr(req, "extra", None)
|
|
if extra:
|
|
_extract_extra_fields(extra, scalar_fields)
|
|
|
|
sp = getattr(req, "sampling_params", None)
|
|
if sp is not None:
|
|
# Forward every non-default, JSON-safe SamplingParams field, not a
|
|
# narrow whitelist. Previously only a handful of fields were carried
|
|
# across roles, which silently dropped per-request config like
|
|
# Qwen-Image's true_cfg_scale (and any future feature added to
|
|
# SamplingParams). Using a field-walk keeps the disagg boundary
|
|
# feature-complete without needing to edit this list.
|
|
for f in dataclasses.fields(sp):
|
|
name = f.name
|
|
if name in _SAMPLING_PARAMS_EXCLUDE_FIELDS:
|
|
continue
|
|
if name in scalar_fields:
|
|
# Req-level field already took precedence (or upstream Req
|
|
# explicitly set it).
|
|
continue
|
|
value = getattr(sp, name, None)
|
|
if value is None:
|
|
continue
|
|
base_default = _BASE_SP_DEFAULTS.get(name, dataclasses.MISSING)
|
|
if base_default is not dataclasses.MISSING and value == base_default:
|
|
continue
|
|
try:
|
|
scalar_fields[name] = _to_json_serializable(value)
|
|
except (TypeError, ValueError):
|
|
pass
|
|
|
|
if getattr(req, "generator", None) is not None:
|
|
seed = getattr(req, "seed", None)
|
|
if seed is not None:
|
|
scalar_fields["seed"] = _to_json_serializable(seed)
|
|
|
|
if _debug_transfer:
|
|
import torch as _torch
|
|
|
|
for _n, _t in tensor_fields.items():
|
|
if isinstance(_t, _torch.Tensor):
|
|
_sz = _t.nelement() * _t.element_size()
|
|
logger.debug(
|
|
"transfer_field %s shape=%s dtype=%s size=%d",
|
|
_n,
|
|
list(_t.shape),
|
|
_t.dtype,
|
|
_sz,
|
|
)
|
|
elif isinstance(_t, list):
|
|
for _i, _ti in enumerate(_t):
|
|
if isinstance(_ti, _torch.Tensor):
|
|
_sz = _ti.nelement() * _ti.element_size()
|
|
logger.debug(
|
|
"transfer_field %s[%d] shape=%s dtype=%s size=%d",
|
|
_n,
|
|
_i,
|
|
list(_ti.shape),
|
|
_ti.dtype,
|
|
_sz,
|
|
)
|
|
|
|
# Propagate OTel trace context over the JSON hop. TraceReqContext.__getstate__
|
|
# reduces the live context to a JSON-safe dict (W3C traceparent/tracestate in
|
|
# root_span_context). Receiver rebuilds via __setstate__ in _build_disagg_req.
|
|
trace_ctx = getattr(req, "trace_ctx", None)
|
|
if trace_ctx is not None and getattr(trace_ctx, "tracing_enable", False):
|
|
try:
|
|
trace_state = trace_ctx.__getstate__()
|
|
if trace_state and trace_state.get("tracing_enable"):
|
|
scalar_fields["_trace_state"] = trace_state
|
|
except Exception as e:
|
|
logger.debug("Failed to export trace state: %s", e)
|
|
|
|
return tensor_fields, scalar_fields
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Helpers for broadcasting Req contents across SP/CFG/TP ranks
|
|
# ---------------------------------------------------------------------------
|
|
|
|
# Sentinel marker key used to distinguish "list of tensors" from a regular
|
|
# nested dict when round-tripping through GroupCoordinator.broadcast_tensor_dict
|
|
# (which only natively understands tensor / nested-dict values).
|
|
_LIST_MARKER_KEY = "__is_list__"
|
|
|
|
|
|
def _pack_tensor_fields_for_broadcast(tensor_fields: dict) -> dict:
|
|
"""Pack ``tensor_fields`` into a structure ``broadcast_tensor_dict`` accepts.
|
|
|
|
``broadcast_tensor_dict`` understands dict-of-tensor values (recursively),
|
|
but not list-of-tensor values. Several Req fields (``prompt_embeds``,
|
|
``image_embeds``, ...) are lists of tensors, so we encode each list as a
|
|
nested dict whose tensors are keyed by their stringified index, with a
|
|
sentinel ``__is_list__`` flag to disambiguate from real nested dicts.
|
|
"""
|
|
packed: dict = {}
|
|
for key, value in tensor_fields.items():
|
|
if isinstance(value, torch.Tensor):
|
|
packed[key] = value
|
|
elif isinstance(value, list):
|
|
sub: dict = {_LIST_MARKER_KEY: True}
|
|
for i, item in enumerate(value):
|
|
if isinstance(item, torch.Tensor):
|
|
sub[str(i)] = item
|
|
packed[key] = sub
|
|
# Anything else (e.g. None, scalars) is intentionally dropped — the
|
|
# scalar_fields broadcast covers non-tensor metadata.
|
|
return packed
|
|
|
|
|
|
def _unpack_tensor_fields_from_broadcast(packed: dict) -> dict:
|
|
"""Inverse of :func:`_pack_tensor_fields_for_broadcast`."""
|
|
out: dict = {}
|
|
for key, value in packed.items():
|
|
if isinstance(value, dict) and value.get(_LIST_MARKER_KEY) is True:
|
|
indexed = [(int(k), v) for k, v in value.items() if k != _LIST_MARKER_KEY]
|
|
indexed.sort(key=lambda kv: kv[0])
|
|
out[key] = [v for _, v in indexed]
|
|
else:
|
|
out[key] = value
|
|
return out
|
|
|
|
|
|
class SchedulerDisaggMixin:
|
|
"""Disaggregated diffusion scheduling: transfer, compute, event loops."""
|
|
|
|
# ------------------------------------------------------------------
|
|
# Initialization
|
|
# ------------------------------------------------------------------
|
|
|
|
def _init_disagg_state(self: Scheduler, server_args, local_rank: int) -> None:
|
|
"""Initialize all disaggregation state, sockets, and transfer infrastructure."""
|
|
from sglang.multimodal_gen.runtime.disaggregation.metrics import DisaggMetrics
|
|
|
|
self._disagg_role = server_args.disagg_role
|
|
self._disagg_timeout_s = float(getattr(server_args, "disagg_timeout", 600))
|
|
self._disagg_metrics = None
|
|
self._disagg_mode = getattr(server_args, "disagg_mode", False)
|
|
self._pool_work_pull = None
|
|
self._pool_result_push = None
|
|
self._transfer_manager = None
|
|
self._transfer_stream = None
|
|
self._rdma_push_queue = None
|
|
self._rdma_push_thread = None
|
|
self._rdma_push_zmq = None
|
|
self._compute_ready_queue = None
|
|
self._recv_prefetch_thread = None
|
|
|
|
if self._disagg_role != RoleType.MONOLITHIC:
|
|
self._disagg_metrics = DisaggMetrics(role=self._disagg_role.value)
|
|
device = torch.device(f"{current_platform.device_type}:{local_rank}")
|
|
self._transfer_stream = torch.get_device_module().Stream(device=device)
|
|
self._init_disagg_sockets()
|
|
self._init_disagg_transfer_manager()
|
|
|
|
def _init_disagg_sockets(self: Scheduler):
|
|
"""Initialize ZMQ sockets for disaggregated mode (DiffusionServer-mediated).
|
|
|
|
Only rank 0 creates ZMQ sockets. Non-rank-0 processes participate
|
|
via NCCL broadcast from rank 0 (see _disagg_recv_work).
|
|
"""
|
|
if self.gpu_id != 0:
|
|
logger.info(
|
|
"Pool mode %s rank %d: no ZMQ sockets (non-rank-0)",
|
|
self._disagg_role.value.upper(),
|
|
self.gpu_id,
|
|
)
|
|
return
|
|
|
|
sa = self.server_args
|
|
|
|
# PULL: receive work from DiffusionServer
|
|
self._pool_work_pull, _ = get_zmq_socket(
|
|
self.context,
|
|
zmq.PULL,
|
|
sa.pool_work_endpoint,
|
|
bind=True,
|
|
max_bind_retries=5,
|
|
same_port=True,
|
|
)
|
|
# PUSH: send results to DiffusionServer
|
|
self._pool_result_push, _ = get_zmq_socket(
|
|
self.context, zmq.PUSH, sa.pool_result_endpoint, bind=False
|
|
)
|
|
logger.info(
|
|
"Disagg %s rank 0: work_pull=%s, result_push=%s",
|
|
self._disagg_role.value.upper(),
|
|
sa.pool_work_endpoint,
|
|
sa.pool_result_endpoint,
|
|
)
|
|
|
|
def _init_disagg_transfer_manager(self: Scheduler):
|
|
"""Initialize TransferManager for transfer mode (rank 0 only).
|
|
|
|
Creates a TransferTensorBuffer (pinned memory pool) and a
|
|
BaseTransferEngine, then wraps them in a DiffusionTransferManager.
|
|
Also sends a transfer_register message to DiffusionServer.
|
|
"""
|
|
if self.gpu_id != 0:
|
|
return
|
|
|
|
sa = self.server_args
|
|
|
|
# Pool size: configurable, default 256 MiB
|
|
pool_size = getattr(sa, "disagg_transfer_pool_size", 256 * 1024 * 1024)
|
|
|
|
# Create transfer engine.
|
|
# NOTE: self.gpu_id is the role-internal rank (0..num_role_gpus-1),
|
|
# not the physical GPU index. In disagg mode with --base-gpu-id > 0,
|
|
# the physical device is self.worker.local_rank. Mooncake needs the
|
|
# physical index to pin the right NIC and register GPUDirect buffers.
|
|
hostname = getattr(sa, "disagg_p2p_hostname", "127.0.0.1")
|
|
ib_device = getattr(sa, "disagg_ib_device", None)
|
|
physical_gpu_id = self.worker.local_rank
|
|
engine = create_transfer_engine(
|
|
hostname=hostname,
|
|
gpu_id=physical_gpu_id,
|
|
ib_device=ib_device,
|
|
)
|
|
|
|
# Use GPU buffer when engine supports GPUDirect RDMA, CPU pinned otherwise
|
|
device = (
|
|
f"{current_platform.device_type}:{physical_gpu_id}"
|
|
if engine.supports_gpu_direct
|
|
else "cpu"
|
|
)
|
|
buffer = TransferTensorBuffer(
|
|
pool_size=pool_size, device=device, role_name=self._disagg_role.value
|
|
)
|
|
|
|
# Create transfer manager
|
|
self._transfer_manager = DiffusionTransferManager(engine=engine, buffer=buffer)
|
|
|
|
# Pre-allocate receive slots for receivers (denoiser/decoder)
|
|
self._preallocated_slots: dict[int, object] = {}
|
|
preallocated_slot_info = []
|
|
if self._disagg_role in (RoleType.DENOISER, RoleType.DECODER):
|
|
capacity = getattr(sa, "disagg_prealloc_slots", 2)
|
|
typical_size = 64 * 1024 * 1024 # 64 MiB per slot
|
|
for i in range(capacity):
|
|
slot = buffer.allocate(typical_size, f"prealloc_{i}")
|
|
if slot is not None:
|
|
self._preallocated_slots[i] = slot
|
|
preallocated_slot_info.append(
|
|
{
|
|
"offset": slot.offset,
|
|
"size": slot.size,
|
|
"slot_id": i,
|
|
"addr": self._transfer_manager.pool_data_ptr + slot.offset,
|
|
}
|
|
)
|
|
if preallocated_slot_info:
|
|
logger.info(
|
|
"Transfer %s: pre-allocated %d receive slots",
|
|
self._disagg_role.value.upper(),
|
|
len(preallocated_slot_info),
|
|
)
|
|
|
|
# Register with DiffusionServer.
|
|
# Include our own work_endpoint so DS can key the peer by URL index,
|
|
# not by registration order (startup order is not guaranteed to match
|
|
# --encoder/denoiser/decoder-urls ordering).
|
|
register_msg = TransferRegisterMsg(
|
|
role=self._disagg_role.value,
|
|
session_id=self._transfer_manager.session_id,
|
|
pool_ptr=self._transfer_manager.pool_data_ptr,
|
|
pool_size=self._transfer_manager.pool_size,
|
|
work_endpoint=sa.pool_work_endpoint,
|
|
preallocated_slots=preallocated_slot_info,
|
|
)
|
|
self._pool_result_push.send_multipart(encode_transfer_msg(register_msg))
|
|
logger.info(
|
|
"Transfer %s: registered with DS (session=%s, pool=%d bytes, prealloc=%d)",
|
|
self._disagg_role.value.upper(),
|
|
self._transfer_manager.session_id,
|
|
pool_size,
|
|
len(preallocated_slot_info),
|
|
)
|
|
|
|
# RDMA push thread for sender roles (encoder/denoiser)
|
|
if self._disagg_role in (RoleType.ENCODER, RoleType.DENOISER):
|
|
self._rdma_push_queue = queue.Queue(maxsize=4)
|
|
self._rdma_push_zmq, _ = get_zmq_socket(
|
|
self.context,
|
|
zmq.PUSH,
|
|
sa.pool_result_endpoint,
|
|
bind=False,
|
|
)
|
|
self._rdma_push_thread = threading.Thread(
|
|
target=self._rdma_push_loop,
|
|
daemon=True,
|
|
name=f"rdma-push-{self._disagg_role.value}",
|
|
)
|
|
self._rdma_push_thread.start()
|
|
logger.info(
|
|
"Transfer %s: RDMA push thread started",
|
|
self._disagg_role.value.upper(),
|
|
)
|
|
|
|
# Recv prefetch thread for receiver roles (denoiser/decoder)
|
|
# Rank 0 only (bg thread does ZMQ recv + load; multi-rank gets
|
|
# scalar fields via broadcast_pyobj from the main thread).
|
|
if self._disagg_role in (RoleType.DENOISER, RoleType.DECODER):
|
|
self._compute_ready_queue = queue.Queue(maxsize=4)
|
|
self._recv_prefetch_thread = threading.Thread(
|
|
target=self._recv_prefetch_loop,
|
|
daemon=True,
|
|
name=f"recv-prefetch-{self._disagg_role.value}",
|
|
)
|
|
self._recv_prefetch_thread.start()
|
|
logger.info(
|
|
"Transfer %s: recv prefetch thread started",
|
|
self._disagg_role.value.upper(),
|
|
)
|
|
|
|
# ------------------------------------------------------------------
|
|
# Background threads
|
|
# ------------------------------------------------------------------
|
|
|
|
def _rdma_push_loop(self: Scheduler):
|
|
"""Background thread: execute RDMA push + notify DS.
|
|
|
|
Runs push_to_peer (blocking RDMA) on a dedicated thread so the
|
|
main event loop can immediately start processing the next request.
|
|
"""
|
|
role_name = self._disagg_role.value.upper()
|
|
while True:
|
|
item = self._rdma_push_queue.get()
|
|
if item is None:
|
|
break # Shutdown signal
|
|
request_id, dest_session_id, dest_addr, transfer_size = item
|
|
try:
|
|
success = self._transfer_manager.push_to_peer(
|
|
request_id=request_id,
|
|
dest_session_id=dest_session_id,
|
|
dest_addr=dest_addr,
|
|
transfer_size=transfer_size,
|
|
)
|
|
if success:
|
|
self._transfer_manager.free_staged(request_id)
|
|
|
|
pushed_msg = TransferPushedMsg(request_id=request_id)
|
|
self._rdma_push_zmq.send_multipart(encode_transfer_msg(pushed_msg))
|
|
|
|
if not success:
|
|
logger.error(
|
|
"Transfer %s: RDMA push failed for %s", role_name, request_id
|
|
)
|
|
except Exception:
|
|
logger.exception(
|
|
"Transfer %s: RDMA push thread error for %s", role_name, request_id
|
|
)
|
|
|
|
def _recv_prefetch_loop(self: Scheduler):
|
|
"""Background thread: recv transfer messages and prefetch tensor loads.
|
|
|
|
For transfer_ready: loads tensors + builds Req in this thread, then
|
|
enqueues the ready-to-compute item. This allows loading of request N+1
|
|
to overlap with compute of request N on the main thread.
|
|
|
|
For transfer_alloc/push: passes them through to the main thread for handling.
|
|
"""
|
|
role_name = self._disagg_role.value.upper()
|
|
while self._running:
|
|
try:
|
|
raw_frames = self._pool_work_pull.recv_multipart()
|
|
frames = [bytes(f) for f in raw_frames]
|
|
|
|
msg = decode_transfer_msg(frames)
|
|
msg_type = msg.get("msg_type", "")
|
|
|
|
if msg_type == TransferMsgType.READY:
|
|
# Prefetch: load tensors + build Req in this thread
|
|
item = self._prefetch_transfer_ready(msg)
|
|
self._compute_ready_queue.put(("transfer_compute", item))
|
|
elif msg_type == TransferMsgType.PUSH:
|
|
# Handle push directly in prefetch thread — it only
|
|
# enqueues to the RDMA bg thread (thread-safe queue).
|
|
# Critical for pipeline parallelism: if deferred to the
|
|
# main thread, this gets blocked behind the next request's
|
|
# GPU compute, preventing the previous request's output
|
|
# from reaching the decoder.
|
|
self._handle_transfer_push(msg)
|
|
else:
|
|
# alloc and other messages: pass to main thread
|
|
# (alloc sends on _pool_result_push which isn't thread-safe)
|
|
self._compute_ready_queue.put(("transfer_control", frames))
|
|
|
|
except zmq.ZMQError as e:
|
|
if not self._running:
|
|
break
|
|
logger.error("Transfer %s recv prefetch: ZMQ error: %s", role_name, e)
|
|
except Exception:
|
|
logger.exception("Transfer %s recv prefetch: error", role_name)
|
|
|
|
def _prefetch_transfer_ready(self: Scheduler, msg: dict) -> tuple:
|
|
"""Load tensors from transfer buffer and build Req for a transfer_ready message.
|
|
|
|
Called from the recv prefetch thread. Loads on _transfer_stream
|
|
and builds the Req, so the main thread can start compute immediately.
|
|
|
|
Returns (req, load_event, request_id, role_name, prealloc_slot_id).
|
|
"""
|
|
request_id = msg["request_id"]
|
|
manifest = msg.get("manifest", {})
|
|
scalar_fields = msg.get("scalar_fields", {})
|
|
role_name = self._disagg_role.value.upper()
|
|
|
|
if self._disagg_metrics:
|
|
self._disagg_metrics.record_request_start(request_id)
|
|
|
|
# Pre-allocated slot handling
|
|
prealloc_slot_id = scalar_fields.pop("_prealloc_slot_id", None)
|
|
if (
|
|
prealloc_slot_id is not None
|
|
and prealloc_slot_id in self._preallocated_slots
|
|
):
|
|
slot = self._preallocated_slots[prealloc_slot_id]
|
|
self._transfer_manager.register_prealloc_as_receive(request_id, slot)
|
|
|
|
# Load tensors on transfer_stream (non-blocking)
|
|
local_device = f"{current_platform.device_type}:{self.worker.local_rank}"
|
|
tensors, load_event = self._transfer_manager.load_tensors_async(
|
|
request_id,
|
|
manifest,
|
|
device=local_device,
|
|
stream=self._transfer_stream,
|
|
)
|
|
|
|
# NOTE: Do NOT free the receive slot here. The async load is still
|
|
# in progress. The slot must remain valid until the main thread waits
|
|
# on load_event. Freeing is done in _disagg_prefetch_event_loop.
|
|
|
|
# Build Req (CPU work, overlapped with load)
|
|
req = self._build_disagg_req(scalar_fields, tensors)
|
|
|
|
# NOTE: Do NOT call scheduler_mod.set_timesteps() here!
|
|
# This runs on the prefetch thread. set_timesteps mutates shared
|
|
# scheduler state (self.sigmas), which would corrupt the currently
|
|
# running denoising loop on the main thread. Deferred to main thread
|
|
# in _disagg_prefetch_event_loop, right before compute.
|
|
|
|
return (req, load_event, request_id, role_name, prealloc_slot_id, scalar_fields)
|
|
|
|
# ------------------------------------------------------------------
|
|
# Broadcast
|
|
# ------------------------------------------------------------------
|
|
|
|
def _broadcast_to_all_ranks(self: Scheduler, data):
|
|
"""Broadcast *data* from rank 0 to all other ranks.
|
|
|
|
Rank 0 passes the real payload; non-rank-0 passes ``None``.
|
|
Broadcasts through all applicable groups (SP, CFG, TP).
|
|
"""
|
|
sa = self.server_args
|
|
|
|
if sa.sp_degree != 1:
|
|
data = broadcast_pyobj(
|
|
data,
|
|
self.worker.sp_group.rank,
|
|
self.worker.sp_cpu_group,
|
|
src=self.worker.sp_group.ranks[0],
|
|
)
|
|
|
|
if sa.enable_cfg_parallel:
|
|
data = broadcast_pyobj(
|
|
data,
|
|
self.worker.cfg_group.rank,
|
|
self.worker.cfg_cpu_group,
|
|
src=self.worker.cfg_group.ranks[0],
|
|
)
|
|
|
|
if sa.tp_size > 1:
|
|
data = broadcast_pyobj(
|
|
data,
|
|
self.worker.tp_group.rank,
|
|
self.worker.tp_cpu_group,
|
|
src=self.worker.tp_group.ranks[0],
|
|
)
|
|
|
|
return data
|
|
|
|
def _is_multi_rank(self: Scheduler) -> bool:
|
|
sa = self.server_args
|
|
return sa.sp_degree != 1 or sa.tp_size > 1 or sa.enable_cfg_parallel
|
|
|
|
def _broadcast_tensor_dict_to_all_ranks(
|
|
self: Scheduler, tensor_dict: dict | None
|
|
) -> dict | None:
|
|
"""Broadcast a tensor dict from rank 0 to non-rank-0 via NCCL.
|
|
|
|
Uses ``GroupCoordinator.broadcast_tensor_dict`` which ships tensor
|
|
metadata over the CPU group and the tensor payload over the device
|
|
(NCCL) group, so large GPU buffers never bounce through CPU.
|
|
"""
|
|
sa = self.server_args
|
|
|
|
if sa.sp_degree != 1:
|
|
tensor_dict = self.worker.sp_group.broadcast_tensor_dict(tensor_dict, src=0)
|
|
if sa.enable_cfg_parallel:
|
|
tensor_dict = self.worker.cfg_group.broadcast_tensor_dict(
|
|
tensor_dict, src=0
|
|
)
|
|
if sa.tp_size > 1:
|
|
tensor_dict = self.worker.tp_group.broadcast_tensor_dict(tensor_dict, src=0)
|
|
return tensor_dict
|
|
|
|
def _broadcast_req_to_all_ranks(self: Scheduler, req: Req | None) -> Req | None:
|
|
"""Broadcast a fully-loaded Req (scalars + GPU tensors) from rank 0.
|
|
|
|
Required for multi-rank denoiser/decoder in disagg mode: only rank 0
|
|
owns the TransferManager and RDMA-loads tensors into GPU memory. All
|
|
other ranks must see the same Req before entering ``execute_forward``,
|
|
otherwise REPLICATED stages (e.g. denoising) blow up on empty tensor
|
|
fields because ``ParallelExecutor`` never broadcasts the batch for
|
|
that paradigm.
|
|
|
|
Tensor fields travel over NCCL (stays on GPU); scalar fields travel
|
|
as a small pickled object over the CPU group.
|
|
"""
|
|
if not self._is_multi_rank():
|
|
return req
|
|
|
|
is_rank0 = self.gpu_id == 0
|
|
|
|
if is_rank0:
|
|
assert req is not None, "rank 0 must pass a loaded Req"
|
|
tensor_fields, scalar_fields = extract_transfer_fields(req)
|
|
packed_tensors = _pack_tensor_fields_for_broadcast(tensor_fields)
|
|
else:
|
|
scalar_fields = None
|
|
packed_tensors = None
|
|
|
|
# 1. Scalars via CPU pyobj broadcast.
|
|
scalar_fields = self._broadcast_to_all_ranks(scalar_fields)
|
|
|
|
# 2. Tensors via NCCL broadcast — keeps GPU buffers on device.
|
|
packed_tensors = self._broadcast_tensor_dict_to_all_ranks(packed_tensors)
|
|
|
|
if is_rank0:
|
|
return req
|
|
|
|
tensor_fields = _unpack_tensor_fields_from_broadcast(packed_tensors or {})
|
|
# Move tensors onto this rank's physical device. The broadcast
|
|
# allocates receive tensors on the receiver's default CUDA device
|
|
# (set via torch.cuda.set_device(local_rank) during init), which is
|
|
# already the right physical GPU — the .to() is effectively a no-op
|
|
# but makes the invariant explicit for future readers.
|
|
local_device = torch.device(
|
|
f"{current_platform.device_type}:{self.worker.local_rank}"
|
|
)
|
|
for key, value in list(tensor_fields.items()):
|
|
if isinstance(value, torch.Tensor):
|
|
tensor_fields[key] = value.to(local_device, non_blocking=True)
|
|
elif isinstance(value, list):
|
|
tensor_fields[key] = [
|
|
(
|
|
t.to(local_device, non_blocking=True)
|
|
if isinstance(t, torch.Tensor)
|
|
else t
|
|
)
|
|
for t in value
|
|
]
|
|
return self._build_disagg_req(scalar_fields or {}, tensor_fields)
|
|
|
|
# ------------------------------------------------------------------
|
|
# Event loops
|
|
# ------------------------------------------------------------------
|
|
|
|
def _disagg_recv_work(self: Scheduler) -> list[bytes] | None:
|
|
"""Receive work frames in pool mode, with multi-rank broadcast.
|
|
|
|
Rank 0: recv from ZMQ PULL socket, broadcast to other ranks.
|
|
Non-rank-0: receive via NCCL broadcast from rank 0.
|
|
|
|
Returns list of bytes frames, or None on shutdown.
|
|
"""
|
|
if self.gpu_id == 0:
|
|
raw_frames = self._pool_work_pull.recv_multipart()
|
|
frames = [bytes(f) for f in raw_frames]
|
|
else:
|
|
frames = None
|
|
|
|
return self._broadcast_to_all_ranks(frames)
|
|
|
|
def _disagg_prefetch_event_loop(self: Scheduler, role_name: str) -> None:
|
|
"""Event loop for transfer receiver roles with recv prefetch thread (rank 0).
|
|
|
|
The recv thread reads from ZMQ and prefetches tensor loads.
|
|
This loop reads from _compute_ready_queue:
|
|
- "transfer_compute": load already done, wait_event + free slot
|
|
→ broadcast scalar_fields to non-rank-0 → compute
|
|
- "transfer_control": alloc/push messages, handle on main thread
|
|
→ broadcast "skip" so non-rank-0 doesn't hang
|
|
- queue timeout: broadcast "skip"
|
|
- shutdown: broadcast None
|
|
"""
|
|
is_multi_rank = (
|
|
self.server_args.sp_degree != 1
|
|
or self.server_args.tp_size > 1
|
|
or self.server_args.enable_cfg_parallel
|
|
)
|
|
|
|
while self._running:
|
|
try:
|
|
try:
|
|
msg_type, data = self._compute_ready_queue.get(timeout=1.0)
|
|
except queue.Empty:
|
|
if is_multi_rank:
|
|
self._broadcast_to_all_ranks(("skip",))
|
|
continue
|
|
|
|
if msg_type == "transfer_compute":
|
|
# Load already done by recv thread
|
|
req, load_event, request_id, rn, prealloc_slot_id, scalar_fields = (
|
|
data
|
|
)
|
|
# Wait for load to complete on compute stream
|
|
if load_event is not None:
|
|
torch.get_device_module().current_stream().wait_event(
|
|
load_event
|
|
)
|
|
# Now safe to free the receive slot
|
|
if prealloc_slot_id is not None:
|
|
with self._transfer_manager._lock:
|
|
self._transfer_manager._pending_receives.pop(
|
|
request_id, None
|
|
)
|
|
else:
|
|
self._transfer_manager.free_receive_slot(request_id)
|
|
# Broadcast the full Req (scalar + tensor fields) to
|
|
# non-rank-0 ranks. Tensors ride NCCL on the SP/CFG/TP
|
|
# groups so downstream REPLICATED stages (e.g. denoising)
|
|
# see identical inputs on every rank — without this, the
|
|
# non-rank-0 ranks would enter execute_forward with empty
|
|
# prompt_embeds and fail verify_input.
|
|
if is_multi_rank:
|
|
self._broadcast_to_all_ranks(("compute",))
|
|
self._broadcast_req_to_all_ranks(req)
|
|
# Init scheduler timesteps on main thread (safe — no
|
|
# concurrent denoising loop can be running here).
|
|
if self._disagg_role == RoleType.DENOISER:
|
|
_init_disagg_request_scheduler(self, req)
|
|
# Run compute
|
|
if self._disagg_role == RoleType.DENOISER:
|
|
self._disagg_denoiser_compute(req, request_id, rn)
|
|
elif self._disagg_role == RoleType.DECODER:
|
|
self._disagg_decoder_compute(req, request_id, rn)
|
|
|
|
elif msg_type == "transfer_control":
|
|
# alloc, push messages — handle on main thread (rank 0 only)
|
|
if is_multi_rank:
|
|
self._broadcast_to_all_ranks(("skip",))
|
|
self._handle_transfer_msg(data)
|
|
|
|
self._consecutive_error_count = 0
|
|
|
|
except Exception as e:
|
|
self._consecutive_error_count += 1
|
|
logger.error(
|
|
"Pool %s rank %d prefetch loop: error (attempt %d/%d): %s",
|
|
role_name,
|
|
self.gpu_id,
|
|
self._consecutive_error_count,
|
|
self._max_consecutive_errors,
|
|
e,
|
|
exc_info=True,
|
|
)
|
|
if self._consecutive_error_count >= self._max_consecutive_errors:
|
|
raise RuntimeError(
|
|
f"Pool {role_name} rank {self.gpu_id} terminated after "
|
|
f"{self._max_consecutive_errors} consecutive errors: {e}"
|
|
) from e
|
|
|
|
# Shutdown: notify non-rank-0 to exit
|
|
if is_multi_rank:
|
|
self._broadcast_to_all_ranks(None)
|
|
self._cleanup_disagg()
|
|
|
|
def _disagg_non_rank0_event_loop(self: Scheduler) -> None:
|
|
"""Event loop for non-rank-0 receivers in multi-rank prefetch mode.
|
|
|
|
Blocks on broadcast from rank 0:
|
|
- ("compute", scalar_fields): build minimal Req → execute_forward
|
|
- ("skip",): continue (rank 0 handled a control msg or timed out)
|
|
- None: shutdown, exit loop
|
|
"""
|
|
role_name = self._disagg_role.value.upper()
|
|
logger.info(
|
|
"Pool %s rank %d: entering non-rank-0 prefetch loop",
|
|
role_name,
|
|
self.gpu_id,
|
|
)
|
|
|
|
while True:
|
|
try:
|
|
msg = self._broadcast_to_all_ranks(None)
|
|
|
|
if msg is None:
|
|
# Shutdown signal
|
|
break
|
|
|
|
if isinstance(msg, tuple) and len(msg) >= 1 and msg[0] == "compute":
|
|
# Participate in the companion tensor broadcast so this
|
|
# rank sees the full Req (scalars + GPU tensors). Without
|
|
# the tensor half, REPLICATED stages would see empty
|
|
# prompt_embeds on non-rank-0 and fail verify_input.
|
|
req = self._broadcast_req_to_all_ranks(None)
|
|
self._disagg_compute_non_rank0(req)
|
|
# else: ("skip",) — continue
|
|
|
|
except Exception as e:
|
|
self._consecutive_error_count += 1
|
|
logger.error(
|
|
"Pool %s rank %d non-rank-0 loop: error (attempt %d/%d): %s",
|
|
role_name,
|
|
self.gpu_id,
|
|
self._consecutive_error_count,
|
|
self._max_consecutive_errors,
|
|
e,
|
|
exc_info=True,
|
|
)
|
|
if self._consecutive_error_count >= self._max_consecutive_errors:
|
|
raise RuntimeError(
|
|
f"Pool {role_name} rank {self.gpu_id} terminated after "
|
|
f"{self._max_consecutive_errors} consecutive errors: {e}"
|
|
) from e
|
|
|
|
self._cleanup_disagg()
|
|
|
|
def _disagg_event_loop(self: Scheduler) -> None:
|
|
"""Event loop for all roles in pool mode (DiffusionServer-mediated).
|
|
|
|
Multi-rank support:
|
|
- Rank 0 receives from ZMQ, broadcasts to other ranks via NCCL
|
|
- All ranks process work (execute_forward with SP/TP sharding)
|
|
- Only rank 0 sends results back to DiffusionServer
|
|
|
|
Transfer:
|
|
- Transfer control messages (transfer_alloc, transfer_push) are rank-0-only.
|
|
- transfer_ready is broadcast to all ranks for compute.
|
|
- Encoder receives pickled Req, runs compute, stages output for transfer.
|
|
- Denoiser/decoder only receive transfer control messages.
|
|
|
|
Receiver prefetch paths:
|
|
- Rank 0: _disagg_prefetch_event_loop (reads from compute_ready_queue)
|
|
- Non-rank-0 in multi-rank: _disagg_non_rank0_event_loop (broadcast)
|
|
- Encoder (any rank): existing _disagg_recv_work while loop below
|
|
"""
|
|
role_name = self._disagg_role.value.upper()
|
|
is_rank0 = self.gpu_id == 0
|
|
is_multi_rank = (
|
|
self.server_args.sp_degree != 1
|
|
or self.server_args.tp_size > 1
|
|
or self.server_args.enable_cfg_parallel
|
|
)
|
|
use_prefetch = self._compute_ready_queue is not None
|
|
logger.info(
|
|
"Pool mode %s rank %d event loop started " "(multi_rank=%s, prefetch=%s)",
|
|
role_name,
|
|
self.gpu_id,
|
|
is_multi_rank,
|
|
use_prefetch,
|
|
)
|
|
|
|
# Rank 0 receiver with prefetch queue → prefetch event loop
|
|
if use_prefetch:
|
|
self._disagg_prefetch_event_loop(role_name)
|
|
return
|
|
|
|
# Non-rank-0 receiver in multi-rank → broadcast-based loop
|
|
if (
|
|
not is_rank0
|
|
and is_multi_rank
|
|
and self._disagg_role in (RoleType.DENOISER, RoleType.DECODER)
|
|
):
|
|
self._disagg_non_rank0_event_loop()
|
|
return
|
|
|
|
while self._running:
|
|
try:
|
|
# All ranks receive work (rank 0 via ZMQ, others via broadcast)
|
|
frames = self._disagg_recv_work()
|
|
|
|
# Transfer dispatch: check on ALL ranks (frames are broadcast)
|
|
if self._is_transfer_frames(frames):
|
|
if is_rank0:
|
|
# Rank 0: handle all transfer messages
|
|
self._handle_transfer_msg(frames)
|
|
else:
|
|
# Non-rank-0: only participate in transfer_ready compute
|
|
self._handle_transfer_non_rank0(frames)
|
|
elif self._disagg_role == RoleType.ENCODER:
|
|
self._disagg_encoder_step(
|
|
send_tensors,
|
|
frames=frames,
|
|
)
|
|
|
|
self._consecutive_error_count = 0
|
|
|
|
except Exception as e:
|
|
self._consecutive_error_count += 1
|
|
logger.error(
|
|
"Pool %s rank %d: error (attempt %d/%d): %s",
|
|
role_name,
|
|
self.gpu_id,
|
|
self._consecutive_error_count,
|
|
self._max_consecutive_errors,
|
|
e,
|
|
exc_info=True,
|
|
)
|
|
if self._consecutive_error_count >= self._max_consecutive_errors:
|
|
raise RuntimeError(
|
|
f"Pool {role_name} rank {self.gpu_id} terminated after "
|
|
f"{self._max_consecutive_errors} consecutive errors: {e}"
|
|
) from e
|
|
|
|
self._cleanup_disagg()
|
|
|
|
def _cleanup_disagg(self: Scheduler):
|
|
"""Clean up all pool mode resources (sockets, threads, transfer manager)."""
|
|
# Shutdown RDMA push thread
|
|
if self._rdma_push_queue is not None:
|
|
self._rdma_push_queue.put(None)
|
|
if self._rdma_push_thread is not None:
|
|
self._rdma_push_thread.join(timeout=5)
|
|
if self._rdma_push_zmq is not None:
|
|
self._rdma_push_zmq.close()
|
|
# Recv prefetch thread stops when self._running = False
|
|
if self._recv_prefetch_thread is not None:
|
|
self._recv_prefetch_thread.join(timeout=5)
|
|
if self._transfer_manager is not None:
|
|
self._transfer_manager.cleanup()
|
|
if self._pool_work_pull is not None:
|
|
self._pool_work_pull.close()
|
|
if self._pool_result_push is not None:
|
|
self._pool_result_push.close()
|
|
|
|
# ------------------------------------------------------------------
|
|
# Transfer message handling
|
|
# ------------------------------------------------------------------
|
|
|
|
@staticmethod
|
|
def _is_transfer_frames(frames: list) -> bool:
|
|
"""Check if ZMQ multipart frames carry a transfer control message."""
|
|
return is_transfer_message(frames)
|
|
|
|
def _handle_transfer_msg(self: Scheduler, frames: list) -> None:
|
|
"""Dispatch a transfer control message to the appropriate handler (rank 0)."""
|
|
msg = decode_transfer_msg(frames)
|
|
msg_type = msg.get("msg_type", "")
|
|
request_id = msg.get("request_id", "")
|
|
|
|
logger.debug(
|
|
"Transfer %s: received %s for %s",
|
|
self._disagg_role.value.upper(),
|
|
msg_type,
|
|
request_id,
|
|
)
|
|
|
|
if msg_type == TransferMsgType.ALLOC:
|
|
self._handle_transfer_alloc(msg)
|
|
elif msg_type == TransferMsgType.PUSH:
|
|
self._handle_transfer_push(msg)
|
|
elif msg_type == TransferMsgType.READY:
|
|
self._handle_transfer_ready(msg)
|
|
else:
|
|
logger.warning(
|
|
"Transfer %s: unknown message type %s",
|
|
self._disagg_role.value.upper(),
|
|
msg_type,
|
|
)
|
|
|
|
def _handle_transfer_non_rank0(self: Scheduler, frames: list) -> None:
|
|
"""Handle transfer messages on non-rank-0 workers.
|
|
|
|
Only transfer_ready requires non-rank-0 participation (for compute).
|
|
transfer_alloc and transfer_push are rank-0-only operations — skip them.
|
|
"""
|
|
msg = decode_transfer_msg(frames)
|
|
msg_type = msg.get("msg_type", "")
|
|
|
|
if msg_type == TransferMsgType.READY:
|
|
# Non-rank-0 has no TransferManager, so rank 0 loads tensors from
|
|
# the RDMA buffer and broadcasts the full Req (scalars + tensors)
|
|
# over NCCL. Participate in the matching broadcast here.
|
|
req = self._broadcast_req_to_all_ranks(None)
|
|
self._disagg_compute_non_rank0(req)
|
|
# else: transfer_alloc, transfer_push — skip (rank-0-only operations)
|
|
|
|
def _handle_transfer_alloc(self: Scheduler, msg: dict) -> None:
|
|
"""Handle transfer_alloc: allocate a receive slot and reply with transfer_allocated."""
|
|
request_id = msg["request_id"]
|
|
data_size = msg.get("data_size", 0)
|
|
|
|
pending = self._transfer_manager.allocate_receive_slot(request_id, data_size)
|
|
if pending is None:
|
|
logger.error(
|
|
"Transfer %s: failed to allocate receive slot for %s (%d bytes)",
|
|
self._disagg_role.value.upper(),
|
|
request_id,
|
|
data_size,
|
|
)
|
|
return
|
|
|
|
allocated_msg = TransferAllocatedMsg(
|
|
request_id=request_id,
|
|
session_id=self._transfer_manager.session_id,
|
|
pool_ptr=self._transfer_manager.pool_data_ptr,
|
|
slot_offset=pending.slot.offset,
|
|
slot_size=pending.slot.size,
|
|
)
|
|
self._pool_result_push.send_multipart(encode_transfer_msg(allocated_msg))
|
|
|
|
logger.debug(
|
|
"Transfer %s: allocated receive slot for %s (offset=%d, size=%d)",
|
|
self._disagg_role.value.upper(),
|
|
request_id,
|
|
pending.slot.offset,
|
|
pending.slot.size,
|
|
)
|
|
|
|
def _handle_transfer_push(self: Scheduler, msg: dict) -> None:
|
|
"""Handle transfer_push: RDMA push staged data to peer, reply with transfer_pushed.
|
|
|
|
If RDMA push thread is active, enqueue non-blocking.
|
|
Otherwise fall back to blocking push (e.g., during shutdown).
|
|
"""
|
|
request_id = msg["request_id"]
|
|
dest_session_id = msg.get("dest_session_id", "")
|
|
dest_addr = msg.get("dest_addr", 0)
|
|
transfer_size = msg.get("transfer_size", 0)
|
|
|
|
if self._rdma_push_queue is not None:
|
|
# Non-blocking: enqueue to RDMA push thread
|
|
self._rdma_push_queue.put(
|
|
(
|
|
request_id,
|
|
dest_session_id,
|
|
dest_addr,
|
|
transfer_size,
|
|
)
|
|
)
|
|
return
|
|
|
|
# Fallback: blocking push on main thread
|
|
success = self._transfer_manager.push_to_peer(
|
|
request_id=request_id,
|
|
dest_session_id=dest_session_id,
|
|
dest_addr=dest_addr,
|
|
transfer_size=transfer_size,
|
|
)
|
|
|
|
if success:
|
|
self._transfer_manager.free_staged(request_id)
|
|
|
|
pushed_msg = TransferPushedMsg(request_id=request_id)
|
|
self._pool_result_push.send_multipart(encode_transfer_msg(pushed_msg))
|
|
|
|
if not success:
|
|
logger.error(
|
|
"Transfer %s: RDMA push failed for %s",
|
|
self._disagg_role.value.upper(),
|
|
request_id,
|
|
)
|
|
|
|
def _handle_transfer_ready(self: Scheduler, msg: dict) -> None:
|
|
"""Handle transfer_ready: load tensors from buffer, run compute, send result.
|
|
|
|
Overlap tensor load with Req construction and scheduler init.
|
|
After the RDMA data arrives:
|
|
1. Start load on transfer_stream (non-blocking)
|
|
2. Build Req from scalar fields + tensors (CPU, overlapped)
|
|
3. Init scheduler timesteps if denoiser (CPU, overlapped)
|
|
4. Wait for load before compute
|
|
5. Run the role's compute
|
|
"""
|
|
|
|
request_id = msg["request_id"]
|
|
manifest = msg.get("manifest", {})
|
|
scalar_fields = msg.get("scalar_fields", {})
|
|
role_name = self._disagg_role.value.upper()
|
|
|
|
if self._disagg_metrics:
|
|
self._disagg_metrics.record_request_start(request_id)
|
|
|
|
# If using a pre-allocated slot, register it as pending receive
|
|
prealloc_slot_id = scalar_fields.pop("_prealloc_slot_id", None)
|
|
if (
|
|
prealloc_slot_id is not None
|
|
and prealloc_slot_id in self._preallocated_slots
|
|
):
|
|
slot = self._preallocated_slots[prealloc_slot_id]
|
|
self._transfer_manager.register_prealloc_as_receive(request_id, slot)
|
|
|
|
# 1. Start load on transfer_stream (non-blocking)
|
|
local_device = f"{current_platform.device_type}:{self.worker.local_rank}"
|
|
tensors, load_event = self._transfer_manager.load_tensors_async(
|
|
request_id,
|
|
manifest,
|
|
device=local_device,
|
|
stream=self._transfer_stream,
|
|
)
|
|
|
|
# 2. Build Req from scalar fields + tensors (CPU work, overlapped)
|
|
req = self._build_disagg_req(scalar_fields, tensors)
|
|
|
|
# 3. Init scheduler timesteps if denoiser (CPU work, overlapped)
|
|
if self._disagg_role == RoleType.DENOISER:
|
|
_init_disagg_request_scheduler(self, req)
|
|
|
|
# 4. Wait for load before compute (GPU must see the data)
|
|
if load_event is not None:
|
|
torch.get_device_module().current_stream().wait_event(load_event)
|
|
|
|
# 5. Free receive slot after load completes (data is on compute GPU)
|
|
if prealloc_slot_id is not None:
|
|
# Pre-allocated slot: just remove from pending receives, don't free buffer
|
|
with self._transfer_manager._lock:
|
|
self._transfer_manager._pending_receives.pop(request_id, None)
|
|
else:
|
|
self._transfer_manager.free_receive_slot(request_id)
|
|
|
|
# 6. In multi-rank mode, broadcast the fully-loaded Req to the other
|
|
# ranks so REPLICATED stages see identical inputs everywhere. See
|
|
# the prefetch-loop variant for the matching receiver broadcast.
|
|
if self._is_multi_rank():
|
|
self._broadcast_req_to_all_ranks(req)
|
|
|
|
# 7. Run compute
|
|
if self._disagg_role == RoleType.DENOISER:
|
|
self._disagg_denoiser_compute(req, request_id, role_name)
|
|
elif self._disagg_role == RoleType.DECODER:
|
|
self._disagg_decoder_compute(req, request_id, role_name)
|
|
|
|
# ------------------------------------------------------------------
|
|
# Compute
|
|
# ------------------------------------------------------------------
|
|
|
|
def _disagg_compute_non_rank0(self: Scheduler, req: Req) -> None:
|
|
"""Non-rank-0 compute: enter execute_forward with a Req received via
|
|
NCCL broadcast from rank 0.
|
|
|
|
The Req already contains tensor fields materialized on this rank's
|
|
GPU (see ``_broadcast_req_to_all_ranks``), so REPLICATED stages such
|
|
as denoising have non-empty prompt_embeds and verify_input passes.
|
|
|
|
Used by both the non-prefetch path (:meth:`_handle_transfer_non_rank0`)
|
|
and the prefetch non-rank-0 loop
|
|
(:meth:`_disagg_non_rank0_event_loop`).
|
|
"""
|
|
if self._disagg_role == RoleType.DENOISER:
|
|
# Initialize scheduler timesteps (same as rank 0)
|
|
_init_disagg_request_scheduler(self, req)
|
|
|
|
with self._disagg_trace_dispatch(req):
|
|
self.worker.execute_forward([req], return_req=True)
|
|
|
|
elif self._disagg_role == RoleType.DECODER:
|
|
req.save_output = False
|
|
req.return_file_paths_only = False
|
|
with self._disagg_trace_dispatch(req):
|
|
self.worker.execute_forward([req])
|
|
|
|
def _build_disagg_req(self: Scheduler, scalar_fields: dict, tensors: dict) -> Req:
|
|
"""Reconstruct a Req from transfer scalar fields and loaded GPU tensors.
|
|
|
|
Initializes all dataclass field defaults first, then overlays
|
|
scalar and tensor fields from the transfer message.
|
|
"""
|
|
# Pop _trace_state before the generic setattr loop so it doesn't land
|
|
# on the Req as a stray attribute.
|
|
trace_state = scalar_fields.pop("_trace_state", None)
|
|
|
|
req = object.__new__(Req)
|
|
# Initialize all dataclass fields with their defaults
|
|
for f in dataclasses.fields(Req):
|
|
if f.default is not dataclasses.MISSING:
|
|
object.__setattr__(req, f.name, f.default)
|
|
elif f.default_factory is not dataclasses.MISSING:
|
|
object.__setattr__(req, f.name, f.default_factory())
|
|
# Ensure sampling_params is not None so __getattr__ delegation works
|
|
object.__setattr__(req, "sampling_params", SamplingParams())
|
|
# Restore _extra_* prefixed fields into req.extra dict
|
|
extra_keys = [k for k in scalar_fields if k.startswith("_extra_")]
|
|
for key in extra_keys:
|
|
req.extra[key[len("_extra_") :]] = scalar_fields.pop(key)
|
|
for key, value in scalar_fields.items():
|
|
setattr(req, key, value)
|
|
# Set tensor fields
|
|
for key, value in tensors.items():
|
|
setattr(req, key, value)
|
|
# Recreate torch.Generator from seed (not serializable over transfer)
|
|
seed = scalar_fields.get("seed")
|
|
if seed is not None:
|
|
if isinstance(seed, list):
|
|
req.generator = [
|
|
torch.Generator(device="cpu").manual_seed(int(item))
|
|
for item in seed
|
|
]
|
|
else:
|
|
req.generator = torch.Generator(device="cpu").manual_seed(int(seed))
|
|
# Rebuild trace_ctx from the propagated __getstate__ dict so this role's
|
|
# spans nest under the sender's trace (same mechanism SRT uses via pickle).
|
|
if trace_state and trace_state.get("tracing_enable"):
|
|
try:
|
|
ctx = object.__new__(TraceReqContext)
|
|
ctx.__setstate__(trace_state)
|
|
req.trace_ctx = ctx
|
|
except Exception as e:
|
|
logger.debug("Failed to rebuild trace_ctx from _trace_state: %s", e)
|
|
req.validate()
|
|
return req
|
|
|
|
@contextlib.contextmanager
|
|
def _disagg_trace_dispatch(self: Scheduler, req: Req):
|
|
"""Wrap a disagg role's worker.execute_forward in the tracing lifecycle.
|
|
|
|
Mirrors the monolithic path in ``scheduler._handle_generation``: rebuild
|
|
the thread context under the (potentially remote) root_span_context that
|
|
was propagated in via ``_trace_state`` / pickle, then emit a
|
|
``scheduler_dispatch`` span for this role with ``thread_finish_flag``
|
|
so the thread span closes when compute returns. If tracing is disabled
|
|
(TraceNullContext), everything is a no-op.
|
|
"""
|
|
ctx = getattr(req, "trace_ctx", None)
|
|
if ctx is None:
|
|
yield
|
|
return
|
|
# Disagg receive (__setstate__) and compute may run on different
|
|
# threads (e.g. recv-prefetch vs scheduler main). Align the ctx's pid
|
|
# with the current compute thread so __create_thread_context's
|
|
# threads_info lookup resolves via the local registration.
|
|
if getattr(ctx, "tracing_enable", False):
|
|
ctx.pid = threading.get_native_id()
|
|
ctx.rebuild_thread_context()
|
|
with trace_slice(ctx, DiffStage.SCHEDULER_DISPATCH, thread_finish_flag=True):
|
|
yield
|
|
|
|
def _disagg_denoiser_compute(
|
|
self: Scheduler, req: Req, request_id: str, role_name: str
|
|
) -> None:
|
|
"""Run denoiser compute in transfer mode, then stage output for decoder.
|
|
|
|
Note: Scheduler timestep init is done in _handle_transfer_ready
|
|
to overlap with tensor loading.
|
|
"""
|
|
# Run denoising
|
|
start_time = time.monotonic()
|
|
with self._disagg_trace_dispatch(req):
|
|
result = self.worker.execute_forward([req], return_req=True)
|
|
duration_s = time.monotonic() - start_time
|
|
|
|
if not isinstance(result, Req):
|
|
error_msg = getattr(result, "error", "denoiser error")
|
|
done_msg = TransferDoneMsg(request_id=request_id, error=str(error_msg))
|
|
self._pool_result_push.send_multipart(encode_transfer_msg(done_msg))
|
|
if self._disagg_metrics:
|
|
self._disagg_metrics.record_request_failed(request_id)
|
|
return
|
|
|
|
# Stage denoiser output for decoder transfer (async staging)
|
|
tensor_fields, scalar_fields = extract_transfer_fields(result)
|
|
|
|
# 1. Stage tensors on transfer_stream (non-blocking)
|
|
staged, stage_event = self._transfer_manager.stage_tensors_async(
|
|
request_id=request_id,
|
|
tensor_fields=tensor_fields,
|
|
scalar_fields=scalar_fields,
|
|
stream=self._transfer_stream,
|
|
)
|
|
|
|
if staged is None:
|
|
done_msg = TransferDoneMsg(
|
|
request_id=request_id,
|
|
error="Failed to stage denoiser output for decoder",
|
|
)
|
|
self._pool_result_push.send_multipart(encode_transfer_msg(done_msg))
|
|
if self._disagg_metrics:
|
|
self._disagg_metrics.record_request_failed(request_id)
|
|
return
|
|
|
|
# 2. Build done_data dict while staging runs (CPU work, overlapped)
|
|
done_data = {
|
|
"msg_type": "transfer_done",
|
|
"request_id": request_id,
|
|
"staged_for_decoder": True,
|
|
"session_id": self._transfer_manager.session_id,
|
|
"pool_ptr": self._transfer_manager.pool_data_ptr,
|
|
"slot_offset": staged.slot.offset if staged.slot else 0,
|
|
"data_size": staged.slot.size if staged.slot else 0,
|
|
"manifest": staged.manifest,
|
|
"scalar_fields": staged.scalar_fields,
|
|
}
|
|
msg_bytes = json.dumps(done_data, separators=(",", ":")).encode("utf-8")
|
|
|
|
# 3. Wait for staging to complete before sending
|
|
if stage_event is not None:
|
|
stage_event.synchronize()
|
|
|
|
# 4. Send transfer_done with staged info
|
|
self._pool_result_push.send_multipart([TRANSFER_MAGIC, msg_bytes])
|
|
|
|
if self._disagg_metrics:
|
|
self._disagg_metrics.record_request_complete(request_id)
|
|
|
|
logger.debug(
|
|
"Transfer DENOISER: processed %s in %.2f s, staged for decoder",
|
|
request_id,
|
|
duration_s,
|
|
)
|
|
|
|
def _disagg_decoder_compute(
|
|
self: Scheduler, req: Req, request_id: str, role_name: str
|
|
) -> None:
|
|
"""Run decoder compute in transfer mode, send result to DS.
|
|
|
|
Decoder result is sent as raw ZMQ multipart frames (same format as
|
|
relay mode) so DiffusionServer handles it via _handle_decoder_result_frames
|
|
without hex/JSON overhead.
|
|
"""
|
|
|
|
# Check for upstream error
|
|
disagg_error = getattr(req, "_disagg_error", None)
|
|
if disagg_error:
|
|
if self._pool_result_push is not None:
|
|
send_tensors(
|
|
self._pool_result_push,
|
|
{},
|
|
{
|
|
"request_id": request_id,
|
|
"error": f"Upstream error: {disagg_error}",
|
|
},
|
|
)
|
|
return
|
|
|
|
req.save_output = False
|
|
req.return_file_paths_only = False
|
|
|
|
start_time = time.monotonic()
|
|
with self._disagg_trace_dispatch(req):
|
|
output_batch = self.worker.execute_forward([req])
|
|
duration_s = time.monotonic() - start_time
|
|
|
|
# Send result as raw ZMQ frames (no TRANSFER_MAGIC prefix).
|
|
# DiffusionServer will route it through _handle_decoder_result_frames,
|
|
# the same path as relay mode.
|
|
tensor_fields = {}
|
|
scalar_fields = {"request_id": request_id}
|
|
if output_batch.output is not None:
|
|
tensor_fields["output"] = output_batch.output
|
|
if output_batch.audio is not None:
|
|
tensor_fields["audio"] = output_batch.audio
|
|
if output_batch.audio_sample_rate is not None:
|
|
scalar_fields["audio_sample_rate"] = output_batch.audio_sample_rate
|
|
if output_batch.error is not None:
|
|
scalar_fields["error"] = output_batch.error
|
|
|
|
if self._pool_result_push is not None:
|
|
send_tensors(self._pool_result_push, tensor_fields, scalar_fields)
|
|
|
|
if self._disagg_metrics:
|
|
if output_batch.error:
|
|
self._disagg_metrics.record_request_failed(request_id)
|
|
else:
|
|
self._disagg_metrics.record_request_complete(request_id)
|
|
|
|
logger.debug("Transfer DECODER: processed %s in %.2f s", request_id, duration_s)
|
|
|
|
def _disagg_encoder_step(
|
|
self: Scheduler,
|
|
send_tensors_fn,
|
|
frames=None,
|
|
):
|
|
"""Single encoder step in pool mode."""
|
|
# Receive: [request_id_bytes, pickled_req_bytes]
|
|
if frames is None:
|
|
frames = self._pool_work_pull.recv_multipart()
|
|
pickled_req = frames[-1]
|
|
reqs = pickle.loads(pickled_req)
|
|
if not isinstance(reqs, list):
|
|
reqs = [reqs]
|
|
|
|
req = reqs[0]
|
|
request_id = getattr(req, "request_id", "unknown")
|
|
|
|
if self._disagg_metrics:
|
|
self._disagg_metrics.record_request_start(request_id)
|
|
|
|
# Run encoder stages
|
|
with self._disagg_trace_dispatch(req):
|
|
req_result = self.worker.execute_forward(reqs, return_req=True)
|
|
|
|
if not isinstance(req_result, Req):
|
|
# Error — send error via scalar fields (rank 0 only)
|
|
if self._pool_result_push is not None:
|
|
error_msg = getattr(req_result, "error", "encoder error")
|
|
send_tensors_fn(
|
|
self._pool_result_push,
|
|
{},
|
|
{"request_id": request_id, "_disagg_error": str(error_msg)},
|
|
)
|
|
if self._disagg_metrics:
|
|
self._disagg_metrics.record_request_failed(request_id)
|
|
return
|
|
|
|
# Pack and send encoder output (rank 0 only sends)
|
|
tensor_fields, scalar_fields = extract_transfer_fields(req_result)
|
|
|
|
if self._pool_result_push is not None:
|
|
if self._transfer_manager is not None:
|
|
# Transfer mode: stage tensors to TransferBuffer, send transfer_staged
|
|
self._disagg_encoder_transfer_stage(
|
|
request_id, tensor_fields, scalar_fields
|
|
)
|
|
else:
|
|
# Fallback: send error (transfer manager not initialized)
|
|
send_tensors_fn(
|
|
self._pool_result_push,
|
|
{},
|
|
{"request_id": request_id, "_disagg_error": "No transfer manager"},
|
|
)
|
|
|
|
if self._disagg_metrics:
|
|
self._disagg_metrics.record_request_complete(request_id)
|
|
|
|
logger.debug("Pool ENCODER: processed %s", request_id)
|
|
|
|
def _disagg_encoder_transfer_stage(
|
|
self: Scheduler, request_id: str, tensor_fields: dict, scalar_fields: dict
|
|
) -> None:
|
|
"""Stage encoder output and send transfer_staged to DS.
|
|
|
|
Overlap staging with metadata JSON serialization.
|
|
"""
|
|
# 1. Stage tensors on transfer_stream (non-blocking)
|
|
staged, stage_event = self._transfer_manager.stage_tensors_async(
|
|
request_id=request_id,
|
|
tensor_fields=tensor_fields,
|
|
scalar_fields=scalar_fields,
|
|
stream=self._transfer_stream,
|
|
)
|
|
|
|
if staged is None:
|
|
# Staging failed — send error via relay as fallback
|
|
send_tensors(
|
|
self._pool_result_push,
|
|
{},
|
|
{"request_id": request_id, "_disagg_error": "Transfer staging failed"},
|
|
)
|
|
if self._disagg_metrics:
|
|
self._disagg_metrics.record_request_failed(request_id)
|
|
return
|
|
|
|
# 2. Build transfer metadata dict while staging runs (CPU work, overlapped)
|
|
staged_data = {
|
|
"msg_type": "transfer_staged",
|
|
"request_id": request_id,
|
|
"data_size": staged.slot.size if staged.slot else 0,
|
|
"manifest": staged.manifest,
|
|
"session_id": self._transfer_manager.session_id,
|
|
"pool_ptr": self._transfer_manager.pool_data_ptr,
|
|
"slot_offset": staged.slot.offset if staged.slot else 0,
|
|
"scalar_fields": staged.scalar_fields,
|
|
}
|
|
msg_bytes = json.dumps(staged_data, separators=(",", ":")).encode("utf-8")
|
|
|
|
# 3. Wait for staging to complete before sending (buffer must be ready)
|
|
if stage_event is not None:
|
|
stage_event.synchronize()
|
|
|
|
# 4. Send transfer staged message
|
|
self._pool_result_push.send_multipart([TRANSFER_MAGIC, msg_bytes])
|