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

1165 lines
46 KiB
Python

# Copied and adapted from: https://github.com/hao-ai-lab/FastVideo
# SPDX-License-Identifier: Apache-2.0
import dataclasses
import pickle
import time
from collections import deque
from contextlib import contextmanager
from copy import deepcopy
from enum import Enum
from typing import Any, Iterator, List
import zmq
from sglang.multimodal_gen.runtime.disaggregation.roles import RoleType
from sglang.multimodal_gen.runtime.disaggregation.scheduler_mixin import (
SchedulerDisaggMixin,
)
from sglang.multimodal_gen.runtime.entrypoints.post_training.io_struct import (
GetWeightsChecksumReqInput,
ReleaseMemoryOccupationReqInput,
ResumeMemoryOccupationReqInput,
UpdateWeightFromDiskReqInput,
UpdateWeightFromTensorCheckerReqInput,
UpdateWeightFromTensorReqInput,
)
from sglang.multimodal_gen.runtime.entrypoints.utils import (
GetDisaggStatsReq,
ListLorasReq,
MergeLoraWeightsReq,
ReleaseRealtimeSessionReq,
SetLoraReq,
ShutdownReq,
UnmergeLoraWeightsReq,
)
from sglang.multimodal_gen.runtime.ipc_array import (
is_local_endpoint,
spill_large_arrays_to_file_refs,
)
from sglang.multimodal_gen.runtime.managers.cpu_worker import CPUWorker
from sglang.multimodal_gen.runtime.managers.dynamic_batch_admission import (
BatchAdmissionController,
)
from sglang.multimodal_gen.runtime.managers.gpu_worker import GPUWorker
from sglang.multimodal_gen.runtime.pipelines_core import Req
from sglang.multimodal_gen.runtime.pipelines_core.schedule_batch import (
BatchMetricsWindow,
OutputBatch,
)
from sglang.multimodal_gen.runtime.post_training.scheduler_post_training_mixin import (
SchedulerPostTrainingMixin,
)
from sglang.multimodal_gen.runtime.server_args import (
PortArgs,
ServerArgs,
set_global_server_args,
)
from sglang.multimodal_gen.runtime.server_warmup import (
SchedulerWarmupMixin,
get_first_generation_req,
is_warmup_req,
should_return_warmup_result,
)
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
logger = init_logger(__name__)
_MAX_RECV_REQS_PER_POLL = 1024
_BATCH_METRICS_LOG_INTERVAL = 5
class Scheduler(SchedulerWarmupMixin, SchedulerPostTrainingMixin, SchedulerDisaggMixin):
"""
Runs the main event loop for the rank 0 worker.
It listens for external requests via ZMQ and coordinates with other workers.
This class does NOT manage worker processes.
"""
def __init__(
self,
server_args: ServerArgs,
gpu_id: int,
port_args: PortArgs,
task_pipes_to_slaves: list = None,
result_pipes_from_slaves: list = None,
local_rank: int | None = None,
):
self.server_args = server_args
self.port_args = port_args
# local_rank is the physical GPU index for torch.cuda.set_device.
# In non-disagg mode, it equals gpu_id. In disagg mode, it may differ
# (e.g., denoiser rank 0 on physical GPU 1).
if local_rank is None:
local_rank = gpu_id
set_global_server_args(server_args=server_args)
# Inter-process Communication
self.context = zmq.Context(io_threads=2)
endpoint = server_args.scheduler_endpoint
if gpu_id == 0:
# router allocates identify (envelope) for each connection
self.receiver, actual_endpoint = get_zmq_socket(
self.context, zmq.ROUTER, endpoint, True
)
logger.info(f"Scheduler bind at endpoint: {actual_endpoint}")
else:
self.receiver = None
from sglang.multimodal_gen.runtime.platforms import current_platform
Exec_worker = CPUWorker if current_platform.is_cpu() else GPUWorker
worker = Exec_worker(
local_rank=local_rank,
master_port=port_args.master_port,
rank=gpu_id,
server_args=server_args,
)
self.worker = worker
self.task_pipes_to_slaves = task_pipes_to_slaves
self.result_pipes_from_slaves = result_pipes_from_slaves
self.gpu_id = gpu_id
self._show_warmup_progress = gpu_id == 0
self._running = True
self.request_handlers = {
SetLoraReq: self._handle_set_lora,
MergeLoraWeightsReq: self._handle_merge_lora,
UnmergeLoraWeightsReq: self._handle_unmerge_lora,
Req: self._handle_generation,
ListLorasReq: self._handle_list_loras,
ShutdownReq: self._handle_shutdown,
ReleaseRealtimeSessionReq: self._handle_release_realtime_session,
GetDisaggStatsReq: self._handle_get_disagg_stats,
UpdateWeightFromDiskReqInput: self._handle_update_weights_from_disk,
UpdateWeightFromTensorReqInput: self._handle_update_weights_from_tensor,
UpdateWeightFromTensorCheckerReqInput: (
self._handle_update_weights_from_tensor_checker
),
GetWeightsChecksumReqInput: self._handle_get_weights_checksum,
ReleaseMemoryOccupationReqInput: self._handle_release_memory_occupation,
ResumeMemoryOccupationReqInput: self._handle_resume_memory_occupation,
}
# FIFO queue entries: (identity, request, enqueue_ts_s)
self.waiting_queue: deque[tuple[bytes | None, Any, float]] = deque()
self._batching_max_size = server_args.batching_max_size
self._batching_delay_s = server_args.batching_delay_ms / 1000.0
self._batch_metrics_enabled = server_args.enable_batching_metrics
self._batch_metrics_window = BatchMetricsWindow()
self._batch_admission = BatchAdmissionController(server_args, gpu_id=local_rank)
self._poller = zmq.Poller()
if self.receiver is not None:
self._poller.register(self.receiver, zmq.POLLIN)
self.req_based_warmup_scheduled = False
# warmup progress tracking
self._warmup_total = 0
self._warmup_processed = 0
self._warmup_progress_bar: Any | None = None
self._logged_server_ready_after_warmup = False
# Maximum consecutive errors before terminating the event loop
self._max_consecutive_errors = 3
self._consecutive_error_count = 0
self._init_disagg_state(server_args, local_rank)
if self._batch_metrics_enabled:
logger.info(
"Dynamic batch metrics enabled; logging summary every %d dispatches.",
_BATCH_METRICS_LOG_INTERVAL,
)
def get_disagg_metrics(self) -> dict | None:
"""Return disagg role metrics snapshot, or None if not in disagg mode."""
if self._disagg_metrics is None:
return None
return self._disagg_metrics.snapshot().to_dict()
def _handle_get_disagg_stats(self, _reqs: List[Any]) -> OutputBatch:
"""Handle stats request — return disagg metrics via OutputBatch.output."""
stats = self.get_disagg_metrics()
return OutputBatch(
output=stats or {"role": "monolithic", "message": "not in disagg mode"}
)
def _handle_set_lora(self, reqs: List[Any]) -> OutputBatch:
# TODO: return set status
# TODO: return with SetLoRAResponse or something more appropriate
req = reqs[0]
return self.worker.set_lora(
req.lora_nickname,
req.lora_path,
req.target,
req.strength,
req.merge_mode,
)
def _handle_merge_lora(self, reqs: List[Any]):
req = reqs[0]
return self.worker.merge_lora_weights(req.target, req.strength)
def _handle_unmerge_lora(self, reqs: List[Any]) -> OutputBatch:
req = reqs[0]
return self.worker.unmerge_lora_weights(req.target)
def _handle_list_loras(self, _reqs: List[Any]) -> OutputBatch:
return self.worker.list_loras()
def _handle_shutdown(self, _reqs: List[Any]) -> OutputBatch:
self._running = False
return OutputBatch()
def _handle_release_realtime_session(self, reqs: List[Any]) -> OutputBatch:
req = reqs[0]
return self.worker.release_realtime_session(req.session_id)
def _handle_update_weights_from_disk(self, reqs: List[Any]) -> OutputBatch:
"""Handle update_weights_from_disk request for RL workflows."""
if self.worker.is_sleeping():
raise RuntimeError(
"Cannot update weights while the server is sleeping. "
"Call resume_memory_occupation first."
)
return super()._handle_update_weights_from_disk(reqs)
@staticmethod
def _normalize_generation_reqs(reqs: list[Any]) -> list[Req]:
if len(reqs) == 1 and isinstance(reqs[0], list):
return reqs[0]
return reqs
def _dispatch_single_request(self, req_or_group: Any) -> OutputBatch:
if isinstance(req_or_group, list):
if not all(isinstance(req, Req) for req in req_or_group):
return OutputBatch(
error=f"Unknown request group type: {type(req_or_group)}"
)
return self._handle_generation(req_or_group, allow_dynamic_batching=False)
handler = self.request_handlers.get(type(req_or_group))
if handler is None:
return OutputBatch(error=f"Unknown request type: {type(req_or_group)}")
return handler([req_or_group])
def _dispatch_items(
self, items: list[tuple[bytes | None, Any]]
) -> OutputBatch | list[OutputBatch]:
"""Dispatch ready queue items; several plain `Req`s form one dynamic batch."""
reqs = [item[1] for item in items]
if len(reqs) > 1 and all(isinstance(req, Req) for req in reqs):
return self._handle_generation(reqs, allow_dynamic_batching=True)
if len(reqs) > 1:
return [self._dispatch_single_request(req) for req in reqs]
return self._dispatch_single_request(reqs[0])
def _handle_generation(
self, reqs: list[Any], *, allow_dynamic_batching: bool = True
):
"""Dispatch generation requests, merging compatible requests when allowed."""
reqs = self._normalize_generation_reqs(reqs)
if self.worker.is_sleeping():
raise RuntimeError(
"Server is sleeping. Call resume_memory_occupation first."
)
warmup_reqs = [req for req in reqs if req.is_warmup]
if warmup_reqs:
self._ensure_warmup_progress_bar(warmup_reqs[0])
# Use the head request trace context for scheduler-side dispatch work.
req = reqs[0]
req.trace_ctx.rebuild_thread_context()
with trace_slice(
req.trace_ctx,
DiffStage.SCHEDULER_DISPATCH,
thread_finish_flag=True,
):
if len(reqs) == 1 or not allow_dynamic_batching:
return self.worker.execute_forward(reqs)
if self.server_args.pipeline_config.supports_native_grouped_requests():
return self._execute_generation_grouped(reqs)
merged_req = self._try_merge_generation_reqs(reqs)
if merged_req is None:
return self._execute_generation_sequential(reqs)
batch_size = len(reqs)
try:
output_batch = self.worker.execute_forward([merged_req])
if output_batch.error:
logger.error(
"Dynamic batch execution returned error. Skipping sequential fallback and returning errors: %s",
output_batch.error,
)
return self._build_dynamic_batch_error_outputs(
reqs=reqs,
error_msg=output_batch.error,
)
split_outputs = self._split_batched_output(output_batch, reqs)
if split_outputs is None:
logger.error(
"Failed to split dynamic batched output cleanly. Skipping sequential fallback and returning errors."
)
return self._build_dynamic_batch_error_outputs(
reqs=reqs,
error_msg="Dynamic batching failed: could not split merged output.",
)
logger.info(
"Processed dynamic batch of %d/%d request(s) with max_delay=%.2fms",
batch_size,
self._batching_max_size,
self._batching_delay_s * 1000.0,
)
return split_outputs
except Exception as e:
logger.error(
"Dynamic batching failed (%s). Skipping sequential fallback and returning errors.",
e,
exc_info=True,
)
return self._build_dynamic_batch_error_outputs(
reqs=reqs,
error_msg=f"Dynamic batching failed: {e}",
)
def _execute_generation_grouped(self, reqs: List[Req]) -> List[OutputBatch]:
batch_size = len(reqs)
try:
output_batch = self.worker.execute_forward(reqs)
if output_batch.error:
logger.error(
"Native grouped execution returned error. Returning per-request errors: %s",
output_batch.error,
)
return self._build_dynamic_batch_error_outputs(
reqs=reqs,
error_msg=output_batch.error,
)
split_outputs = self._split_batched_output(output_batch, reqs)
if split_outputs is None:
logger.error(
"Failed to split native grouped output cleanly. Returning per-request errors."
)
return self._build_dynamic_batch_error_outputs(
reqs=reqs,
error_msg="Native grouped execution failed: could not split output.",
)
logger.info(
"Processed native grouped batch of %d/%d request(s) with max_delay=%.2fms",
batch_size,
self._batching_max_size,
self._batching_delay_s * 1000.0,
)
return split_outputs
except Exception as e:
logger.error(
"Native grouped execution failed (%s). Returning per-request errors.",
e,
exc_info=True,
)
return self._build_dynamic_batch_error_outputs(
reqs=reqs,
error_msg=f"Native grouped execution failed: {e}",
)
def _execute_generation_sequential(self, reqs: List[Req]) -> List[OutputBatch]:
return [self.worker.execute_forward([req]) for req in reqs]
@staticmethod
def _percentile(values: list[float], percentile: float) -> float:
if not values:
return 0.0
ordered = sorted(values)
index = min(
len(ordered) - 1,
max(0, int(round((percentile / 100.0) * (len(ordered) - 1)))),
)
return ordered[index]
def _freeze_signature_value(self, value: Any):
"""Convert a value into a hashable, order-stable form for signature comparison."""
if isinstance(value, (str, int, float, bool, type(None))):
return value
if isinstance(value, Enum):
return value.value
if isinstance(value, dict):
return {
str(k): self._freeze_signature_value(v)
for k, v in sorted(value.items(), key=lambda kv: str(kv[0]))
}
if isinstance(value, (list, tuple)):
return tuple(self._freeze_signature_value(v) for v in value)
return repr(value)
def _sampling_param_signature_items(self, req: Req) -> list[tuple[str, Any]] | None:
"""Return per-field sampling-param signature items, skipping batch_sig_exclude fields."""
sp = req.sampling_params
if sp is None:
return None
try:
sp_fields = dataclasses.fields(sp)
except Exception:
return None
return [
(f.name, self._freeze_signature_value(getattr(sp, f.name, None)))
for f in sp_fields
if not f.metadata.get("batch_sig_exclude", False)
]
def _diffusers_kwargs_signature_value(self, req: Req) -> Any:
return self._freeze_signature_value((req.extra or {}).get("diffusers_kwargs"))
def _build_dynamic_batch_signature(self, req: Req) -> tuple[Any, ...] | None:
"""Build the request compatibility signature for dynamic batching.
The signature is built from `SamplingParams` fields, excluding fields
marked with `batch_sig_exclude`, plus generation-affecting
`extra.diffusers_kwargs`.
"""
signature_items = self._sampling_param_signature_items(req)
if signature_items is None:
return None
if req.extra:
diffusers_kwargs = req.extra.get("diffusers_kwargs")
if diffusers_kwargs:
signature_items.append(
(
"diffusers_kwargs",
self._freeze_signature_value(diffusers_kwargs),
)
)
return tuple(signature_items)
def _get_cached_signature(self, req: Req) -> tuple[Any, ...] | None:
cached = getattr(req, "_dynamic_batch_sig", None)
if cached is not None:
return cached
sig = self._build_dynamic_batch_signature(req)
req._dynamic_batch_sig = sig # type: ignore[attr-defined]
return sig
def _find_sampling_param_mismatch_field(
self, base_req: Req, candidate_req: Req
) -> str | None:
base_items = self._sampling_param_signature_items(base_req)
candidate_items = self._sampling_param_signature_items(candidate_req)
if base_items is None or candidate_items is None:
return None
if len(base_items) != len(candidate_items):
return "sampling_params"
for (name, base_value), (candidate_name, candidate_value) in zip(
base_items, candidate_items
):
if name != candidate_name:
return "sampling_params"
if base_value != candidate_value:
return f"sampling_params.{name}"
base_diffusers_kwargs = self._diffusers_kwargs_signature_value(base_req)
candidate_diffusers_kwargs = self._diffusers_kwargs_signature_value(
candidate_req
)
if base_diffusers_kwargs != candidate_diffusers_kwargs:
return "extra.diffusers_kwargs"
return None
def _get_dynamic_batch_reject_reason(
self, base_req: Req, candidate_req: Req
) -> str | None:
"""Return the first reason `candidate_req` cannot batch with `base_req`, or None."""
if self._can_dynamic_batch(base_req, candidate_req):
return None
if base_req.is_warmup or candidate_req.is_warmup:
return "warmup"
if self._has_realtime_session(base_req) or self._has_realtime_session(
candidate_req
):
return "realtime_session"
if not isinstance(base_req.prompt, str) or not isinstance(
candidate_req.prompt, str
):
return "prompt_type"
if (
getattr(base_req, "image_path", None) is not None
or getattr(candidate_req, "image_path", None) is not None
):
return "image_conditioning"
if base_req.return_file_paths_only != candidate_req.return_file_paths_only:
return "return_file_paths_only"
base_sig = self._get_cached_signature(base_req)
candidate_sig = self._get_cached_signature(candidate_req)
if base_sig is None or candidate_sig is None:
return "signature_unavailable"
return (
self._find_sampling_param_mismatch_field(base_req, candidate_req)
or "signature_mismatch"
)
@staticmethod
def _has_realtime_session(req: Req) -> bool:
return bool(req.realtime_session_id) or req.session is not None
def _can_dynamic_batch(self, base_req: Req, candidate_req: Req) -> bool:
"""Return whether `candidate_req` can be merged into a batch with `base_req`."""
if base_req.is_warmup or candidate_req.is_warmup:
return False
if self._has_realtime_session(base_req) or self._has_realtime_session(
candidate_req
):
return False
if not isinstance(base_req.prompt, str) or not isinstance(
candidate_req.prompt, str
):
return False
if (
getattr(base_req, "image_path", None) is not None
or getattr(candidate_req, "image_path", None) is not None
):
return False
if base_req.return_file_paths_only != candidate_req.return_file_paths_only:
return False
base_sig = self._get_cached_signature(base_req)
cand_sig = self._get_cached_signature(candidate_req)
return base_sig is not None and base_sig == cand_sig
def _record_batch_dispatch_metrics(
self,
batch_size: int,
queue_wait_ms: float,
effective_max_batch_size: int,
reject_reasons: list[str] | None = None,
stop_reason: str | None = None,
) -> None:
if not self._batch_metrics_enabled:
return
effective_max_batch_size = max(1, effective_max_batch_size)
logger.info(
"Dynamic batch dispatch: size=%d/%d, user_max=%d, queue_wait=%.2fms, stop_reason=%s",
batch_size,
effective_max_batch_size,
self._batching_max_size,
max(queue_wait_ms, 0.0),
stop_reason or "unspecified",
)
window = self._batch_metrics_window
window.dispatches += 1
window.total_requests += batch_size
window.total_capacity += effective_max_batch_size
if batch_size > 1:
window.merged_dispatches += 1
if self._dynamic_batching_enabled() and batch_size >= effective_max_batch_size:
window.full_dispatches += 1
window.wait_times_ms.append(max(queue_wait_ms, 0.0))
if reject_reasons:
window.reject_reasons.update(reject_reasons)
if window.dispatches >= _BATCH_METRICS_LOG_INTERVAL:
self._log_batch_metrics_summary()
def _log_batch_metrics_summary(self) -> None:
if not self._batch_metrics_enabled:
return
window = self._batch_metrics_window
if window.dispatches == 0:
return
avg_size = window.total_requests / window.dispatches
utilization = window.total_requests / max(1, window.total_capacity)
avg_wait_ms = sum(window.wait_times_ms) / len(window.wait_times_ms)
p95_wait_ms = self._percentile(window.wait_times_ms, 95.0)
merged_rate = window.merged_dispatches / window.dispatches
full_rate = window.full_dispatches / window.dispatches
top_rejects = ", ".join(
f"{reason}={count}"
for reason, count in window.reject_reasons.most_common(5)
)
if not top_rejects:
top_rejects = "none"
logger.info(
"Dynamic batch stats (last %d dispatches): avg_size=%.2f, merged_rate=%.1f%%, full_rate=%.1f%%, utilization=%.1f%%, wait_avg=%.2fms, wait_p95=%.2fms, top_rejects=%s",
window.dispatches,
avg_size,
merged_rate * 100.0,
full_rate * 100.0,
utilization * 100.0,
avg_wait_ms,
p95_wait_ms,
top_rejects,
)
self._batch_metrics_window = BatchMetricsWindow()
def _build_dynamic_batch_error_outputs(
self,
reqs: List[Req],
error_msg: str,
) -> List[OutputBatch]:
return [OutputBatch(error=error_msg) for _ in reqs]
def _should_return_lightweight_warmup_result(self, processed_req: Any) -> bool:
req = get_first_generation_req(processed_req)
return (req is not None and bool(req.extra.get("server_internal_prewarm"))) or (
is_warmup_req(processed_req) and should_return_warmup_result(processed_req)
)
def return_result(
self,
output_batch: OutputBatch,
identity: bytes | None = None,
should_not_return: bool = False,
):
"""
replies to client, only on rank 0
"""
if not should_not_return and self.receiver is not None and identity is not None:
# if the server is local, use temp file to spill the frame array instead of
# leaving it in OutputBatch to be pickled later
if is_local_endpoint(self.server_args.scheduler_endpoint):
with self._record_return_stage(
output_batch, "Scheduler.return_result.spill_arrays"
):
output_batch.output = spill_large_arrays_to_file_refs(
output_batch.output
)
with self._record_return_stage(
output_batch, "Scheduler.return_result.pickle"
):
payload = pickle.dumps(output_batch)
with self._record_return_stage(
output_batch, "Scheduler.return_result.send"
):
self.receiver.send_multipart([identity, b"", payload])
@contextmanager
def _record_return_stage(
self, output_batch: OutputBatch, stage_name: str
) -> Iterator[None]:
"""helper function to record a stage metric"""
start_time = time.perf_counter()
yield
if output_batch.metrics is not None:
output_batch.metrics.record_stage(
stage_name, time.perf_counter() - start_time
)
def _try_merge_generation_reqs(self, reqs: List[Req]) -> Req | None:
"""Create a batched generation request from compatible requests.
Per-request seeds and output paths are stored in `extra` so downstream
stages can preserve request ordering.
"""
if len(reqs) <= 1:
return reqs[0] if reqs else None
base_req = reqs[0]
for req in reqs[1:]:
if not self._can_dynamic_batch(base_req, req):
return None
merged_req = deepcopy(base_req)
merged_req.prompt = [req.prompt for req in reqs]
merged_req.extra = deepcopy(merged_req.extra)
merged_req.extra["dynamic_batch_seeds"] = [req.seed for req in reqs]
merged_req.return_file_paths_only = base_req.return_file_paths_only
if merged_req.return_file_paths_only:
dynamic_output_paths: list[str] = []
for req in reqs:
for output_idx in range(req.num_outputs_per_prompt):
dynamic_output_paths.append(
req.output_file_path(req.num_outputs_per_prompt, output_idx)
)
merged_req.extra["dynamic_batch_output_paths"] = dynamic_output_paths
merged_req.request_id = f"dynamic_batch::{merged_req.request_id}"
return merged_req
@staticmethod
def _count_first_dim(value: Any) -> int | None:
if value is None:
return None
if isinstance(value, (list, tuple)):
return len(value)
shape = getattr(value, "shape", None)
if shape is not None:
try:
if len(shape) > 0:
return int(shape[0])
except Exception:
return None
return None
def _slice_batched_value(
self, value: Any, start: int, end: int, total_items: int
) -> Any:
if value is None:
return None
if isinstance(value, (list, tuple)):
if len(value) == total_items:
sliced = value[start:end]
return list(sliced) if isinstance(value, list) else tuple(sliced)
return deepcopy(value)
value_items = self._count_first_dim(value)
if value_items == total_items:
try:
return value[start:end]
except Exception:
pass
# Scalar / non-batched metadata
return deepcopy(value)
def _split_batched_output(
self, output_batch: OutputBatch, reqs: List[Req]
) -> List[OutputBatch] | None:
"""Split a merged result only when outputs map one-to-one to requests."""
per_req_counts = [req.num_outputs_per_prompt for req in reqs]
total_items = sum(per_req_counts)
output_items = self._count_first_dim(output_batch.output)
output_path_items = self._count_first_dim(output_batch.output_file_paths)
if output_items is None and output_path_items is None:
logger.warning(
"Batched output has neither tensor outputs nor output_file_paths; cannot split safely."
)
return None
if output_items is not None and output_items != total_items:
logger.warning(
"Unexpected batched output size: got %s items, expected %s",
output_items,
total_items,
)
return None
if output_path_items is not None and output_path_items != total_items:
logger.warning(
"Unexpected batched output_file_paths size: got %s items, expected %s",
output_path_items,
total_items,
)
return None
outputs: list[OutputBatch] = []
start = 0
for req_index, (req, req_count) in enumerate(zip(reqs, per_req_counts)):
end = start + req_count
metrics = (
deepcopy(output_batch.metrics_list[req_index])
if output_batch.metrics_list is not None
and req_index < len(output_batch.metrics_list)
else deepcopy(output_batch.metrics)
)
split = OutputBatch(
output=self._slice_batched_value(
output_batch.output, start, end, total_items
),
audio=self._slice_batched_value(
output_batch.audio, start, end, total_items
),
audio_sample_rate=output_batch.audio_sample_rate,
action_pred=self._slice_batched_value(
output_batch.action_pred, start, end, total_items
),
trajectory_timesteps=self._slice_batched_value(
output_batch.trajectory_timesteps, start, end, total_items
),
trajectory_latents=self._slice_batched_value(
output_batch.trajectory_latents, start, end, total_items
),
trajectory_decoded=self._slice_batched_value(
output_batch.trajectory_decoded, start, end, total_items
),
error=output_batch.error,
output_file_paths=self._slice_batched_value(
output_batch.output_file_paths, start, end, total_items
),
metrics=metrics,
noise_pred=self._slice_batched_value(
output_batch.noise_pred, start, end, total_items
),
peak_memory_mb=output_batch.peak_memory_mb,
)
if split.metrics is not None:
split.metrics.request_id = req.request_id
outputs.append(split)
start = end
return outputs
def _dynamic_batching_enabled(self) -> bool:
"""Return whether this server and pipeline can use dynamic batching.
This is the coarse gate; request-level checks decide which requests can
actually be merged.
"""
pipeline_config = self.server_args.pipeline_config
supports_dynamic_batching = getattr(
pipeline_config, "supports_dynamic_batching", None
)
if callable(supports_dynamic_batching):
return self._batch_admission.enabled and supports_dynamic_batching()
return self._batch_admission.enabled
def get_next_batch_to_run(self) -> list[tuple[bytes | None, Any]] | None:
"""Return the next dispatchable queue item or dynamic batch.
Returns None when the head request is waiting for more compatible
requests within the configured batching delay.
"""
if not self.waiting_queue:
return None
if not self._dynamic_batching_enabled():
identity, req, enqueue_time = self.waiting_queue.popleft()
if isinstance(req, Req):
self._record_batch_dispatch_metrics(
batch_size=1,
queue_wait_ms=(time.monotonic() - enqueue_time) * 1000.0,
effective_max_batch_size=1,
stop_reason="dynamic_disabled",
)
return [(identity, req)]
identity, req, enqueue_time = self.waiting_queue[0]
if not isinstance(req, Req):
identity, req, _ = self.waiting_queue.popleft()
return [(identity, req)]
# If the head request itself is not eligible for dynamic batching
# (e.g., image-conditioned i2i request), dispatch it immediately.
if not self._can_dynamic_batch(req, req):
identity, req, head_enqueue_time = self.waiting_queue.popleft()
reject_reasons: list[str] = []
if self._batch_metrics_enabled:
reason = self._get_dynamic_batch_reject_reason(req, req)
if reason is not None:
reject_reasons.append(f"head:{reason}")
self._record_batch_dispatch_metrics(
batch_size=1,
queue_wait_ms=(time.monotonic() - head_enqueue_time) * 1000.0,
effective_max_batch_size=1,
reject_reasons=reject_reasons,
stop_reason=reject_reasons[0] if reject_reasons else "head_ineligible",
)
return [(identity, req)]
compatible_indices: list[int] = [0]
compatible_reqs: list[Req] = [req]
reject_reasons: list[str] = []
for idx in range(1, len(self.waiting_queue)):
if len(
compatible_indices
) >= self._batching_max_size or self._batch_admission.batch_is_full(
compatible_reqs
):
break
_identity, candidate_req, _enqueue_time = self.waiting_queue[idx]
if isinstance(candidate_req, Req) and self._can_dynamic_batch(
req, candidate_req
):
admission_reject = self._batch_admission.reject_reason_for_candidate(
compatible_reqs, candidate_req
)
if admission_reject is None:
compatible_indices.append(idx)
compatible_reqs.append(candidate_req)
elif self._batch_metrics_enabled:
reject_reasons.append(admission_reject)
elif self._batch_metrics_enabled and isinstance(candidate_req, Req):
reason = self._get_dynamic_batch_reject_reason(req, candidate_req)
if reason is not None:
reject_reasons.append(reason)
batch_len = len(compatible_indices)
oldest_wait_s = time.monotonic() - enqueue_time
should_wait_for_more = (
batch_len < self._batching_max_size
and not self._batch_admission.batch_is_full(compatible_reqs)
and oldest_wait_s < self._batching_delay_s
)
if should_wait_for_more:
return None
batch_items: list[tuple[bytes | None, Any]] = [None] * batch_len
for pos, idx in enumerate(reversed(compatible_indices)):
item_identity, item_req, _ = self.waiting_queue[idx]
batch_items[batch_len - 1 - pos] = (item_identity, item_req)
del self.waiting_queue[idx]
stop_reason = self._batch_admission.limit_reason_for_batch(compatible_reqs)
if stop_reason is None:
if batch_len >= self._batching_max_size:
stop_reason = "max_size"
elif reject_reasons:
stop_reason = reject_reasons[0]
elif oldest_wait_s >= self._batching_delay_s:
stop_reason = "delay"
else:
stop_reason = "ready"
self._record_batch_dispatch_metrics(
batch_size=batch_len,
queue_wait_ms=oldest_wait_s * 1000.0,
effective_max_batch_size=self._batch_admission.max_admissible_batch_size(
compatible_reqs[0]
),
reject_reasons=reject_reasons,
stop_reason=stop_reason,
)
return batch_items
@staticmethod
def _normalize_received_payload(
identity: bytes, reqs: Any
) -> list[tuple[bytes, Any]]:
"""Normalize client payloads into queue entries.
A single-item `[Req]` is one request; a multi-item `list[Req]` remains
grouped as one logical request.
"""
if not isinstance(reqs, list):
return [(identity, reqs)]
if not reqs:
return []
if all(isinstance(req, Req) for req in reqs):
# AsyncSchedulerClient sends ordinary single requests as [Req].
# Only multi-item list[Req] payloads represent a grouped multi-output request.
if len(reqs) == 1:
return [(identity, reqs[0])]
return [(identity, reqs)]
return [(identity, req) for req in reqs]
def recv_reqs(self) -> List[tuple[bytes, Any]]:
"""
For non-main schedulers, reqs are broadcasted from main using broadcast_pyobj
"""
if self.receiver is not None:
try:
recv_reqs: list[tuple[bytes, Any]] = []
while len(recv_reqs) < _MAX_RECV_REQS_PER_POLL:
try:
# Accept valid REQ envelopes only, ignore malformed/probe frames.
parts = self.receiver.recv_multipart(zmq.NOBLOCK)
except zmq.Again:
break
try:
identity, payload = parts[0], parts[-1]
reqs = pickle.loads(payload) if len(parts) > 2 else []
except (pickle.UnpicklingError, IndexError, EOFError):
continue
recv_reqs.extend(self._normalize_received_payload(identity, reqs))
except zmq.ZMQError:
# re-raise or handle appropriately to let the outer loop continue
raise
else:
recv_reqs = None
# TODO: fix this condition
if self.server_args.sp_degree != 1:
recv_reqs = broadcast_pyobj(
recv_reqs,
self.worker.sp_group.rank,
self.worker.sp_cpu_group,
src=self.worker.sp_group.ranks[0],
)
if self.server_args.enable_cfg_parallel:
recv_reqs = broadcast_pyobj(
recv_reqs,
self.worker.cfg_group.rank,
self.worker.cfg_cpu_group,
src=self.worker.cfg_group.ranks[0],
)
if self.server_args.tp_size > 1:
recv_reqs = broadcast_pyobj(
recv_reqs,
self.worker.tp_group.rank,
self.worker.tp_cpu_group,
src=self.worker.tp_group.ranks[0],
)
assert recv_reqs is not None
return recv_reqs
def event_loop(self) -> None:
"""
The main event loop that listens for ZMQ requests.
Handles abortion
"""
# Pool mode: all roles use the pool event loop
if self._disagg_role != RoleType.MONOLITHIC:
self._disagg_event_loop()
return
logger.debug(
f"Rank 0 scheduler listening on tcp://*:{self.server_args.scheduler_port}"
)
while self._running:
# Update queue depth for metrics
if self._disagg_metrics:
self._disagg_metrics.update_queue_depth(len(self.waiting_queue))
# 1: receive requests
try:
new_reqs = self.recv_reqs()
new_reqs = self.process_received_reqs_with_req_based_warmup(new_reqs)
now = time.monotonic()
self.waiting_queue.extend(
[(identity, req, now) for identity, req in new_reqs]
)
# Reset error count on success
self._consecutive_error_count = 0
except Exception as e:
self._consecutive_error_count += 1
logger.error(
f"Error receiving requests in scheduler event loop "
f"(attempt {self._consecutive_error_count}/{self._max_consecutive_errors}): {e}",
exc_info=True,
)
if self._consecutive_error_count >= self._max_consecutive_errors:
logger.error(
f"Maximum consecutive errors ({self._max_consecutive_errors}) reached. "
"Terminating scheduler event loop."
)
raise RuntimeError(
f"Scheduler terminated after {self._max_consecutive_errors} "
f"consecutive errors. Last error: {e}"
) from e
continue
# 2: execute, make sure a reply is always sent
items = self.get_next_batch_to_run()
if not items:
if self.waiting_queue and self._dynamic_batching_enabled():
oldest_ts = self.waiting_queue[0][2]
elapsed_ms = (time.monotonic() - oldest_ts) * 1000.0
remaining_ms = max(0, self._batching_delay_s * 1000.0 - elapsed_ms)
if remaining_ms > 0 and self.receiver is not None:
self._poller.poll(timeout=remaining_ms)
elif remaining_ms > 0:
time.sleep(remaining_ms / 1000.0)
continue
try:
handler_result = self._dispatch_items(items)
except Exception as e:
logger.error(
f"Error executing request in scheduler event loop: {e}",
exc_info=True,
)
handler_result = OutputBatch(error=str(e))
if isinstance(handler_result, list):
output_batches = handler_result
else:
output_batches = [handler_result]
if len(output_batches) != len(items):
logger.error(
"Handler returned %d output(s) for %d request(s). Returning error for unmatched requests.",
len(output_batches),
len(items),
)
output_batches = [
OutputBatch(
error=(
f"Internal scheduler error: expected {len(items)} outputs, "
f"got {len(output_batches)}."
)
)
for _ in items
]
# 3. return results
try:
for (identity, processed_req), output_batch in zip(
items, output_batches, strict=True
):
is_warmup = is_warmup_req(processed_req)
self._log_warmup_result(output_batch, processed_req, is_warmup)
should_return_lightweight_warmup_result = (
self._should_return_lightweight_warmup_result(processed_req)
)
if should_return_lightweight_warmup_result:
# internal prewarm is a real-path request; reply but drop payloads
output_batch.drop_payload_for_warmup()
self.return_result(
output_batch, identity, should_not_return=False
)
else:
self.return_result(
output_batch, identity, should_not_return=is_warmup
)
except zmq.ZMQError as e:
# Reply failed; log and keep loop alive to accept future requests
logger.error(f"ZMQ error sending reply: {e}")
continue
self._log_batch_metrics_summary()
if self.receiver is not None:
self.receiver.close()
self._cleanup_disagg()
self.context.destroy(linger=0)
def _broadcast_task(self, payload: dict[str, Any]) -> None:
"""Broadcast a task to all slave worker processes."""
method = payload["method"]
kwargs = {k: v for k, v in payload.items() if k != "method"}
task = {"method": method, "kwargs": kwargs}
for pipe in self.task_pipes_to_slaves:
pipe.send(task)
def _collect_slave_results(self) -> List[dict[str, Any]]:
"""Collect results from all slave worker processes."""
results = []
for pipe in self.result_pipes_from_slaves:
results.append(pipe.recv())
return results
def _handle_release_memory_occupation(self, _reqs: List[Any]) -> OutputBatch:
logger.info(f"[SLEEP] handle_release_memory_occupation on rank={self.gpu_id}")
return OutputBatch(output=self.worker.release_memory_occupation())
def _handle_resume_memory_occupation(self, _reqs: List[Any]) -> OutputBatch:
logger.info(f"[WAKE] handle_resume_memory_occupation on rank={self.gpu_id}")
return OutputBatch(output=self.worker.resume_memory_occupation())