Files
vllm-omni/vllm_omni/diffusion/diffusion_engine.py
T

1680 lines
73 KiB
Python

# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
from __future__ import annotations
import asyncio
import concurrent.futures
import copy
import inspect
import os
import queue
import threading
import time
from collections.abc import AsyncGenerator, Iterable
from dataclasses import dataclass, field
from enum import Enum
from typing import TYPE_CHECKING, Any
import numpy as np
import PIL.Image
import torch
from vllm.config import VllmConfig
from vllm.logger import init_logger
from vllm.utils.import_utils import resolve_obj_by_qualname
from vllm.v1.engine.exceptions import EngineDeadError
from vllm.v1.kv_cache_interface import KVCacheConfig
from vllm_omni.diffusion.cancellation import RequestCancellationRegistry
from vllm_omni.diffusion.data import (
DIFFUSION_REQUEST_LIFECYCLE_KEY,
DIFFUSION_REQUEST_STARTED,
DiffusionOutput,
DiffusionRequestAbortedError,
OmniDiffusionConfig,
uses_diffusers_adapter,
)
from vllm_omni.diffusion.diffusion_kv.config import DiffusionKVCacheMode, is_scheduler_paged_kv_mode
from vllm_omni.diffusion.diffusion_kv.initialization import initialize_diffusion_kv_control_plane
from vllm_omni.diffusion.executor.abstract import DiffusionExecutor
from vllm_omni.diffusion.io_support import (
get_dummy_run_num_frames,
get_dummy_run_num_image_inputs,
image_color_format,
supports_audio_output,
supports_multimodal_input,
)
from vllm_omni.diffusion.offloader.config import any_selected_component_uses_allgather
from vllm_omni.diffusion.output_formatter import (
format_diffusion_outputs,
format_empty_diffusion_outputs,
normalize_diffusion_postprocess_output,
)
from vllm_omni.diffusion.postprocess.media import finalize_diffusion_media
from vllm_omni.diffusion.registry import (
DiffusionModelRegistry,
get_diffusion_post_process_func,
get_diffusion_pre_process_func,
get_diffusion_prefix_cache_func,
)
from vllm_omni.diffusion.request import DUMMY_DIFFUSION_REQUEST_ID, OmniDiffusionRequest
from vllm_omni.diffusion.sched import BaseScheduler, RequestScheduler, StepScheduler
from vllm_omni.diffusion.sched.interface import DiffusionRequestStatus, DiffusionSchedulerOutput
from vllm_omni.diffusion.worker.utils import BaseRunnerOutput, BatchRunnerOutput, RunnerOutput
from vllm_omni.errors import client_error_from_metadata, is_client_error_status
from vllm_omni.inputs.data import OmniDiffusionSamplingParams, OmniTextPrompt
from vllm_omni.metrics.utils import (
diffusion_scheduler_waiting_metrics,
extract_diffusion_denoise_ms,
extract_diffusion_vae_decode_ms,
)
if TYPE_CHECKING:
from vllm_omni.outputs import OmniRequestOutput
logger = init_logger(__name__)
_ASYNC_OUTPUT_TIMEOUT_ENV = "VLLM_OMNI_ASYNC_OUTPUT_TIMEOUT"
_ASYNC_OUTPUT_TIMEOUT_DEFAULT = 600.0 # seconds
def _async_output_timeout() -> float:
"""Seconds to wait for one step's background D2H/SHM copy.
The copy itself finishes in milliseconds, but it is queued behind the GPU
work for that step, so the wall-clock wait tracks step time — a single-GPU
box legitimately runs tens of seconds per step on large shapes. A tight
bound therefore does not catch a hung engine (worker death and a dead
result pump are surfaced by the worker monitor and ``check_health``); it
only aborts renders that are still making progress, throwing away the
denoise that already completed. The default matches
``_DLO_DP_WAVE_TIMEOUT_S`` in the same subsystem.
Resolved here rather than at import so a malformed value degrades to the
default instead of raising on the request path: this runs inside
``step_streaming``/``add_req_and_wait_for_response``, where a typo in the
environment must not start failing generations.
"""
raw = os.environ.get(_ASYNC_OUTPUT_TIMEOUT_ENV)
if raw is None:
return _ASYNC_OUTPUT_TIMEOUT_DEFAULT
try:
timeout = float(raw)
except ValueError:
logger.warning_once(
"Ignoring %s=%r: not a number. Using the default %.1fs.",
_ASYNC_OUTPUT_TIMEOUT_ENV,
raw,
_ASYNC_OUTPUT_TIMEOUT_DEFAULT,
)
return _ASYNC_OUTPUT_TIMEOUT_DEFAULT
if timeout <= 0:
logger.warning_once(
"Ignoring %s=%r: must be positive. Using the default %.1fs.",
_ASYNC_OUTPUT_TIMEOUT_ENV,
raw,
_ASYNC_OUTPUT_TIMEOUT_DEFAULT,
)
return _ASYNC_OUTPUT_TIMEOUT_DEFAULT
return timeout
__all__ = [
"DiffusionEngine",
"DiffusionExecutionMode",
"_RpcTask",
"_move_tensor_tree_to_cpu",
"get_dummy_run_num_frames",
"image_color_format",
"supports_audio_output",
"supports_multimodal_input",
]
def _func_accepts_parameter(func: object | None, parameter_name: str) -> bool:
if not callable(func):
return False
parameters = inspect.signature(func).parameters
return parameter_name in parameters or any(
parameter.kind == inspect.Parameter.VAR_KEYWORD for parameter in parameters.values()
)
def _resolve_custom_pipeline_cls(custom_pipeline_args: dict[str, Any] | None) -> type | None:
if not custom_pipeline_args or "pipeline_class" not in custom_pipeline_args:
return None
pipeline_cls = custom_pipeline_args["pipeline_class"]
if isinstance(pipeline_cls, type):
return pipeline_cls
if isinstance(pipeline_cls, str):
try:
return resolve_obj_by_qualname(pipeline_cls)
except (AttributeError, ImportError, ValueError) as exc:
raise ValueError(f"Failed to resolve custom diffusion pipeline class {pipeline_cls!r}.") from exc
raise TypeError(
f"custom_pipeline_args['pipeline_class'] must be a qualified name string or a class, "
f"got {type(pipeline_cls).__name__}"
)
def supports_request_batch(od_config: OmniDiffusionConfig) -> bool:
model_cls = _resolve_custom_pipeline_cls(getattr(od_config, "custom_pipeline_args", None))
if model_cls is not None:
return bool(getattr(model_cls, "supports_request_batch", False))
if uses_diffusers_adapter(od_config):
model_cls = DiffusionModelRegistry._try_load_model_cls("DiffusersAdapterPipeline")
return bool(getattr(model_cls, "supports_request_batch", False))
model_cls = DiffusionModelRegistry._try_load_model_cls(getattr(od_config, "model_class_name", None))
if model_cls is None:
return False
return bool(getattr(model_cls, "supports_request_batch", False))
def _max_num_seqs(od_config: OmniDiffusionConfig) -> int:
try:
return max(1, int(getattr(od_config, "max_num_seqs", 1)))
except (TypeError, ValueError):
return 1
def supports_request_cancellation(od_config: OmniDiffusionConfig) -> bool:
"""Whether the local pipeline checks cooperative cancellation boundaries."""
model_cls = _resolve_custom_pipeline_cls(getattr(od_config, "custom_pipeline_args", None))
if model_cls is None:
name = (
"DiffusersAdapterPipeline"
if uses_diffusers_adapter(od_config)
else getattr(od_config, "model_class_name", None)
)
model_cls = DiffusionModelRegistry._try_load_model_cls(name)
return getattr(model_cls, "supports_request_cancellation", False) is True
def _uses_dlo_dp_concurrency(od_config: OmniDiffusionConfig) -> bool:
parallel_config = getattr(od_config, "parallel_config", None)
dp_size = getattr(parallel_config, "data_parallel_size", 1)
return dp_size > 1 and any_selected_component_uses_allgather(od_config)
def _move_tensor_tree_to_cpu(value: object) -> object:
if isinstance(value, torch.Tensor):
return value.cpu() if value.device.type != "cpu" else value
if isinstance(value, dict):
return {key: _move_tensor_tree_to_cpu(item) for key, item in value.items()}
if isinstance(value, list):
return [_move_tensor_tree_to_cpu(item) for item in value]
if isinstance(value, tuple):
return tuple(_move_tensor_tree_to_cpu(item) for item in value)
return value
@dataclass
class _RpcTask:
"""A pending collective_rpc invocation queued for the busy loop."""
method: str
args: tuple
kwargs: dict | None
deadline: float | None
unique_reply_rank: int | None
future: concurrent.futures.Future = field(default_factory=concurrent.futures.Future)
class DiffusionExecutionMode(str, Enum):
REQUEST_BATCH = "request_batch"
STEP_BATCH = "step_batch"
class DiffusionEngine:
"""The diffusion engine for vLLM-Omni diffusion models."""
#: Import path of the model runner this engine's workers should build, or
#: ``None`` for the platform default. Subclasses declare their runner here
#: (e.g. the AR-Diffusion engine), the worker resolves it through
#: :meth:`resolve_engine_class` — so engine->runner routing lives on the
#: engine class itself, and ``od_config.diffusion_model_runner_cls``
#: remains a pure explicit user override (never mutated by engines).
default_diffusion_model_runner_cls: str | None = None
# Class-level defaults so tests using object.__new__ (without __init__)
# don't hit AttributeError when _busy_loop accesses them.
dp_concurrent: bool = False
_scheduling_paused: bool = False
# Disabled until runtime initialization resolves the pipeline capability.
_request_cancellations: RequestCancellationRegistry | None = None
def __init__(
self,
od_config: OmniDiffusionConfig,
scheduler: BaseScheduler | None = None,
):
"""Initialize the diffusion engine.
Args:
od_config: The configuration for the diffusion engine.
scheduler: Optional scheduler override for tests or custom engine
integrations. When omitted, the engine selects a scheduler
from the resolved execution mode.
"""
self.od_config = od_config
# Set after the paged-KV profile request has gone through model-owned
# preprocessing. Real requests are admitted only within this measured
# activation envelope: (max execution sequences, max seq_len,
# max target_len).
self._diffusion_kv_profile_limits: tuple[int, int, int] | None = None
self._init_process_hooks(od_config)
self.execution_mode = self._resolve_execution_mode(od_config)
self._init_executor(od_config)
try:
profile_requests = self._prepare_diffusion_kv_profile_requests()
kv_control_plane = initialize_diffusion_kv_control_plane(
self.executor,
od_config,
profile_requests=profile_requests,
)
if kv_control_plane is None:
self._init_scheduler(od_config, scheduler)
else:
(
kv_cache_config,
scheduler_block_size,
hash_block_size,
kv_vllm_config,
) = kv_control_plane
self._init_scheduler(
od_config,
scheduler,
kv_cache_config,
scheduler_block_size=scheduler_block_size,
hash_block_size=hash_block_size,
kv_vllm_config=kv_vllm_config,
)
self._init_runtime_state()
self._init_execute_fn()
self._log_execution_mode(od_config)
except Exception:
# close() cannot be used because runtime synchronization state may not exist yet.
scheduler_to_close = getattr(self, "scheduler", None)
if scheduler_to_close is not None:
try:
scheduler_to_close.close()
except Exception:
logger.exception("Failed to close Scheduler after DiffusionEngine initialization failed")
try:
self.executor.shutdown()
except Exception:
logger.exception("Failed to shut down Executor after DiffusionEngine initialization failed")
raise
def _init_process_hooks(self, od_config: OmniDiffusionConfig) -> None:
self.post_process_func = get_diffusion_post_process_func(od_config)
self.pre_process_func = get_diffusion_pre_process_func(od_config)
self.prefix_cache_func = get_diffusion_prefix_cache_func(od_config) if self._prefix_cache_enabled() else None
if self._prefix_cache_enabled() and self.prefix_cache_func is None:
raise ValueError(
"enable_prefix_caching=True requires a registered prefix-cache hook for "
f"{od_config.model_class_name!r}; "
"disable enable_prefix_caching or use a supported native pipeline such as HunyuanImage3ForCausalMM"
)
# Cache whether the model-specific postprocess accepts request-level
# sampling params so step() can support both legacy and extended hooks.
self._post_process_accepts_sampling_params = _func_accepts_parameter(self.post_process_func, "sampling_params")
def _resolve_execution_mode(self, od_config: OmniDiffusionConfig) -> DiffusionExecutionMode:
self.step_execution = bool(getattr(od_config, "step_execution", False))
if od_config.streaming_output and not self.step_execution:
logger.warning("streaming_output=True requires step_execution=True; enabling step execution.")
od_config.step_execution = True
self.step_execution = True
if self.step_execution:
self.supports_request_batch = False
return DiffusionExecutionMode.STEP_BATCH
self.supports_request_batch = supports_request_batch(od_config)
if not self.supports_request_batch and _max_num_seqs(od_config) > 1 and not _uses_dlo_dp_concurrency(od_config):
raise ValueError(
f"{getattr(od_config, 'model_class_name', None)!r} does not support request-level batching. "
"Use max_num_seqs=1 for serial request execution, or choose a pipeline with "
"supports_request_batch=True."
)
return DiffusionExecutionMode.REQUEST_BATCH
def _init_executor(self, od_config: OmniDiffusionConfig) -> None:
executor_class = DiffusionExecutor.get_class(od_config)
self.executor = executor_class(od_config)
def _init_scheduler(
self,
od_config: OmniDiffusionConfig,
scheduler: BaseScheduler | None = None,
kv_cache_config: KVCacheConfig | None = None,
*,
scheduler_block_size: int | None = None,
hash_block_size: int | None = None,
kv_vllm_config: VllmConfig | None = None,
) -> None:
if scheduler is not None:
self.scheduler = scheduler
elif self.execution_mode == DiffusionExecutionMode.STEP_BATCH:
self.scheduler = StepScheduler()
else:
self.scheduler = RequestScheduler()
if kv_cache_config is None:
self.scheduler.initialize(od_config)
else:
self.scheduler.initialize(
od_config,
kv_cache_config=kv_cache_config,
scheduler_block_size=scheduler_block_size,
hash_block_size=hash_block_size,
kv_vllm_config=kv_vllm_config,
)
def _init_runtime_state(self) -> None:
# DP multi-concurrency: allow batching dp_size requests so each
# worker processes a different request in parallel. Only enabled
# for distributed layerwise offload (which shards weights and
# needs all ranks active simultaneously). Ordinary DP with a
# non-batch pipeline should not schedule multiple requests.
dp_size = getattr(getattr(self.od_config, "parallel_config", None), "data_parallel_size", 1)
if _uses_dlo_dp_concurrency(self.od_config):
self.scheduler.max_num_running_reqs = dp_size
self.dp_concurrent = True
logger.info(
"dp_concurrent: max_num_running_reqs=%d, batch_wait=%sms",
dp_size,
self.od_config.request_batch_max_wait_ms,
)
else:
self.dp_concurrent = False
self.main_loop: asyncio.AbstractEventLoop | None = None
self.stop_event: threading.Event | None = None
self.worker_thread: threading.Thread | None = None
self._loop_started = False
self._init_lock = asyncio.Lock()
# _rpc_lock is retained solely as the underlying lock for self._cv,
# which is used to signal the busy loop. Worker-call serialization is
# now handled structurally by routing all executor calls through the
# busy loop rather than via mutual exclusion.
self._rpc_lock = threading.RLock()
self._cv = threading.Condition(self._rpc_lock)
self._out_streams: dict[str, asyncio.Queue[DiffusionOutput]] = {}
self._closed = False
self._shutting_down = False
self._shutdown_complete = False
self.abort_queue: queue.Queue[str] = queue.Queue()
self._request_cancellations = (
RequestCancellationRegistry() if supports_request_cancellation(self.od_config) else None
)
self._rpc_queue: queue.Queue[_RpcTask] = queue.Queue()
# pause_scheduler(mode="keep"): no new batch is scheduled while set.
self._scheduling_paused = False
# Copied onto the existing output metrics payload so queue monitoring
# reuses the normal diffusion result path without additional IPC.
self._scheduler_num_waiting_reqs = 0
def _init_execute_fn(self) -> None:
if self.execution_mode == DiffusionExecutionMode.STEP_BATCH:
self.execute_fn = self.executor.execute_step
else:
self.execute_fn = self.executor.execute_batch
def _log_execution_mode(self, od_config: OmniDiffusionConfig) -> None:
if self.execution_mode == DiffusionExecutionMode.REQUEST_BATCH:
logger.info(
"[RequestBatch] engine init max_num_seqs=%s max_wait_ms=%s",
getattr(od_config, "max_num_seqs", None),
getattr(od_config, "request_batch_max_wait_ms", None),
)
async def _check_and_start_background_loop(self):
if self._closed:
raise RuntimeError("DiffusionEngine is closed.")
if self._loop_started:
return
async with self._init_lock:
# double check, in case of lock queue issue
if self._closed:
raise RuntimeError("DiffusionEngine is closed.")
if self._loop_started:
return
self.main_loop = asyncio.get_running_loop()
self.stop_event = threading.Event()
self.worker_thread = threading.Thread(target=self._busy_loop)
self.worker_thread.start()
self._loop_started = True
async def step_streaming(self, request: OmniDiffusionRequest) -> AsyncGenerator[list[OmniRequestOutput], None]:
await self._check_and_start_background_loop()
diffusion_engine_start_time = time.perf_counter()
preprocess_time = 0.0
has_preprocessor = getattr(self, "pre_process_func", None) is not None
preprocess_start_time = time.perf_counter() if has_preprocessor else None
request = self._prepare_request_for_admission(request)
if preprocess_start_time is not None:
preprocess_time = time.perf_counter() - preprocess_start_time
logger.debug("Pre-processing completed in %.4f seconds", preprocess_time)
exec_start_time = time.perf_counter()
request_id = self._add_prepared_request(request)
generator = self.get_output_stream(request_id)
async for output in generator:
exec_total_time = time.perf_counter() - exec_start_time
output_ready_wait_time = 0.0
# Async mode: wait for background D2H/SHM to complete.
if output.async_output_id:
output_ready_wait_start_time = time.perf_counter()
fut = self.executor.wait_output_ready(output.async_output_id)
timeout = _async_output_timeout()
try:
output = await asyncio.wait_for(asyncio.wrap_future(fut), timeout=timeout)
except (TimeoutError, asyncio.TimeoutError):
describe = getattr(self.executor, "describe_pending_state", None)
logger.error(
"Timed out after %.1fs waiting for async output; set %s to a larger value "
"to allow slower steps. Executor state: %s",
timeout,
_ASYNC_OUTPUT_TIMEOUT_ENV,
describe(output.async_output_id) if describe else "unavailable",
)
raise
output_ready_wait_time = time.perf_counter() - output_ready_wait_start_time
postprocess_start_time = time.perf_counter()
scheduler_metrics = diffusion_scheduler_waiting_metrics(getattr(self, "_scheduler_num_waiting_reqs", 0))
try:
formatted_outputs = self.postprocess_output(request, output)
except Exception as exc:
# Preserve the latest scheduler snapshot across terminal
# abort/error paths, which do not produce formatted outputs.
setattr(exc, "diffusion_metrics", scheduler_metrics)
raise
postprocess_time = time.perf_counter() - postprocess_start_time
step_total_ms = (time.perf_counter() - diffusion_engine_start_time) * 1000
logger.debug(
"DiffusionEngine.step_streaming breakdown: preprocess=%.2f ms, "
"add_req_and_wait=%.2f ms, output_ready_wait=%.2f ms, "
"postprocess=%.2f ms, total=%.2f ms",
preprocess_time * 1000,
exec_total_time * 1000,
output_ready_wait_time * 1000,
postprocess_time * 1000,
step_total_ms,
)
for request_output in formatted_outputs:
metrics_update = {
"preprocess_time_ms": preprocess_time * 1000,
"diffusion_engine_exec_time_ms": exec_total_time * 1000,
"output_ready_wait_time_ms": output_ready_wait_time * 1000,
"postprocess_time_ms": postprocess_time * 1000,
**scheduler_metrics,
}
if request.scheduler_queue_wait_ms is not None:
metrics_update["scheduler_queue_wait_ms"] = request.scheduler_queue_wait_ms
vae_decode_ms = extract_diffusion_vae_decode_ms(output)
if vae_decode_ms is not None:
metrics_update["vae_decode_time_ms"] = vae_decode_ms
forward_ms = extract_diffusion_denoise_ms(output)
if forward_ms is not None:
metrics_update["forward_time_ms"] = forward_ms
kv_recv_ms = getattr(output, "kv_recv_ms", 0.0)
if kv_recv_ms > 0:
metrics_update["kv_recv_time_ms"] = kv_recv_ms
request_output.metrics.update(metrics_update)
yield formatted_outputs
async def step(self, request: OmniDiffusionRequest) -> list[OmniRequestOutput]:
"""Deprecated compatibility wrapper over ``step_streaming()``.
Use ``step_streaming()`` for new callers. This method drains the
unified output stream and returns only the final output batch, matching
the historical non-streaming ``step()`` behavior.
"""
logger.warning_once(
"DiffusionEngine.step() is deprecated; use step_streaming() and consume the final output batch instead."
)
final_output: list[OmniRequestOutput] | None = None
async for output in self.step_streaming(request):
final_output = output
return final_output or []
def postprocess_output(
self,
request: OmniDiffusionRequest,
output: DiffusionOutput,
) -> list[OmniRequestOutput]:
"""Convert a DiffusionOutput to a list of OmniRequestOutput."""
if output.aborted:
raise DiffusionRequestAbortedError(output.abort_message or "Diffusion request aborted.")
if output.error:
if is_client_error_status(output.error_status_code):
raise client_error_from_metadata(
output.error,
status_code=output.error_status_code,
error_type=output.error_type,
)
raise RuntimeError(output.error)
if output.request_started:
return format_empty_diffusion_outputs(
request,
finished=False,
custom_output={DIFFUSION_REQUEST_LIFECYCLE_KEY: DIFFUSION_REQUEST_STARTED},
)
if output.media is not None:
if output.output is not None:
raise ValueError("DiffusionOutput cannot contain both media and legacy output")
media = output.media.to_cpu() if self.od_config.enable_cpu_offload else output.media
output_data = media.video.tensor
outputs = finalize_diffusion_media(media, sampling_params=request.sampling_params)
else:
if output.output is None:
logger.warning("Output is None, returning empty OmniRequestOutput")
return format_empty_diffusion_outputs(request, finished=output.finished)
# When CPU offload is enabled, move output to CPU before
# post-processing to avoid device OOM — model weights may still
# reside on the device and leave no headroom for intermediates.
output_data = output.output
if self.od_config.enable_cpu_offload:
output_data = _move_tensor_tree_to_cpu(output_data)
if self.post_process_func is not None:
# Some video pipelines need request-level controls during
# postprocess (for example worker-side frame interpolation).
postprocess_kwargs: dict[str, object] = {}
if self._post_process_accepts_sampling_params:
postprocess_kwargs["sampling_params"] = request.sampling_params
outputs = self.post_process_func(output_data, **postprocess_kwargs)
else:
outputs = output_data
postprocess_output = normalize_diffusion_postprocess_output(outputs)
return format_diffusion_outputs(
request=request,
od_config=self.od_config,
diffusion_output=output,
output_data=output_data,
postprocess_output=postprocess_output,
)
def _busy_loop(self):
assert self.stop_event is not None
while not self.stop_event.is_set():
self._process_aborts_queue()
self._process_rpc_queue()
if self._scheduling_paused:
# No wave runs while paused, so requests finished by an abort
# would otherwise wait for the resume to surface.
with self._cv:
pending_finished = self.scheduler.pending_finished_request_ids()
self._emit_finished_outputs(pending_finished, None)
with self._cv:
while (
(self._scheduling_paused or not self.scheduler.has_requests())
and self._rpc_queue.empty()
and self.abort_queue.empty()
and not self.stop_event.is_set()
):
self._cv.wait(timeout=1.0)
if self.stop_event.is_set():
break
if self._scheduling_paused or not self.scheduler.has_requests():
# Only RPC / abort work pending; loop back to drain it.
continue
self._wait_for_admission_if_needed_locked()
if self._scheduling_paused:
# The gate closed while the batching wait released the lock.
continue
try:
sched_output = self.scheduler.schedule()
except Exception as exc:
self._fail_engine(exc)
return
self._scheduler_num_waiting_reqs = max(int(sched_output.num_waiting_reqs), 0)
self._emit_request_started_outputs(sched_output)
if sched_output.is_empty:
self._emit_finished_outputs(sched_output.finished_req_ids, None)
continue
try:
self._prepare_kv_for_forward(sched_output)
runner_output: BaseRunnerOutput = (
self.execute_fn(sched_output)
if sched_output.scheduled_request_ids
else BatchRunnerOutput.from_list([])
)
self._poll_native_kv()
worker_execution_completed = True
except Exception as exc:
if self._closed:
return
worker_execution_completed = False
logger.error(
"Execution failed for diffusion requests %s", sched_output.scheduled_request_ids, exc_info=True
)
runner_output = BatchRunnerOutput.from_list(
[
RunnerOutput(
request_id=request_id,
step_index=None,
finished=True,
result=DiffusionOutput.from_exception(exc),
)
for request_id in sched_output.scheduled_request_ids
]
)
self._process_aborts_queue()
self._process_rpc_queue()
try:
finished_req_ids = self.scheduler.update_from_output(sched_output, runner_output)
self._emit_outputs(
finished_req_ids,
sched_output.scheduled_request_ids,
runner_output,
worker_execution_completed=worker_execution_completed,
)
except Exception as exc:
self._fail_engine(exc)
return
if not sched_output.scheduled_request_ids and self.scheduler._kv_draining_requests:
# Only exceptional in-flight receives need idle polling. New
# requests / aborts wake this wait immediately.
with self._cv:
self._cv.wait(timeout=0.01)
# Engine is stopping: fail any RPCs still queued so callers don't hang.
self._fail_pending_rpcs(RuntimeError("DiffusionEngine is shutting down."))
def _emit_request_started_outputs(self, sched_output: DiffusionSchedulerOutput) -> None:
"""Notify opted-in callers when requests leave the scheduler queue."""
for new_req in sched_output.scheduled_new_reqs:
request = getattr(new_req, "req", None)
sampling_params = getattr(request, "sampling_params", None)
if getattr(sampling_params, "emit_request_lifecycle", False):
self._put_output(
new_req.request_id,
DiffusionOutput(finished=False, request_started=True),
)
def _wait_for_admission_if_needed_locked(self) -> None:
"""Apply scheduler admission policy while holding the engine condition.
Caller must hold ``self._cv``.
"""
assert self.stop_event is not None
start = time.monotonic()
decision = self.scheduler.get_admission_wait_decision(
now=start,
dp_concurrent=self.dp_concurrent,
)
if not decision.should_wait:
return
last_waiting = -1
stable_since = start
while not self.stop_event.is_set() and not self._scheduling_paused:
waiting = self.scheduler.num_waiting_requests()
now = time.monotonic()
if waiting > last_waiting:
stable_since = now
last_waiting = waiting
if self.scheduler.should_end_admission_wait(
decision,
now=now,
stable_since=stable_since,
):
break
remaining = decision.deadline - now if decision.deadline is not None else 0.002
self._cv.wait(timeout=min(max(remaining, 0.0), 0.002))
waited_ms = (time.monotonic() - start) * 1000.0
final_waiting = self.scheduler.num_waiting_requests()
if final_waiting > 0:
logger.info(
"[RequestBatch] admission wait done waiting=%d max_batch=%d waited_ms=%.1f",
final_waiting,
decision.max_batch,
waited_ms,
)
def _process_rpc_queue(self) -> None:
"""Execute pending collective_rpc tasks from the busy-loop thread.
Running these here means executor calls are naturally serialized
against execute_fn() without any mutual-exclusion locking.
"""
while True:
try:
task = self._rpc_queue.get_nowait()
except queue.Empty:
return
self._run_rpc_task(task)
if task.method == "pause_scheduler":
# A dequeued pause ends this drain whatever became of it, so
# the batch that just ran is delivered before anything the
# caller queued behind the pause. The rest of the queue is
# picked up on the next pass of the busy loop.
return
def _run_rpc_task(self, task: _RpcTask) -> None:
fut = task.future
if fut.cancelled() or fut.done():
return
remaining: float | None = None
if task.deadline is not None:
remaining = task.deadline - time.monotonic()
if remaining <= 0:
if not fut.done():
fut.set_exception(TimeoutError(f"RPC call to {task.method} timed out before execution."))
return
try:
if task.method == "pause_scheduler":
result = self._run_pause_barrier(remaining)
elif task.method == "resume_scheduler":
result = self._open_scheduling_gate()
else:
result = self.executor.collective_rpc(
method=task.method,
timeout=remaining,
args=task.args,
kwargs=task.kwargs,
unique_reply_rank=task.unique_reply_rank,
)
except BaseException as exc: # noqa: BLE001 - propagate to caller
# The future may have been cancelled (e.g. by a sync timeout
# or asyncio cancellation) while the executor call was
# running. Setting state on a cancelled/done future raises
# InvalidStateError, which would kill the busy loop.
if not fut.done():
fut.set_exception(exc)
else:
if not fut.done():
fut.set_result(result)
def _fail_pending_rpcs(self, exc: BaseException) -> None:
while True:
try:
task = self._rpc_queue.get_nowait()
except queue.Empty:
return
if not task.future.done():
task.future.set_exception(exc)
def _check_pause_request(self, kwargs: dict | None) -> None:
mode = (kwargs or {}).get("mode")
if mode != "keep":
raise ValueError(f"DiffusionEngine pause supports mode='keep' only, got {mode!r}.")
if self.execution_mode != DiffusionExecutionMode.REQUEST_BATCH:
raise NotImplementedError("DiffusionEngine pause supports request-level execution only.")
def _run_pause_barrier(self, timeout: float | None) -> None:
"""Return once every worker has finished the device work of the batch that ran."""
self.executor.collective_rpc("synchronize_device", timeout=timeout, kwargs={"timeout": timeout})
def _open_scheduling_gate(self) -> None:
with self._cv:
self._scheduling_paused = False
self._cv.notify_all()
def _run_engine_control(self, method: str, timeout: float | None, kwargs: dict | None) -> None:
"""Bootstrap-path dispatch for engine-local control methods."""
if method == "pause_scheduler":
self._check_pause_request(kwargs)
self._scheduling_paused = True
return self._run_pause_barrier(timeout)
return self._open_scheduling_gate()
def _remove_diffusion_kv_requests(self, request_ids: Iterable[str]) -> None:
"""Clear terminal Worker rows while Scheduler owns the allocations."""
od_config = getattr(self, "od_config", None)
if od_config is None or not is_scheduler_paged_kv_mode(
getattr(od_config, "diffusion_kv_mode", DiffusionKVCacheMode.DENSE_LEGACY)
):
return
unique_request_ids = list(dict.fromkeys(request_ids))
if unique_request_ids:
scheduler = getattr(self, "scheduler", None)
target_builder = getattr(scheduler, "get_diffusion_kv_cleanup_targets", None)
cleanup_targets = target_builder(unique_request_ids) if target_builder is not None else unique_request_ids
try:
if cleanup_targets:
self.executor.remove_diffusion_kv_requests(cleanup_targets)
except Exception as exc:
self._fail_engine(exc)
raise
def _prepare_kv_for_forward(self, sched_output: DiffusionSchedulerOutput) -> None:
if (
getattr(sched_output, "kv_connector_metadata", None) is None
and getattr(sched_output, "kv_prefetch_connector_metadata", None) is None
and not getattr(sched_output, "kv_required_request_ids", None)
and not getattr(sched_output, "kv_poll_only", False)
):
return
try:
output = self.executor.prepare_kv_for_forward(sched_output)
assert output is not None
self.scheduler.update_kv_connector_output(output)
if not self.abort_queue.empty():
self._process_aborts_queue()
required_ids = sched_output.kv_required_request_ids
if required_ids is None:
required_ids = sched_output.kv_transfer_request_ids
incomplete = required_ids - (output.finished_recving or set())
sched_output.finished_req_ids.update(self.scheduler.fail_incomplete_kv_loads(incomplete))
# Timed-out/cancelled requests must never reach model execution.
terminal = {
rid
for rid in sched_output.scheduled_request_ids
if (state := self.scheduler.get_request_state(rid)) is not None and state.is_finished()
}
if terminal:
sched_output.finished_req_ids.update(terminal)
sched_output.scheduled_new_reqs = [
req for req in sched_output.scheduled_new_reqs if req.request_id not in terminal
]
sched_output.scheduled_cached_reqs.request_ids = [
rid for rid in sched_output.scheduled_cached_reqs.request_ids if rid not in terminal
]
# is_empty / started-output handling may have already cached it.
sched_output.__dict__.pop("scheduled_request_ids", None)
drained = self.scheduler.completed_kv_drains()
if drained:
self._remove_diffusion_kv_requests(drained)
self.scheduler.release_kv_drains(drained)
except Exception as exc:
self._fail_engine(exc)
raise
def _poll_native_kv(self, *, drain_request_ids: list[str] | None = None) -> None:
poll = getattr(self.scheduler, "native_kv_poll_output", None)
if poll is None:
return
output = poll(drain_request_ids=drain_request_ids)
if output is not None:
self._prepare_kv_for_forward(output)
def _fail_engine(self, exc: Exception) -> None:
with self._cv:
if getattr(self, "_shutdown_complete", False) or getattr(self, "_shutting_down", False):
return
# Mark shutdown before invoking any component callbacks. A
# failing shutdown can re-enter this method from another error
# path, and repeating executor.shutdown() is unsafe.
self._shutting_down = True
self._closed = True
if self._request_cancellations is not None:
self._request_cancellations.cancel_all()
if self.stop_event is not None:
self.stop_event.set()
streams = list(self._out_streams.values())
self._cv.notify_all()
logger.error("Diffusion engine failed; stopping workers before releasing KV pages", exc_info=exc)
for stream in streams:
self._put_queue_output(stream, DiffusionOutput.from_exception(exc))
self._fail_pending_rpcs(exc)
# If Worker shutdown fails, retain the Scheduler reservations. A
# remote producer may still be writing into those allocations.
self.executor.shutdown()
try:
self.scheduler.close()
finally:
# Workers are down, so readers cannot attach or use the signals.
# Release them even if scheduler cleanup itself fails.
if self._request_cancellations is not None:
self._request_cancellations.close()
self._shutdown_complete = True
def _emit_finished_outputs(
self,
finished_ids: set[str],
runner_output: BaseRunnerOutput | None = None,
missing_result_error: str = "Diffusion execution finished without a final output",
worker_cleaned_ids: set[str] | None = None,
) -> None:
self._remove_diffusion_kv_requests(finished_ids - (worker_cleaned_ids or set()))
for rid in finished_ids:
if runner_output is not None:
_output = runner_output.get_request_output(rid)
else:
_output = None
out = self._finalize_finished_request(rid, _output, missing_result_error)
self._put_output(rid, out)
def _emit_outputs(
self,
finished_ids: set[str],
scheduled_request_ids: list[str],
runner_output: BaseRunnerOutput,
*,
worker_execution_completed: bool = False,
) -> None:
"""Emit output chunks for every request through the unified output stream."""
finished_ids = set(finished_ids)
worker_cleaned_ids = finished_ids.intersection(scheduled_request_ids) if worker_execution_completed else set()
if self.execution_mode != DiffusionExecutionMode.STEP_BATCH:
self._emit_finished_outputs(
finished_ids,
runner_output,
worker_cleaned_ids=worker_cleaned_ids,
)
return
self._remove_diffusion_kv_requests(finished_ids - worker_cleaned_ids)
delivered_finished_req_ids: set[str] = set()
# finished_ids may have some requests that are not scheduler in this round.
# First handle this-round requests.
for request_id in scheduled_request_ids:
req_output = runner_output.get_request_output(request_id)
if request_id in finished_ids:
# This entire request is finished (this is the last chunk)
out = self._finalize_finished_request(
request_id,
req_output,
missing_result_error="Diffusion step execution finished without a final output.",
)
self._put_output(request_id, out)
delivered_finished_req_ids.add(request_id)
elif req_output is not None and req_output.result is not None:
# This is a non-terminal chunk. It is not in scheduler's
# finished_ids, but it still belongs on the request stream.
self._put_output(request_id, req_output.result)
# Then handle other requests that are finished in this round.
for request_id in finished_ids - delivered_finished_req_ids:
out = self._finalize_finished_request(
request_id,
missing_result_error="Diffusion step request finished without execution output.",
)
self._put_output(request_id, out)
def _has_output_stream(self, request_id: str) -> bool:
with self._cv:
return request_id in self._out_streams
@staticmethod
def resolve_engine_class(config: OmniDiffusionConfig) -> type[DiffusionEngine]:
"""Resolve the engine class selected by ``config.engine_backend``.
Mirrors ``DiffusionExecutor.get_class``: accepts ``"default"``, a
``DiffusionEngine`` subclass, or an import-path string (e.g. a deploy
config's ``engine_backend``). Kept separate from :meth:`make_engine` so the
selection is testable without constructing an engine (which runs a dummy forward).
Args:
config: The configuration for the diffusion engine.
Returns:
The ``DiffusionEngine`` (sub)class to instantiate.
"""
backend = getattr(config, "engine_backend", "default") or "default"
if isinstance(backend, type):
if not issubclass(backend, DiffusionEngine):
raise TypeError(f"engine_backend must be a DiffusionEngine subclass. Got {backend}.")
return backend
if backend == "default":
return DiffusionEngine
if isinstance(backend, str):
try:
engine_class = resolve_obj_by_qualname(backend)
except (ImportError, ValueError) as e:
raise ValueError(
f"Failed to load engine_backend '{backend}'. Ensure it is a valid python path. Error: {e}"
) from e
if not issubclass(engine_class, DiffusionEngine):
raise TypeError(f"engine_backend must resolve to a DiffusionEngine subclass. Got {engine_class}.")
return engine_class
raise ValueError(f"Unknown engine_backend: {backend!r}")
@staticmethod
def make_engine(
config: OmniDiffusionConfig,
scheduler: BaseScheduler | None = None,
) -> DiffusionEngine:
"""Factory method to create the engine selected by ``config.engine_backend``.
Args:
config: The configuration for the diffusion engine.
scheduler: Optional scheduler override. When omitted, the selected
engine chooses the scheduler from its execution mode.
Returns:
An instance of the resolved ``DiffusionEngine`` (sub)class.
"""
engine_class = DiffusionEngine.resolve_engine_class(config)
engine = engine_class(config, scheduler=scheduler)
engine.run_startup_warmup()
return engine
def _prefix_cache_enabled(self) -> bool:
config = getattr(self, "od_config", None)
return getattr(config, "diffusion_kv_mode", None) is DiffusionKVCacheMode.PAGED_SCHEDULER and bool(
getattr(config, "enable_prefix_caching", False)
)
def _prepare_request_for_admission(self, request: OmniDiffusionRequest) -> OmniDiffusionRequest:
"""Run model-owned preprocessing once, before entering Engine locks."""
pre_process_func = getattr(self, "pre_process_func", None)
if pre_process_func is not None:
request = pre_process_func(request)
self._validate_diffusion_kv_profile_limits(request)
# Gate cache-input preparation itself: disabled caching must not inspect
# tensors / RNG state or copy token IDs just to discard their hashes.
# Both dependency hashing and preprocessing stay outside Engine locks.
prefix_cache_func = getattr(self, "prefix_cache_func", None)
if self._prefix_cache_enabled() and prefix_cache_func is not None and request.diffusion_kv_requests:
prefix_cache_func(request)
return request
def _validate_diffusion_kv_profile_limits(self, request: OmniDiffusionRequest) -> None:
"""Keep admitted paged-KV requests within the profiled activation shape."""
profile_limits = getattr(self, "_diffusion_kv_profile_limits", None)
if profile_limits is None:
return
kv_requests = request.diffusion_kv_requests
if not kv_requests:
return
request_limits = (
len(kv_requests),
max(kv_request.seq_len for kv_request in kv_requests),
max(kv_request.target_len for kv_request in kv_requests),
)
if any(actual > profiled for actual, profiled in zip(request_limits, profile_limits, strict=True)):
request_sequences, request_seq_len, request_target_len = request_limits
profile_sequences, profile_seq_len, profile_target_len = profile_limits
raise ValueError(
f"Diffusion KV request {request.request_id!r} exceeds the startup memory-profile envelope: "
f"sequences={request_sequences} (profiled={profile_sequences}), "
f"max_seq_len={request_seq_len} (profiled={profile_seq_len}), "
f"max_target_len={request_target_len} (profiled={profile_target_len}). "
"Reduce the request shape or extend the model's paged-KV profile recipe."
)
def _add_prepared_request(self, request: OmniDiffusionRequest) -> str:
"""Admit a request whose model-owned preprocessing is complete."""
with self._cv:
if self._closed:
raise RuntimeError("DiffusionEngine is closed.")
queue: asyncio.Queue[DiffusionOutput] = asyncio.Queue()
if self._request_cancellations is not None:
request.cancellation_signal = self._request_cancellations.create(request.request_id)
try:
request_id = self.scheduler.add_request(request)
except BaseException:
if self._request_cancellations is not None:
self._request_cancellations.finish(request.request_id)
request.cancellation_signal = None
raise
self._out_streams[request_id] = queue
self._cv.notify_all()
return request_id
def add_request(self, request: OmniDiffusionRequest) -> str:
request = self._prepare_request_for_admission(request)
return self._add_prepared_request(request)
async def get_output_stream(self, request_id: str) -> AsyncGenerator[DiffusionOutput, None]:
with self._cv:
queue = self._out_streams.get(request_id)
if queue is None:
raise RuntimeError(f"Request {request_id} not found in output queue.")
try:
while True:
output: DiffusionOutput = await queue.get()
yield output
if output.finished:
break
except Exception as e:
logger.error(f"Wait for response failed: {e}")
raise
finally:
with self._cv:
if self._out_streams.get(request_id) is queue:
self._out_streams.pop(request_id, None)
def async_add_req_and_stream_response(self, request: OmniDiffusionRequest) -> AsyncGenerator[DiffusionOutput, None]:
request_id = self.add_request(request)
return self.get_output_stream(request_id)
async def async_add_req_and_wait_for_response(self, request: OmniDiffusionRequest) -> DiffusionOutput:
"""Deprecated compatibility wrapper over ``async_add_req_and_stream_response()``.
Use ``async_add_req_and_stream_response()`` for new callers. This
method drains the unified output stream and returns only the final
``DiffusionOutput``, matching the historical non-streaming behavior.
"""
logger.warning_once(
"DiffusionEngine.async_add_req_and_wait_for_response() is deprecated; "
"use async_add_req_and_stream_response() and consume the final output instead."
)
final_output: DiffusionOutput | None = None
async for output in self.async_add_req_and_stream_response(request):
final_output = output
if final_output is None:
raise RuntimeError("Diffusion execution completed without an output.")
return final_output
def add_req_and_wait_for_response(self, request: OmniDiffusionRequest) -> DiffusionOutput:
request = self._prepare_request_for_admission(request)
with self._rpc_lock:
if self._closed:
raise RuntimeError("DiffusionEngine is closed.")
target_request_id = self.scheduler.add_request(request)
# keep scheduling and executing until the target request is finished
while True:
self._process_aborts_queue()
try:
sched_output = self.scheduler.schedule()
except Exception as exc:
self._fail_engine(exc)
raise
if sched_output.is_empty:
if target_request_id in sched_output.finished_req_ids:
self._remove_diffusion_kv_requests([target_request_id])
return self._finalize_finished_request(target_request_id)
if not self.scheduler.has_requests():
raise RuntimeError("Diffusion scheduler has no runnable requests.")
continue
# NOTE: add_req_and_wait_for_response() is synchronous, will be only called
# within _dummy_run, only one request will be scheduled
request_id = target_request_id
try:
self._prepare_kv_for_forward(sched_output)
runner_output: BaseRunnerOutput = (
self.execute_fn(sched_output)
if sched_output.scheduled_request_ids
else BatchRunnerOutput.from_list([])
)
self._poll_native_kv()
except EngineDeadError:
raise
except Exception as exc:
if self._closed:
raise
logger.error("Execution failed for diffusion request %s", request_id, exc_info=True)
runner_output = RunnerOutput(
request_id=request_id,
step_index=None,
finished=True,
result=DiffusionOutput.from_exception(exc),
)
self._process_aborts_queue()
finished_req_ids = self.scheduler.update_from_output(sched_output, runner_output)
# sync func should receive one result
if (
sched_output.scheduled_request_ids
and isinstance(runner_output, BatchRunnerOutput)
and len(runner_output) != 1
):
raise ValueError("Sync func should receive one result at one time")
if target_request_id in finished_req_ids:
self._remove_diffusion_kv_requests([target_request_id])
req_output = runner_output.get_request_output(target_request_id)
output = self._finalize_finished_request(
target_request_id,
runner_output=req_output,
missing_result_error="Diffusion execution finished without a final output.",
)
if output.async_output_id:
fut = self.executor.wait_output_ready(output.async_output_id)
output = fut.result(timeout=_async_output_timeout())
return output
def profile(self, is_start: bool = True, profile_prefix: str | None = None) -> None:
"""Start or stop profiling on all diffusion workers.
Args:
is_start: True to start profiling, False to stop.
profile_prefix: Optional prefix for trace filename.
"""
if is_start:
if profile_prefix is None:
profile_prefix = f"diffusion_{int(time.time())}"
logger.info(f"Starting diffusion profiling with prefix: {profile_prefix}")
else:
logger.info("Stopping diffusion profiling...")
try:
self.collective_rpc(method="profile", args=(is_start, profile_prefix))
except Exception as e:
action = "start" if is_start else "stop"
logger.error(f"Failed to {action} profiling on workers", exc_info=True)
if is_start:
raise RuntimeError(f"Could not {action} profiler: {e}") from e
def run_startup_warmup(self) -> None:
try:
self._dummy_run()
except Exception as e:
logger.error(f"Dummy run failed: {e}")
self.close()
raise
def _make_dummy_request(
self,
*,
height: int,
width: int,
guidance_scale: float,
num_image_inputs: int = 1,
num_inference_steps: int = 1,
num_frames: int | None = None,
) -> OmniDiffusionRequest | None:
"""Build a startup request; explicit frame counts bypass the warmup policy."""
prompt = OmniTextPrompt(prompt="dummy run")
model_class_name = self.od_config.model_class_name
if model_class_name is None:
raise RuntimeError("Dummy request requires a resolved model_class_name")
supports_image_input, supports_audio_input = supports_multimodal_input(self.od_config)
if supports_image_input:
color_format = image_color_format(model_class_name)
images = [PIL.Image.new(color_format, (width, height)) for _ in range(num_image_inputs)]
prompt.setdefault("multi_modal_data", {})["image"] = images[0] if len(images) == 1 else images
if supports_audio_input:
audio_sr = 16000
prompt.setdefault("multi_modal_data", {})["audio"] = np.random.randn(audio_sr * 2).astype(np.float32)
if num_frames is None:
num_frames = get_dummy_run_num_frames(model_class_name, supports_audio_input)
if num_frames <= 0:
return None
return OmniDiffusionRequest(
prompt=prompt,
request_id=DUMMY_DIFFUSION_REQUEST_ID,
sampling_params=OmniDiffusionSamplingParams(
height=height,
width=width,
num_inference_steps=num_inference_steps,
num_frames=num_frames,
guidance_scale=guidance_scale,
num_outputs_per_prompt=1,
extra_args={"cfg_text_scale": 1.0, "cfg_img_scale": 1.0},
),
)
def _prepare_diffusion_kv_profile_requests(self) -> list[OmniDiffusionRequest] | None:
"""Prepare the per-rank request batch used to profile paged-KV headroom.
The profile executes directly on each Worker before the Scheduler and
its KV manager exist. It uses the maximum number of requests that one
rank can execute together. DLO+DP is the exception because each rank
executes one request from the collective wave.
Hunyuan is currently the only model integrated with
``paged_scheduler``. The stage's configured default resolution (or
1024x1024 when absent), enabled CFG, and the maximum advertised
reference-image count exercise its first-step activation peak.
Admission compares each preprocessed request's CFG count and tokenized
sequence/target shape with the resulting per-request profile envelope.
Future paged model integrations must extend this recipe for their
serving limits (for example, video frame count) rather than reusing it
silently.
"""
if (
getattr(self.od_config, "diffusion_kv_mode", DiffusionKVCacheMode.DENSE_LEGACY)
is not DiffusionKVCacheMode.PAGED_SCHEDULER
):
return None
model_class_name = self.od_config.model_class_name
if model_class_name is None:
raise RuntimeError("Diffusion KV profiling requires a resolved model_class_name")
additional_config = getattr(self.od_config, "additional_config", {}) or {}
profile_height = additional_config.get("diffusion_kv_profile_height", 1024)
profile_width = additional_config.get("diffusion_kv_profile_width", 1024)
request = self._make_dummy_request(
height=profile_height,
width=profile_width,
guidance_scale=5.0,
num_image_inputs=get_dummy_run_num_image_inputs(model_class_name),
# Hunyuan skips generic warmup, but paged KV still needs its
# prepared image request to measure the startup memory envelope.
num_frames=1,
)
if request is None:
raise RuntimeError("paged_scheduler requires a runnable Diffusion KV memory profile request")
request = self._prepare_request_for_admission(request)
kv_requests = request.diffusion_kv_requests
if not kv_requests:
raise RuntimeError("paged_scheduler profile preprocessing must produce Diffusion KV requests")
self._diffusion_kv_profile_limits = (
len(kv_requests),
max(kv_request.seq_len for kv_request in kv_requests),
max(kv_request.target_len for kv_request in kv_requests),
)
dlo_dp_request_mode = self.execution_mode is DiffusionExecutionMode.REQUEST_BATCH and _uses_dlo_dp_concurrency(
self.od_config
)
profile_batch_size = 1 if dlo_dp_request_mode else _max_num_seqs(self.od_config)
profile_requests: list[OmniDiffusionRequest] = []
for index in range(profile_batch_size):
profile_request = copy.copy(request)
profile_request.request_id = f"{DUMMY_DIFFUSION_REQUEST_ID}/kv-profile-{index}"
profile_request.sampling_params = copy.deepcopy(request.sampling_params)
# Preprocessing owns request geometry and prepared model inputs,
# but native KV requests remain Scheduler-only mutable state. The
# profile bypasses Scheduler admission and must not send them to
# the Worker.
profile_request.diffusion_kv_requests = None
profile_requests.append(profile_request)
return profile_requests
def _dummy_run(self):
"""A dummy run to warm up the model."""
req = self._make_dummy_request(
height=512,
width=512,
guidance_scale=0.0,
# Dummy warmup must exercise at least one denoising iteration in
# every execution mode. Some pipelines (for example BAGEL) perform
# ``num_inference_steps - 1`` scheduler updates and reject an empty
# one-step schedule.
num_inference_steps=2,
)
if req is None:
logger.info("Skipping dummy warmup run (num_frames=0)")
return
logger.info("dummy run to warm up the model")
output = self.add_req_and_wait_for_response(req)
if output.error:
raise RuntimeError(f"Dummy run failed: {output.error}")
def _submit_rpc(
self,
method: str,
timeout: float | None,
args: tuple,
kwargs: dict | None,
unique_reply_rank: int | None,
) -> _RpcTask:
assert isinstance(method, str), "Only string method names are supported for now"
deadline = None if timeout is None else time.monotonic() + timeout
task = _RpcTask(
method=method,
args=args,
kwargs=kwargs,
deadline=deadline,
unique_reply_rank=unique_reply_rank,
)
with self._cv:
if method == "pause_scheduler":
self._check_pause_request(kwargs)
self._scheduling_paused = True
self._rpc_queue.put(task)
self._cv.notify_all()
return task
def collective_rpc(
self,
method: str,
timeout: float | None = None,
args: tuple = (),
kwargs: dict | None = None,
unique_reply_rank: int | None = None,
) -> Any:
"""Call a method on worker processes and get results immediately.
The call is enqueued and executed by the engine's busy loop between
scheduler steps, so it is naturally serialized against per-request
execute_fn() invocations without any explicit mutual-exclusion lock.
Args:
method: The method name (str) to execute on workers
timeout: Optional timeout in seconds
args: Positional arguments for the method
kwargs: Keyword arguments for the method
unique_reply_rank: If set, only get reply from this rank
Returns:
Single result if unique_reply_rank is provided, otherwise list of results
"""
assert isinstance(method, str), "Only string method names are supported for now"
# If the busy loop hasn't started yet (e.g. during _dummy_run in
# __init__, or before the first async request after construction),
# there is no busy-loop thread to drain the RPC queue. Fall back to
# calling the executor directly, but serialize concurrent callers
# via self._cv's underlying lock so multiple threads in this window
# cannot race on the shared broadcast_mq / result_mq transport.
if not self._loop_started:
with self._cv:
# Re-check under the lock: the busy loop may have started
# between the outer check and acquiring the lock, in which
# case we should use the queued path for proper ordering.
if not self._loop_started:
if method in ("pause_scheduler", "resume_scheduler"):
return self._run_engine_control(method, timeout, kwargs)
return self.executor.collective_rpc(
method=method,
timeout=timeout,
args=args,
kwargs=kwargs,
unique_reply_rank=unique_reply_rank,
)
task = self._submit_rpc(method, timeout, args, kwargs, unique_reply_rank)
try:
return task.future.result(timeout=timeout)
except concurrent.futures.TimeoutError as exc:
task.future.cancel()
raise TimeoutError(f"RPC call to {method} timed out.") from exc
async def async_collective_rpc(
self,
method: str,
timeout: float | None = None,
args: tuple = (),
kwargs: dict | None = None,
unique_reply_rank: int | None = None,
) -> Any:
"""Async variant of :meth:`collective_rpc` for event-loop callers.
Enqueue a task keyed by a future and ``await`` the result without
blocking the loop.
"""
await self._check_and_start_background_loop()
task = self._submit_rpc(method, timeout, args, kwargs, unique_reply_rank)
aio_fut = asyncio.wrap_future(task.future)
try:
if timeout is None:
return await aio_fut
return await asyncio.wait_for(aio_fut, timeout=timeout)
except asyncio.TimeoutError as exc:
task.future.cancel()
raise TimeoutError(f"RPC call to {method} timed out.") from exc
def _put_queue_output(
self,
queue: asyncio.Queue[DiffusionOutput],
output: DiffusionOutput,
) -> None:
loop = self.main_loop
if loop is not None and loop.is_running():
loop.call_soon_threadsafe(queue.put_nowait, output)
else:
queue.put_nowait(output)
def _put_output(self, request_id: str, output: DiffusionOutput) -> None:
with self._cv:
queue = self._out_streams.get(request_id)
if queue is None:
return
self._put_queue_output(queue, output)
def close(self) -> None:
pending_streams: list[asyncio.Queue[DiffusionOutput]] = []
with self._cv:
if self._closed and self._shutdown_complete:
return
if not self._closed:
self._closed = True
if self._request_cancellations is not None:
self._request_cancellations.cancel_all()
if self.stop_event is not None:
self.stop_event.set()
pending_streams = list(self._out_streams.values())
self._out_streams.clear()
self._cv.notify_all()
closed_output = DiffusionOutput(error="DiffusionEngine is closed.")
for stream in pending_streams:
self._put_queue_output(stream, closed_output)
worker_thread = self.worker_thread
if worker_thread is not None:
if worker_thread.is_alive():
worker_thread.join(timeout=10)
if worker_thread.is_alive():
# Keep cancellation names available: an in-flight worker may
# not have attached its readers yet. A later close releases
# them after execution and executor shutdown have completed.
logger.warning(
"Worker thread did not terminate within 10s; scheduler and executor shutdown will be deferred."
)
return
else:
self._loop_started = False
else:
self._loop_started = False
if getattr(self.scheduler, "_native_prefetch_enabled", False):
# No new schedules after the busy loop exits. Finish outstanding
# writes before shutting down consumers and releasing reservations.
self._poll_native_kv(drain_request_ids=list(self.scheduler._kv_loading_request_ids))
self.executor.shutdown()
self.scheduler.close()
else:
self.scheduler.close()
self.executor.shutdown()
if self._request_cancellations is not None:
self._request_cancellations.close()
self._shutdown_complete = True
def abort(self, request_id: str | Iterable[str]) -> None:
request_ids = [request_id] if isinstance(request_id, str) else list(request_id)
with self._cv:
if self._closed:
return
if self._request_cancellations is not None:
# Do not queue this behind the full-forward executor call.
# Scheduler state is still mutated only by the busy loop.
self._request_cancellations.cancel(request_ids)
for req_id in request_ids:
self.abort_queue.put(req_id)
self._cv.notify_all()
def _process_aborts_queue(self) -> None:
with self._cv:
try:
self._drain_abort_queue()
except Exception as exc:
self._fail_engine(exc)
raise
def _drain_abort_queue(self) -> None:
if self.abort_queue.empty():
return
request_ids: list[str] = []
while not self.abort_queue.empty():
ids = self.abort_queue.get_nowait()
request_ids.extend((ids,) if isinstance(ids, str) else ids)
self._abort_requests(request_ids)
def _abort_requests(self, request_ids: str | Iterable[str]) -> None:
request_ids = [request_ids] if isinstance(request_ids, str) else list(request_ids)
request_ids = list(dict.fromkeys(request_ids))
self._poll_native_kv(drain_request_ids=request_ids)
for request_id in request_ids:
if self.scheduler.get_request_state(request_id) is not None:
self.scheduler.finish_requests(request_id, DiffusionRequestStatus.FINISHED_ABORTED)
self._remove_diffusion_kv_requests(request_ids)
def _finalize_finished_request(
self,
request_id: str,
runner_output: RunnerOutput | None = None,
missing_result_error: str = "Diffusion scheduler finished target request without execution output.",
) -> DiffusionOutput:
state = self.scheduler.get_request_state(request_id)
popped_state = self.scheduler.pop_request_state(request_id)
state = state or popped_state
if self._request_cancellations is not None:
self._request_cancellations.finish(request_id)
if state is None:
raise RuntimeError(f"Diffusion scheduler lost state for request {request_id}.")
if state.status == DiffusionRequestStatus.FINISHED_ABORTED:
# An aborted request is never waited on, so a pending async output
# would be cached forever by the executor result pump (issue #6413).
# Tell the executor to drop it before returning the aborted result.
if runner_output is not None and runner_output.async_output_id is not None:
self.executor.drop_output(runner_output.async_output_id)
# Preserve runner-provided abort details when available.
if runner_output is not None and runner_output.result is not None and runner_output.result.aborted:
return runner_output.result
return DiffusionOutput(
aborted=True,
abort_message=f"Request {request_id} aborted.",
)
if runner_output is not None and runner_output.result is not None:
return runner_output.result
if runner_output is not None and runner_output.async_output_id is not None:
return DiffusionOutput(async_output_id=runner_output.async_output_id)
if state.status == DiffusionRequestStatus.FINISHED_ERROR and state.error:
return DiffusionOutput(error=state.error)
return DiffusionOutput(error=missing_result_error)