# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Autoregressive diffusion model: scheduler + transformer."""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any, Generic, cast
import torch
import torch.nn as nn
from torch import Tensor
from typing_extensions import TypeVar
from flashdreams.infra.config import InstantiateConfig
from flashdreams.infra.diffusion.scheduler import Scheduler, SchedulerConfig
from flashdreams.infra.diffusion.transformer import (
Transformer,
TransformerAutoregressiveCache,
TransformerCacheT,
TransformerConfig,
)
# Distinct TypeVar for ``DiffusionModel.FinalState``: nested generics don't
# inherit the enclosing class's TypeVar, and reusing the outer name only
# shadows it (then ty resolves it to the TypeVar's default).
_FinalStateCacheT = TypeVar(
"_FinalStateCacheT",
bound=TransformerAutoregressiveCache,
default=TransformerAutoregressiveCache,
)
[docs]
@dataclass(kw_only=True)
class DiffusionModelConfig(InstantiateConfig):
"""Config for the autoregressive diffusion model."""
_target: type["DiffusionModel"] = field(default_factory=lambda: DiffusionModel)
transformer: TransformerConfig
"""Flow-prediction network config."""
scheduler: SchedulerConfig
"""Denoising-loop config."""
seed: int | None = None
"""RNG seed for initial-noise draws and scheduler sampling.
``None`` uses the global RNG."""
context_noise: int = 0
"""Timestep used by ``finalize`` for the AR cache-update forward.
``0`` skips ``add_noise``."""
noise_in_unpatchified_shape: bool = False
"""Debug-only: draw the initial noise in the unpatchified shape, then
patchify. Slower than the default patchified path; useful when matching
another implementation's RNG sequence."""
[docs]
class DiffusionModel(nn.Module, Generic[TransformerCacheT]):
"""Autoregressive diffusion model (scheduler + transformer).
Generic over the transformer's AR cache type so user-facing typing on
``cache`` is preserved end-to-end.
Examples:
model = config.setup().to("cuda")
cache = model.transformer.initialize_autoregressive_cache(...)
clean, final_state = model.generate(autoregressive_index=0, cache=cache)
model.finalize(final_state)
"""
[docs]
@dataclass(kw_only=True)
class FinalState(Generic[_FinalStateCacheT]):
"""State passed from ``generate`` to ``finalize``."""
clean_latent: Tensor
"""Patchified clean latent at the end of denoising."""
autoregressive_index: int
"""AR step this state was produced at."""
cache: _FinalStateCacheT
"""Long-lived AR cache used during generation."""
input: Any = None
"""Patchified per-AR-step encoder output, or ``None``."""
transformer: Transformer[TransformerCacheT]
scheduler: Scheduler
def __init__(self, config: DiffusionModelConfig) -> None:
super().__init__()
self.config = config
self.transformer = cast(
Transformer[TransformerCacheT], self.config.transformer.setup()
)
self.scheduler = self.config.scheduler.setup()
self._rng: torch.Generator | None = None
@property
def device(self) -> torch.device:
return self.transformer.device
@property
def dtype(self) -> torch.dtype:
return self.transformer.dtype
@property
def rng(self) -> torch.Generator | None:
"""Per-model generator, lazily built on the current device.
Returns ``None`` when ``config.seed`` is ``None``. Rebuilt the first
time the model's device changes after a ``.to(...)``. A device move
resets the RNG stream — fine for "construct on CPU, ``.to(gpu)``
once" but mid-rollout device hops lose RNG state.
"""
if self.config.seed is None:
return None
if self._rng is None or self._rng.device != self.device:
self._rng = torch.Generator(device=self.device).manual_seed(
self.config.seed
)
return self._rng
@property
def latent_shape(self) -> tuple[int, ...]:
return self.transformer.latent_shape
[docs]
def generate(
self,
autoregressive_index: int,
cache: TransformerCacheT,
input: Any = None,
) -> tuple[Tensor, "DiffusionModel.FinalState[TransformerCacheT]"]:
"""Run the denoising loop for one AR step.
Args:
autoregressive_index: AR step index.
cache: Long-lived AR cache, mutated in place.
input: Optional per-AR-step encoder output. Patchified here and
forwarded to ``predict_flow`` / ``postprocess_clean_latent``,
then stashed on the returned ``FinalState`` for ``finalize``.
Returns:
``(clean_latent, final_state)``. ``clean_latent`` is unpatchified;
``final_state`` should be passed to ``finalize``.
"""
if input is not None:
input = self.transformer.patchify_and_maybe_split_cp(input)
cache.start(autoregressive_index)
if self.config.noise_in_unpatchified_shape:
# Draw noise at the unpatchified shape so the RNG sequence matches
# implementations that sample before patchify; ``self.latent_shape``
# is patchified, so go through ``unpatchify_and_maybe_gather_cp``
# first to recover the pixel-side latent shape.
dummy_latent = torch.empty(
self.latent_shape, device=self.device, dtype=self.dtype
)
dummy_latent = self.transformer.unpatchify_and_maybe_gather_cp(dummy_latent)
initial_noise = torch.randn(
dummy_latent.shape,
device=self.device,
dtype=self.dtype,
generator=self.rng,
)
else:
initial_noise = torch.randn(
self.latent_shape,
device=self.device,
dtype=self.dtype,
generator=self.rng,
)
def predict_flow(noisy_latent: Tensor, timestep: Tensor) -> Tensor:
# Round-trip patchify/unpatchify around the network when the
# scheduler is operating on unpatchified latents; the transformer
# itself always consumes/emits patchified shapes.
if self.config.noise_in_unpatchified_shape:
noisy_latent = self.transformer.patchify_and_maybe_split_cp(
noisy_latent
)
output = self.transformer.predict_flow(
noisy_latent=noisy_latent,
timestep=timestep,
cache=cache,
input=input,
)
if self.config.noise_in_unpatchified_shape:
output = self.transformer.unpatchify_and_maybe_gather_cp(output)
return output
clean_latent = self.scheduler.sample(
initial_noise=initial_noise,
predict_flow=predict_flow,
rng=self.rng,
)
if self.config.noise_in_unpatchified_shape:
# Scheduler emitted an unpatchified latent; patchify before
# ``postprocess_clean_latent`` and the cache-update path.
clean_latent = self.transformer.patchify_and_maybe_split_cp(clean_latent)
clean_latent = self.transformer.postprocess_clean_latent(
clean_latent=clean_latent,
cache=cache,
input=input,
)
# Postpone KV cache update to the finalization step. No runtime
# subscript: ``_FinalStateCacheT`` is bound from ``cache``'s type.
final_state = DiffusionModel.FinalState(
clean_latent=clean_latent,
autoregressive_index=autoregressive_index,
cache=cache,
input=input,
)
clean_latent = self.transformer.unpatchify_and_maybe_gather_cp(clean_latent)
return clean_latent, final_state
[docs]
def finalize(
self,
final_state: "DiffusionModel.FinalState[TransformerCacheT]",
) -> None:
"""Advance the AR cache using the clean latent from ``generate``.
Re-noises the clean latent to ``config.context_noise`` and runs the
transformer's ``finalize_kv_cache`` (one forward for vanilla
transformers, multiple for dual-network DiTs).
``context_noise == 0`` skips ``add_noise`` (sigma=0 is identity) and
feeds the clean latent directly. This also dodges the requirement
for schedulers to support a ``t=0`` lookup (UniPC's inference
schedule has no ``t=0`` entry).
"""
context_noise = self.config.context_noise
timestep = torch.tensor(context_noise, device=self.device, dtype=self.dtype)
if context_noise > 0.0:
clean_latent = final_state.clean_latent
# Round-trip patchify/unpatchify around ``add_noise`` so the
# scheduler sees the same shape as in ``generate``; the cache
# update below always consumes a patchified latent.
if self.config.noise_in_unpatchified_shape:
clean_latent = self.transformer.unpatchify_and_maybe_gather_cp(
final_state.clean_latent
)
noisy_latent = self.scheduler.add_noise(
clean_input=clean_latent,
timestep=timestep,
rng=self.rng,
)
if self.config.noise_in_unpatchified_shape:
noisy_latent = self.transformer.patchify_and_maybe_split_cp(
noisy_latent
)
else:
noisy_latent = final_state.clean_latent
self.transformer.finalize_kv_cache(
noisy_latent=noisy_latent,
timestep=timestep,
cache=final_state.cache,
input=final_state.input,
)
final_state.cache.finalize(final_state.autoregressive_index)