[bugfix] cosmos3: fix black-video UniPC config; wire E2E inference

The first real-weights Cosmos3-Nano T2V run produced an all-black video.
Root cause: the checkpoint's diffusers-style scheduler_config.json sets
use_karras_sigmas=true, and FastVideo's vendored UniPC checks karras
*before* use_flow_sigmas, so it built diffusion (beta) sigmas instead of
flow-matching sigmas -> scheduler.step diverged to NaN latents -> black
frames. The DiT/CFG velocity itself was clean.

The framework samples with FlowUniPCMultistepScheduler (pure flow:
shift + num_train_timesteps only). FastVideo's vendored UniPC is the same
algorithm, so the fix is config, not a port:

- Coerce the loaded scheduler to the flow setup in
  Cosmos3OmniDiffusersPipeline.initialize_pipeline (kill karras/exp/beta
  sigmas, force use_flow_sigmas + flow_prediction + final_sigmas_type=zero)
  and rebuild the runtime scheduler from it.
- Use FastVideo's native UniPC (not diffusers) in the pipeline and tests,
  honoring the "no diffusers at runtime" constraint.

Parity:
- Add test_cosmos3_scheduler_parity.py: FastVideo vendored UniPC (flow
  config) vs the framework FlowUniPCMultistepScheduler. timesteps bit-exact,
  sigmas within fp32 epsilon (~1e-8), full multi-step trajectory < ~1e-6
  across shift in {10 (video), 3 (t2i)} and steps in {4,10,35}.
- Fix test_cosmos3_denoise_cfg_parity to use the framework scheduler as the
  oracle (it previously compared diffusers-vs-diffusers, so the scheduler
  was never actually checked against the framework).

Integration fixes to run end-to-end on real weights:
- registry: map checkpoint DiT class Cosmos3OmniTransformer ->
  Cosmos3VFMTransformer.
- component_loader: route the "text_tokenizer" module to TokenizerLoader;
  filter scheduler config to the class's __init__ params (robust to
  diffusers schema drift like shift_terminal/sigma_min/sigma_max).
- DiT: materialize_non_persistent_buffers (recompute rotary_emb.inv_freq
  after meta-device FSDP load) + cast vision latents / timestep embeds to
  the compute dtype (no-ops in the fp32 parity tests).
- sequence_packing: move packed tensors to the model device in to_dit_kwargs.
- config: drop the unused identity text-preprocess (Cosmos3 has no text
  encoder; the DiT tokenizes in the denoise stage).

