Files
nvlabs--longlive/pipeline/causal_diffusion_inference.py
2026-07-13 12:31:40 +08:00

1046 lines
50 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Adopted from https://github.com/guandeh17/Self-Forcing
# SPDX-License-Identifier: Apache-2.0
from tqdm import tqdm
from typing import List, Optional
import os
import statistics
import threading
import torch
import math
_LLV2_TIME = os.environ.get("LLV2_TIME") == "1"
_LLV2_DUMP_LATENT_DIR = os.environ.get("LLV2_DUMP_LATENT_DIR", "").strip()
# LLV2_PROFILE format: "<call_idx>:<wait>:<warmup>:<active>", e.g. "0:20:2:2"
_LLV2_PROFILE_SPEC = os.environ.get("LLV2_PROFILE", "").strip()
_LLV2_PROFILE_OUTPUT_DIR = os.environ.get("LLV2_PROFILE_OUTPUT_DIR", "").strip()
_LLV2_PROFILE_CALL_COUNTER = 0
from wan_5b.utils.fm_solvers import FlowDPMSolverMultistepScheduler, get_sampling_sigmas, retrieve_timesteps
from wan_5b.utils.fm_solvers_unipc import FlowUniPCMultistepScheduler
from utils.wan_5b_wrapper import WanDiffusionWrapper, WanTextEncoder, build_vae_5b
from utils.dataset import DEFAULT_SCENE_CUT_PREFIX
from utils.config import section_get, wan_default_config
from utils.i2v_conditioning import (
_overwrite_i2v_context,
_zero_i2v_context_timestep,
)
class CausalDiffusionInferencePipeline(torch.nn.Module):
def __init__(
self,
args,
device,
generator=None,
text_encoder=None,
vae=None
):
super().__init__()
# Step 1: Initialize all models
model_name = getattr(args.model_kwargs, "model_name", "Wan2.2-TI2V-5B")
if "5B" not in model_name:
raise ValueError(f"Only Wan2.2-TI2V-5B is supported in this release, got {model_name}")
self.generator = WanDiffusionWrapper(
**getattr(args, "model_kwargs", {}), is_causal=True) if generator is None else generator
self.text_encoder = WanTextEncoder() if text_encoder is None else text_encoder
self.vae = build_vae_5b(args) if vae is None else vae
# iter-33: optionally compile the VAE decoder (cuda:2). The Python
# `for step in range(total_steps)` wrapper in cached_decode keeps the
# mutating feat_cache in eager; per-step self.decoder() call hits the
# compiled version. Opt-in via LLV2_COMPILE_VAE=1; default off.
if os.environ.get("LLV2_COMPILE_VAE", "0") == "1":
try:
inner = getattr(self.vae, "model", None)
if inner is not None and hasattr(inner, "decoder"):
inner.decoder = torch.compile(
inner.decoder,
backend="inductor",
mode="max-autotune-no-cudagraphs",
fullgraph=False,
dynamic=False,
)
print("[torch.compile] VAE.decoder wrapped")
except Exception as exc:
print(f"[torch.compile][warn] VAE compile setup failed: {exc}")
# Step 2: Initialize scheduler
self.num_train_timesteps = getattr(args, "num_train_timestep", 1000)
self.sampling_steps = section_get(args, "inference", "sampling_steps", 50)
self.sample_solver = 'unipc'
self.shift = getattr(args, "timestep_shift",
getattr(args.model_kwargs, "timestep_shift", 5.0))
self.frame_seq_length = math.prod(args.image_or_video_shape[-2:]) // 4
self.model_name = model_name
self.num_transformer_blocks = wan_default_config[self.model_name]["num_transformer_blocks"]
self.kv_cache_pos = None
self.kv_cache_neg = None
self.crossattn_cache_pos = None
self.crossattn_cache_neg = None
self.args = args
self.num_frame_per_block = getattr(args, "num_frame_per_block", 1)
self.quantize_kv = getattr(args, "kv_quant", False)
self.kv_quant_scale_rule = getattr(args, "kv_quant_scale_rule", "mse")
self.kv_quant_backend = getattr(args, "kv_quant_backend", "cuda")
self.independent_first_frame = section_get(args, "inference", "independent_first_frame", False)
self.local_attn_size = section_get(
args, "inference", "local_attn_size", -1, aliases=("inference_local_attn_size",)
)
if self.local_attn_size == -1:
self.local_attn_size = getattr(args, "model_kwargs", {}).get("local_attn_size", -1)
self.sink_size = section_get(
args, "inference", "sink_size", None, aliases=("inference_sink_size",)
)
if self.sink_size is None:
_model_sink = getattr(args, "model_kwargs", {}).get("sink_size", None)
if _model_sink is not None:
self.sink_size = _model_sink
if self.sink_size is None:
self.sink_size = 0
self.scene_cut_prefix = section_get(args, "inference", "scene_cut_prefix", DEFAULT_SCENE_CUT_PREFIX)
self.multi_shot_sink = section_get(args, "inference", "multi_shot_sink", False)
self.shot_clean_recache = section_get(args, "inference", "shot_clean_recache", False)
self.global_sink_size = self.sink_size if self.multi_shot_sink else 0
self.multi_shot_rope_offset = section_get(
args,
"inference",
"multi_shot_rope_offset",
0.0,
)
self.guidance_scale = section_get(args, "inference", "guidance_scale", getattr(args, "guidance_scale", 1.0))
self.negative_prompt = section_get(args, "inference", "negative_prompt", getattr(args, "negative_prompt", ""))
self.streaming_vae = section_get(args, "inference", "streaming_vae", getattr(args, "streaming_vae", False))
self.async_vae = section_get(args, "inference", "async_vae", getattr(args, "async_vae", False))
vae_device = section_get(args, "inference", "vae_device", getattr(args, "vae_device", None))
self.vae_device = torch.device(vae_device) if vae_device else None
if self.quantize_kv:
from utils.quant import LongLiveQuantizationConfig
self.kv_quant_config = LongLiveQuantizationConfig(
scale_rule=self.kv_quant_scale_rule,
backend=self.kv_quant_backend,
type="kv",
)
else:
self.kv_quant_config = None
self._dit_model.kv_quant_config = self.kv_quant_config
if self.streaming_vae and self.vae_device is not None:
vae_mode = "streaming-pipeline"
elif self.streaming_vae and self.async_vae:
vae_mode = "streaming-async"
elif self.streaming_vae:
vae_mode = "streaming"
else:
vae_mode = "batch"
print(
f"KV inference with {self.num_frame_per_block} frames per block "
f"(kv_quant={self.quantize_kv}, vae_decode={vae_mode})"
)
if self.num_frame_per_block > 1:
self.generator.model.num_frame_per_block = self.num_frame_per_block
self.inference_t_scale = getattr(args, "inference_t_scale", None)
self.use_relative_rope = getattr(args, "use_relative_rope", False)
self._rope_method_override = getattr(args, "rope_method", None)
self._original_seq_len_override = getattr(args, "original_seq_len", None)
@property
def _dit_model(self):
"""Return the underlying CausalWanModel, unwrapping PeftModel if present.
After LoRA wrapping, ``self.generator.model`` is a PeftModel whose
structure is PeftModel -> LoraModel (.base_model) -> CausalWanModel
(.model). Direct attribute writes on PeftModel do NOT propagate to
CausalWanModel, so any runtime overrides (t_scale, rope_method, …)
must target the unwrapped model returned by this property.
"""
model = self.generator.model
if hasattr(model, 'base_model') and hasattr(model.base_model, 'model'):
return model.base_model.model
return model
def inference(
self,
noise: torch.Tensor,
text_prompts: List[str],
initial_latent: Optional[torch.Tensor] = None,
return_latents: bool = False,
start_frame_index: Optional[int] = 0
) -> torch.Tensor:
"""
Perform inference on the given noise and text prompts.
Inputs:
noise (torch.Tensor): The input noise tensor of shape
(batch_size, num_output_frames, num_channels, height, width).
text_prompts (List[str]): The list of text prompts.
initial_latent (torch.Tensor): The initial latent tensor of shape
(batch_size, num_input_frames, num_channels, height, width).
If num_input_frames is 1, perform image to video.
If num_input_frames is greater than 1, perform video extension.
return_latents (bool): Whether to return the latents.
start_frame_index (int): In long video generation, where does the current window start?
Outputs:
video (torch.Tensor): The generated video tensor of shape
(batch_size, num_frames, num_channels, height, width). It is normalized to be in the range [0, 1].
"""
batch_size, num_frames, num_channels, height, width = noise.shape
num_input_frames = initial_latent.shape[1] if initial_latent is not None else 0
clamp_i2v_first_chunk = self.independent_first_frame and initial_latent is not None
if clamp_i2v_first_chunk and num_input_frames != 1:
raise ValueError(
f"i2v first-chunk clamp expects one conditioning latent frame, got {num_input_frames}."
)
if not self.independent_first_frame or clamp_i2v_first_chunk:
# If the first frame is independent and the first frame is provided, then the number of frames in the
# noise should still be a multiple of num_frame_per_block
assert num_frames % self.num_frame_per_block == 0
num_blocks = num_frames // self.num_frame_per_block
elif self.independent_first_frame and initial_latent is None:
# Using a [1, 4, 4, 4, 4, 4] model to generate a video without image conditioning
assert (num_frames - 1) % self.num_frame_per_block == 0
num_blocks = (num_frames - 1) // self.num_frame_per_block
num_output_frames = (
num_frames if clamp_i2v_first_chunk else num_frames + num_input_frames
)
conditional_dict = self.text_encoder(
text_prompts=text_prompts[0]
)
conditional_dict_list = [
{"prompt_embeds": conditional_dict["prompt_embeds"][i:i+1]}
for i in range(conditional_dict["prompt_embeds"].shape[0])
]
use_cfg = self.guidance_scale != 1.0
if use_cfg:
unconditional_dict = self.text_encoder(
text_prompts=[self.negative_prompt] * batch_size
)
else:
unconditional_dict = None
output = torch.zeros(
[batch_size, num_output_frames, num_channels, height, width],
device=noise.device,
dtype=noise.dtype
)
# Step 1: Initialize KV cache to all zeros
if self.kv_cache_pos is None:
self._initialize_kv_cache(
batch_size=batch_size,
dtype=noise.dtype,
device=noise.device
)
self._initialize_crossattn_cache(
batch_size=batch_size,
dtype=noise.dtype,
device=noise.device
)
else:
# reset cross attn cache
for block_index in range(self.num_transformer_blocks):
self.crossattn_cache_pos[block_index]["is_init"] = False
if use_cfg:
self.crossattn_cache_neg[block_index]["is_init"] = False
# reset kv cache
for block_index in range(len(self.kv_cache_pos)):
self.kv_cache_pos[block_index]["global_end_index"] = torch.tensor(
[0], dtype=torch.long, device=noise.device)
self.kv_cache_pos[block_index]["local_end_index"] = torch.tensor(
[0], dtype=torch.long, device=noise.device)
self.kv_cache_pos[block_index]["pinned_start"].fill_(-1)
self.kv_cache_pos[block_index]["pinned_len"].zero_()
if use_cfg:
self.kv_cache_neg[block_index]["global_end_index"] = torch.tensor(
[0], dtype=torch.long, device=noise.device)
self.kv_cache_neg[block_index]["local_end_index"] = torch.tensor(
[0], dtype=torch.long, device=noise.device)
self.kv_cache_neg[block_index]["pinned_start"].fill_(-1)
self.kv_cache_neg[block_index]["pinned_len"].zero_()
# Step 2: Cache context feature
current_start_frame = start_frame_index
cache_start_frame = 0
# Save model state before overriding for inference.
# Use _dit_model to reach the real CausalWanModel (PeftModel wrapping
# intercepts attribute writes, so self.generator.model.xxx would land
# on the wrapper instead of the model that reads them in forward()).
dit = self._dit_model
prev_local_attn_size = dit.local_attn_size
prev_t_scale = getattr(dit, 't_scale', 1.0)
prev_rope_method = getattr(dit, 'rope_method', 'linear')
prev_original_seq_len = getattr(dit, 'original_seq_len', None)
prev_use_relative_rope = getattr(dit, 'use_relative_rope', False)
prev_rope_temporal_offset = getattr(dit, 'rope_temporal_offset', 0.0)
prev_max_attention_sizes = {}
prev_sink_sizes = {}
prev_global_sink_sizes = {}
for name, module in self.generator.model.named_modules():
if hasattr(module, 'max_attention_size'):
prev_max_attention_sizes[name] = module.max_attention_size
if hasattr(module, 'sink_size'):
prev_sink_sizes[name] = module.sink_size
if hasattr(module, 'global_sink_size'):
prev_global_sink_sizes[name] = module.global_sink_size
dit.local_attn_size = self.local_attn_size
print(f"[inference] local_attn_size set on model: {dit.local_attn_size}")
self._set_all_modules_max_attention_size(self.local_attn_size)
if self.sink_size is not None:
self._set_all_modules_sink_size(self.sink_size)
print(f"[inference] sink_size set to: {self.sink_size}"
f"{', multi_shot_sink enabled (pinned position)' if self.multi_shot_sink else ''}"
f"{', shot_clean_recache enabled' if self.shot_clean_recache else ''}")
# Propagate the internally derived global sink length.
self._set_all_modules_global_sink_size(self.global_sink_size)
if self.global_sink_size and self.global_sink_size > 0:
print(f"[inference] auto_global_sink_size set to: {self.global_sink_size} "
f"(first {self.global_sink_size} frames permanently anchored)")
if self.inference_t_scale is not None:
dit.t_scale = self.inference_t_scale
print(f"[inference] t_scale overridden to: {dit.t_scale}")
if self._rope_method_override is not None:
dit.rope_method = self._rope_method_override
if self._original_seq_len_override is not None:
dit.original_seq_len = self._original_seq_len_override
print(f"[inference] rope_method={dit.rope_method}, "
f"original_seq_len={dit.original_seq_len}")
dit.use_relative_rope = self.use_relative_rope
if self.use_relative_rope:
print(f"[inference] use_relative_rope enabled")
dit.rope_temporal_offset = 0.0
if self.multi_shot_rope_offset != 0.0:
print(f"[inference] multi_shot_rope_offset={self.multi_shot_rope_offset} "
f"(multi-shot RoPE offset enabled)")
try:
raw_prompts = text_prompts[0] if isinstance(text_prompts[0], (list, tuple)) else text_prompts
return self._inference_inner(
noise=noise, batch_size=batch_size, num_frames=num_frames,
num_channels=num_channels, height=height, width=width,
num_blocks=num_blocks, num_input_frames=num_input_frames,
num_output_frames=num_output_frames, output=output,
conditional_dict=conditional_dict,
conditional_dict_list=conditional_dict_list,
unconditional_dict=unconditional_dict,
use_cfg=use_cfg, initial_latent=initial_latent,
clamp_i2v_first_chunk=clamp_i2v_first_chunk,
return_latents=return_latents,
current_start_frame=current_start_frame,
cache_start_frame=cache_start_frame,
raw_prompts=raw_prompts,
)
finally:
dit.local_attn_size = prev_local_attn_size
dit.t_scale = prev_t_scale
dit.rope_method = prev_rope_method
dit.original_seq_len = prev_original_seq_len
dit.use_relative_rope = prev_use_relative_rope
dit.rope_temporal_offset = prev_rope_temporal_offset
for name, module in self.generator.model.named_modules():
if name in prev_max_attention_sizes:
try:
module.max_attention_size = prev_max_attention_sizes[name]
except Exception:
pass
if name in prev_sink_sizes:
try:
module.sink_size = prev_sink_sizes[name]
except Exception:
pass
if name in prev_global_sink_sizes:
try:
module.global_sink_size = prev_global_sink_sizes[name]
except Exception:
pass
def _inference_inner(
self, noise, batch_size, num_frames, num_channels, height, width,
num_blocks, num_input_frames, num_output_frames, output,
conditional_dict, conditional_dict_list, unconditional_dict,
use_cfg, initial_latent, clamp_i2v_first_chunk, return_latents,
current_start_frame, cache_start_frame,
raw_prompts=None,
):
if initial_latent is not None and not clamp_i2v_first_chunk:
timestep = torch.ones([batch_size, 1], device=noise.device, dtype=torch.int64) * 0
if self.independent_first_frame:
# Assume num_input_frames is 1 + self.num_frame_per_block * num_input_blocks
assert (num_input_frames - 1) % self.num_frame_per_block == 0
num_input_blocks = (num_input_frames - 1) // self.num_frame_per_block
output[:, :1] = initial_latent[:, :1]
self.generator(
noisy_image_or_video=initial_latent[:, :1],
conditional_dict=conditional_dict,
timestep=timestep * 0,
kv_cache=self.kv_cache_pos,
crossattn_cache=self.crossattn_cache_pos,
current_start=current_start_frame * self.frame_seq_length,
cache_start=cache_start_frame * self.frame_seq_length
)
if use_cfg:
self.generator(
noisy_image_or_video=initial_latent[:, :1],
conditional_dict=unconditional_dict,
timestep=timestep * 0,
kv_cache=self.kv_cache_neg,
crossattn_cache=self.crossattn_cache_neg,
current_start=current_start_frame * self.frame_seq_length,
cache_start=cache_start_frame * self.frame_seq_length
)
current_start_frame += 1
cache_start_frame += 1
else:
# Assume num_input_frames is self.num_frame_per_block * num_input_blocks
assert num_input_frames % self.num_frame_per_block == 0
num_input_blocks = num_input_frames // self.num_frame_per_block
for block_index in range(num_input_blocks):
current_ref_latents = \
initial_latent[:, cache_start_frame:cache_start_frame + self.num_frame_per_block]
output[:, cache_start_frame:cache_start_frame + self.num_frame_per_block] = current_ref_latents
self.generator(
noisy_image_or_video=current_ref_latents,
conditional_dict=conditional_dict,
timestep=timestep * 0,
kv_cache=self.kv_cache_pos,
crossattn_cache=self.crossattn_cache_pos,
current_start=current_start_frame * self.frame_seq_length,
cache_start=cache_start_frame * self.frame_seq_length
)
if use_cfg:
self.generator(
noisy_image_or_video=current_ref_latents,
conditional_dict=unconditional_dict,
timestep=timestep * 0,
kv_cache=self.kv_cache_neg,
crossattn_cache=self.crossattn_cache_neg,
current_start=current_start_frame * self.frame_seq_length,
cache_start=cache_start_frame * self.frame_seq_length
)
current_start_frame += self.num_frame_per_block
cache_start_frame += self.num_frame_per_block
# Step 3: Temporal denoising loop
all_num_frames = [self.num_frame_per_block] * num_blocks
if self.independent_first_frame and initial_latent is None:
all_num_frames = [1] + all_num_frames
# Multi-shot RoPE offset: track current shot index for phase offset.
current_shot_index = 0
phi = self.multi_shot_rope_offset
self._dit_model.rope_temporal_offset = 0.0
streaming_decode = self.streaming_vae and not return_latents
pipeline_vae = streaming_decode and self.vae_device is not None
async_vae = streaming_decode and self.async_vae and not pipeline_vae
if streaming_decode:
vae_dev = self.vae_device if pipeline_vae else noise.device
vae_scale = [
self.vae.mean.to(device=vae_dev, dtype=noise.dtype),
1.0 / self.vae.std.to(device=vae_dev, dtype=noise.dtype),
]
self.vae.model.clear_cache()
video_chunks = []
if async_vae:
vae_stream = torch.cuda.Stream(device=noise.device)
prev_vae_done = None
if pipeline_vae:
vae_thread_error = []
vae_thread_chunks = []
vae_work_queue = []
vae_queue_lock = threading.Lock()
vae_work_ready = threading.Event()
vae_all_done = threading.Event()
def _vae_thread_fn():
try:
while True:
vae_work_ready.wait()
vae_work_ready.clear()
while True:
with vae_queue_lock:
if not vae_work_queue:
break
item = vae_work_queue.pop(0)
if item is None:
vae_all_done.set()
return
decoded = self.vae.model.cached_decode(
item,
vae_scale,
).float().clamp_(-1, 1)
# Pinned-memory DtoH: pageable copy hits ~0.2 GB/s
# (1.5s per 313MB chunk → ~80s/prompt at end); pinned
# path runs at PCIe limit (~25 GB/s = ~12ms / chunk).
pinned = torch.empty(
decoded.shape, dtype=decoded.dtype,
device="cpu", pin_memory=True,
)
pinned.copy_(decoded, non_blocking=True)
torch.cuda.synchronize(decoded.device)
vae_thread_chunks.append(pinned)
except Exception as exc:
vae_thread_error.append(exc)
vae_all_done.set()
vae_bg_thread = threading.Thread(target=_vae_thread_fn, daemon=True)
vae_bg_thread.start()
_block_events = [] if _LLV2_TIME else None
global _LLV2_PROFILE_CALL_COUNTER
_call_idx = _LLV2_PROFILE_CALL_COUNTER
_LLV2_PROFILE_CALL_COUNTER += 1
_prof = None
_prof_trace_path = None
if _LLV2_PROFILE_SPEC and _LLV2_PROFILE_OUTPUT_DIR:
_parts = _LLV2_PROFILE_SPEC.split(":")
_target_call = int(_parts[0]) if len(_parts) > 0 else 0
_wait_n = int(_parts[1]) if len(_parts) > 1 else 20
_warmup_n = int(_parts[2]) if len(_parts) > 2 else 2
_active_n = int(_parts[3]) if len(_parts) > 3 else 2
if _call_idx == _target_call:
from torch.profiler import profile as _tp_profile, ProfilerActivity, schedule
os.makedirs(_LLV2_PROFILE_OUTPUT_DIR, exist_ok=True)
_prof_trace_path = os.path.join(
_LLV2_PROFILE_OUTPUT_DIR, f"trace_call{_call_idx}.json"
)
_prof = _tp_profile(
activities=[ProfilerActivity.CPU, ProfilerActivity.CUDA],
schedule=schedule(
wait=_wait_n, warmup=_warmup_n, active=_active_n, repeat=1
),
record_shapes=False,
with_stack=False,
)
_prof.start()
print(
f"[LLV2_PROFILE] enabled for call={_call_idx} "
f"wait={_wait_n} warmup={_warmup_n} active={_active_n} "
f"-> {_prof_trace_path}",
flush=True,
)
for chunk_index, current_num_frames in enumerate(all_num_frames):
if _LLV2_TIME:
_ev_s = torch.cuda.Event(enable_timing=True)
_ev_e = torch.cuda.Event(enable_timing=True)
_ev_s.record()
conditional_dict = conditional_dict_list[chunk_index]
# Reset the cross-attention cache when each chunk uses a different
# prompt; otherwise the model reuses the previous chunk's k/v and
# ignores the current conditional_dict.
for block_index in range(self.num_transformer_blocks):
self.crossattn_cache_pos[block_index]["is_init"] = False
self.crossattn_cache_neg[block_index]["is_init"] = False
# Update RoPE phase offset on shot boundaries.
is_shot_boundary = self._is_shot_boundary(raw_prompts, chunk_index)
if is_shot_boundary and phi != 0.0:
current_shot_index += 1
self._dit_model.rope_temporal_offset = current_shot_index * phi
print(f"[inference] multi-shot RoPE: shot_index={current_shot_index}, "
f"temporal_offset={self._dit_model.rope_temporal_offset:.4f}")
first_i2v_block = clamp_i2v_first_chunk and chunk_index == 0
noise_start_frame = (
cache_start_frame
if clamp_i2v_first_chunk
else cache_start_frame - num_input_frames
)
noisy_input = noise[
:,
noise_start_frame:noise_start_frame + current_num_frames,
]
latents = noisy_input
# Step 3.1: Spatial denoising loop
sample_scheduler = self._initialize_sample_scheduler(noise)
for _, t in enumerate(tqdm(sample_scheduler.timesteps)):
timestep = t * torch.ones(
[batch_size, current_num_frames], device=noise.device, dtype=torch.float32
)
if first_i2v_block:
latents = _overwrite_i2v_context(
latents, initial_latent, num_input_frames
)
timestep = _zero_i2v_context_timestep(
timestep, num_input_frames
)
latent_model_input = latents
flow_pred_cond, _ = self.generator(
noisy_image_or_video=latent_model_input,
conditional_dict=conditional_dict,
timestep=timestep,
kv_cache=self.kv_cache_pos,
crossattn_cache=self.crossattn_cache_pos,
current_start=current_start_frame * self.frame_seq_length,
cache_start=cache_start_frame * self.frame_seq_length
)
if use_cfg:
flow_pred_uncond, _ = self.generator(
noisy_image_or_video=latent_model_input,
conditional_dict=unconditional_dict,
timestep=timestep,
kv_cache=self.kv_cache_neg,
crossattn_cache=self.crossattn_cache_neg,
current_start=current_start_frame * self.frame_seq_length,
cache_start=cache_start_frame * self.frame_seq_length
)
flow_pred = flow_pred_uncond + self.guidance_scale * (
flow_pred_cond - flow_pred_uncond)
else:
flow_pred = flow_pred_cond
temp_x0 = sample_scheduler.step(
flow_pred,
t,
latents,
return_dict=False)[0]
latents = temp_x0
if first_i2v_block:
latents = _overwrite_i2v_context(
latents, initial_latent, num_input_frames
)
# iter-34: removed per-step debug print of kv_cache scalar
# tensors (was forcing GPU→CPU sync every sampling step ×
# 4 steps × 48 chunks = 192 stalls per prompt). Re-enable
# behind LLV2_DEBUG_KV=1 if needed for debugging.
if os.environ.get("LLV2_DEBUG_KV", "0") == "1":
print(f"kv_cache['local_end_index']: {self.kv_cache_pos[0]['local_end_index']}")
print(f"kv_cache['global_end_index']: {self.kv_cache_pos[0]['global_end_index']}")
# Step 3.2: record the model's output
if first_i2v_block:
latents = _overwrite_i2v_context(
latents, initial_latent, num_input_frames
)
output[:, cache_start_frame:cache_start_frame + current_num_frames] = latents
# Step 3.3: rerun with timestep zero to update KV cache using clean context
is_scene_cut = self._is_scene_cut(raw_prompts, chunk_index)
if is_scene_cut and self.shot_clean_recache:
print(f"[inference] Scene cut at chunk {chunk_index}, zeroing KV before recache")
current_start_tokens = current_start_frame * self.frame_seq_length
self._zero_kv_data(self.kv_cache_pos, current_start_tokens)
if use_cfg:
self._zero_kv_data(self.kv_cache_neg, current_start_tokens)
self.generator(
noisy_image_or_video=latents,
conditional_dict=conditional_dict,
timestep=timestep * 0,
kv_cache=self.kv_cache_pos,
crossattn_cache=self.crossattn_cache_pos,
current_start=current_start_frame * self.frame_seq_length,
cache_start=cache_start_frame * self.frame_seq_length
)
if use_cfg:
self.generator(
noisy_image_or_video=latents,
conditional_dict=unconditional_dict,
timestep=timestep * 0,
kv_cache=self.kv_cache_neg,
crossattn_cache=self.crossattn_cache_neg,
current_start=current_start_frame * self.frame_seq_length,
cache_start=cache_start_frame * self.frame_seq_length
)
# Step 3.3b: pin the current chunk for multi-shot sink on scene cut.
if is_scene_cut:
print(f"[inference] Scene cut at chunk {chunk_index}, pinning chunk as shot-sink")
self._pin_current_chunk(self.kv_cache_pos, current_num_frames)
if use_cfg:
self._pin_current_chunk(self.kv_cache_neg, current_num_frames)
if streaming_decode:
if async_vae:
diffusion_done = torch.cuda.Event()
diffusion_done.record()
if prev_vae_done is not None:
prev_vae_done.synchronize()
with torch.cuda.stream(vae_stream):
vae_stream.wait_event(diffusion_done)
chunk_bcthw = latents.permute(0, 2, 1, 3, 4).contiguous()
decoded_chunk = self.vae.model.cached_decode(
chunk_bcthw,
vae_scale,
).float().clamp_(-1, 1)
video_chunks.append(decoded_chunk)
prev_vae_done = torch.cuda.Event()
prev_vae_done.record(vae_stream)
elif pipeline_vae:
latent_on_vae = latents.permute(0, 2, 1, 3, 4).contiguous().to(vae_dev)
with vae_queue_lock:
vae_work_queue.append(latent_on_vae)
vae_work_ready.set()
else:
chunk_bcthw = latents.permute(0, 2, 1, 3, 4).contiguous()
decoded_chunk = self.vae.model.cached_decode(
chunk_bcthw,
vae_scale,
).float().clamp_(-1, 1)
video_chunks.append(decoded_chunk.cpu())
del decoded_chunk, chunk_bcthw
torch.cuda.empty_cache()
# Step 3.4: update the start and end frame indices
current_start_frame += current_num_frames
cache_start_frame += current_num_frames
if _LLV2_TIME:
_ev_e.record()
_block_events.append((_ev_s, _ev_e))
if _prof is not None:
_prof.step()
if _prof is not None:
_prof.stop()
_prof.export_chrome_trace(_prof_trace_path)
print(f"[LLV2_PROFILE] saved trace -> {_prof_trace_path}", flush=True)
if _LLV2_TIME and _block_events:
torch.cuda.synchronize()
_times = [_s.elapsed_time(_e) for _s, _e in _block_events]
_sorted = sorted(_times)
_n = len(_sorted)
_p10 = _sorted[max(0, _n // 10 - 1)]
_p50 = statistics.median(_sorted)
_p90 = _sorted[max(0, _n - _n // 10 - 1)]
_mean = sum(_times) / _n
_total = sum(_times)
print(
f"[LLV2_TIME] blocks={_n} mean_ms={_mean:.2f} p10_ms={_p10:.2f} "
f"median_ms={_p50:.2f} p90_ms={_p90:.2f} total_ms={_total:.2f}",
flush=True,
)
if _LLV2_DUMP_LATENT_DIR:
os.makedirs(_LLV2_DUMP_LATENT_DIR, exist_ok=True)
_existing = sum(
1 for _f in os.listdir(_LLV2_DUMP_LATENT_DIR)
if _f.startswith("latent_") and _f.endswith(".pt")
)
_path = os.path.join(_LLV2_DUMP_LATENT_DIR, f"latent_{_existing:04d}.pt")
torch.save(output.detach().cpu(), _path)
print(f"[LLV2_DUMP] saved latent {tuple(output.shape)} -> {_path}", flush=True)
# Step 4: Decode the output
if return_latents:
return output
elif streaming_decode:
if async_vae:
vae_stream.synchronize()
elif pipeline_vae:
with vae_queue_lock:
vae_work_queue.append(None)
vae_work_ready.set()
vae_all_done.wait()
vae_bg_thread.join()
if vae_thread_error:
raise RuntimeError(
f"[pipeline_vae] VAE decode failed: {vae_thread_error[0]}"
) from vae_thread_error[0]
video_chunks = vae_thread_chunks
video_bcthw = torch.cat(video_chunks, dim=2)
video = video_bcthw.permute(0, 2, 1, 3, 4)
video = (video * 0.5 + 0.5).clamp(0, 1)
self.vae.model.clear_cache()
return video
else:
video = self.vae.decode_to_pixel(output)
video = (video * 0.5 + 0.5).clamp(0, 1)
return video
def _initialize_kv_cache(self, batch_size, dtype, device):
"""
Initialize a Per-GPU KV cache for the Wan model.
"""
kv_cache_pos = []
kv_cache_neg = []
num_heads = wan_default_config[self.model_name]["num_heads"]
head_dim = wan_default_config[self.model_name]["head_dim"]
if self.local_attn_size != -1:
# Use the local attention size to compute the KV cache size
kv_cache_size = self.local_attn_size * self.frame_seq_length
else:
# Use the default KV cache size
kv_cache_size = 3 * self.num_frame_per_block * self.frame_seq_length
block_token_size = self.num_frame_per_block * self.frame_seq_length
max_blocks = kv_cache_size // block_token_size
if self.quantize_kv:
from utils.quant import clone_quantized_tensor, quantize_to_fp4
print(
f"[KV Cache] Quantized (nvfp4): block_token_size={block_token_size}, "
f"max_blocks={max_blocks}, num_heads={num_heads}, layers={self.num_transformer_blocks}"
)
zero_block = torch.zeros(
[block_token_size * num_heads, head_dim],
dtype=dtype,
device=device,
)
zero_qt = quantize_to_fp4(zero_block, self.kv_quant_config)
for _ in range(self.num_transformer_blocks):
if self.quantize_kv:
kv_cache_pos.append({
"k": [clone_quantized_tensor(zero_qt) for _ in range(max_blocks)],
"v": [clone_quantized_tensor(zero_qt) for _ in range(max_blocks)],
"quantized": True,
"block_token_size": block_token_size,
"max_blocks": max_blocks,
"num_heads": num_heads,
"num_filled_blocks": 0,
"global_end_index": torch.tensor([0], dtype=torch.long, device=device),
"local_end_index": torch.tensor([0], dtype=torch.long, device=device),
"pinned_start": torch.tensor([-1], dtype=torch.long, device=device),
"pinned_len": torch.tensor([0], dtype=torch.long, device=device),
})
kv_cache_neg.append({
"k": [clone_quantized_tensor(zero_qt) for _ in range(max_blocks)],
"v": [clone_quantized_tensor(zero_qt) for _ in range(max_blocks)],
"quantized": True,
"block_token_size": block_token_size,
"max_blocks": max_blocks,
"num_heads": num_heads,
"num_filled_blocks": 0,
"global_end_index": torch.tensor([0], dtype=torch.long, device=device),
"local_end_index": torch.tensor([0], dtype=torch.long, device=device),
"pinned_start": torch.tensor([-1], dtype=torch.long, device=device),
"pinned_len": torch.tensor([0], dtype=torch.long, device=device),
})
else:
kv_cache_pos.append({
"k": torch.zeros([batch_size, kv_cache_size, num_heads, head_dim], dtype=dtype, device=device),
"v": torch.zeros([batch_size, kv_cache_size, num_heads, head_dim], dtype=dtype, device=device),
"quantized": False,
"block_token_size": block_token_size,
"max_blocks": max_blocks,
"num_heads": num_heads,
"num_filled_blocks": 0,
"global_end_index": torch.tensor([0], dtype=torch.long, device=device),
"local_end_index": torch.tensor([0], dtype=torch.long, device=device),
"pinned_start": torch.tensor([-1], dtype=torch.long, device=device),
"pinned_len": torch.tensor([0], dtype=torch.long, device=device),
})
kv_cache_neg.append({
"k": torch.zeros([batch_size, kv_cache_size, num_heads, head_dim], dtype=dtype, device=device),
"v": torch.zeros([batch_size, kv_cache_size, num_heads, head_dim], dtype=dtype, device=device),
"quantized": False,
"block_token_size": block_token_size,
"max_blocks": max_blocks,
"num_heads": num_heads,
"num_filled_blocks": 0,
"global_end_index": torch.tensor([0], dtype=torch.long, device=device),
"local_end_index": torch.tensor([0], dtype=torch.long, device=device),
"pinned_start": torch.tensor([-1], dtype=torch.long, device=device),
"pinned_len": torch.tensor([0], dtype=torch.long, device=device),
})
self.kv_cache_pos = kv_cache_pos # always store the clean cache
self.kv_cache_neg = kv_cache_neg # always store the clean cache
def _initialize_crossattn_cache(self, batch_size, dtype, device):
"""
Initialize a Per-GPU cross-attention cache for the Wan model.
"""
crossattn_cache_pos = []
crossattn_cache_neg = []
num_heads = wan_default_config[self.model_name]["num_heads"]
head_dim = wan_default_config[self.model_name]["head_dim"]
for _ in range(self.num_transformer_blocks):
crossattn_cache_pos.append({
"k": torch.zeros([batch_size, 512, num_heads, head_dim], dtype=dtype, device=device),
"v": torch.zeros([batch_size, 512, num_heads, head_dim], dtype=dtype, device=device),
"is_init": False
})
crossattn_cache_neg.append({
"k": torch.zeros([batch_size, 512, num_heads, head_dim], dtype=dtype, device=device),
"v": torch.zeros([batch_size, 512, num_heads, head_dim], dtype=dtype, device=device),
"is_init": False
})
self.crossattn_cache_pos = crossattn_cache_pos # always store the clean cache
self.crossattn_cache_neg = crossattn_cache_neg # always store the clean cache
def clear_cache(self):
"""
Explicitly release large KV / cross-attention caches to free GPU memory.
Safe to call between independent inference calls; caches will be
re-created on demand by _initialize_kv_cache/_initialize_crossattn_cache.
"""
self.kv_cache_pos = None
self.kv_cache_neg = None
self.crossattn_cache_pos = None
self.crossattn_cache_neg = None
def _initialize_sample_scheduler(self, noise):
if self.sample_solver == 'unipc':
sample_scheduler = FlowUniPCMultistepScheduler(
num_train_timesteps=self.num_train_timesteps,
shift=1,
use_dynamic_shifting=False)
sample_scheduler.set_timesteps(
self.sampling_steps, device=noise.device, shift=self.shift)
self.timesteps = sample_scheduler.timesteps
elif self.sample_solver == 'dpm++':
sample_scheduler = FlowDPMSolverMultistepScheduler(
num_train_timesteps=self.num_train_timesteps,
shift=1,
use_dynamic_shifting=False)
sampling_sigmas = get_sampling_sigmas(self.sampling_steps, self.shift)
self.timesteps, _ = retrieve_timesteps(
sample_scheduler,
device=noise.device,
sigmas=sampling_sigmas)
else:
raise NotImplementedError("Unsupported solver.")
return sample_scheduler
def _set_all_modules_max_attention_size(self, local_attn_size_value: int):
"""
Set max_attention_size on all submodules that define it.
If local_attn_size_value == -1, use the model's global default (32760 for Wan, 28160 for 5B).
Otherwise, set to local_attn_size_value * frame_seq_length.
"""
if local_attn_size_value == -1:
target_size = 32760
policy = "global"
else:
target_size = int(local_attn_size_value) * self.frame_seq_length
policy = "local"
updated_modules = []
# Update root model if applicable
if hasattr(self.generator.model, "max_attention_size"):
try:
prev = getattr(self.generator.model, "max_attention_size")
except Exception:
prev = None
setattr(self.generator.model, "max_attention_size", target_size)
updated_modules.append("<root_model>")
# Update all child modules
for name, module in self.generator.model.named_modules():
if hasattr(module, "max_attention_size"):
try:
prev = getattr(module, "max_attention_size")
except Exception:
prev = None
try:
setattr(module, "max_attention_size", target_size)
updated_modules.append(name if name else module.__class__.__name__)
except Exception:
pass
def _set_all_modules_sink_size(self, sink_size_value: int):
"""
Override sink_size on all submodules that define it.
"""
if hasattr(self.generator.model, "sink_size"):
setattr(self.generator.model, "sink_size", sink_size_value)
for name, module in self.generator.model.named_modules():
if hasattr(module, "sink_size"):
try:
setattr(module, "sink_size", sink_size_value)
except Exception:
pass
def _set_all_modules_global_sink_size(self, value: int):
"""Override global_sink_size on all submodules; create the attribute if missing."""
setattr(self.generator.model, "global_sink_size", value)
for _, module in self.generator.model.named_modules():
try:
setattr(module, "global_sink_size", value)
except Exception:
pass
def _is_shot_boundary(self, raw_prompts, chunk_index):
"""Return True when *chunk_index* starts a new shot (prompt-based detection).
Pure prompt check — no dependency on sink config so that Narrative
RoPE and other shot-aware features can reuse it independently.
"""
if chunk_index == 0:
return False
if not isinstance(raw_prompts, (list, tuple)):
return False
if chunk_index >= len(raw_prompts):
return False
prompt = raw_prompts[chunk_index]
return isinstance(prompt, str) and prompt.startswith(self.scene_cut_prefix)
def _is_scene_cut(self, raw_prompts, chunk_index):
"""Return True when *chunk_index* is the first chunk of a new scene
AND multi-shot sink is enabled."""
if not self.multi_shot_sink:
return False
if not self.sink_size or self.sink_size == 0:
return False
return self._is_shot_boundary(raw_prompts, chunk_index)
def _update_sink_for_scene_cut(self, kv_cache, current_num_frames):
"""Legacy copy-to-front sink relocation (used by training pipeline)."""
global_sink_tokens = self.global_sink_size * self.frame_seq_length
shot_sink_tokens = self.sink_size * self.frame_seq_length
chunk_tokens = current_num_frames * self.frame_seq_length
copy_len = min(shot_sink_tokens, chunk_tokens)
# iter-38: local_end_index is in lockstep across all blocks (set by
# _apply_cache_updates with the same value). Read once → 60 syncs to 1.
local_end = int(kv_cache[0]["local_end_index"].item())
chunk_start = local_end - chunk_tokens
dst_start = global_sink_tokens
src_slice = slice(chunk_start, chunk_start + copy_len)
dst_slice = slice(dst_start, dst_start + copy_len)
for block_cache in kv_cache:
block_cache["k"][:, dst_slice] = block_cache["k"][:, src_slice].clone()
block_cache["v"][:, dst_slice] = block_cache["v"][:, src_slice].clone()
def _pin_current_chunk(self, kv_cache, current_num_frames):
"""Mark the current chunk's buffer position as pinned for multi-shot sink.
The pinned region REPLACES the original sink on the next rolling event.
No data is copied here — relocation happens inside the attention layer
during rolling, ensuring zero duplication.
"""
chunk_tokens = current_num_frames * self.frame_seq_length
pin_len = min(self.sink_size * self.frame_seq_length, chunk_tokens)
# iter-38: local_end_index is in lockstep across all blocks. Read once.
local_end = int(kv_cache[0]["local_end_index"].item())
chunk_start = local_end - chunk_tokens
for block_cache in kv_cache:
block_cache["pinned_start"].fill_(chunk_start)
block_cache["pinned_len"].fill_(pin_len)
def _zero_kv_data(self, kv_cache, current_start_tokens):
"""Reset KV cache for clean recache, preserving global sink."""
global_sink_tokens = self.global_sink_size * self.frame_seq_length
for block_cache in kv_cache:
block_cache["local_end_index"].fill_(global_sink_tokens)
block_cache["global_end_index"].fill_(current_start_tokens)
block_cache["pinned_start"].fill_(-1)
block_cache["pinned_len"].zero_()