Compare commits

..
Author SHA1 Message Date
Y-aang c1d7c51bc7 fixi2v demo bug 2025-11-19 20:53:43 +00:00
Y-aang 5de2d80ebd add full i2v demo 2025-11-19 15:40:04 +00:00
RandNMR73 6cd227349c add demo prompts 2025-11-19 05:49:01 +00:00
RandNMR73 7e15e5dab1 add demo images 2025-11-19 05:42:37 +00:00
RandNMR73 c9ee4f8bf8 add i2v demo 2025-11-18 09:40:05 +00:00
SolitaryThinker f764b43aaa nit 2025-11-16 23:10:47 +00:00
SolitaryThinker 592af8c954 fix lint 2025-11-16 23:09:04 +00:00
SolitaryThinker 4225a96a40 use new ckpt 2025-11-16 12:49:06 +00:00
JerryZhou54 733c14a2cb Change to fp32 inference 2025-11-16 04:53:57 +00:00
JerryZhou54 81b302409e Add i2v images 2025-11-16 04:51:14 +00:00
JerryZhou54 7fbd0cfec7 Fi lint 2025-11-15 23:50:04 +00:00
RandNMR73 85b9934079 Add inference for MoE SF 2025-11-15 23:44:21 +00:00
17 changed files with 159 additions and 366 deletions
-1
View File
@@ -15,7 +15,6 @@ FastVideo features an end-to-end unified pipeline for accelerating diffusion mod
</div>
## NEWS
- ```2025/11/19```: Release [CausalWan2.2 I2V A14B Preview](https://huggingface.co/FastVideo/CausalWan2.2-I2V-A14B-Preview-Diffusers) models, [Blog](https://hao-ai-lab.github.io/blogs/fastvideo_causalwan_preview/) and [Inference Code!](https://github.com/hao-ai-lab/FastVideo/blob/main/examples/inference/basic/basic_self_forcing_causal_wan2_2_i2v.py)
- ```2025/08/04```: Release [FastWan](https://hao-ai-lab.github.io/FastVideo/distillation/dmd.html) models and [Sparse-Distillation](https://hao-ai-lab.github.io/blogs/fastvideo_post_training/).
- ```2025/06/14```: Release finetuning and inference code for [VSA](https://arxiv.org/pdf/2505.13389)
- ```2025/04/24```: [FastVideo V1](https://hao-ai-lab.github.io/blogs/fastvideo/) is released!
@@ -12,34 +12,20 @@ def main():
generator = VideoGenerator.from_pretrained(
model_name,
# FastVideo will automatically handle distributed setup
num_gpus=4,
num_gpus=1,
use_fsdp_inference=True,
text_encoder_cpu_offload=False,
dit_cpu_offload=False,
num_frame_per_block=4,
)
sampling_param = SamplingParam.from_pretrained(model_name)
# sampling_param.num_frames = 13
sampling_param.num_frames = 77
prompt = (
"A curious raccoon peers through a vibrant field of yellow sunflowers, its eyes "
"wide with interest. The playful yet serene atmosphere is complemented by soft "
"natural light filtering through the petals. Mid-shot, warm and cheerful tones."
)
video = generator.generate_video(prompt, output_path=OUTPUT_PATH, save_video=True, sampling_param=sampling_param)
# return
import time
start_time = time.perf_counter()
for i in range(10):
prompt = (
"A curious raccoon peers through a vibrant field of yellow sunflowers, its eyes "
"wide with interest. The playful yet serene atmosphere is complemented by soft "
"natural light filtering through the petals. Mid-shot, warm and cheerful tones."
)
video = generator.generate_video(prompt, output_path=OUTPUT_PATH, save_video=True, sampling_param=sampling_param)
end_time = time.perf_counter()
print(f"Time taken to generate 10 videos: {end_time - start_time} seconds, average time per video: {(end_time - start_time) / 10} seconds")
if __name__ == "__main__":
main()
@@ -13,21 +13,20 @@ def main():
generator = VideoGenerator.from_pretrained(
"FastVideo/SFWan2.2-I2V-A14B-Preview-Diffusers",
# FastVideo will automatically handle distributed setup
num_gpus=4,
num_gpus=1,
use_fsdp_inference=True,
dit_cpu_offload=False, # DiT need to be offloaded for MoE
dit_cpu_offload=True, # DiT need to be offloaded for MoE
dit_precision="fp32",
vae_cpu_offload=False,
text_encoder_cpu_offload=True,
dmd_denoising_steps=[1000, 850, 700, 550, 350, 275, 200, 125],
# Set pin_cpu_memory to false if CPU RAM is limited and there're no frequent CPU-GPU transfer
pin_cpu_memory=True,
num_frame_per_block=4,
# image_encoder_cpu_offload=False,
)
sampling_param = SamplingParam.from_pretrained("FastVideo/SFWan2.2-I2V-A14B-Preview-Diffusers")
sampling_param.num_frames = 77
sampling_param.num_frames = 73
sampling_param.width = 832
sampling_param.height = 480
sampling_param.seed = 1000
@@ -1,7 +1,6 @@
# NOTE: This is still a work in progress, and the checkpoints are not released yet.
from fastvideo import VideoGenerator
import time
# from fastvideo.configs.sample import SamplingParam
@@ -11,7 +10,6 @@ def main():
# model.
# If a local path is provided, FastVideo will make a best effort
# attempt to identify the optimal arguments.
load_start_time = time.perf_counter()
generator = VideoGenerator.from_pretrained(
"rand0nmr/SFWan2.2-T2V-A14B-Diffusers",
# FastVideo will automatically handle distributed setup
@@ -25,10 +23,9 @@ def main():
pin_cpu_memory=True,
init_weights_from_safetensors="/mnt/sharefs/users/hao.zhang/wei/SFwan2.2_distill_self_forcing_release_cfg2/checkpoint-246_weight_only/generator_inference_transformer/",
init_weights_from_safetensors_2="/mnt/sharefs/users/hao.zhang/wei/SFwan2.2_distill_self_forcing_release_cfg2/checkpoint-246_weight_only/generator_2_inference_transformer/",
num_frame_per_block=4,
num_frame_per_block=7,
# image_encoder_cpu_offload=False,
)
load_end_time = time.perf_counter()
load_time = load_end_time - load_start_time
# sampling_param = SamplingParam.from_pretrained("Wan-AI/Wan2.1-T2V-1.3B-Diffusers")
# sampling_param.num_frames = 45
@@ -39,16 +36,8 @@ def main():
"wide with interest. The playful yet serene atmosphere is complemented by soft "
"natural light filtering through the petals. Mid-shot, warm and cheerful tones."
)
_ = generator.generate_video(prompt, output_path=OUTPUT_PATH, save_video=False, num_frames=77)
# return
_ = generator.generate_video(prompt, output_path=OUTPUT_PATH, save_video=True, num_frames=81)
start_time = time.perf_counter()
for _ in range(10):
video2 = generator.generate_video(prompt, output_path=OUTPUT_PATH, save_video=False, num_frames=77)
end_time = time.perf_counter()
gen_time = (end_time - start_time) / 10
print(f"Time taken to load model: {load_time} seconds")
print(f"Time taken to generate 10 videos: {gen_time} seconds")
if __name__ == "__main__":
main()
@@ -6,8 +6,10 @@ import time
import json
from pathlib import Path
import tempfile
from io import BytesIO
import gradio as gr
from PIL import Image
from fastvideo.configs.sample.base import SamplingParam
@@ -85,28 +87,39 @@ def save_video_from_base64(video_data: str, output_dir: str, prompt: str) -> str
return None
def encode_image_to_base64(image_path: str) -> str:
"""Encode an image file to base64 string."""
if not image_path or not os.path.exists(image_path):
def encode_image_to_base64(image_input) -> str:
"""Encode an image file path or in-memory image to a base64 string."""
if image_input is None:
return None
mime_types = {
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.png': 'image/png',
'.gif': 'image/gif',
'.webp': 'image/webp',
}
try:
with open(image_path, 'rb') as f:
image_bytes = f.read()
if isinstance(image_input, str):
if not os.path.exists(image_input):
return None
with open(image_input, 'rb') as f:
image_bytes = f.read()
ext = os.path.splitext(image_input)[1].lower()
mime_type = mime_types.get(ext, 'image/jpeg')
elif isinstance(image_input, Image.Image):
buffer = BytesIO()
image_to_save = image_input.convert("RGB")
image_to_save.save(buffer, format="PNG")
image_bytes = buffer.getvalue()
mime_type = 'image/png'
else:
return None
image_base64 = base64.b64encode(image_bytes).decode('utf-8')
# Determine image type from extension
ext = os.path.splitext(image_path)[1].lower()
mime_types = {
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.png': 'image/png',
'.gif': 'image/gif',
'.webp': 'image/webp',
}
mime_type = mime_types.get(ext, 'image/jpeg')
return f"data:{mime_type};base64,{image_base64}"
except Exception as e:
@@ -426,7 +439,7 @@ def create_gradio_interface(backend_url: str, default_params: dict[str, Sampling
gr.Markdown("**Please make sure you upload a 480x832 image**")
input_image = gr.Image(
label="",
type="filepath",
type="pil",
height=400,
)
@@ -512,7 +525,17 @@ def create_gradio_interface(backend_url: str, default_params: dict[str, Sampling
if example_label and example_label in example_labels:
index = example_labels.index(example_label)
selected_prompt = examples[index]
selected_image = example_images[index] if index < len(example_images) else None
selected_image_path = example_images[index] if index < len(example_images) else None
if selected_image_path and os.path.exists(selected_image_path):
try:
with Image.open(selected_image_path) as img:
selected_image = img.convert("RGB")
except Exception:
selected_image = None
else:
selected_image = None
return selected_prompt, selected_image
return "", None
@@ -733,6 +756,7 @@ def main():
app,
demo,
path="/gradio",
root_path="/gradio",
allowed_paths=[
os.path.abspath("outputs"),
os.path.abspath("fastvideo-logos"),
+7 -41
View File
@@ -11,9 +11,6 @@ from fastvideo.distributed.parallel_state import (get_sp_parallel_rank,
from fastvideo.forward_context import ForwardContext, get_forward_context
from fastvideo.platforms import AttentionBackendEnum
from fastvideo.utils import get_compute_dtype
from fastvideo.logger import init_logger
logger = init_logger(__name__)
class DistributedAttention(nn.Module):
@@ -67,11 +64,7 @@ class DistributedAttention(nn.Module):
replicated_q: torch.Tensor | None = None,
replicated_k: torch.Tensor | None = None,
replicated_v: torch.Tensor | None = None,
k_cache: torch.Tensor | None = None,
v_cache: torch.Tensor | None = None,
return_current_kv: bool = False,
) -> tuple[torch.Tensor, torch.Tensor | None, torch.Tensor | None,
torch.Tensor | None]:
) -> tuple[torch.Tensor, torch.Tensor | None]:
"""Forward pass for distributed attention.
Args:
@@ -104,7 +97,6 @@ class DistributedAttention(nn.Module):
qkv = sequence_model_parallel_all_to_all_4D(qkv,
scatter_dim=2,
gather_dim=1)
# Apply backend-specific preprocess_qkv
qkv = self.attn_impl.preprocess_qkv(qkv, ctx_attn_metadata)
@@ -120,22 +112,9 @@ class DistributedAttention(nn.Module):
heads_per_rank]
qkv = torch.cat([qkv, replicated_qkv], dim=1)
q, k_new, v_new = qkv.chunk(3, dim=0)
k_total = k_new
v_total = v_new
q, k, v = qkv.chunk(3, dim=0)
if k_cache is not None or v_cache is not None:
# assert False, "KV cache is not supported"
assert k_cache is not None and v_cache is not None
assert k_cache.shape == v_cache.shape
assert k_cache.shape[2] == v_cache.shape[
2], "Number of heads must be the same"
if k_cache.shape[1] > 0:
k_total = torch.cat([k_cache, k_new], dim=1)
v_total = torch.cat([v_cache, v_new], dim=1)
assert k_total.shape == v_total.shape, "Key and value shapes must be the same"
output = self.attn_impl.forward(q, k_total, v_total, ctx_attn_metadata)
output = self.attn_impl.forward(q, k, v, ctx_attn_metadata)
# Redistribute back if using sequence parallelism
replicated_output = None
@@ -145,17 +124,13 @@ class DistributedAttention(nn.Module):
# TODO: make this asynchronous
replicated_output = sequence_model_parallel_all_gather(
replicated_output.contiguous(), dim=2)
# Apply backend-specific postprocess_output
output = self.attn_impl.postprocess_output(output, ctx_attn_metadata)
output = sequence_model_parallel_all_to_all_4D(output,
scatter_dim=1,
gather_dim=2)
if return_current_kv:
return output, replicated_output, k_new, v_new # [batch_size, seq_len, num_heads/sp_world_size, head_dim]
else:
return output, replicated_output, None, None
return output, replicated_output
class DistributedAttention_VSA(DistributedAttention):
@@ -172,9 +147,7 @@ class DistributedAttention_VSA(DistributedAttention):
replicated_k: torch.Tensor | None = None,
replicated_v: torch.Tensor | None = None,
gate_compress: torch.Tensor | None = None,
**kwargs,
) -> tuple[torch.Tensor, torch.Tensor | None, torch.Tensor | None,
torch.Tensor | None]:
) -> tuple[torch.Tensor, torch.Tensor | None]:
"""Forward pass for distributed attention.
Args:
@@ -191,15 +164,8 @@ class DistributedAttention_VSA(DistributedAttention):
- o (torch.Tensor): Output tensor after attention for the main sequence
- replicated_o (Optional[torch.Tensor]): Output tensor for replicated tokens, if provided
"""
k_cache = kwargs.pop("k_cache", None)
v_cache = kwargs.pop("v_cache", None)
return_current_kv = kwargs.pop("return_current_kv", False)
assert not kwargs, f"Unexpected kwargs for DistributedAttention_VSA: {kwargs.keys()}"
# Check unsupported features
# Check text tokens are not supported for VSA now
assert replicated_q is None and replicated_k is None and replicated_v is None, "Replicated QKV is not supported for VSA now"
assert not return_current_kv, "return_current_kv is not supported for VSA attention"
assert k_cache is None and v_cache is None, "KV cache is not supported for VSA attention"
# Check input shapes
assert q.dim() == 4 and k.dim() == 4 and v.dim(
) == 4, "Expected 4D tensors"
@@ -231,7 +197,7 @@ class DistributedAttention_VSA(DistributedAttention):
output = sequence_model_parallel_all_to_all_4D(output,
scatter_dim=1,
gather_dim=2)
return output, replicated_output, None, None
return output, replicated_output
class LocalAttention(nn.Module):
-1
View File
@@ -45,7 +45,6 @@ class PipelineConfig:
embedded_cfg_scale: float = 6.0
flow_shift: float | None = None
disable_autocast: bool = False
is_causal: bool = False
# Model configuration
dit_config: DiTConfig = field(default_factory=DiTConfig)
+6 -10
View File
@@ -174,9 +174,6 @@ class FastVideoArgs:
init_weights_from_safetensors: str = "" # path to safetensors file for initial weight loading
init_weights_from_safetensors_2: str = "" # path to safetensors file for initial weight loading for transformer_2
# Self-forcing specific arguments
num_frame_per_block: int = 3
# # DMD parameters
# dmd_denoising_steps: List[int] | None = field(default=None)
@@ -436,13 +433,6 @@ class FastVideoArgs:
type=str,
help="Path to safetensors file for initial weight loading")
# Self-forcing specific arguments
parser.add_argument(
"--num-frame-per-block",
type=int,
default=FastVideoArgs.num_frame_per_block,
help="Number of frames per block for causal generation")
# Add pipeline configuration arguments
PipelineConfig.add_cli_args(parser)
@@ -761,6 +751,7 @@ class TrainingArgs(FastVideoArgs):
warp_denoising_step: bool = False
# Self-forcing specific arguments
num_frame_per_block: int = 3
independent_first_frame: bool = False
enable_gradient_masking: bool = True
gradient_mask_last_n_frames: int = 21
@@ -1155,6 +1146,11 @@ class TrainingArgs(FastVideoArgs):
)
# Self-forcing specific arguments
parser.add_argument(
"--num-frame-per-block",
type=int,
default=TrainingArgs.num_frame_per_block,
help="Number of frames per block for causal generation")
parser.add_argument(
"--independent-first-frame",
action=StoreBoolean,
+4 -21
View File
@@ -101,19 +101,12 @@ class BaseLayerWithLoRA(nn.Module):
def set_lora_weights(self,
A: torch.Tensor,
B: torch.Tensor,
lora_alpha: float | None = None,
training_mode: bool = False,
lora_path: str | None = None) -> None:
self.lora_A = torch.nn.Parameter(
A) # share storage with weights in the pipeline
self.lora_B = torch.nn.Parameter(B)
self.disable_lora = False
# Store rank and alpha directly
rank = A.shape[0] # rank is the first dimension of A
self.lora_rank = rank
self.lora_alpha = int(lora_alpha) if lora_alpha is not None else rank
if not training_mode:
self.merge_lora_weights()
self.lora_path = lora_path
@@ -141,13 +134,8 @@ class BaseLayerWithLoRA(nn.Module):
current_device = self.base_layer.weight.data.device
data = self.base_layer.weight.data.to(
get_local_torch_device()).full_tensor()
# Apply LoRA with alpha scaling
lora_delta = (self.slice_lora_b_weights(self.lora_B).to(data)
@ self.slice_lora_a_weights(self.lora_A).to(data))
if self.lora_alpha and self.lora_rank and self.lora_alpha != self.lora_rank:
lora_delta *= (self.lora_alpha / self.lora_rank)
data += lora_delta
data += (self.slice_lora_b_weights(self.lora_B).to(data)
@ self.slice_lora_a_weights(self.lora_A).to(data))
unsharded_base_layer.weight = nn.Parameter(data.to(current_device))
if isinstance(getattr(self.base_layer, "bias", None), DTensor):
unsharded_base_layer.bias = nn.Parameter(
@@ -166,13 +154,8 @@ class BaseLayerWithLoRA(nn.Module):
else:
current_device = self.base_layer.weight.data.device
data = self.base_layer.weight.data.to(get_local_torch_device())
# Apply LoRA with alpha scaling
lora_delta = (self.slice_lora_b_weights(self.lora_B.to(data))
@ self.slice_lora_a_weights(self.lora_A.to(data)))
if self.lora_alpha and self.lora_rank and self.lora_alpha != self.lora_rank:
lora_delta *= (self.lora_alpha / self.lora_rank)
data += lora_delta
data += \
(self.slice_lora_b_weights(self.lora_B.to(data)) @ self.slice_lora_a_weights(self.lora_A.to(data)))
self.base_layer.weight.data = data.to(current_device,
non_blocking=True)
+35 -66
View File
@@ -20,7 +20,6 @@ import fastvideo.envs as envs
from fastvideo.attention import (DistributedAttention,
LocalAttention)
from fastvideo.configs.models.dits import WanVideoConfig
from fastvideo.distributed import sequence_model_parallel_all_gather
from fastvideo.distributed.parallel_state import get_sp_world_size
from fastvideo.forward_context import get_forward_context
from fastvideo.layers.layernorm import (FP32LayerNorm, LayerNormScaleShift,
@@ -60,7 +59,7 @@ class CausalWanSelfAttention(nn.Module):
self.max_attention_size = 32760 if local_attn_size == -1 else local_attn_size * 1560
# Scaled dot product attention
self.attn = DistributedAttention(
self.attn = LocalAttention(
num_heads=num_heads,
head_size=self.head_dim,
dropout_rate=0,
@@ -122,65 +121,42 @@ class CausalWanSelfAttention(nn.Module):
)[:, :, :-padded_length].transpose(2, 1)
else:
frame_seqlen = q.shape[1]
current_end = current_start + roped_query.shape[1]
sink_tokens = self.sink_size * frame_seqlen
# If we are using local attention and the current KV cache size is larger than the local attention size, we need to truncate the KV cache
kv_cache_size = kv_cache["k"].shape[1]
sp_world_size = max(get_sp_world_size(), 1)
num_new_tokens = roped_query.shape[1] * sp_world_size
prev_local_end = current_start
current_end = current_start + num_new_tokens
assert self.local_attn_size == -1, "Local attention is not supported"
# if self.local_attn_size != -1 and (current_end > prev_global_end) and (
# num_new_tokens + prev_local_end > kv_cache_size):
# num_evicted_tokens = num_new_tokens + prev_local_end - kv_cache_size
# num_rolled_tokens = prev_local_end - num_evicted_tokens - sink_tokens
# if num_rolled_tokens > 0:
# src_slice = slice(sink_tokens + num_evicted_tokens,
# sink_tokens + num_evicted_tokens + num_rolled_tokens)
# dst_slice = slice(sink_tokens, sink_tokens + num_rolled_tokens)
# kv_cache["k"][:, dst_slice] = kv_cache["k"][:, src_slice].clone()
# kv_cache["v"][:, dst_slice] = kv_cache["v"][:, src_slice].clone()
# prev_local_end = max(sink_tokens, prev_local_end - num_evicted_tokens)
# current_end = current_start + roped_query.shape[1]
local_end_index = kv_cache["local_end_index"].item() + current_end - kv_cache["global_end_index"].item()
local_start_index = local_end_index - num_new_tokens
max_history_tokens = max(self.max_attention_size - num_new_tokens, 0)
history_len = min(prev_local_end, max_history_tokens)
history_start = prev_local_end - history_len
if history_len > 0:
k_history = kv_cache["k"][:, history_start:history_start + history_len]
v_history = kv_cache["v"][:, history_start:history_start + history_len]
num_new_tokens = roped_query.shape[1]
if self.local_attn_size != -1 and (current_end > kv_cache["global_end_index"].item()) and (
num_new_tokens + kv_cache["local_end_index"].item() > kv_cache_size):
# Calculate the number of new tokens added in this step
# Shift existing cache content left to discard oldest tokens
# Clone the source slice to avoid overlapping memory error
num_evicted_tokens = num_new_tokens + kv_cache["local_end_index"].item() - kv_cache_size
num_rolled_tokens = kv_cache["local_end_index"].item() - num_evicted_tokens - sink_tokens
kv_cache["k"][:, sink_tokens:sink_tokens + num_rolled_tokens] = \
kv_cache["k"][:, sink_tokens + num_evicted_tokens:sink_tokens + num_evicted_tokens + num_rolled_tokens].clone()
kv_cache["v"][:, sink_tokens:sink_tokens + num_rolled_tokens] = \
kv_cache["v"][:, sink_tokens + num_evicted_tokens:sink_tokens + num_evicted_tokens + num_rolled_tokens].clone()
# Insert the new keys/values at the end
local_end_index = kv_cache["local_end_index"].item() + current_end - \
kv_cache["global_end_index"].item() - num_evicted_tokens
local_start_index = local_end_index - num_new_tokens
kv_cache["k"][:, local_start_index:local_end_index] = roped_key
kv_cache["v"][:, local_start_index:local_end_index] = v
else:
k_history = None
v_history = None
attn_output, _, k_new, v_new = self.attn(
# Assign new keys/values directly up to current_end
local_end_index = kv_cache["local_end_index"].item() + current_end - kv_cache["global_end_index"].item()
local_start_index = local_end_index - num_new_tokens
kv_cache["k"] = kv_cache["k"].detach()
kv_cache["v"] = kv_cache["v"].detach()
# logger.info("kv_cache['k'] is in comp graph: %s", kv_cache["k"].requires_grad or kv_cache["k"].grad_fn is not None)
kv_cache["k"][:, local_start_index:local_end_index] = roped_key
kv_cache["v"][:, local_start_index:local_end_index] = v
x = self.attn(
roped_query,
roped_key,
v,
k_cache=k_history,
v_cache=v_history,
return_current_kv=True,
kv_cache["k"][:, max(0, local_end_index - self.max_attention_size):local_end_index],
kv_cache["v"][:, max(0, local_end_index - self.max_attention_size):local_end_index]
)
assert k_new.shape[1] == num_new_tokens == v_new.shape[1], "New keys and values must have the same number of tokens"
# assert actual_new_tokens == num_new_tokens, "Actual new tokens must match expected new tokens"
# num_new_tokens = actual_new_tokens
# current_end = current_start + num_new_tokens
new_k = k_new.detach().to(kv_cache["k"].dtype)
new_v = v_new.detach().to(kv_cache["v"].dtype)
kv_cache["k"] = kv_cache["k"].detach()
kv_cache["v"] = kv_cache["v"].detach()
kv_cache["k"][:, local_start_index:local_end_index] = new_k
kv_cache["v"][:, local_start_index:local_end_index] = new_v
x = attn_output
kv_cache["global_end_index"].fill_(current_end)
kv_cache["local_end_index"].fill_(local_end_index)
@@ -395,8 +371,7 @@ class CausalWanTransformer3DModel(BaseDiT):
# Causal-specific
self.block_mask = None
self.num_frame_per_block = config.arch_config.num_frames_per_block
self.num_frame_per_block = 4
# assert self.num_frame_per_block <= 3
assert self.num_frame_per_block <= 3
self.independent_first_frame = False
self.__post_init__()
@@ -497,8 +472,6 @@ class CausalWanTransformer3DModel(BaseDiT):
# Get rotary embeddings
d = self.hidden_size // self.num_attention_heads
rope_dim_list = [d - 4 * (d // 6), 2 * (d // 6), 2 * (d // 6)]
# For Ulysses SP, we need full rotary embeddings (no temporal sharding)
# because after all-to-all, each rank will have the full sequence
freqs_cos, freqs_sin = get_rotary_pos_embed(
(post_patch_num_frames * get_sp_world_size(), post_patch_height,
post_patch_width),
@@ -507,8 +480,7 @@ class CausalWanTransformer3DModel(BaseDiT):
rope_dim_list,
dtype=torch.float32 if current_platform.is_mps() else torch.float64,
rope_theta=10000,
start_frame=start_frame, # Assume that start_frame is 0 when kv_cache is None
# use_sp_shard=False # Don't shard rotary embeddings for Ulysses SP
start_frame=start_frame # Assume that start_frame is 0 when kv_cache is None
)
freqs_cos = freqs_cos.to(hidden_states.device)
freqs_sin = freqs_sin.to(hidden_states.device)
@@ -599,8 +571,6 @@ class CausalWanTransformer3DModel(BaseDiT):
# Get rotary embeddings
d = self.hidden_size // self.num_attention_heads
rope_dim_list = [d - 4 * (d // 6), 2 * (d // 6), 2 * (d // 6)]
# For Ulysses SP, we need full rotary embeddings (no temporal sharding)
# because after all-to-all, each rank will have the full sequence
freqs_cos, freqs_sin = get_rotary_pos_embed(
(post_patch_num_frames * get_sp_world_size(), post_patch_height,
post_patch_width),
@@ -609,8 +579,7 @@ class CausalWanTransformer3DModel(BaseDiT):
rope_dim_list,
dtype=torch.float32 if current_platform.is_mps() else torch.float64,
rope_theta=10000,
start_frame=start_frame,
use_sp_shard=False # Don't shard rotary embeddings for Ulysses SP
start_frame=start_frame
)
freqs_cos = freqs_cos.to(hidden_states.device)
freqs_sin = freqs_sin.to(hidden_states.device)
+3 -28
View File
@@ -28,8 +28,7 @@ class LoRAPipeline(ComposedPipelineBase):
TODO: support training.
"""
lora_adapters: dict[str, dict[str, torch.Tensor]] = defaultdict(
dict
) # state dicts of loaded lora adapters (includes lora_A, lora_B, and lora_alpha)
dict) # state dicts of loaded lora adapters
cur_adapter_name: str = ""
cur_adapter_path: str = ""
lora_layers: dict[str, BaseLayerWithLoRA] = {}
@@ -184,26 +183,11 @@ class LoRAPipeline(ComposedPipelineBase):
lora_param_names_mapping_fn = get_param_names_mapping(
self.modules["transformer"].lora_param_names_mapping)
# Extract alpha values and weights in a single pass
to_merge_params: defaultdict[Hashable,
dict[Any, Any]] = defaultdict(dict)
for name, weight in lora_state_dict.items():
# Extract weights (lora_A, lora_B, and lora_alpha)
name = name.replace("diffusion_model.", "")
name = name.replace(".weight", "")
if "lora_alpha" in name:
# Store alpha with minimal mapping - same processing as lora_A/lora_B
# but store in lora_adapters with ".lora_alpha" suffix
layer_name = name.replace(".lora_alpha", "")
layer_name, _, _ = lora_param_names_mapping_fn(layer_name)
target_name, _, _ = param_names_mapping_fn(layer_name)
# Store alpha alongside weights with same target_name base
alpha_key = target_name + ".lora_alpha"
self.lora_adapters[lora_nickname][alpha_key] = weight.item(
) if weight.numel() == 1 else float(weight.mean())
continue
name, _, _ = lora_param_names_mapping_fn(name)
target_name, merge_index, num_params_to_merge = param_names_mapping_fn(
name)
@@ -241,20 +225,11 @@ class LoRAPipeline(ComposedPipelineBase):
for name, layer in self.lora_layers.items():
lora_A_name = name + ".lora_A"
lora_B_name = name + ".lora_B"
lora_alpha_name = name + ".lora_alpha"
if lora_A_name in self.lora_adapters[lora_nickname]\
and lora_B_name in self.lora_adapters[lora_nickname]:
# Get alpha value for this layer (defaults to None if not present)
lora_A = self.lora_adapters[lora_nickname][lora_A_name]
lora_B = self.lora_adapters[lora_nickname][lora_B_name]
# Simple lookup - alpha stored with same naming scheme as lora_A/lora_B
alpha = self.lora_adapters[lora_nickname].get(
lora_alpha_name) if adapter_updated else None
layer.set_lora_weights(
lora_A,
lora_B,
lora_alpha=alpha,
self.lora_adapters[lora_nickname][lora_A_name],
self.lora_adapters[lora_nickname][lora_B_name],
training_mode=self.fastvideo_args.training_mode,
lora_path=lora_path)
adapted_count += 1
+19 -72
View File
@@ -1,9 +1,6 @@
import torch # type: ignore
from fastvideo.distributed import (get_local_torch_device,
sequence_model_parallel_all_gather,
get_sp_parallel_rank, get_sp_world_size)
from einops import rearrange
from fastvideo.distributed import get_local_torch_device
from fastvideo.fastvideo_args import FastVideoArgs
from fastvideo.forward_context import set_forward_context
from fastvideo.logger import init_logger
@@ -50,7 +47,6 @@ class CausalDMDDenosingStage(DenoisingStage):
# Model-dependent constants (aligned with causal_inference.py assumptions)
self.num_transformer_blocks = len(self.transformer.blocks)
self.num_frames_per_block = self.transformer.config.arch_config.num_frames_per_block
self.num_frames_per_block = 4
self.sliding_window_num_frames = self.transformer.config.arch_config.sliding_window_num_frames
try:
@@ -89,10 +85,10 @@ class CausalDMDDenosingStage(DenoisingStage):
if fastvideo_args.pipeline_config.dit_config.boundary_ratio is not None:
boundary_timestep = fastvideo_args.pipeline_config.dit_config.boundary_ratio * self.scheduler.num_train_timesteps
high_noise_timesteps = timesteps[timesteps >= boundary_timestep]
else:
boundary_timestep = None
high_noise_timesteps = None
high_noise_timesteps = timesteps[timesteps >= boundary_timestep]
# Image kwargs (kept empty unless caller provides compatible args)
image_kwargs: dict = {}
@@ -146,18 +142,10 @@ class CausalDMDDenosingStage(DenoisingStage):
pos_start_base = 0
# Determine block sizes
if t % self.num_frames_per_block != 0:
raise ValueError(
"num_frames must be divisible by num_frames_per_block for causal DMD denoising"
)
num_blocks = t // self.num_frames_per_block
block_sizes = [self.num_frames_per_block] * num_blocks
block_sizes = [self.num_frames_per_block] * 7
block_sizes[0] = 1
start_index = 0
# For now hardcode the first block to be 1 frame assuming the model is Wan2.2-MoE
if boundary_timestep is not None:
block_sizes[0] = 1
first_frame_latent = None
if batch.pil_image is not None:
# Causal video gen directly replaces the first frame of the latent with
@@ -222,44 +210,16 @@ class CausalDMDDenosingStage(DenoisingStage):
block_sizes.pop(0)
latents[:, :, :1, :, :] = first_frame_latent
# Setup noise generator for SP
sp_world_size = get_sp_world_size()
sp_rank = get_sp_parallel_rank()
sp_group = sp_world_size > 1
noise_generator = batch.generator[0] if isinstance(
batch.generator, list) else batch.generator
if sp_group:
try:
# Use initial_seed + rank to ensure diversity across ranks
seed = noise_generator.initial_seed()
noise_generator = torch.Generator(
device=noise_generator.device).manual_seed(seed + sp_rank)
except Exception as e:
logger.warning(f"Could not create rank-specific generator: {e}")
# DMD loop in causal blocks
with self.progress_bar(total=len(block_sizes) *
len(timesteps)) as progress_bar:
for idx, current_num_frames in enumerate(block_sizes):
for current_num_frames in block_sizes:
current_latents = latents[:, :, start_index:start_index +
current_num_frames, :, :]
sp_world_size, rank_in_sp_group = get_sp_world_size(
), get_sp_parallel_rank()
sp_group = sp_world_size > 1
if sp_group:
current_latents = rearrange(current_latents,
"b c (n t) h w -> b c n t h w",
n=sp_world_size).contiguous()
current_latents = current_latents[:, :,
rank_in_sp_group, :, :, :]
# use BTCHW for DMD conversion routines
noise_latents_btchw = current_latents.permute(0, 2, 1, 3, 4)
video_raw_latent_shape = noise_latents_btchw.shape
logger.info(
f"before DMD: Noise latents shape: {noise_latents_btchw.shape}"
)
for i, t_cur in enumerate(timesteps):
if boundary_timestep is not None and t_cur < boundary_timestep:
current_model = self.transformer_2
@@ -315,7 +275,6 @@ class CausalDMDDenosingStage(DenoisingStage):
(latent_model_input.shape[0], 1),
device=latent_model_input.device,
dtype=torch.long)
assert current_model is not None, "current_model is not initialized"
pred_noise_btchw = current_model(
latent_model_input,
prompt_embeds,
@@ -352,10 +311,12 @@ class CausalDMDDenosingStage(DenoisingStage):
[1],
dtype=torch.long,
device=pred_video_btchw.device)
noise = torch.randn(video_raw_latent_shape,
dtype=pred_video_btchw.dtype,
generator=noise_generator).to(
self.device)
noise = torch.randn(
video_raw_latent_shape,
dtype=pred_video_btchw.dtype,
generator=(batch.generator[0] if isinstance(
batch.generator, list) else
batch.generator)).to(self.device)
noise_btchw = noise
if boundary_timestep is not None and i < len(
high_noise_timesteps) - 1:
@@ -383,6 +344,10 @@ class CausalDMDDenosingStage(DenoisingStage):
if progress_bar is not None:
progress_bar.update()
# Write back and advance
latents[:, :, start_index:start_index +
current_num_frames, :, :] = current_latents
# Re-run with context timestep to update KV cache using clean context
context_noise = getattr(fastvideo_args.pipeline_config,
"context_noise", 0)
@@ -390,7 +355,6 @@ class CausalDMDDenosingStage(DenoisingStage):
device=latents.device,
dtype=torch.long) * int(context_noise)
context_bcthw = current_latents.to(target_dtype)
with torch.autocast(device_type="cuda",
dtype=target_dtype,
enabled=autocast_enabled), \
@@ -400,7 +364,6 @@ class CausalDMDDenosingStage(DenoisingStage):
t_expanded_context = t_context.unsqueeze(1)
if boundary_timestep is not None:
assert self.transformer_2 is not None, "transformer_2 is not initialized"
self.transformer_2(
context_bcthw,
prompt_embeds,
@@ -427,19 +390,8 @@ class CausalDMDDenosingStage(DenoisingStage):
**pos_cond_kwargs,
)
if sp_group:
current_latents = sequence_model_parallel_all_gather(
current_latents, dim=2)
# Write back and advance
latents[:, :, start_index:start_index +
current_num_frames, :, :] = current_latents
start_index += current_num_frames
if boundary_timestep is not None:
num_frames_to_remove = self.num_frames_per_block - 1
latents = latents[:, :, :-num_frames_to_remove, :, :]
batch.latents = latents
return batch
@@ -449,11 +401,6 @@ class CausalDMDDenosingStage(DenoisingStage):
"""
kv_cache1 = []
num_attention_heads = self.transformer.num_attention_heads
sp_world_size = get_sp_world_size()
assert num_attention_heads % max(
sp_world_size,
1) == 0, "num_attention_heads must be divisible by sp_world_size"
heads_per_rank = num_attention_heads // max(sp_world_size, 1)
attention_head_dim = self.transformer.attention_head_dim
if self.local_attn_size != -1:
kv_cache_size = self.local_attn_size * self.frame_seq_length
@@ -464,14 +411,14 @@ class CausalDMDDenosingStage(DenoisingStage):
kv_cache1.append({
"k":
torch.zeros([
batch_size, kv_cache_size, heads_per_rank,
batch_size, kv_cache_size, num_attention_heads,
attention_head_dim
],
dtype=dtype,
device=device),
"v":
torch.zeros([
batch_size, kv_cache_size, heads_per_rank,
batch_size, kv_cache_size, num_attention_heads,
attention_head_dim
],
dtype=dtype,
+19 -19
View File
@@ -5,7 +5,6 @@ Input validation stage for diffusion pipelines.
import torch
import torchvision.transforms.functional as TF
from PIL import Image
from fastvideo.fastvideo_args import FastVideoArgs
from fastvideo.logger import init_logger
@@ -14,7 +13,6 @@ from fastvideo.pipelines.pipeline_batch_info import ForwardBatch
from fastvideo.pipelines.stages.base import PipelineStage
from fastvideo.pipelines.stages.validators import (StageValidators,
VerificationResult)
from fastvideo.utils import best_output_size
logger = init_logger(__name__)
@@ -110,25 +108,27 @@ class InputValidationStage(PipelineStage):
or fastvideo_args.pipeline_config.is_causal
) and batch.pil_image is not None:
img = batch.pil_image
ih, iw = img.height, img.width
patch_size = fastvideo_args.pipeline_config.dit_config.arch_config.patch_size
vae_stride = fastvideo_args.pipeline_config.vae_config.arch_config.scale_factor_spatial
dh, dw = patch_size[1] * vae_stride, patch_size[2] * vae_stride
max_area = 480 * 832
ow, oh = best_output_size(iw, ih, dw, dh, max_area)
# ih, iw = img.height, img.width
# patch_size = fastvideo_args.pipeline_config.dit_config.arch_config.patch_size
# vae_stride = fastvideo_args.pipeline_config.vae_config.arch_config.scale_factor_spatial
# dh, dw = patch_size[1] * vae_stride, patch_size[2] * vae_stride
# max_area = 720 * 1280
# ow, oh = best_output_size(iw, ih, dw, dh, max_area)
scale = max(ow / iw, oh / ih)
img = img.resize((round(iw * scale), round(ih * scale)),
Image.LANCZOS)
# center-crop
x1 = (img.width - ow) // 2
y1 = (img.height - oh) // 2
img = img.crop((x1, y1, x1 + ow, y1 + oh))
assert img.width == ow and img.height == oh
logger.info("final processed img height: %s, img width: %s",
img.height, img.width)
# scale = max(ow / iw, oh / ih)
# img = img.resize((round(iw * scale), round(ih * scale)),
# Image.LANCZOS)
# logger.info("resized img height: %s, img width: %s", img.height,
# img.width)
# # center-crop
# x1 = (img.width - ow) // 2
# y1 = (img.height - oh) // 2
# img = img.crop((x1, y1, x1 + ow, y1 + oh))
# assert img.width == ow and img.height == oh
logger.info("img height: %s, img width: %s", img.height, img.width)
oh = img.height
ow = img.width
# to tensor
img = TF.to_tensor(img).sub_(0.5).div_(0.5).to(
self.device).unsqueeze(1)
@@ -1,7 +1,6 @@
# SPDX-License-Identifier: Apache-2.0
import json
import os
import re
import pytest
@@ -53,38 +52,12 @@ LORA_CONFIGS = [
"negative_prompt": "bad quality video,色调艳丽,过曝,静态,细节模糊不清,字幕,风格,作品,画作,画面,静止,整体发灰,最差质量,低质量,JPEG压缩残留,丑陋的,残缺的,多余的手指,画得不好的手部,画得不好的脸部,畸形的,毁容的,形态畸形的肢体,手指融合,静止不动的画面,杂乱的背景,三条腿,背景人很多,倒着走",
"ssim_threshold": 0.79
}
# TODO: Add a LoRA with lora_alpha values to test alpha scaling
#
# Context: This change is mainly for an in-progress ticket porting over LongCat-Video,
# where they used an alpha value that is two times smaller than their rank. This fix
# ensures that LoRA weights are correctly scaled by the alpha/rank ratio when merged.
#
# Issue: Currently, we cannot add a test for LoRA adapters with alpha values because:
# - The existing public LoRAs for Wan-AI/Wan2.1-T2V-1.3B-Diffusers don't store lora_alpha
# - No publicly available LoRA for this model includes lora_alpha tensors in their weights
# - This is why the alpha/rank scaling bug wasn't caught by existing tests
#
# The fix has been validated with:
# - LongCat-Video distilled LoRA (which includes alpha values)
# - Manual testing shows correct alpha/rank scaling behavior
# - Backward compatibility confirmed with LoRAs without alpha values
#
# Future work:
# - Add a synthetic LoRA test fixture with alpha values when feasible
# - Or wait for public Wan LoRAs with alpha to become available
]
MODEL_TO_PARAMS = {
"Wan-AI/Wan2.1-T2V-1.3B-Diffusers": WAN_LORA_PARAMS,
}
def _sanitize_filename_component(name: str) -> str:
"""Sanitize filename to remove invalid characters (same logic as VideoGenerator)"""
sanitized = re.sub(r'[\\/:*?"<>|]', '', name)
sanitized = sanitized.strip().strip('.')
sanitized = re.sub(r'\s+', ' ', sanitized)
return sanitized or "video"
@pytest.mark.parametrize("model_id", list(MODEL_TO_PARAMS.keys()))
def test_merge_lora_weights(model_id):
lora_config = LORA_CONFIGS[0] # test only one
@@ -164,16 +137,14 @@ def test_lora_inference_similarity(ATTENTION_BACKEND, model_id):
generation_kwargs["negative_prompt"] = lora_config["negative_prompt"]
generator.set_lora_adapter(lora_nickname=lora_nickname, lora_path=lora_path)
# Sanitize the filename before adding .mp4 extension to match VideoGenerator's behavior
output_video_name = f"{lora_path.split('/')[-1]}_{prompt[:50]}"
output_video_name = _sanitize_filename_component(output_video_name)
generated_video_path = os.path.join(output_dir, f"{output_video_name}.mp4")
generation_kwargs["output_path"] = generated_video_path
generation_kwargs["output_path"] = output_dir
generation_kwargs["output_video_name"] = output_video_name
generator.generate_video(prompt, **generation_kwargs)
assert os.path.exists(
generated_video_path), f"Output video was not generated at {generated_video_path}"
output_dir), f"Output video was not generated at {output_dir}"
reference_folder = os.path.join(script_dir, 'L40S_reference_videos', model_id.split('/')[-1], ATTENTION_BACKEND)
@@ -182,25 +153,13 @@ def test_lora_inference_similarity(ATTENTION_BACKEND, model_id):
raise FileNotFoundError(
f"Reference video folder does not exist: {reference_folder}")
# Find the matching reference video - try exact match first, then fuzzy match
# The reference might have different sanitization (e.g., trailing spaces)
# Find the matching reference video for the switched LoRA
reference_video_name = None
unsanitized_prefix = f"{lora_path.split('/')[-1]}_{prompt[:50]}"
for filename in os.listdir(reference_folder):
if not filename.endswith('.mp4'):
continue
# Try exact match with sanitized name
if filename.startswith(output_video_name):
reference_video_name = filename
break
# Try match with unsanitized prefix (for legacy reference videos)
# Remove .mp4 and compare the base names after sanitization
base_filename = filename[:-4] # Remove .mp4
if _sanitize_filename_component(base_filename) == output_video_name:
reference_video_name = filename
# Check if the filename starts with the expected output_video_name and ends with .mp4
if filename.startswith(output_video_name) and filename.endswith('.mp4'):
reference_video_name = filename # Remove .mp4 extension to match the logic below
break
if not reference_video_name:
@@ -208,6 +167,7 @@ def test_lora_inference_similarity(ATTENTION_BACKEND, model_id):
raise FileNotFoundError(f"Reference video missing for adapter {lora_path}")
reference_video_path = os.path.join(reference_folder, reference_video_name)
generated_video_path = os.path.join(output_dir, output_video_name + ".mp4")
logger.info(
f"Computing SSIM between {reference_video_path} and {generated_video_path}"
@@ -729,6 +729,9 @@ class DistillationPipeline(TrainingPipeline):
self.num_train_timestep, [1],
device=self.device,
dtype=torch.long)
world_group = get_world_group()
if world_group.world_size > 1:
world_group.broadcast(timestep, src=0)
timestep = shift_timestep(
timestep,
@@ -841,6 +844,9 @@ class DistillationPipeline(TrainingPipeline):
self.num_train_timestep, [1],
device=self.device,
dtype=torch.long)
world_group = get_world_group()
if world_group.world_size > 1:
world_group.broadcast(fake_score_timestep, src=0)
fake_score_timestep = shift_timestep(
fake_score_timestep,
-4
View File
@@ -467,10 +467,6 @@ class WorkerMultiprocProc:
"output_batch": output_batch.output.cpu(),
"logging_info": logging_info
})
else:
result = self.worker.execute_method(
method, *args, **kwargs)
self.pipe.send(result)
else:
result = self.worker.execute_method(method, *args, **kwargs)
self.pipe.send(result)
-1
View File
@@ -14,7 +14,6 @@ edit_uri: edit/main/docs/
# Configuration
theme:
name: material
favicon: assets/logos/icon_simple.svg
palette:
- scheme: default
toggle: