Files
nvlabs--sana/diffusion/scheduler/self_forcing_flow_euler_sampler.py
2026-07-13 13:09:03 +08:00

844 lines
36 KiB
Python

# Copyright 2024 NVIDIA CORPORATION & AFFILIATES
#
# 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.
#
# SPDX-License-Identifier: Apache-2.0
"""Self-forcing flow Euler samplers for chunk-causal autoregressive video.
This module provides the streaming Sana-WM inference samplers:
* ``SelfForcingFlowEuler`` is the base chunk-causal autoregressive sampler.
It walks ``base_chunk_frames``-sized chunks left-to-right, denoising each
chunk against a KV cache accumulated from previously generated chunks.
* ``SelfForcingFlowEulerCamCtrl`` extends the base sampler with the camera
conditioning extras (``camera_conditions``, ``chunk_plucker``, etc.),
first-frame conditioning, and the 10-slot dual-mode (state / concat) KV
cache layout used by the camctrl ``forward_long`` path. This is the
sampler used by the end-to-end streaming Sana-WM + LTX-2 refiner.
"""
from __future__ import annotations
import importlib
import os
import sys
import torch
# Diffusers ships with a hard ``import flash_attn`` in some attention backends
# that raises before ``flash_attn_interface`` (FA4) is considered. We
# temporarily hide the installed ``flash_attn`` module so diffusers takes the
# FA-not-installed branch, then restore it so downstream code can still use FA.
_fa_spec = importlib.util.find_spec("flash_attn")
_has_fa = _fa_spec is not None
_real_fa_module = None
if _has_fa:
_real_fa_module = sys.modules.get("flash_attn")
sys.modules["flash_attn"] = None
try:
from diffusers import FlowMatchEulerDiscreteScheduler
from diffusers.models.modeling_outputs import Transformer2DModelOutput
from diffusers.pipelines.stable_diffusion_3.pipeline_stable_diffusion_3 import retrieve_timesteps
finally:
if _has_fa:
if _real_fa_module is not None:
sys.modules["flash_attn"] = _real_fa_module
else:
del sys.modules["flash_attn"]
from tqdm import tqdm
from diffusion.model.nets.basic_modules import CachedGLUMBConvTemp
from diffusion.model.nets.sana_blocks import CachedCausalAttention
# ---------------------------------------------------------------------------
# Shared helpers
# ---------------------------------------------------------------------------
def _inject_sliced_extras(
extra: dict[str, object],
kwargs: dict,
num_chunk_frames: int,
end_f: int,
) -> None:
"""Inject ``extra`` kwargs into ``kwargs``, slicing temporal dims.
Tensors whose temporal axis is longer than ``num_chunk_frames`` are sliced
to ``[end_f - num_chunk_frames, end_f)``. Layouts handled:
* ``(B, C, T, H, W)`` — e.g. ``chunk_plucker``; sliced on dim 2.
* ``(B, T, ...)`` — e.g. ``camera_conditions``; sliced on dim 1.
Any key already present in ``kwargs`` is left untouched.
"""
begin_f = end_f - num_chunk_frames
for k, v in extra.items():
if k in kwargs:
continue
if isinstance(v, torch.Tensor):
if v.ndim == 5:
kwargs[k] = v[:, :, begin_f:end_f] if v.shape[2] > num_chunk_frames else v
elif v.ndim >= 3 and v.shape[1] > num_chunk_frames:
kwargs[k] = v[:, begin_f:end_f]
else:
kwargs[k] = v
else:
kwargs[k] = v
def _pop_extra_model_kwargs(model_kwargs: dict) -> dict:
"""Pop all keys from ``model_kwargs`` except ``mask`` and ``data_info``.
The popped entries are the "extras" (camera tensors, RoPE caches, etc.)
that need per-chunk temporal slicing before being forwarded to the model.
"""
extra: dict = {}
for key in list(model_kwargs):
if key not in ("mask", "data_info"):
extra[key] = model_kwargs.pop(key)
return extra
# ---------------------------------------------------------------------------
# Cache-slot index constants
# ---------------------------------------------------------------------------
#
# The camctrl ``forward_long`` path uses a 10-slot KV cache per block. Two
# layouts share the slot table, distinguished by slot 6 (``_SLOT_TYPE_FLAG``):
#
# Old layout (ChunkCausalGDN / ChunkCausalSoftmaxAttn):
# 0: k, 1: v, 2: beta, 3: decay, 4: shortconv, 5: tconv, 6-9: None
#
# New layout (CachedChunkCausalGDN / CachedChunkCausalSoftmaxAttn):
# GDN: 0: S_kv state, 1: S_z state, 2: cam_S_kv state, 3: cam K-conv state
# Softmax: 0: k post-RoPE, 1: v, 2: cam_k post-UCPE, 3: cam_v post-UCPE
# Both: 4: shortconv, 5: tconv, 6: type flag (1.0=state, 0.0=concat),
# 9: FFN tconv state written by ``CachedGLUMBConvTemp``.
_NUM_CACHE_SLOTS = 10 # 7 active + 3 reserved
_SLOT_K = 0
_SLOT_V = 1
_SLOT_BETA = 2
_SLOT_DECAY = 3
_SLOT_SHORTCONV = 4
_SLOT_TCONV = 5
_SLOT_TYPE_FLAG = 6
# Old-layout concat slots (when type flag is absent).
_CONCAT_SLOTS = (_SLOT_K, _SLOT_V, _SLOT_BETA, _SLOT_DECAY)
# New-layout softmax concat slots (when type flag == 0.0).
_SOFTMAX_CONCAT_SLOTS = (0, 1, 2, 3)
_LAST_CHUNK_SLOTS = (_SLOT_SHORTCONV, _SLOT_TCONV)
# ---------------------------------------------------------------------------
# Base self-forcing sampler
# ---------------------------------------------------------------------------
class SelfForcingFlowEuler:
"""Chunk-causal autoregressive flow-Euler sampler with KV cache support.
Walks ``base_chunk_frames``-sized chunks left-to-right. For each chunk the
sampler runs the diffusion schedule, then performs one extra ``t = 0``
forward with ``save_kv_cache=True`` to write the KV cache that subsequent
chunks consume. This implements the "self-forcing" recipe where every
chunk is conditioned on the model's own previously-generated context.
"""
def __init__(
self,
model_fn: object,
condition: torch.Tensor,
uncondition: torch.Tensor,
cfg_scale: float,
flow_shift: float = 3.0,
model_kwargs: dict | None = None,
base_chunk_frames: int = 10,
num_cached_blocks: int = -1,
**kwargs: object,
) -> None:
self.model = model_fn
self.condition = condition
self.uncondition = uncondition
self.cfg_scale = cfg_scale
self.model_kwargs = model_kwargs or {}
self.mask = self.model_kwargs.pop("mask", None)
self.flow_shift = flow_shift
self.base_chunk_frames = base_chunk_frames
self.rank = os.environ.get("RANK", 0)
self.cached_modules = None
# Populate ``self.cached_modules`` and ``self.num_model_blocks``.
self.get_cached_modules_by_block()
self.num_cached_blocks = num_cached_blocks
self.use_softmax_attention = kwargs.get("use_softmax_attention", False)
self.sink_token = kwargs.get("sink_token", False)
def create_autoregressive_segments(self, total_frames: int) -> list[int]:
"""Build chunk boundaries for an autoregressive sweep.
Returns a list of frame indices ``[0, c1, c2, ..., total_frames]`` of
length ``num_chunks + 1`` such that chunk ``i`` covers frames
``[chunk_indices[i], chunk_indices[i + 1])``. The first chunk absorbs
any remainder so subsequent chunks all have exactly
``base_chunk_frames`` frames.
"""
remained_frames = total_frames % self.base_chunk_frames
num_chunks = total_frames // self.base_chunk_frames
chunk_indices = [0]
for i in range(num_chunks):
cur_idx = chunk_indices[-1] + self.base_chunk_frames
if i == 0:
cur_idx += remained_frames
chunk_indices.append(cur_idx)
return chunk_indices
def get_cached_modules_by_block(self) -> list[list[torch.nn.Module]]:
"""Locate ``CachedCausalAttention`` and ``CachedGLUMBConvTemp`` modules.
The result is a list (one entry per transformer block) of the cached
modules inside that block. ``self.num_model_blocks`` is set as a side
effect.
"""
if self.cached_modules is not None:
return self.cached_modules
# Unwrap DDP if present.
model = self.model.module if hasattr(self.model, "module") else self.model
cached_modules: list[list[torch.nn.Module]] = []
def collect_from_block(block: torch.nn.Module, block_idx: int) -> list[torch.nn.Module]:
attention_modules: list[torch.nn.Module] = []
conv_modules: list[torch.nn.Module] = []
def collect_recursive(module: torch.nn.Module) -> None:
if isinstance(module, CachedCausalAttention):
attention_modules.append(module)
elif isinstance(module, CachedGLUMBConvTemp):
conv_modules.append(module)
for child in module.children():
collect_recursive(child)
collect_recursive(block)
return attention_modules + conv_modules
if hasattr(model, "blocks"):
blocks = model.blocks
elif hasattr(model, "transformer_blocks"):
blocks = model.transformer_blocks
elif hasattr(model, "layers"):
blocks = model.layers
else:
raise ValueError("Model does not have any blocks")
self.num_model_blocks = len(blocks)
for block_idx, block in enumerate(blocks):
block_modules = collect_from_block(block, block_idx)
cached_modules.append(block_modules)
self.cached_modules = cached_modules
return cached_modules
# NOTE: SelfForcingFlowEulerCamCtrl overrides ``_initialize_kv_cache``,
# ``_accumulate_softmax_kv_cache``, ``accumulate_kv_cache`` and ``sample``
# with the 10-slot dual-mode (state / concat) cache layout used by the
# camctrl ``forward_long`` path. The base class keeps ``__init__``,
# ``create_autoregressive_segments`` and ``get_cached_modules_by_block``
# only — the inherited entry points for CamCtrl. The non-camctrl
# streaming path (6-slot softmax + 3-slot linear caches) is intentionally
# not shipped in this repo.
# ---------------------------------------------------------------------------
# CamCtrl self-forcing sampler
# ---------------------------------------------------------------------------
class SelfForcingFlowEulerCamCtrl(SelfForcingFlowEuler):
"""SelfForcingFlowEuler with camera conditioning and first-frame anchoring.
Wraps ``SelfForcingFlowEuler`` to support the camctrl ``forward_long`` API
used by the streaming Sana-WM pipeline:
* Camera tensors (``camera_conditions``, ``chunk_plucker``, etc.) are
popped from ``model_kwargs`` at init and injected into each model call
sliced to the current temporal window.
* The KV cache uses the 10-slot layout with a dual-mode (state / concat)
type flag at slot 6, and supports a "sink" chunk anchored at the start
of the sequence when the sliding window has scrolled past chunk 0.
* ``condition_frame_info`` in ``data_info`` (e.g. ``{0: 0.0}``) marks
frames that should be treated as fully clean and restored after every
denoising step so the per-token scheduler cannot corrupt them.
"""
def __init__(
self,
model_fn: object,
condition: torch.Tensor,
uncondition: torch.Tensor,
cfg_scale: float,
model_kwargs: dict | None = None,
**kw: object,
) -> None:
model_kwargs = model_kwargs or {}
self._extra_model_kwargs = _pop_extra_model_kwargs(model_kwargs)
super().__init__(
model_fn,
condition,
uncondition,
cfg_scale,
model_kwargs=model_kwargs,
**kw,
)
self._patch_model()
# ------------------------------------------------------------------
# Camera tensor slicing
# ------------------------------------------------------------------
def _patch_model(self) -> None:
"""Monkey-patch ``model.forward_long`` to inject sliced camera tensors."""
extra = self._extra_model_kwargs
orig_forward_long = self.model.forward_long
# Keys that ``forward_long`` recomputes or doesn't need:
# - ``pos_embeds``: RoPE is recomputed from (start_f, end_f) or frame_index.
# - ``cam_pos_embeds``: dict of full-sequence tensors; forward_long
# recomputes from sliced camera_conditions instead.
# - ``chunk_index``: not used in KV-cache mode (blocks check kv_cache).
# - ``frame_index``: forwarded explicitly (not via _inject_sliced_extras).
_SKIP_FOR_FORWARD_LONG = frozenset({"pos_embeds", "chunk_index", "cam_pos_embeds", "frame_index"})
def _forward_long_with_extras(
x: torch.Tensor,
timestep: torch.Tensor,
y: torch.Tensor,
mask: torch.Tensor | None = None,
**kwargs: object,
) -> object:
end_f = kwargs.get("end_f", x.shape[2])
num_chunk_frames = x.shape[2]
filtered_extra = {k: v for k, v in extra.items() if k not in _SKIP_FOR_FORWARD_LONG}
_inject_sliced_extras(filtered_extra, kwargs, num_chunk_frames, end_f)
return orig_forward_long(x, timestep, y, mask=mask, **kwargs)
self.model.forward_long = _forward_long_with_extras
# ------------------------------------------------------------------
# Sample (override for first-frame conditioning + distilled schedules)
# ------------------------------------------------------------------
@torch.no_grad()
def sample(
self,
latents: torch.Tensor,
steps: int = 50,
generator: torch.Generator | None = None,
*,
denoising_step_list: list[int] | None = None,
**kwargs: object,
) -> torch.Tensor:
"""Sample with first-frame conditioning support.
Reads ``condition_frame_info`` from ``data_info`` (e.g. ``{0: 0.0}``
means frame 0 is fully clean). For each conditioned frame that falls
inside the current chunk, its latent is restored after every denoising
step so the scheduler cannot corrupt it.
Args:
latents: Initial latent tensor of shape ``(B, C, T, H, W)``.
steps: Number of denoising steps per chunk. Ignored when
``denoising_step_list`` is supplied.
generator: Optional torch generator (currently unused; the
scheduler is deterministic given the noise input).
denoising_step_list: Optional explicit student timestep schedule
(e.g. ``[1000, 967, 908, 764, 0]``). MUST end with 0. When
provided, ``steps`` is ignored and the
``FlowMatchEulerDiscreteScheduler`` is set up with these
exact sigmas (no shift re-applied — the schedule is taken
verbatim, so it should already incorporate the teacher's
``flow_shift``). Use this for distilled students that were
trained on a fixed subsampled subset of teacher timesteps.
Returns:
Denoised latent tensor with the same shape as ``latents``.
"""
for _ in self.sample_chunks(
latents,
steps=steps,
generator=generator,
denoising_step_list=denoising_step_list,
**kwargs,
):
pass
return latents
@torch.no_grad()
def sample_chunks(
self,
latents: torch.Tensor,
steps: int = 50,
generator: torch.Generator | None = None,
*,
denoising_step_list: list[int] | None = None,
**kwargs: object,
):
"""Streaming variant of :meth:`sample` — yields one chunk at a time.
After each AR chunk completes (denoising + KV-cache save pass), yields
a tuple ``(chunk_idx, latent_chunk_view, start_f, end_f)`` where
``latent_chunk_view`` is a *view* into the in-place-mutated ``latents``
tensor for the just-finished chunk. The view stays valid for the
remainder of inference (subsequent chunks never overwrite earlier
frames), so the orchestrator may launch downstream work on a separate
CUDA stream and continue pulling chunks without copying.
``sample(latents, ...)`` is implemented as ``for _ in sample_chunks(...)``
and returns ``latents`` after exhaustion, so the legacy whole-volume
API is preserved.
"""
# Resolve scheduler factory once (a fresh instance is built per chunk).
if denoising_step_list is not None:
if len(denoising_step_list) < 2 or denoising_step_list[-1] != 0:
raise ValueError(
"denoising_step_list must have >=2 entries and end with 0; " f"got {denoising_step_list}"
)
# Drop trailing 0; FlowMatchEulerDiscreteScheduler auto-appends sigma=0.
# ``shift=1.0`` keeps our explicit sigmas verbatim (no second shift).
_explicit_sigmas = [float(t) / 1000.0 for t in denoising_step_list[:-1]]
else:
_explicit_sigmas = None
device = self.condition.device
do_classifier_free_guidance = self.cfg_scale > 1
batch_size, num_latent_channels, total_frames, height, width = latents.shape
self.total_frames = total_frames
if total_frames <= self.base_chunk_frames:
raise ValueError("Please use FlowEuler for short videos")
chunk_indices = self.create_autoregressive_segments(total_frames)
self._chunk_indices = chunk_indices
num_chunks = len(chunk_indices) - 1
kv_cache = self._initialize_kv_cache(num_chunks)
kv_save_stride = int(os.environ.get("SANA_WM_STAGE1_KV_SAVE_STRIDE", "1"))
if kv_save_stride < 0:
raise ValueError("SANA_WM_STAGE1_KV_SAVE_STRIDE must be >= 0.")
assert self.condition.shape[0] == batch_size or self.condition.shape[0] == num_chunks
if self.condition.shape[0] == batch_size:
self.condition = self.condition.repeat_interleave(num_chunks, dim=0)
self.mask = self.mask[None].repeat_interleave(num_chunks, dim=0) if self.mask is not None else None
# -- First-frame conditioning --
data_info = self.model_kwargs.pop("data_info", {})
condition_frame_info = data_info.pop("condition_frame_info", {})
# Save a clean copy of conditioned frame latents.
init_latents = latents.clone()
image_vae_embeds = data_info.get("image_vae_embeds", None)
# Build the scheduler once (sigmas / shift don't change per chunk).
if _explicit_sigmas is not None:
_shared_scheduler = FlowMatchEulerDiscreteScheduler(shift=1.0)
_shared_scheduler.set_timesteps(sigmas=_explicit_sigmas, device=device)
_shared_timesteps = _shared_scheduler.timesteps
_shared_num_steps = len(_shared_timesteps)
else:
_shared_scheduler = FlowMatchEulerDiscreteScheduler(shift=self.flow_shift)
_shared_timesteps, _shared_num_steps = retrieve_timesteps(_shared_scheduler, steps, device, None)
for chunk_idx in range(num_chunks):
(
chunk_kv_cache,
num_chunks_to_accumulate,
sink_num,
num_cached_frames,
) = self.accumulate_kv_cache(kv_cache, chunk_idx)
prompt_embeds = self.condition[chunk_idx].unsqueeze(0)
if do_classifier_free_guidance:
prompt_embeds = torch.cat([self.uncondition, prompt_embeds], dim=0)
mask = self.mask[chunk_idx] if self.mask is not None else None
# Reuse the scheduler built outside the chunk loop; re-set
# timesteps to reset its internal ``_step_index`` to zero.
self.scheduler = _shared_scheduler
if _explicit_sigmas is not None:
self.scheduler.set_timesteps(sigmas=_explicit_sigmas, device=device)
timesteps = self.scheduler.timesteps
num_inference_steps = _shared_num_steps
else:
timesteps, num_inference_steps = retrieve_timesteps(self.scheduler, steps, device, None)
start_f = chunk_indices[chunk_idx]
end_f = chunk_indices[chunk_idx + 1]
end_f - start_f
max(chunk_idx - self.num_cached_blocks, 0) if self.num_cached_blocks > 0 else 0
# Build frame_index for the current chunk's RoPE positions.
#
# In camctrl, ``CachedChunkCausalSoftmaxAttn`` caches POST-RoPE
# K/V (each chunk keeps its own absolute positions). When attending
# over [sink + window + current], cached tokens already have the
# correct rope baked in — only the CURRENT chunk's Q/K need
# rotation, which uses positions ``[start_f, end_f)``. So
# frame_index here describes only the current chunk; sink_token
# affects cache contents but not the per-chunk rope shape.
#
# We still pass frame_index (instead of just relying on
# start_f/end_f) so future experiments can override per-frame
# positions for the current chunk without changing this
# scaffolding.
frame_index: torch.Tensor | None = None
rope_start_f = start_f
rope_end_f = end_f
if sink_num > 0:
frame_index = torch.arange(start_f, end_f, device=device, dtype=torch.long)
# Shallow copy — we only need to override image_vae_embeds per chunk.
local_data_info = dict(data_info)
if image_vae_embeds is not None:
local_data_info["image_vae_embeds"] = image_vae_embeds[:, :, start_f:end_f]
# Identify conditioned frames inside this chunk (local indices).
chunk_frames = end_f - start_f
cond_local_indices: list[int] = []
for frame_idx in condition_frame_info:
if start_f <= frame_idx < end_f:
cond_local_indices.append(frame_idx - start_f)
# Build a per-frame mask instead of a full (B,C,F,H,W) tensor.
# The model consumes frame-level timesteps and the scheduler uses
# the same frame value broadcast over spatial tokens.
condition_frame_mask = None
if cond_local_indices:
condition_frame_mask = torch.zeros(
batch_size,
chunk_frames,
device=device,
dtype=torch.float32,
)
for loc in cond_local_indices:
condition_frame_mask[:, loc] = 1.0
spatial_tokens = height * width
for i, t in tqdm(
list(enumerate(timesteps)),
disable=os.getenv("DPM_TQDM", "False") == "True",
desc=f"Processing chunk {chunk_idx}",
):
latent_model_input = (
torch.cat([latents[:, :, start_f:end_f]] * 2)
if do_classifier_free_guidance
else latents[:, :, start_f:end_f]
)
# Keep the timestep on device without `.item()` syncs, but
# avoid materialising a full channel x spatial mask.
t_dev = t.to(device=device, dtype=torch.float32).reshape(1)
if condition_frame_mask is None:
timestep_frames = t_dev.expand(batch_size, chunk_frames)
per_token_timesteps = t_dev.expand(batch_size, chunk_frames * spatial_tokens)
else:
timestep_frames = (1.0 - condition_frame_mask) * t_dev
per_token_timesteps = (
timestep_frames[:, :, None]
.expand(
batch_size,
chunk_frames,
spatial_tokens,
)
.reshape(batch_size, -1)
)
timestep_tensor_model = timestep_frames[:, None, :]
if do_classifier_free_guidance:
timestep_tensor_model = torch.cat([timestep_tensor_model, timestep_tensor_model], dim=0)
noise_pred, _ = self.model(
latent_model_input,
timestep_tensor_model,
prompt_embeds,
start_f=rope_start_f,
end_f=rope_end_f,
frame_index=frame_index,
save_kv_cache=False,
kv_cache=chunk_kv_cache,
mask=mask,
data_info=local_data_info,
**self.model_kwargs,
)
if isinstance(noise_pred, Transformer2DModelOutput):
noise_pred = noise_pred[0]
if do_classifier_free_guidance:
noise_pred_uncond, noise_pred_text = noise_pred.chunk(2)
noise_pred = noise_pred_uncond + self.cfg_scale * (noise_pred_text - noise_pred_uncond)
# Per-token scheduler step with ``per_token_timesteps`` (same
# reshape convention as ChunkFlowEuler).
latents_dtype = latents.dtype
chunk_latents_cur = latents[:, :, start_f:end_f]
chunk_shape = chunk_latents_cur.shape
denoised = self.scheduler.step(
-noise_pred.reshape(
batch_size,
num_latent_channels,
-1,
).transpose(1, 2),
t,
chunk_latents_cur.reshape(
batch_size,
num_latent_channels,
-1,
).transpose(1, 2),
per_token_timesteps=per_token_timesteps,
return_dict=False,
)[0]
denoised = denoised.transpose(1, 2).reshape(chunk_shape)
latents[:, :, start_f:end_f] = denoised
# Safety: explicitly restore conditioned frames in case of
# numerical drift from the per-token scheduler step.
for loc in cond_local_indices:
latents[:, :, start_f + loc] = init_latents[:, :, start_f + loc]
if latents.dtype != latents_dtype:
latents = latents.to(latents_dtype)
# KV cache save pass — populates the chunk's clean-sigma K/V for
# future chunks' self-attention. A stride >1 is an experimental
# Stage-1-only approximation; stage 2 still refines every chunk.
do_kv_save = kv_save_stride == 1 or (kv_save_stride > 1 and chunk_idx % kv_save_stride == 0)
if kv_save_stride == 0:
do_kv_save = bool(self.sink_token and chunk_idx == 0)
if do_kv_save:
latent_model_input = (
torch.cat([latents[:, :, start_f:end_f]] * 2)
if do_classifier_free_guidance
else latents[:, :, start_f:end_f]
)
timestep = torch.zeros(latent_model_input.shape[0], device=device)
noise_pred, updated_kv_cache = self.model(
latent_model_input,
timestep,
prompt_embeds,
start_f=rope_start_f,
end_f=rope_end_f,
frame_index=frame_index,
save_kv_cache=True,
kv_cache=chunk_kv_cache,
mask=mask,
data_info=local_data_info,
**self.model_kwargs,
)
kv_cache[chunk_idx] = updated_kv_cache
else:
kv_cache[chunk_idx] = [[None] * _NUM_CACHE_SLOTS for _ in range(self.num_model_blocks)]
yield chunk_idx, latents[:, :, start_f:end_f], start_f, end_f
# ------------------------------------------------------------------
# KV cache management
# ------------------------------------------------------------------
def _initialize_kv_cache(self, num_chunks: int) -> list[list[list[torch.Tensor | None]]]:
"""Create empty 10-slot cache: ``kv_cache[chunk][block] = [None]*10``."""
return [[[None] * _NUM_CACHE_SLOTS for _ in range(self.num_model_blocks)] for _ in range(num_chunks)]
def accumulate_kv_cache(self, kv_cache: list, chunk_idx: int):
"""Override parent dispatcher to always use 10-slot cache logic."""
return self._accumulate_softmax_kv_cache(kv_cache, chunk_idx)
def _accumulate_softmax_kv_cache(
self,
kv_cache: list,
chunk_idx: int,
) -> tuple[list, int, int, int]:
"""Accumulate KV cache for chunk ``chunk_idx``.
Two cache layouts are supported, distinguished by slot 6 (type flag):
* **State-based** (type flag == 1.0, GDN blocks): slots 0-3 hold
recurrent states from the last chunk — no concatenation needed.
``sink_token`` has no effect here: state already encodes full
history.
* **Concat-based** (type flag == 0.0, Softmax blocks): slots 0-3 hold
K/V tensors concatenated across cached chunks. When
``self.sink_token`` is set and the sliding window has scrolled past
chunk 0, chunk 0 is always retained at the front (sink anchor).
* **Legacy** (type flag absent): falls back to the old concat logic
using beta/decay detection. Sink behavior mirrors the softmax path.
Slot 4 (shortconv) always comes from the preceding chunk.
Slot 9 (``kv_cache[-1]``) holds the FFN tconv state written by
``CachedGLUMBConvTemp`` and comes from the preceding chunk.
Returns:
``(cur_kv_cache, num_chunks_accumulated, sink_num,
num_cached_frames)``.
"""
if chunk_idx == 0:
return kv_cache[0], 0, 0, 0
cur_kv_cache = kv_cache[chunk_idx]
# Clamp to >= 0: when ``chunk_idx < num_cached_blocks`` the window has
# not yet slid past chunk 0, so the effective start is 0.
start_chunk_idx = max(chunk_idx - self.num_cached_blocks, 0) if self.num_cached_blocks > 0 else 0
# Sink-aware iteration order: when ``num_cached_blocks`` slid past
# chunk 0, prepend chunk 0 as a permanent anchor.
sink_num = 0
valid_cached_chunks = list(range(start_chunk_idx, chunk_idx))
if self.sink_token and self.num_cached_blocks > 0:
s = max(chunk_idx - self.num_cached_blocks + 1, 0)
if s > 0:
valid_cached_chunks = [0] + list(range(s, chunk_idx))
sink_num = self._chunk_indices[1] - self._chunk_indices[0]
valid_cached_chunks = [
i
for i in valid_cached_chunks
if kv_cache[i][0][_SLOT_K] is not None or kv_cache[i][0][_SLOT_TYPE_FLAG] is not None
]
# Count cached frames in latent units (independent of patch_size). The
# sampler builds frame_index in latent units so this stays consistent.
num_cached_frames = sum(self._chunk_indices[i + 1] - self._chunk_indices[i] for i in valid_cached_chunks)
prev_cache_idx = valid_cached_chunks[-1] if valid_cached_chunks else chunk_idx
for block_id in range(self.num_model_blocks):
prev_last = kv_cache[prev_cache_idx][block_id]
# Detect cache layout from type flag (slot 6).
type_flag = prev_last[_SLOT_TYPE_FLAG] if prev_last[_SLOT_TYPE_FLAG] is not None else None
type_flag_value = None
if type_flag is not None:
type_flag_value = float(type_flag.item()) if isinstance(type_flag, torch.Tensor) else float(type_flag)
if type_flag_value is not None and type_flag_value > 0.5:
# --- State-based (GDN): last chunk's state is the full history ---
# NOTE: ``CachedGLUMBConvTemp`` writes tconv state to
# ``kv_cache[-1]`` (slot 9), not ``_SLOT_TCONV`` (slot 5). We
# must read from [-1] and place into [-1] so the MLP finds it
# on the next chunk.
cur_kv_cache[block_id] = [
prev_last[0], # S_kv state (or accumulated softmax k)
prev_last[1], # S_z state (or accumulated softmax v)
prev_last[2], # cam_S_kv state
prev_last[3], # camera K ShortConv state
prev_last[_SLOT_SHORTCONV], # ShortConv state
None, # (slot 5 unused)
prev_last[_SLOT_TYPE_FLAG], # type flag
None,
None,
prev_last[-1], # FFN tconv state (slot 9)
]
elif type_flag_value is not None:
# --- Concat-based (Softmax): concatenate K/V across chunks ---
acc: list[torch.Tensor | None] = [None] * _NUM_CACHE_SLOTS
for i in valid_cached_chunks:
prev = kv_cache[i][block_id]
if prev[0] is None:
continue
for s in _SOFTMAX_CONCAT_SLOTS:
if prev[s] is None:
continue
# Softmax K/V are (B, H, N, D) — concat along dim 2.
if acc[s] is None:
acc[s] = prev[s]
else:
acc[s] = torch.cat([acc[s], prev[s]], dim=2)
cur_kv_cache[block_id] = [
acc[0], # accumulated k
acc[1], # accumulated v
acc[2], # accumulated cam_k
acc[3], # accumulated cam_v
prev_last[_SLOT_SHORTCONV], # ShortConv state (last chunk)
None, # (slot 5 unused)
prev_last[_SLOT_TYPE_FLAG], # type flag
None,
None,
prev_last[-1], # FFN tconv state (slot 9)
]
else:
# --- Legacy layout (no type flag): old concat logic ---
acc_legacy: list[torch.Tensor | None] = [None] * _NUM_CACHE_SLOTS
for i in valid_cached_chunks:
prev = kv_cache[i][block_id]
if prev[_SLOT_K] is None:
continue
is_gdn = prev[_SLOT_BETA] is not None
kv_cat_dim = -1 if is_gdn else 2
for s in _CONCAT_SLOTS:
if prev[s] is None:
continue
if s in (_SLOT_K, _SLOT_V):
cat_dim = kv_cat_dim
elif s == _SLOT_BETA:
cat_dim = 2
else:
cat_dim = -1
if acc_legacy[s] is None:
acc_legacy[s] = prev[s]
else:
acc_legacy[s] = torch.cat([acc_legacy[s], prev[s]], dim=cat_dim)
cur_kv_cache[block_id] = [
acc_legacy[_SLOT_K],
acc_legacy[_SLOT_V],
acc_legacy[_SLOT_BETA],
acc_legacy[_SLOT_DECAY],
prev_last[_SLOT_SHORTCONV],
None, # (slot 5 unused)
None,
None,
None,
prev_last[-1], # FFN tconv state (slot 9)
]
# Evict cached chunks outside the (possibly sink-augmented) window.
if self.num_cached_blocks > 0:
kept = set(valid_cached_chunks)
for i in range(chunk_idx):
if i not in kept:
kv_cache[i][block_id] = [None] * _NUM_CACHE_SLOTS
return (
cur_kv_cache,
chunk_idx - start_chunk_idx,
sink_num,
num_cached_frames,
)