Files
vllm-omni/vllm_omni/diffusion/worker/diffusion_model_runner.py
T

469 lines
20 KiB
Python
Raw Normal View History

2026-01-17 19:49:13 +08:00
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""
Diffusion Model Runner for vLLM-Omni.
Handles model loading, compilation, caching, and execution of diffusion model
forward passes. This follows the AR pattern where the Runner handles all
model-related operations.
"""
from __future__ import annotations
import copy
2026-01-17 19:49:13 +08:00
import time
from collections.abc import Iterable
from contextlib import nullcontext
from typing import Any
2026-01-17 19:49:13 +08:00
import torch
from torch.profiler import record_function
2026-01-17 19:49:13 +08:00
from vllm.config import LoadConfig
from vllm.logger import init_logger
from vllm.utils.mem_utils import DeviceMemoryProfiler, GiB_bytes
from vllm_omni.diffusion.cache.cache_dit_backend import cache_summary
2026-01-17 19:49:13 +08:00
from vllm_omni.diffusion.cache.selector import get_cache_backend
from vllm_omni.diffusion.compile import regionally_compile
from vllm_omni.diffusion.data import DiffusionOutput, OmniDiffusionConfig
from vllm_omni.diffusion.forward_context import set_forward_context
from vllm_omni.diffusion.model_loader.diffusers_loader import DiffusersPipelineLoader
from vllm_omni.diffusion.models.interface import supports_step_execution
from vllm_omni.diffusion.offloader import get_offload_backend
from vllm_omni.diffusion.registry import _NO_CACHE_ACCELERATION
2026-01-17 19:49:13 +08:00
from vllm_omni.diffusion.request import OmniDiffusionRequest
from vllm_omni.diffusion.sched.interface import DiffusionSchedulerOutput
from vllm_omni.diffusion.worker.input_batch import InputBatch, scatter_latents
from vllm_omni.diffusion.worker.utils import BatchRunnerOutput, DiffusionRequestState, RunnerOutput
from vllm_omni.distributed.omni_connectors.kv_transfer_manager import OmniKVTransferManager
from vllm_omni.platforms import current_omni_platform
from vllm_omni.worker.omni_connector_model_runner_mixin import OmniConnectorModelRunnerMixin
2026-01-17 19:49:13 +08:00
logger = init_logger(__name__)
class DiffusionModelRunner(OmniConnectorModelRunnerMixin):
2026-01-17 19:49:13 +08:00
"""
Model runner that handles model loading and execution for diffusion models.
This class follows the AR pattern where the Runner handles all model-related
operations including loading, compilation, offloading, caching, and execution.
The Worker only handles infrastructure (device, distributed env).
"""
def __init__(
self,
vllm_config,
od_config: OmniDiffusionConfig,
device: torch.device,
):
"""
Initialize the diffusion model runner.
Args:
vllm_config: vLLM configuration.
od_config: OmniDiffusion configuration.
device: The device to run on.
"""
self.vllm_config = vllm_config
self.od_config = od_config
self.device = device
self.pipeline = None
self.cache_backend = None
self.offload_backend = None
2026-01-22 18:09:55 +08:00
# Cache for per-request stepwise state.
self.state_cache: dict[str, DiffusionRequestState] = {}
# Initialize KV cache manager for connector management
self.kv_transfer_manager = OmniKVTransferManager.from_od_config(od_config)
2026-01-17 19:49:13 +08:00
def _compile_transformer(self, attr_name: str) -> None:
"""Compile a transformer attribute on the pipeline with torch.compile."""
model = getattr(self.pipeline, attr_name, None)
if model is None:
return
try:
setattr(self.pipeline, attr_name, regionally_compile(model, dynamic=True))
logger.info("Model runner: %s compiled with torch.compile.", attr_name)
except Exception as e:
logger.warning(
"Model runner: torch.compile for %s failed: %s. Using eager mode.",
attr_name,
e,
)
2026-01-17 19:49:13 +08:00
def load_model(
self,
memory_pool_context_fn: callable | None = None,
load_format: str | None = None,
custom_pipeline_name: str | None = None,
2026-01-17 19:49:13 +08:00
) -> None:
"""
Load the diffusion model, apply compilation and offloading.
Args:
memory_pool_context_fn: Optional function that returns a context manager
for memory pool allocation (used for sleep mode).
load_format: Format for loading model weights. Supported formats:
- "default" (default): Automatically detect and use the default format based on configuration
- "custom_pipeline": Init model from a custom pipeline class specified by `custom_pipeline_name`
- "dummy": Skip actual weight loading, useful for testing and custom pipelines that
don't require default weights.
custom_pipeline_name: Optional custom pipeline class name to use.
2026-01-17 19:49:13 +08:00
"""
if load_format == "dummy":
return
load_device = (
"cpu" if self.od_config.enable_cpu_offload or self.od_config.enable_layerwise_offload else str(self.device)
)
2026-01-17 19:49:13 +08:00
def get_memory_context():
if memory_pool_context_fn is not None:
return memory_pool_context_fn(tag="weights")
return nullcontext()
# Load model within forward context
load_config = LoadConfig()
model_loader = DiffusersPipelineLoader(load_config, od_config=self.od_config)
time_before_load = time.perf_counter()
2026-01-17 19:49:13 +08:00
with get_memory_context():
with DeviceMemoryProfiler() as m:
self.pipeline = model_loader.load_model(
od_config=self.od_config,
load_device=load_device,
load_format=load_format,
custom_pipeline_name=custom_pipeline_name,
device=self.device,
)
time_after_load = time.perf_counter()
2026-01-17 19:49:13 +08:00
logger.info(
"Model loading took %.4f GiB and %.6f seconds",
m.consumed_memory / GiB_bytes,
time_after_load - time_before_load,
)
logger.info("Model runner: Model loaded successfully.")
if getattr(self.od_config, "step_execution", False) and not self.supports_step_mode():
raise ValueError(
"step_execution=True requires a pipeline implementing "
"prepare_encode(), denoise_step(), step_scheduler(), and post_decode(); "
f"{self.od_config.model_class_name} does not support that contract."
)
# Apply CPU offloading
self.offload_backend = get_offload_backend(self.od_config, device=self.device)
if self.offload_backend is not None:
logger.info(f" Enabling offloader backend: {self.offload_backend.__class__.__name__}")
self.offload_backend.enable(self.pipeline)
2026-01-17 19:49:13 +08:00
# Apply torch.compile if not in eager mode
if not self.od_config.enforce_eager:
if current_omni_platform.supports_torch_inductor():
self._compile_transformer("transformer")
self._compile_transformer("transformer_2")
else:
logger.warning(
"Model runner: Platform %s does not support torch inductor, skipping torch.compile.",
current_omni_platform.get_torch_device(),
2026-01-17 19:49:13 +08:00
)
# Setup cache backend
self.cache_backend = get_cache_backend(self.od_config.cache_backend, self.od_config.cache_config)
if self.cache_backend is not None:
if self.od_config.model_class_name in _NO_CACHE_ACCELERATION:
logger.warning(
"Cache backend '%s' is not supported for %s; disabling cache acceleration.",
self.od_config.cache_backend,
self.od_config.model_class_name,
)
self.cache_backend = None
self.od_config.cache_backend = None
else:
self.cache_backend.enable(self.pipeline)
2026-01-17 19:49:13 +08:00
logger.info("Model runner: Initialization complete.")
def load_weights(self, weights: Iterable[tuple[str, torch.Tensor]]) -> set[str]:
"""Load weights into the pipeline."""
return self.pipeline.load_weights(weights)
def _record_peak_memory(self, output: DiffusionOutput) -> None:
"""Record peak GPU memory for the current forward pass into output.
Must be called immediately after pipeline.forward(), with
reset_peak_memory_stats() called just before it, so the measurement
reflects this request only and not the global historical maximum.
Uses max_memory_reserved (CUDA memory pool high-water mark) rather than
max_memory_allocated so that allocator fragmentation is also visible.
See: https://docs.pytorch.org/docs/stable/generated/torch.cuda.memory.max_memory_reserved.html
"""
peak_reserved_bytes = current_omni_platform.max_memory_reserved()
peak_allocated_bytes = current_omni_platform.max_memory_allocated()
output.peak_memory_mb = peak_reserved_bytes / (1024**2)
peak_reserved_gb = peak_reserved_bytes / (1024**3)
peak_allocated_gb = peak_allocated_bytes / (1024**3)
pool_overhead_gb = peak_reserved_gb - peak_allocated_gb
logger.info(
"Peak GPU memory (this request): %.2f GB reserved, %.2f GB allocated, %.2f GB pool overhead (%.1f%%)",
peak_reserved_gb,
peak_allocated_gb,
pool_overhead_gb,
pool_overhead_gb / peak_reserved_gb * 100 if peak_reserved_gb > 0 else 0.0,
)
def execute_model(self, req: OmniDiffusionRequest) -> DiffusionOutput:
2026-01-17 19:49:13 +08:00
"""
Execute a forward pass for the given requests.
Args:
req: A diffusion request containing a list of prompts to process.
2026-01-17 19:49:13 +08:00
Returns:
DiffusionOutput with generated results.
Note:
We use torch.no_grad() for HSDP because HSDP2's fully_shard requires access
to tensor version counters in pre_forward hooks, which inference tensors do
not track. For non-HSDP inference, we use torch.inference_mode() for better
performance.
2026-01-17 19:49:13 +08:00
"""
assert self.pipeline is not None, "Model not loaded. Call load_model() first."
if len(req.prompts) == 0:
2026-01-17 19:49:13 +08:00
raise ValueError("Cannot execute model with empty request list")
# Use no_grad() for HSDP compatibility, inference_mode() otherwise for better perf
use_hsdp = self.od_config.parallel_config.use_hsdp
grad_context = torch.no_grad() if use_hsdp else torch.inference_mode()
with grad_context:
# The manager handles the check for need_recv_cache internally
2026-03-23 21:52:50 +08:00
self.kv_transfer_manager.receive_multi_kv_cache_distributed(
req,
cfg_kv_collect_func=getattr(self.od_config, "cfg_kv_collect_func", None),
target_device=getattr(self.pipeline, "device", None),
)
2026-01-22 18:09:55 +08:00
if req.sampling_params.generator is None and req.sampling_params.seed is not None:
if req.sampling_params.generator_device is not None:
gen_device = req.sampling_params.generator_device
elif self.device.type == "cpu":
gen_device = "cpu"
else:
gen_device = self.device
req.sampling_params.generator = torch.Generator(device=gen_device).manual_seed(req.sampling_params.seed)
2026-01-17 19:49:13 +08:00
# Refresh cache context if needed
if (
not getattr(req, "skip_cache_refresh", False)
and self.cache_backend is not None
and self.cache_backend.is_enabled()
and req.sampling_params.num_inference_steps is not None
):
self.cache_backend.refresh(self.pipeline, req.sampling_params.num_inference_steps)
2026-01-17 19:49:13 +08:00
is_primary = not torch.distributed.is_initialized() or torch.distributed.get_rank() == 0
if is_primary:
current_omni_platform.reset_peak_memory_stats()
with set_forward_context(vllm_config=self.vllm_config, omni_diffusion_config=self.od_config):
with record_function("pipeline_forward"):
output = self.pipeline.forward(req)
2026-01-17 19:49:13 +08:00
if is_primary:
self._record_peak_memory(output)
# NOTE:
if (
self.cache_backend is not None
and self.cache_backend.is_enabled()
and self.od_config.cache_backend == "cache_dit"
and self.od_config.enable_cache_dit_summary
):
cache_summary(self.pipeline, details=True)
return output
# ------------------------------------------------------------------
# Step-wise execution
# ------------------------------------------------------------------
def supports_step_mode(self) -> bool:
"""Return whether current pipeline supports step execution."""
return self.pipeline is not None and supports_step_execution(self.pipeline)
def _update_states(
self, scheduler_output: DiffusionSchedulerOutput
) -> tuple[list[DiffusionRequestState], list[str]]:
"""Step-before update: cleanup finished requests and get/create one running state."""
for req_id in scheduler_output.finished_req_ids:
self.state_cache.pop(req_id, None)
resolved: list[DiffusionRequestState] = []
new_req_id: list[str] = []
try:
# process new requests
for sched_new_req in scheduler_output.scheduled_new_reqs:
# new_req_data = scheduler_output.scheduled_new_reqs[0]
req_id = sched_new_req.sched_req_id
req = sched_new_req.req
new_req_id.append(req_id)
if req_id in self.state_cache:
raise ValueError(f"Received duplicate new-request payload for cached request {req_id}.")
request_ids = req.request_ids or [req_id]
if len(request_ids) != len(req.prompts):
raise ValueError(
f"request_ids length ({len(request_ids)}) does not match prompts length ({len(req.prompts)})"
)
new_state = DiffusionRequestState(
req_id=req_id,
sampling=copy.deepcopy(req.sampling_params),
prompts=req.prompts,
)
self.state_cache[req_id] = new_state
resolved.append(new_state)
# process cached requests
for req_id in scheduler_output.scheduled_cached_reqs.sched_req_ids:
state = self.state_cache.get(req_id)
if state is None:
raise ValueError(f"Missing cached state for request {req_id}.")
resolved.append(state)
except Exception:
for req_id in new_req_id:
self.state_cache.pop(req_id, None)
raise
return resolved, new_req_id
def _prepare_batch_inputs(self, states: list[DiffusionRequestState], new_request_ids: list[str]) -> InputBatch:
# process new reqs
for state in states:
if state.req_id in new_request_ids:
# set generator
if state.sampling.generator is None and state.sampling.seed is not None:
if state.sampling.generator_device is not None:
gen_device = state.sampling.generator_device
elif self.device.type == "cpu":
gen_device = "cpu"
else:
gen_device = self.device
state.sampling.generator = torch.Generator(device=gen_device).manual_seed(state.sampling.seed)
# encode
self.pipeline.prepare_encode(state)
input_batch = InputBatch.make_batch(
states,
cached_batch=getattr(self, "input_batch", None),
)
self.input_batch = input_batch
return input_batch
def _update_states_after(
self,
states: list[DiffusionRequestState],
input_batch: InputBatch,
interrupted: bool = False,
):
"""Step-after update: clear cached state for completed request."""
gathered_latents = torch.cat([state.latents for state in states], dim=0)
if (
input_batch.latents.size() == gathered_latents.size()
and input_batch.latents.dtype == gathered_latents.dtype
and input_batch.latents.device == gathered_latents.device
):
input_batch.latents.copy_(gathered_latents)
else:
input_batch.latents = gathered_latents.clone()
self.input_batch = input_batch
scatter_latents(states, input_batch)
for state in states:
if interrupted or state.denoise_completed:
self.state_cache.pop(state.req_id, None)
def _prepare_attn_metadata(self, input_batch: InputBatch) -> Any:
model_state = getattr(self, "model_state", None)
if model_state is None:
return {}
prepare_attn = getattr(model_state, "prepare_attn", None)
if not callable(prepare_attn):
return {}
return prepare_attn(input_batch)
def execute_stepwise(self, scheduler_output: DiffusionSchedulerOutput) -> BatchRunnerOutput:
"""Execute one step for one scheduled request and return runner output."""
assert self.pipeline is not None, "Model not loaded. Call load_model() first."
if not self.supports_step_mode():
raise ValueError("Current pipeline does not support step execution.")
# Stepwise mode only supports the basic state-driven denoise path for now.
# Request-mode extras such as cache backends, KV transfer, editing inputs,
# and similar features are not supported here yet.
if self.od_config.cache_backend not in (None, "none"):
raise ValueError("Step mode does not support cache_backend yet.")
use_hsdp = self.od_config.parallel_config.use_hsdp
grad_context = torch.no_grad() if use_hsdp else torch.inference_mode()
with grad_context:
states, new_request_ids = self._update_states(scheduler_output)
input_batch = self._prepare_batch_inputs(states, new_request_ids)
attn_metadata = self._prepare_attn_metadata(input_batch)
with set_forward_context(
vllm_config=self.vllm_config,
omni_diffusion_config=self.od_config,
attn_metadata=attn_metadata,
):
noise_pred = self.pipeline.denoise_step(input_batch)
runner_output_list = []
pipeline_interrupted = getattr(self.pipeline, "interrupt", False)
if noise_pred is None and pipeline_interrupted:
for state in states:
runner_output_list.append(
RunnerOutput(
req_id=state.req_id,
step_index=state.step_index,
finished=True,
result=DiffusionOutput(error="stepwise denoise interrupted"),
)
)
else:
offset = 0
for req in states:
row_num = req.latents.shape[0]
self.pipeline.step_scheduler(
req, noise_pred[offset : offset + row_num] if noise_pred is not None else None
)
offset = offset + row_num
if req.denoise_completed:
result = self.pipeline.post_decode(req)
else:
result = None
runner_output_list.append(
RunnerOutput(
req_id=req.req_id,
step_index=req.step_index,
finished=req.denoise_completed,
result=result,
)
)
if noise_pred is not None and offset != noise_pred.shape[0]:
raise ValueError(
f"Stepwise noise_pred consumed {offset} rows, "
f"but batched noise_pred has {noise_pred.shape[0]} rows."
)
self._update_states_after(states, input_batch, pipeline_interrupted)
return BatchRunnerOutput.from_list(runner_output_list)