Adds examples/inference/basic/basic_cosmos3_new_api.py (new VideoGenerator
API). Verified: 1280x704, 29 frames, 35 steps on B200 -> coherent video
matching the prompt (no NaNs, real pixel variance). Full cosmos3 parity
suite green.
This commit is contained in:
SolitaryThinker
2026-07-20 12:19:45 -07:00
parent 4747d51265
commit 455cd83bed
11 changed files with 334 additions and 53 deletions
@@ -0,0 +1,77 @@
import os
import time
from fastvideo import VideoGenerator
from fastvideo.api import (
EngineConfig,
GenerationRequest,
GeneratorConfig,
OffloadConfig,
OutputConfig,
SamplingConfig,
)
# NVIDIA Cosmos3-Nano omni world model — this example exercises the
# text-to-video (T2V) path through FastVideo's native Cosmos3 pipeline.
# Point COSMOS3_MODEL_PATH at a local diffusers checkpoint (e.g.
# ``official_weights/cosmos3``) to skip the Hugging Face download.
OUTPUT_PATH = "video_samples_cosmos3"
def main():
model_name = os.environ.get("COSMOS3_MODEL_PATH", "nvidia/Cosmos3-Nano")
generator_config = GeneratorConfig(
model_path=model_name,
engine=EngineConfig(
num_gpus=1,
use_fsdp_inference=False,
offload=OffloadConfig(
text_encoder=True,
pin_cpu_memory=True,
dit=False,
vae=False,
),
),
)
load_start_time = time.perf_counter()
generator = VideoGenerator.from_config(generator_config)
load_time = time.perf_counter() - load_start_time
prompt = (
"A golden retriever puppy runs across a sunlit meadow toward the camera, "
"ears flopping and wildflowers swaying in the breeze. Shallow depth of "
"field, warm afternoon light, smooth cinematic tracking shot."
)
request = GenerationRequest(
prompt=prompt,
sampling=SamplingConfig(
# cosmos3_nano native defaults are 704x1280, 189 frames, 35 steps;
# overridable via env for quick smoke runs.
num_frames=int(os.environ.get("COSMOS3_NUM_FRAMES", "189")),
height=int(os.environ.get("COSMOS3_HEIGHT", "704")),
width=int(os.environ.get("COSMOS3_WIDTH", "1280")),
num_inference_steps=int(os.environ.get("COSMOS3_STEPS", "35")),
guidance_scale=6.0,
fps=24,
seed=1024,
),
output=OutputConfig(
output_path=OUTPUT_PATH,
save_video=True,
return_frames=False,
),
)
start_time = time.perf_counter()
result = generator.generate(request)
gen_time = time.perf_counter() - start_time
print(f"Time taken to load model: {load_time} seconds")
print(f"Time taken to generate video: {gen_time} seconds")
print(f"Output written to: {result.video_path}")
if __name__ == "__main__":
main()
+9 -10
View File
@@ -12,8 +12,11 @@ Cosmos 2.5:
the ``text_tokenizer`` checkpoint module by the component loader.
- VAE is Wan2.2 ``AutoencoderKLWan`` (z_dim=48, scale_factor_spatial=16),
configured by ``Cosmos3VAEConfig`` (the checkpoint's exact latents_mean/std).
- Scheduler is diffusers ``UniPCMultistepScheduler`` (flow_prediction,
use_flow_sigmas), loaded from the checkpoint.
- Scheduler is FastVideo-native ``UniPCMultistepScheduler`` configured for
pure flow matching (flow_prediction, use_flow_sigmas), equivalent to the
framework's ``FlowUniPCMultistepScheduler``. The checkpoint's diffusers-style
scheduler config (karras/sigma_min/max) is coerced to the flow setup in
``Cosmos3OmniDiffusersPipeline.initialize_pipeline``.
- T2I default ``flow_shift`` is 3.0 (set per-request by ``_set_flow_shift``);
T2V/I2V use the engine-init default of 1.0 baked into this config.
"""
@@ -29,10 +32,6 @@ from fastvideo.configs.models.vaes import Cosmos3VAEConfig # Wan2.2 Autoencoder
from fastvideo.configs.pipelines.base import PipelineConfig
def _identity_preprocess_text(prompt: str) -> str:
return prompt
@dataclass
class Cosmos3Config(PipelineConfig):
"""Configuration for the Cosmos3 video generation pipeline (T2V/I2V/T2I).
@@ -46,11 +45,11 @@ class Cosmos3Config(PipelineConfig):
vae_config: VAEConfig = field(default_factory=Cosmos3VAEConfig)
# No separate text encoder: Cosmos3LanguageModel lives inside the DiT.
# No separate text encoder: the Qwen3-VL-text backbone lives inside the DiT
# and the pipeline tokenizes in Cosmos3DenoisingStage, so all three
# text-encoder lists are empty (the generic text-encode stage is not used).
text_encoder_configs: tuple[EncoderConfig, ...] = field(default_factory=tuple)
preprocess_text_funcs: tuple[Callable[[str], str],
...] = field(default_factory=lambda: (_identity_preprocess_text, ))
preprocess_text_funcs: tuple[Callable[[str], str], ...] = field(default_factory=tuple)
postprocess_text_funcs: tuple[Callable[[BaseEncoderOutput], torch.Tensor], ...] = field(default_factory=tuple)
dit_precision: str = "bf16"
+28 -3
View File
@@ -161,11 +161,21 @@ class Cosmos3TextRotaryEmbedding(nn.Module):
) -> None:
super().__init__()
self.head_dim = head_dim
self.rope_theta = float(rope_theta)
self.mrope_section = list(mrope_section)
inv_freq = 1.0 / (rope_theta**(torch.arange(0, head_dim, 2, dtype=torch.int64).float() / head_dim))
self.register_buffer("inv_freq", inv_freq, persistent=False)
self.register_buffer("inv_freq", self._compute_inv_freq(), persistent=False)
self.attention_scaling = 1.0 # default RoPE has unit attention scaling
def _compute_inv_freq(self, device: torch.device | None = None) -> torch.Tensor:
exponent = torch.arange(0, self.head_dim, 2, dtype=torch.int64, device=device).float() / self.head_dim
return 1.0 / (self.rope_theta**exponent)
def reset_inv_freq(self, device: torch.device) -> None:
"""Recompute the non-persistent ``inv_freq`` on ``device`` after a
meta-device weight load (it is derived from ``rope_theta`` and is not
part of the checkpoint)."""
self.register_buffer("inv_freq", self._compute_inv_freq(device), persistent=False)
def _apply_interleaved_mrope(self, freqs: torch.Tensor) -> torch.Tensor:
"""freqs: [3, N, head_dim//2] -> [N, head_dim//2] (interleaved T/H/W)."""
freqs_t = freqs[0].clone()
@@ -323,6 +333,9 @@ class Cosmos3TimestepEmbedder(nn.Module):
def forward(self, t: torch.Tensor) -> torch.Tensor:
t_freq = self.timestep_embedding(t, self.frequency_embedding_size)
# Sinusoidal runs in fp32 (timestep_scale makes inputs tiny); cast to the
# MLP weight dtype (bf16 at inference; no-op in the fp32 parity tests).
t_freq = t_freq.to(self.linear_1.weight.dtype)
h, _ = self.linear_1(t_freq)
h = self.act(h)
out, _ = self.linear_2(h)
@@ -770,6 +783,16 @@ class Cosmos3VFMTransformer(BaseDiT):
flat = flat.unsqueeze(-1).expand(-1, packed_tokens.shape[1])
return packed_tokens.scatter_add(0, flat, packed_timestep_embeds)
def materialize_non_persistent_buffers(self, device: torch.device, dtype: torch.dtype | None = None) -> None:
"""Recompute non-persistent buffers lost by the meta-device FSDP load.
Only ``rotary_emb.inv_freq`` is non-persistent (derived from
``rope_theta``, absent from the checkpoint). The FastVideo loader calls
this after ``load_model_from_full_model_state_dict``.
"""
del dtype # inv_freq stays float32 regardless of compute dtype
self.rotary_emb.reset_inv_freq(device)
# ------------------------------------------------------------------
# forward
# ------------------------------------------------------------------
@@ -809,7 +832,9 @@ class Cosmos3VFMTransformer(BaseDiT):
original_shapes: list[tuple[int, int, int]] | None = None
if vision_tokens:
packed_vision, original_shapes = self._patchify_and_pack(vision_tokens, vision_token_shapes)
packed_vision, _ = self.proj_in(packed_vision)
# Vision latents arrive in fp32 (noise/VAE); cast to the model's
# compute dtype (bf16 at inference; no-op in the fp32 parity tests).
packed_vision, _ = self.proj_in(packed_vision.to(target_dtype))
if self.latent_pos_embed is not None:
pos_emb = self.latent_pos_embed(vision_token_shapes, fps=fps_vision).to(target_dtype)
+15 -1
View File
@@ -92,6 +92,8 @@ class ComponentLoader(ABC):
"tokenizer": (TokenizerLoader, "transformers"),
"tokenizer_2": (TokenizerLoader, "transformers"),
"tokenizer_3": (TokenizerLoader, "transformers"),
# Cosmos3's model_index names its Qwen2 tokenizer "text_tokenizer".
"text_tokenizer": (TokenizerLoader, "transformers"),
"image_processor": (ImageProcessorLoader, "transformers"),
"feature_extractor": (ImageProcessorLoader, "transformers"),
"image_encoder": (ImageEncoderLoader, "transformers"),
@@ -1159,7 +1161,19 @@ class SchedulerLoader(ComponentLoader):
scheduler_cls, _ = ModelRegistry.resolve_model_cls(class_name)
scheduler = scheduler_cls(**config)
# Diffusers checkpoints can carry newer scheduler config keys than the
# vendored scheduler accepts (e.g. shift_terminal / sigma_min / sigma_max
# from a newer diffusers release). Filter to the class's __init__ params,
# mirroring diffusers' ``from_config``, so loading is robust to schema
# drift instead of crashing on an unexpected kwarg.
import inspect
valid_params = set(inspect.signature(scheduler_cls.__init__).parameters)
filtered_config = {k: v for k, v in config.items() if k in valid_params}
dropped = sorted(set(config) - set(filtered_config))
if dropped:
logger.warning("Scheduler %s: dropping unsupported config keys %s", class_name, dropped)
scheduler = scheduler_cls(**filtered_config)
if fastvideo_args.pipeline_config.flow_shift is not None:
scheduler.set_shift(fastvideo_args.pipeline_config.flow_shift)
return scheduler
+3
View File
@@ -37,6 +37,9 @@ _TEXT_TO_VIDEO_DIT_MODELS = {
"CausalWanTransformer3DModel": ("dits", "causal_wanvideo", "CausalWanTransformer3DModel"),
"CosmosTransformer3DModel": ("dits", "cosmos", "CosmosTransformer3DModel"),
"Cosmos25Transformer3DModel": ("dits", "cosmos2_5", "Cosmos25Transformer3DModel"),
# Cosmos3-Nano's checkpoint model_index names the DiT "Cosmos3OmniTransformer";
# map that HF class name to FastVideo's native Cosmos3VFMTransformer.
"Cosmos3OmniTransformer": ("dits", "cosmos3", "Cosmos3VFMTransformer"),
"LongCatVideoTransformer3DModel": ("dits", "longcat_video_dit", "LongCatVideoTransformer3DModel"), # Wrapper (Phase 1)
"LongCatTransformer3DModel": ("dits", "longcat", "LongCatTransformer3DModel"), # Native (Phase 2)
"LTX2Transformer3DModel": ("dits", "ltx2", "LTX2Transformer3DModel"),
@@ -11,8 +11,10 @@ Cosmos3 components:
encode normalizes ``(mu - mean) * inv_std`` and decode denormalizes + clamps,
* sequence-packing: :func:`pack_cosmos3_video_sequence` (native, parity-tested),
* DiT: ``Cosmos3VFMTransformer`` (native, bit-identical to the framework),
* scheduler: diffusers ``UniPCMultistepScheduler`` (the actual scheduler the
checkpoint ships, ``flow_prediction`` + ``use_flow_sigmas``).
* scheduler: FastVideo-native ``UniPCMultistepScheduler`` configured for pure
flow matching (``flow_prediction`` + ``use_flow_sigmas``), numerically
equivalent to the framework's ``FlowUniPCMultistepScheduler`` (parity-tested
in ``test_cosmos3_scheduler_parity``).
The denoise/CFG glue is a faithful port of the framework's
``Cosmos3OmniDiffusersPipeline`` math (mirrored in the framework-equivalent
@@ -40,10 +42,11 @@ from dataclasses import dataclass
from typing import Any
import torch
from diffusers import UniPCMultistepScheduler
from fastvideo.fastvideo_args import FastVideoArgs
from fastvideo.logger import init_logger
from fastvideo.models.schedulers.scheduling_unipc_multistep import (
UniPCMultistepScheduler, )
from fastvideo.pipelines.basic.cosmos3.sequence_packing import (
Cosmos3SampleInputs,
Cosmos3VisionItem,
@@ -265,7 +268,8 @@ def cosmos3_get_cfg_velocity(
base_fps=base_fps,
temporal_compression_factor=temporal_compression_factor,
)
out = transformer(**packed.to_dit_kwargs())
device = next(transformer.parameters()).device
out = transformer(**packed.to_dit_kwargs(device=device))
preds = out.get("preds_vision")
if preds is None:
# Fully-conditioned clip: no noisy frames, zero velocity.
@@ -424,6 +428,31 @@ class Cosmos3OmniDiffusersPipeline(ComposedPipelineBase):
_base_scheduler_config: Any = None
_current_flow_shift: float | None = None
@staticmethod
def _flow_scheduler_config(config: Any) -> dict[str, Any]:
"""Coerce a loaded UniPC config to the framework's flow-matching setup.
The checkpoint ``scheduler_config.json`` carries diffusers-style fields
(``use_karras_sigmas=True``, ``sigma_min``/``sigma_max``, beta schedule)
that do not describe the framework sampler. The framework uses
``FlowUniPCMultistepScheduler`` (pure flow matching: ``shift`` +
``num_train_timesteps`` only). FastVideo's vendored UniPC checks
``use_karras_sigmas`` *before* ``use_flow_sigmas``, so leaving karras on
builds diffusion-style sigmas and the denoise diverges to NaN. Force the
flow config here (parity-verified in ``test_cosmos3_scheduler_parity``).
"""
cfg = dict(config)
cfg.update(
use_karras_sigmas=False,
use_exponential_sigmas=False,
use_beta_sigmas=False,
use_flow_sigmas=True,
prediction_type="flow_prediction",
predict_x0=True,
final_sigmas_type="zero",
)
return cfg
def initialize_pipeline(self, fastvideo_args: FastVideoArgs) -> None:
"""Bind the loaded scheduler + snapshot its config so per-request
flow_shift rebuilds are cheap and the engine-init shift is applied."""
@@ -433,9 +462,14 @@ class Cosmos3OmniDiffusersPipeline(ComposedPipelineBase):
self._engine_init_flow_shift = float(engine_shift)
scheduler = self.get_module("scheduler")
if scheduler is not None:
self.scheduler = scheduler
self._base_scheduler_config = scheduler.config
self._current_flow_shift = float(getattr(scheduler.config, "flow_shift", 1.0))
# Rebuild from a flow-coerced config so the runtime scheduler matches
# the framework sampler (the loaded checkpoint config is diffusers-style).
flow_config = self._flow_scheduler_config(scheduler.config)
self.scheduler = UniPCMultistepScheduler.from_config(flow_config)
if isinstance(self.modules, dict):
self.modules["scheduler"] = self.scheduler
self._base_scheduler_config = self.scheduler.config
self._current_flow_shift = float(getattr(self.scheduler.config, "flow_shift", 1.0))
def create_pipeline_stages(self, fastvideo_args: FastVideoArgs) -> None:
"""Wire the Cosmos3 stages.
@@ -111,21 +111,30 @@ class Cosmos3PackedSequence:
vision_condition_mask: list[torch.Tensor]
fps_vision: torch.Tensor | None = None
def to_dit_kwargs(self) -> dict[str, Any]:
"""Return the kwargs dict for ``Cosmos3VFMTransformer.forward``."""
def to_dit_kwargs(self, device: torch.device | str | None = None) -> dict[str, Any]:
"""Return the kwargs dict for ``Cosmos3VFMTransformer.forward``.
Packing is device-agnostic (ids/indexes/position-ids are built on CPU).
When ``device`` is given, every tensor input is moved to it so the DiT
forward runs on a single device (e.g. the model's GPU at inference).
"""
def _mv(x: Any) -> Any:
return x.to(device) if (device is not None and torch.is_tensor(x)) else x
return dict(
text_ids=self.text_ids,
text_indexes=self.text_indexes,
position_ids=self.position_ids,
text_ids=_mv(self.text_ids),
text_indexes=_mv(self.text_indexes),
position_ids=_mv(self.position_ids),
sequence_length=int(self.sequence_length),
split_lens=list(self.split_lens),
attn_modes=list(self.attn_modes),
vision_tokens=list(self.vision_tokens),
vision_tokens=[_mv(t) for t in self.vision_tokens],
vision_token_shapes=list(self.vision_token_shapes),
vision_sequence_indexes=self.vision_sequence_indexes,
vision_timesteps=self.vision_timesteps,
vision_mse_loss_indexes=self.vision_mse_loss_indexes,
vision_noisy_frame_indexes=list(self.vision_noisy_frame_indexes),
vision_sequence_indexes=_mv(self.vision_sequence_indexes),
vision_timesteps=_mv(self.vision_timesteps),
vision_mse_loss_indexes=_mv(self.vision_mse_loss_indexes),
vision_noisy_frame_indexes=[_mv(t) for t in self.vision_noisy_frame_indexes],
fps_vision=self.fps_vision,
)
+3 -1
View File
@@ -18,7 +18,9 @@ from typing import Any
import pytest
import torch
from diffusers import UniPCMultistepScheduler
from fastvideo.models.schedulers.scheduling_unipc_multistep import (
UniPCMultistepScheduler,
)
from torch import nn
_LATENT_CHANNEL = 16
@@ -39,8 +39,6 @@ cosmos_framework = pytest.importorskip(
reason="cosmos_framework not installed; run in fv-cosmos3 env.",
)
from diffusers import UniPCMultistepScheduler # noqa: E402
from .test_cosmos3_dit_parity import _copy_weights # noqa: E402
from .test_cosmos3_dit_parity_mrope import ( # noqa: E402
_LATENT_CHANNEL,
@@ -52,6 +50,10 @@ from .test_cosmos3_dit_parity_mrope import ( # noqa: E402
_build_tiny_fastvideo_dit_mrope,
)
from .test_cosmos3_reference_forward import _apply_sdpa_patches # noqa: E402
from .test_cosmos3_scheduler_parity import ( # noqa: E402
_fastvideo_scheduler,
_framework_scheduler,
)
pytestmark = [pytest.mark.local]
@@ -64,16 +66,9 @@ _SPECIAL_TOKENS = {
"eos_token_id": 62,
}
def _make_scheduler() -> UniPCMultistepScheduler:
"""UniPC scheduler matching the Cosmos3 checkpoint flow config."""
return UniPCMultistepScheduler(
num_train_timesteps=1000,
solver_order=2,
prediction_type="flow_prediction",
use_flow_sigmas=True,
flow_shift=10.0,
)
# Cosmos3 video flow_shift; framework scheduler is the parity oracle, FastVideo's
# vendored UniPC (flow config) is the unit under test.
_FLOW_SHIFT = 10.0
# ---------------------------------------------------------------------------
@@ -250,10 +245,8 @@ class TestCosmos3DenoiseCFGParity:
flat_latent = torch.randn(int(torch.tensor(vision_shape).prod()))
guidance = 6.0
fw_sched = _make_scheduler()
fv_sched = _make_scheduler()
fw_sched.set_timesteps(4, device=torch.device("cpu"))
fv_sched.set_timesteps(4, device=torch.device("cpu"))
fw_sched = _framework_scheduler(4, _FLOW_SHIFT)
fv_sched = _fastvideo_scheduler(4, _FLOW_SHIFT)
t = fw_sched.timesteps[0]
fw_v = _framework_cfg_velocity(
@@ -304,8 +297,7 @@ class TestCosmos3DenoiseCFGParity:
num_steps = 3
# Manual framework loop (oracle).
fw_sched = _make_scheduler()
fw_sched.set_timesteps(num_steps, device=torch.device("cpu"))
fw_sched = _framework_scheduler(num_steps, _FLOW_SHIFT)
fw_latent = flat_latent.clone()
for t in fw_sched.timesteps:
v = _framework_cfg_velocity(
@@ -322,8 +314,7 @@ class TestCosmos3DenoiseCFGParity:
return_dict=False)[0].squeeze(0)
# FastVideo engine loop.
fv_sched = _make_scheduler()
fv_sched.set_timesteps(num_steps, device=torch.device("cpu"))
fv_sched = _fastvideo_scheduler(num_steps, _FLOW_SHIFT)
engine = Cosmos3DenoiseEngine(
transformer=dit,
scheduler=fv_sched,
@@ -35,7 +35,10 @@ from typing import Any
import pytest
import torch
from diffusers import UniPCMultistepScheduler
from fastvideo.models.schedulers.scheduling_unipc_multistep import (
UniPCMultistepScheduler,
)
pytestmark = [pytest.mark.local]
@@ -0,0 +1,124 @@
# SPDX-License-Identifier: Apache-2.0
"""Numerical-parity test: FastVideo's UniPC scheduler vs the framework's.
The Cosmos3 video sampler is the framework's flow-matching UniPC
(``cosmos_framework.model.vfm.diffusion.samplers.fm_solvers_unipc.FlowUniPCMultistepScheduler``),
driven by ``UniPCSampler`` with config ``num_train_timesteps=1000``,
``use_dynamic_shifting=False`` and a per-mode ``shift`` (10.0 for T2V/I2V,
3.0 for T2I). FastVideo reuses its vendored
``UniPCMultistepScheduler`` configured for pure flow matching
(``use_flow_sigmas=True``, ``prediction_type="flow_prediction"``,
``predict_x0=True``, ``solver_type="bh2"``, ``solver_order=2``,
``final_sigmas_type="zero"``) with ``flow_shift`` set to the same shift.
This pins the scheduler — the one Cosmos3 component whose earlier test
(``test_cosmos3_denoise_cfg_parity``) compared diffusers-vs-diffusers rather
than against the framework oracle — by:
* asserting the discrete ``timesteps`` and ``sigmas`` match the framework;
* running a full multi-step UniPC trajectory with a fixed sequence of
pseudo-random "velocity" model outputs (identical on both sides, so the
DiT is factored out) and asserting every intermediate + final latent
matches the framework bit-for-bit.
CPU / float32. The framework scheduler is the parity ORACLE.
Run:
cd <worktree> && <fv-cosmos3 python> -m pytest \
tests/local_tests/cosmos3/test_cosmos3_scheduler_parity.py -q
"""
from __future__ import annotations
import pytest
import torch
# The official framework provides the parity oracle.
fm_unipc = pytest.importorskip(
"cosmos_framework.model.vfm.diffusion.samplers.fm_solvers_unipc",
reason="cosmos_framework not installed; run in fv-cosmos3 env.",
)
FlowUniPCMultistepScheduler = fm_unipc.FlowUniPCMultistepScheduler
from fastvideo.models.schedulers.scheduling_unipc_multistep import ( # noqa: E402
UniPCMultistepScheduler,
)
pytestmark = [pytest.mark.local]
def _framework_scheduler(num_steps: int, shift: float) -> FlowUniPCMultistepScheduler:
"""Exactly how ``UniPCSampler`` builds + primes its scheduler."""
sched = FlowUniPCMultistepScheduler(
num_train_timesteps=1000,
shift=1.0,
use_dynamic_shifting=False,
)
sched.set_timesteps(num_steps, device=torch.device("cpu"), shift=shift)
return sched
def _fastvideo_scheduler(num_steps: int, shift: float) -> UniPCMultistepScheduler:
"""FastVideo's vendored UniPC configured for the framework's flow setup."""
sched = UniPCMultistepScheduler(
num_train_timesteps=1000,
solver_order=2,
prediction_type="flow_prediction",
use_flow_sigmas=True,
predict_x0=True,
solver_type="bh2",
final_sigmas_type="zero",
flow_shift=shift,
)
sched.set_timesteps(num_steps, device=torch.device("cpu"))
return sched
_SHIFTS = [pytest.param(10.0, id="shift10_video"), pytest.param(3.0, id="shift3_t2i")]
_STEPS = [pytest.param(4, id="4steps"), pytest.param(10, id="10steps"), pytest.param(35, id="35steps")]
class TestCosmos3SchedulerParity:
@pytest.mark.parametrize("shift", _SHIFTS)
@pytest.mark.parametrize("num_steps", _STEPS)
def test_timesteps_and_sigmas_match_framework(self, num_steps, shift):
fw = _framework_scheduler(num_steps, shift)
fv = _fastvideo_scheduler(num_steps, shift)
t_max = (fw.timesteps.float() - fv.timesteps.float()).abs().max().item()
s_max = (fw.sigmas.float() - fv.sigmas.float()).abs().max().item()
print(f"\n[sched n={num_steps} shift={shift}] timesteps max diff={t_max:.3e} "
f"sigmas max diff={s_max:.3e}")
assert fw.timesteps.shape == fv.timesteps.shape
assert fw.sigmas.shape == fv.sigmas.shape
torch.testing.assert_close(fv.timesteps, fw.timesteps)
torch.testing.assert_close(fv.sigmas, fw.sigmas)
@pytest.mark.parametrize("shift", _SHIFTS)
@pytest.mark.parametrize("num_steps", _STEPS)
def test_full_trajectory_matches_framework(self, num_steps, shift):
# A small latent so order-2 einsum paths exercise; batch axis as the
# samplers expect ([B, C, T, H, W]).
shape = (1, 4, 2, 3, 3)
torch.manual_seed(123)
init = torch.randn(shape, dtype=torch.float32)
# One pseudo-random "velocity" per step, identical on both sides.
velocities = [torch.randn(shape, dtype=torch.float32) for _ in range(num_steps)]
fw = _framework_scheduler(num_steps, shift)
fv = _fastvideo_scheduler(num_steps, shift)
fw_lat = init.clone()
fv_lat = init.clone()
worst = 0.0
for i, t in enumerate(fw.timesteps):
v = velocities[i]
fw_lat = fw.step(model_output=v, timestep=t, sample=fw_lat, return_dict=False)[0]
fv_lat = fv.step(model_output=v, timestep=t, sample=fv_lat, return_dict=False)[0]
step_max = (fw_lat - fv_lat).abs().max().item()
worst = max(worst, step_max)
assert not torch.isnan(fv_lat).any(), f"FastVideo latent NaN at step {i}"
mean_abs = (fw_lat - fv_lat).abs().mean().item()
print(f"\n[traj n={num_steps} shift={shift}] worst step max diff={worst:.3e} "
f"final mean diff={mean_abs:.3e}")
torch.testing.assert_close(fv_lat, fw_lat, atol=1e-5, rtol=1e-4)