# SPDX-License-Identifier: Apache-2.0 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project """Diffusion input-batch structures following the MRV2-style vLLM layout. Request states remain the only persistent source of truth. Static tensors are normalized/padded onto the request state once, while :class:`InputBatch` assembles an ephemeral step-local view. Dynamic tensors are re-gathered every step, and step outputs are scattered back into request states by ``scatter_latents()`` using ``idx_mapping``. """ from __future__ import annotations from collections.abc import Sequence from dataclasses import dataclass, field import numpy as np import torch from vllm_omni.diffusion.prompt_update import prompt_update_versions from vllm_omni.diffusion.worker.utils import StepRequestState def _normalize_prompt_embeds(x: torch.Tensor) -> torch.Tensor: if x.ndim == 2: return x.unsqueeze(0) if x.ndim == 3: return x raise ValueError(f"prompt_embeds must be 2D or 3D, got shape={tuple(x.shape)}") def _normalize_mask(x: torch.Tensor) -> torch.Tensor: if x.ndim == 1: x = x.unsqueeze(0) elif x.ndim != 2: raise ValueError(f"prompt mask must be 1D or 2D, got shape={tuple(x.shape)}") if x.dtype != torch.bool: x = x != 0 return x def _pad_prompt_embeds(x: torch.Tensor, target_seq_len: int) -> torch.Tensor: x = _normalize_prompt_embeds(x) bsz, seq_len, hidden = x.shape if seq_len == target_seq_len: return x out = x.new_zeros((bsz, target_seq_len, hidden)) out[:, :seq_len] = x return out def _pad_mask(x: torch.Tensor, target_seq_len: int) -> torch.Tensor: x = _normalize_mask(x) bsz, seq_len = x.shape if seq_len == target_seq_len: return x out = torch.zeros((bsz, target_seq_len), dtype=torch.bool, device=x.device) out[:, :seq_len] = x return out def _select_states( states: Sequence[StepRequestState], idx_mapping: torch.Tensor | None, ) -> tuple[list[StepRequestState], torch.Tensor, np.ndarray]: if not states: raise ValueError("Cannot build InputBatch from empty states.") if idx_mapping is None: device = states[0].latents.device if states[0].latents is not None else None idx_mapping = torch.arange(len(states), dtype=torch.int32, device=device) else: if idx_mapping.ndim != 1: raise ValueError("idx_mapping must be a 1D tensor.") idx_mapping = idx_mapping.to(dtype=torch.int32) selected_states: list[StepRequestState] = [] for batch_idx, state_idx in enumerate(idx_mapping.tolist()): if state_idx < 0 or state_idx >= len(states): raise ValueError(f"idx_mapping[{batch_idx}]={state_idx} is out of range for states.") selected_states.append(states[state_idx]) return selected_states, idx_mapping, idx_mapping.detach().cpu().numpy() def _prepare_request_ids(states: Sequence[StepRequestState]) -> list[str]: return [state.request_id for state in states] def _prepare_prompt_field_on_state( state: StepRequestState, *, embeds_attr: str, mask_attr: str, ) -> tuple[torch.Tensor | None, torch.Tensor | None]: embeds = getattr(state, embeds_attr) if embeds is None: return None, None normalized_embeds = _normalize_prompt_embeds(embeds) if normalized_embeds is not embeds: setattr(state, embeds_attr, normalized_embeds) embeds = normalized_embeds mask = getattr(state, mask_attr) if mask is None: return embeds, None normalized_mask = _normalize_mask(mask) if normalized_mask is not mask: setattr(state, mask_attr, normalized_mask) return embeds, normalized_mask def _prepare_reused_buffer( current: torch.Tensor | None, *, shape: tuple[int, ...], dtype: torch.dtype, device: torch.device, ) -> torch.Tensor: if current is not None and tuple(current.shape) == shape and current.dtype == dtype and current.device == device: return current return torch.empty(shape, dtype=dtype, device=device) def _validate_gather_tensors( values: Sequence[torch.Tensor], *, field_name: str, ) -> tuple[torch.dtype, torch.device, tuple[int, ...], int]: if not values: raise ValueError(f"Cannot gather empty tensor list for {field_name}.") first = values[0] dtype = first.dtype device = first.device suffix_shape = tuple(first.shape[1:]) total_rows = 0 for value in values: if value.dtype != dtype: raise ValueError(f"Mixed dtypes in {field_name} batch.") if value.device != device: raise ValueError(f"Mixed devices in {field_name} batch.") if tuple(value.shape[1:]) != suffix_shape: raise ValueError( f"Mixed trailing shapes in {field_name} batch: expected {suffix_shape}, got {tuple(value.shape[1:])}." ) total_rows += int(value.shape[0]) return dtype, device, suffix_shape, total_rows def _gather_tensor_rows( values: Sequence[torch.Tensor], *, field_name: str, out: torch.Tensor | None = None, ) -> torch.Tensor: dtype, device, suffix_shape, total_rows = _validate_gather_tensors( values, field_name=field_name, ) gathered = _prepare_reused_buffer( out, shape=(total_rows, *suffix_shape), dtype=dtype, device=device, ) row_offset = 0 for value in values: next_row_offset = row_offset + int(value.shape[0]) gathered[row_offset:next_row_offset].copy_(value) row_offset = next_row_offset return gathered def _get_seq_lens_from_mask(mask: torch.Tensor) -> list[int]: return mask.sum(dim=1, dtype=torch.int32).tolist() def _get_request_prompt_seq_lens( state: StepRequestState, *, embeds_attr: str, mask_attr: str, seq_lens_attr: str, ) -> list[int]: embeds, mask = _prepare_prompt_field_on_state( state, embeds_attr=embeds_attr, mask_attr=mask_attr, ) if embeds is None: raise ValueError(f"{embeds_attr} is not initialized on request {state.request_id}.") if mask is not None: if mask.shape[0] != embeds.shape[0]: raise ValueError( f"{mask_attr} batch dimension does not match {embeds_attr} for request {state.request_id}." ) return _get_seq_lens_from_mask(mask) seq_lens = getattr(state, seq_lens_attr) if seq_lens is not None: return [int(value) for value in seq_lens] return [int(embeds.shape[1])] * int(embeds.shape[0]) def _prepare_request_prompt_field( state: StepRequestState, *, embeds_attr: str, mask_attr: str, seq_lens_attr: str, target_seq_len: int, ) -> tuple[torch.Tensor, torch.Tensor | None]: embeds, mask = _prepare_prompt_field_on_state( state, embeds_attr=embeds_attr, mask_attr=mask_attr, ) if embeds is None: raise ValueError(f"{embeds_attr} is not initialized on request {state.request_id}.") actual_seq_lens = _get_request_prompt_seq_lens( state, embeds_attr=embeds_attr, mask_attr=mask_attr, seq_lens_attr=seq_lens_attr, ) max_actual_seq_len = max(actual_seq_lens) if actual_seq_lens else 0 if mask is None and max_actual_seq_len != target_seq_len: raise ValueError( f"Variable-length {embeds_attr} in batch but {mask_attr} is None. " f"Provide masks or ensure {embeds_attr} have the same seq_len." ) current_seq_len = int(embeds.shape[1]) if current_seq_len < target_seq_len: embeds = _pad_prompt_embeds(embeds, target_seq_len) setattr(state, embeds_attr, embeds) if mask is not None: mask = _pad_mask(mask, target_seq_len) setattr(state, mask_attr, mask) current_seq_len = target_seq_len if current_seq_len > target_seq_len: if max_actual_seq_len > target_seq_len: raise ValueError( f"{embeds_attr} for request {state.request_id} requires seq_len " f"{max_actual_seq_len}, got target {target_seq_len}." ) embeds = embeds[:, :target_seq_len] mask = None if mask is None else mask[:, :target_seq_len] setattr(state, embeds_attr, embeds) if mask is not None: setattr(state, mask_attr, mask) setattr(state, seq_lens_attr, [int(embeds.shape[1])] * int(embeds.shape[0])) return embeds, mask setattr(state, seq_lens_attr, [int(embeds.shape[1])] * int(embeds.shape[0])) return embeds, mask def _prepare_padded_prompt_fields( states: Sequence[StepRequestState], *, embeds_attr: str, mask_attr: str, seq_lens_attr: str, embeds_out: torch.Tensor | None = None, mask_out: torch.Tensor | None = None, ) -> tuple[torch.Tensor | None, torch.Tensor | None]: prepared_fields = [ _prepare_prompt_field_on_state( state, embeds_attr=embeds_attr, mask_attr=mask_attr, ) for state in states ] embeds_values = [embeds for embeds, _ in prepared_fields] if not any(embeds is not None for embeds in embeds_values): return None, None if not all(embeds is not None for embeds in embeds_values): raise ValueError(f"Mixed {embeds_attr} in batch.") mask_values = [mask for _, mask in prepared_fields] if any(mask is None for mask in mask_values): if any(mask is not None for mask in mask_values): raise ValueError(f"Mixed {mask_attr} in batch.") target_seq_len = max( max( _get_request_prompt_seq_lens( state, embeds_attr=embeds_attr, mask_attr=mask_attr, seq_lens_attr=seq_lens_attr, ) ) for state in states ) request_embeds: list[torch.Tensor] = [] request_masks: list[torch.Tensor] = [] for state in states: prepared_embeds, prepared_mask = _prepare_request_prompt_field( state, embeds_attr=embeds_attr, mask_attr=mask_attr, seq_lens_attr=seq_lens_attr, target_seq_len=target_seq_len, ) request_embeds.append(prepared_embeds) if prepared_mask is not None: request_masks.append(prepared_mask) gathered_embeds = _gather_tensor_rows( request_embeds, field_name=embeds_attr, out=embeds_out, ) if not request_masks: return gathered_embeds, None gathered_masks = _gather_tensor_rows( request_masks, field_name=mask_attr, out=mask_out, ) return gathered_embeds, gathered_masks def _prepare_latents( states: Sequence[StepRequestState], *, out: torch.Tensor | None = None, ) -> torch.Tensor: latents_values = [state.latents for state in states] if any(latents is None for latents in latents_values): raise ValueError("All requests must have `latents` initialized.") return _gather_tensor_rows( [latents for latents in latents_values if latents is not None], field_name="latents", out=out, ) def _require_state_latents( state: StepRequestState, *, for_field: str, ) -> torch.Tensor: latents = state.latents if latents is None: raise ValueError(f"Request {state.request_id} has no latents while preparing {for_field}.") return latents def _expand_scalar_or_vector( value: torch.Tensor, *, num_rows: int, field_name: str, ) -> torch.Tensor: if value.ndim == 0: return value.reshape(1).expand(num_rows) if value.ndim != 1: raise ValueError(f"{field_name} must be scalar or 1D, got ndim={value.ndim}.") if value.shape[0] == num_rows: return value if value.shape[0] == 1: return value.expand(num_rows) raise ValueError( f"Per-request {field_name} must have either 1 element or {num_rows} elements; got {value.shape[0]}." ) def _prepare_timesteps( states: Sequence[StepRequestState], *, out: torch.Tensor | None = None, ) -> torch.Tensor: timestep_values: list[torch.Tensor] = [] for state in states: timestep = state.current_timestep if timestep is None: raise ValueError("All requests must have a current timestep initialized.") if not torch.is_tensor(timestep): raise ValueError("InputBatch expects tensor timesteps; normalize them before batching.") latents = _require_state_latents(state, for_field="timesteps") timestep_values.append( _expand_scalar_or_vector( timestep, num_rows=int(latents.shape[0]), field_name="timestep tensor", ) ) return _gather_tensor_rows( timestep_values, field_name="timesteps", out=out, ) def _prepare_cfg_scalars( states: Sequence[StepRequestState], ) -> tuple[bool, float, bool]: def _cfg_scalars(state: StepRequestState) -> tuple[bool, float, bool]: true_cfg_scale = getattr(state.sampling, "true_cfg_scale", None) or 4.0 cfg_normalize = bool(getattr(state.sampling, "cfg_normalize", False)) return state.do_true_cfg, true_cfg_scale, cfg_normalize scalars = _cfg_scalars(states[0]) for state in states[1:]: if _cfg_scalars(state) != scalars: raise ValueError("Mixed CFG settings in one diffusion batch are not supported.") return scalars def _prepare_guidance( states: Sequence[StepRequestState], *, out: torch.Tensor | None = None, ) -> torch.Tensor | None: guidance_values = [state.guidance for state in states] if all(guidance is None for guidance in guidance_values): return None if any(guidance is None for guidance in guidance_values): raise ValueError("Mixed guidance in one diffusion batch are not supported.") gathered_guidance: list[torch.Tensor] = [] for state in states: latents = _require_state_latents(state, for_field="guidance") guidance = state.guidance assert guidance is not None guidance_tensor = torch.as_tensor( guidance, device=latents.device, dtype=latents.dtype, ) gathered_guidance.append( _expand_scalar_or_vector( guidance_tensor, num_rows=int(latents.shape[0]), field_name="guidance tensor", ) ) return _gather_tensor_rows( gathered_guidance, field_name="guidance", out=out, ) def _prepare_image_latents( states: Sequence[StepRequestState], *, out: torch.Tensor | None = None, ) -> torch.Tensor | None: image_latents = [getattr(state.sampling, "image_latent", None) for state in states] if all(image_latent is None for image_latent in image_latents): return None if any(image_latent is None for image_latent in image_latents): raise ValueError("Mixed image_latent presence in one diffusion batch is not supported.") return _gather_tensor_rows( [image_latent for image_latent in image_latents if image_latent is not None], field_name="image_latents", out=out, ) def _prepare_seq_lens( states: Sequence[StepRequestState], attr_name: str, ) -> list[int] | None: values = [getattr(state, attr_name) for state in states] if all(value is None for value in values): return None if any(value is None for value in values): raise ValueError(f"Mixed {attr_name} in batch.") return [int(value[0]) for value in values if value is not None] def _prepare_img_shapes(states: Sequence[StepRequestState]) -> list | None: values = [state.img_shapes for state in states] if all(value is None for value in values): return None if any(value is None for value in values): raise ValueError("Mixed img_shapes in batch.") return [value[0] if value else [] for value in values if value is not None] def _prepare_prompt_embeds( states: Sequence[StepRequestState], *, embeds_out: torch.Tensor | None = None, mask_out: torch.Tensor | None = None, ) -> tuple[torch.Tensor | None, torch.Tensor | None]: prompt_embeds, prompt_embeds_mask = _prepare_padded_prompt_fields( states, embeds_attr="prompt_embeds", mask_attr="prompt_embeds_mask", seq_lens_attr="txt_seq_lens", embeds_out=embeds_out, mask_out=mask_out, ) return prompt_embeds, prompt_embeds_mask def _prepare_negative_prompt_embeds( states: Sequence[StepRequestState], *, embeds_out: torch.Tensor | None = None, mask_out: torch.Tensor | None = None, ) -> tuple[torch.Tensor | None, torch.Tensor | None]: return _prepare_padded_prompt_fields( states, embeds_attr="negative_prompt_embeds", mask_attr="negative_prompt_embeds_mask", seq_lens_attr="negative_txt_seq_lens", embeds_out=embeds_out, mask_out=mask_out, ) def _same_composition( cached_batch: InputBatch | None, request_ids: list[str], idx_mapping_np: np.ndarray, states: Sequence[StepRequestState], ) -> bool: if cached_batch is None: return False if cached_batch.request_ids != request_ids: return False if not np.array_equal(cached_batch.idx_mapping_np, idx_mapping_np): return False # Midway prompt updates (typically for video generation) can change prompt_embeds without changing ids/mapping. # In this case, each request state manages the embedding's "version". Use it to determine if cache is still valid. return cached_batch._prompt_update_versions == prompt_update_versions(states) def _scatter_batch_tensor_by_mapping( states: Sequence[StepRequestState], idx_mapping_np: np.ndarray, *, attr_name: str, value: torch.Tensor, ) -> None: row_offset = 0 for batch_idx, state_idx in enumerate(idx_mapping_np.tolist()): if state_idx < 0 or state_idx >= len(states): raise ValueError(f"idx_mapping[{batch_idx}]={state_idx} is out of range for states.") state = states[state_idx] state_value = getattr(state, attr_name) num_rows = 1 if state_value is None else int(state_value.shape[0]) next_row_offset = row_offset + num_rows value_slice = value[row_offset:next_row_offset] if state_value is None: setattr(state, attr_name, value_slice.clone()) elif ( tuple(state_value.shape) != tuple(value_slice.shape) or state_value.dtype != value_slice.dtype or state_value.device != value_slice.device ): setattr(state, attr_name, value_slice.clone()) else: state_value.copy_(value_slice) row_offset = next_row_offset if row_offset != int(value.shape[0]): raise ValueError( f"Scatter for {attr_name} consumed {row_offset} rows, but batch has {int(value.shape[0])} rows." ) @dataclass class InputBatch: """Ephemeral step-level batch view. Static request-local tensors are normalized and padded onto ``StepRequestState`` itself, making the request state the persistent source of truth. ``InputBatch`` only assembles a contiguous view for the current step and refreshes dynamic fields in-place when composition is unchanged. ``states`` is a narrow escape hatch for Hunyuan-style state-driven pipelines that need request-private KV/cache metadata during denoise. Other pipelines should continue to use the standard batch fields. """ request_ids: list[str] num_reqs: int num_reqs_after_padding: int idx_mapping: torch.Tensor idx_mapping_np: np.ndarray latents: torch.Tensor timesteps: torch.Tensor prompt_embeds: torch.Tensor | None prompt_embeds_mask: torch.Tensor | None negative_prompt_embeds: torch.Tensor | None negative_prompt_embeds_mask: torch.Tensor | None guidance: torch.Tensor | None = None do_true_cfg: bool = False true_cfg_scale: float = 4.0 cfg_normalize: bool = False image_latents: torch.Tensor | None = None img_shapes: list | None = None txt_seq_lens: list[int] | None = None negative_txt_seq_lens: list[int] | None = None states: Sequence[StepRequestState] = field(default_factory=tuple) # For midway prompt updates (typically for video generation) that changes embeddings without changing ids, # Keep a snapshot of the current per-request versions of prompt embeddings for later runtime comparison _prompt_update_versions: tuple[int, ...] = () def __post_init__(self) -> None: if len(self.request_ids) != int(self.idx_mapping.numel()): raise ValueError("`request_ids` and `idx_mapping` must have the same length.") if self.num_reqs != len(self.request_ids): raise ValueError("`num_reqs` must match the number of request ids.") if self.num_reqs_after_padding < self.num_reqs: raise ValueError("`num_reqs_after_padding` must be >= `num_reqs`.") def _refresh_dynamic_fields( self, selected_states: Sequence[StepRequestState], ) -> None: self.latents = _prepare_latents(selected_states, out=self.latents) self.timesteps = _prepare_timesteps(selected_states, out=self.timesteps) def _refresh_static_fields( self, states: Sequence[StepRequestState], ) -> None: self.do_true_cfg, self.true_cfg_scale, self.cfg_normalize = _prepare_cfg_scalars(states) self.guidance = _prepare_guidance(states, out=self.guidance) self.image_latents = _prepare_image_latents(states, out=self.image_latents) self.prompt_embeds, self.prompt_embeds_mask = _prepare_prompt_embeds( states, embeds_out=self.prompt_embeds, mask_out=self.prompt_embeds_mask, ) ( self.negative_prompt_embeds, self.negative_prompt_embeds_mask, ) = _prepare_negative_prompt_embeds( states, embeds_out=self.negative_prompt_embeds, mask_out=self.negative_prompt_embeds_mask, ) self.img_shapes = _prepare_img_shapes(states) self.txt_seq_lens = _prepare_seq_lens(states, "txt_seq_lens") self.negative_txt_seq_lens = _prepare_seq_lens(states, "negative_txt_seq_lens") self._prompt_update_versions = prompt_update_versions(states) def _repack_dynamic_fields( self, selected_states: Sequence[StepRequestState], ) -> None: # Same-composition cache hits reuse static prompt fields; request ids # must keep the same encoded prompt metadata for the batch lifetime. self.states = tuple(selected_states) self._refresh_dynamic_fields(selected_states) def _rebuild( self, selected_states: Sequence[StepRequestState], idx_mapping: torch.Tensor, idx_mapping_np: np.ndarray, request_ids: list[str], ) -> InputBatch: self.request_ids = request_ids self.num_reqs = len(request_ids) self.num_reqs_after_padding = len(request_ids) self.idx_mapping = idx_mapping self.idx_mapping_np = idx_mapping_np self.states = tuple(selected_states) self.latents = _prepare_latents(selected_states, out=self.latents) self.timesteps = _prepare_timesteps(selected_states, out=self.timesteps) self._refresh_static_fields(selected_states) self.__post_init__() return self @classmethod def make_batch( cls, states: Sequence[StepRequestState], idx_mapping: torch.Tensor | None = None, cached_batch: InputBatch | None = None, ) -> InputBatch: """Build a temporary step-local batch view from request states.""" selected_states, idx_mapping, idx_mapping_np = _select_states(states, idx_mapping) request_ids = _prepare_request_ids(selected_states) if _same_composition(cached_batch, request_ids, idx_mapping_np, selected_states): assert cached_batch is not None cached_batch._repack_dynamic_fields(selected_states) return cached_batch if cached_batch is not None: return cached_batch._rebuild( selected_states, idx_mapping, idx_mapping_np, request_ids, ) prompt_embeds, prompt_embeds_mask = _prepare_prompt_embeds(selected_states) negative_prompt_embeds, negative_prompt_embeds_mask = _prepare_negative_prompt_embeds(selected_states) do_true_cfg, true_cfg_scale, cfg_normalize = _prepare_cfg_scalars(selected_states) return cls( request_ids=request_ids, num_reqs=len(selected_states), num_reqs_after_padding=len(selected_states), idx_mapping=idx_mapping, idx_mapping_np=idx_mapping_np, latents=_prepare_latents(selected_states), timesteps=_prepare_timesteps(selected_states), guidance=_prepare_guidance(selected_states), do_true_cfg=do_true_cfg, true_cfg_scale=true_cfg_scale, cfg_normalize=cfg_normalize, image_latents=_prepare_image_latents(selected_states), prompt_embeds=prompt_embeds, prompt_embeds_mask=prompt_embeds_mask, negative_prompt_embeds=negative_prompt_embeds, negative_prompt_embeds_mask=negative_prompt_embeds_mask, img_shapes=_prepare_img_shapes(selected_states), txt_seq_lens=_prepare_seq_lens(selected_states, "txt_seq_lens"), negative_txt_seq_lens=_prepare_seq_lens( selected_states, "negative_txt_seq_lens", ), states=tuple(selected_states), _prompt_update_versions=prompt_update_versions(selected_states), ) def scatter_latents( states: Sequence[StepRequestState], input_batch: InputBatch, ) -> None: """Scatter the step-updated latents back into persistent request states. This is the CPU fallback of the vLLM-style post-update path. The mapping is driven entirely by ``input_batch.idx_mapping_np`` so the runner remains free to keep request states in its own persistent storage layout. """ _scatter_batch_tensor_by_mapping( states, input_batch.idx_mapping_np, attr_name="latents", value=input_batch.latents, ) # Alias: InputBatch is the step/tensor-level batch. # DiffusionRequestBatch (in request_batch.py) is the request-level batch. StepInputBatch = InputBatch DiffusionInputBatch = StepInputBatch