[ci] Replace flaky LTX-2 pixel SSIM with latent-slice cosine regression (#1253)
Co-authored-by: SolitaryThinker <wlsaidhi@gmail.com>
This commit is contained in:
@@ -1,26 +1,41 @@
|
|||||||
---
|
---
|
||||||
name: seed-ssim-references
|
name: seed-ssim-references
|
||||||
description: Seed HF reference videos for a single newly-added SSIM test. Runs the test on Modal L40S, downloads the generated mp4s via `modal volume get`, pauses for the user to eyeball quality, then uploads only that test's files to `FastVideo/ssim-reference-videos`. Use when a new `fastvideo/tests/ssim/test_*_similarity.py` has just been added and has no references on HF yet.
|
description: Seed HF reference artefacts for a single newly-added SSIM test (pixel `.mp4` for `run_text_to_video_similarity_test`-style tests, or latent `.pt` for `run_text_to_latent_similarity_test`-style tests). Runs the test on Modal L40S, downloads the generated artefacts via `modal volume get`, pauses for the user to verify (visual eyeball for mp4, numerics dump for pt), then uploads only that test's files to `FastVideo/ssim-reference-videos`. Use when a new `fastvideo/tests/ssim/test_*_similarity.py` has just been added and has no references on HF yet.
|
||||||
---
|
---
|
||||||
|
|
||||||
# Seed SSIM Reference Videos
|
# Seed SSIM Reference Artefacts (mp4 or pt)
|
||||||
|
|
||||||
## Purpose
|
## Purpose
|
||||||
|
|
||||||
A brand-new SSIM test in `fastvideo/tests/ssim/` fails forever until its
|
A brand-new SSIM test in `fastvideo/tests/ssim/` fails forever until its
|
||||||
reference videos exist on the HF dataset (`FastVideo/ssim-reference-videos`).
|
reference artefacts exist on the HF dataset
|
||||||
|
(`FastVideo/ssim-reference-videos`). The dataset hosts two kinds of artefacts
|
||||||
|
side-by-side per `(model_id, backend, prompt)`:
|
||||||
|
|
||||||
|
- **`.mp4`** — pixel ground-truth for tests that call
|
||||||
|
`run_text_to_video_similarity_test` / `run_image_to_video_similarity_test`
|
||||||
|
in `inference_similarity_utils.py`. Compared via SSIM.
|
||||||
|
- **`.pt`** — pre-VAE latent bundle (fp16 full latent + fp32 slice +
|
||||||
|
metadata + `slice_spec` + `format_version`) for tests that call
|
||||||
|
`run_text_to_latent_similarity_test` in `latent_similarity_utils.py`.
|
||||||
|
Compared via cosine distance on the slice and the full tensor.
|
||||||
|
|
||||||
This skill:
|
This skill:
|
||||||
|
|
||||||
1. Runs the test on Modal's L40S pool to generate the videos.
|
1. Detects which artefact type the test produces (pixel vs latent).
|
||||||
2. Downloads them to the local repo via `modal volume get`.
|
2. Runs the test on Modal's L40S pool to generate the artefacts.
|
||||||
3. Pauses so the user can eyeball the mp4s and confirm quality.
|
3. Downloads them to the local repo via `modal volume get`.
|
||||||
4. Uploads only the new test's files to HF, with a guard that refuses to
|
4. Pauses so the user can verify quality:
|
||||||
|
- **mp4**: visual eyeball in a video player.
|
||||||
|
- **pt**: numerics dump (shape, slice stats, NaN/Inf check, metadata).
|
||||||
|
5. Uploads only the new test's files to HF, with a guard that refuses to
|
||||||
overwrite anything already present.
|
overwrite anything already present.
|
||||||
|
|
||||||
The skill is run **manually**, once per new test. Before invoking it, the user
|
The skill is run **manually**, once per new test. Before invoking it, the user
|
||||||
has already sanity-tested the new test locally — it launches `VideoGenerator`
|
has already sanity-tested the new test locally — it launches `VideoGenerator`
|
||||||
and writes an mp4 without crashing. The skill does not re-test locally; it
|
and writes an artefact without crashing (the missing-reference assertion at
|
||||||
goes straight to Modal L40S (which is what CI uses).
|
the end is expected). The skill does not re-test locally; it goes straight
|
||||||
|
to Modal L40S (which is what CI uses).
|
||||||
|
|
||||||
## When to use
|
## When to use
|
||||||
|
|
||||||
@@ -69,7 +84,7 @@ Fail fast if the token env var is missing.
|
|||||||
|
|
||||||
## Steps
|
## Steps
|
||||||
|
|
||||||
### 1. Ask for the test file
|
### 1. Ask for the test file, then detect artefact type
|
||||||
|
|
||||||
If the user didn't name one, ask: *"Which SSIM test file do you want to seed
|
If the user didn't name one, ask: *"Which SSIM test file do you want to seed
|
||||||
references for? (e.g. `fastvideo/tests/ssim/test_ltx2_similarity.py`)"*.
|
references for? (e.g. `fastvideo/tests/ssim/test_ltx2_similarity.py`)"*.
|
||||||
@@ -80,6 +95,22 @@ Validate:
|
|||||||
- File defines a `*_MODEL_TO_PARAMS` dict — grep it to extract the set of
|
- File defines a `*_MODEL_TO_PARAMS` dict — grep it to extract the set of
|
||||||
model ids. Those ids drive step 5.
|
model ids. Those ids drive step 5.
|
||||||
|
|
||||||
|
Detect artefact type by inspecting the file's imports / helper call:
|
||||||
|
|
||||||
|
- **latent** (`.pt`) — file imports `run_text_to_latent_similarity_test`
|
||||||
|
from `fastvideo.tests.ssim.latent_similarity_utils` (or any other helper
|
||||||
|
that ends with `_latent_similarity_test`).
|
||||||
|
- **pixel** (`.mp4`) — file imports
|
||||||
|
`run_text_to_video_similarity_test` / `run_image_to_video_similarity_test`
|
||||||
|
from `fastvideo.tests.ssim.inference_similarity_utils`, OR uses the
|
||||||
|
legacy custom-inline helper pattern (see `test_gamecraft`,
|
||||||
|
`test_longcat`, etc.). Default to pixel when both heuristics fail.
|
||||||
|
|
||||||
|
Record `ARTEFACT_TYPE ∈ {pixel, latent}` for use in step 4. Steps 2, 3, 5,
|
||||||
|
and 6 are artefact-type-agnostic — `_iter_reference_files`,
|
||||||
|
`copy_generated_to_reference`, and `upload_reference_videos` already walk
|
||||||
|
both `.mp4` and `.pt` (see `reference_videos_cli.py`).
|
||||||
|
|
||||||
If either check fails, stop and tell the user what's wrong.
|
If either check fails, stop and tell the user what's wrong.
|
||||||
|
|
||||||
### 2. Run the test on Modal L40S
|
### 2. Run the test on Modal L40S
|
||||||
@@ -166,17 +197,59 @@ get` preserves that trailing `generated_videos/` segment.
|
|||||||
|
|
||||||
### 4. PAUSE — user reviews quality
|
### 4. PAUSE — user reviews quality
|
||||||
|
|
||||||
Print the list of downloaded mp4s and their paths, then stop. Tell the user:
|
Type-aware verification.
|
||||||
|
|
||||||
|
**For `ARTEFACT_TYPE = pixel`** — list the downloaded mp4s and ask the user to
|
||||||
|
open them in a video player:
|
||||||
|
|
||||||
> "Generated videos downloaded to `./generated_videos_modal/default/generated_videos/L40S_reference_videos/`. Please open them and confirm the quality looks correct. Reply **`upload`** to continue, or anything else to abort."
|
> "Generated videos downloaded to `./generated_videos_modal/default/generated_videos/L40S_reference_videos/`. Please open them and confirm the quality looks correct. Reply **`upload`** to continue, or anything else to abort."
|
||||||
|
|
||||||
|
**For `ARTEFACT_TYPE = latent`** — `.pt` files are not human-watchable. Print
|
||||||
|
a numerics dump for each `.pt` so the user can sanity-check shape, distribution,
|
||||||
|
and metadata:
|
||||||
|
|
||||||
|
```python
|
||||||
|
import torch
|
||||||
|
from pathlib import Path
|
||||||
|
ROOT = Path("./generated_videos_modal/default/generated_videos/L40S_reference_videos")
|
||||||
|
for p in sorted(ROOT.rglob("*.pt")):
|
||||||
|
d = torch.load(p, map_location="cpu", weights_only=False)
|
||||||
|
s = d["expected_slice"]
|
||||||
|
L = d["latent"].float()
|
||||||
|
print(f"=== {p.relative_to(ROOT)} ===")
|
||||||
|
print(f" format_version: {d['format_version']}")
|
||||||
|
print(f" shape: {d['shape']}")
|
||||||
|
print(f" dtype_original: {d['dtype_original']}")
|
||||||
|
print(f" slice_spec: {d['slice_spec']}")
|
||||||
|
print(f" slice shape={tuple(s.shape)} mean={s.mean():+.4f} std={s.std():.4f} min={s.min():+.4f} max={s.max():+.4f}")
|
||||||
|
print(f" latent shape={tuple(L.shape)} mean={L.mean():+.4f} std={L.std():.4f} min={L.min():+.4f} max={L.max():+.4f}")
|
||||||
|
print(f" finite: latent NaN={torch.isnan(L).any().item()} Inf={torch.isinf(L).any().item()}; "
|
||||||
|
f"slice NaN={torch.isnan(s).any().item()} Inf={torch.isinf(s).any().item()}")
|
||||||
|
print(f" metadata: {d['metadata']}\n")
|
||||||
|
```
|
||||||
|
|
||||||
|
Sanity criteria:
|
||||||
|
- `format_version == 1` (matches `LATENT_REFERENCE_FORMAT_VERSION`).
|
||||||
|
- `shape` matches what the model produces (e.g. LTX-2 distilled =
|
||||||
|
`[1, 128, T_lat, H_lat, W_lat]`; Stable Audio Open 1.0 = `[1, 64, 1024]`).
|
||||||
|
- `slice_spec.kind` matches a registered kind (`corner_3x3_first_frame`
|
||||||
|
for video, `audio_first_8_timesteps` for audio).
|
||||||
|
- No `NaN`/`Inf`. `mean ≈ 0`, `std ≈ 1` (denoised latents stay close to
|
||||||
|
the initial Gaussian distribution; very wide deviations suggest
|
||||||
|
numerical drift).
|
||||||
|
- `metadata.prompt` matches the test's prompt.
|
||||||
|
|
||||||
|
Then ask:
|
||||||
|
|
||||||
|
> "Numerics look right? Reply **`upload`** to continue, or anything else to abort."
|
||||||
|
|
||||||
Do not proceed until the user explicitly says `upload`. If they abort, leave
|
Do not proceed until the user explicitly says `upload`. If they abort, leave
|
||||||
everything on disk so they can inspect further — no cleanup.
|
everything on disk so they can inspect further — no cleanup.
|
||||||
|
|
||||||
### 5. Copy into the local reference layout
|
### 5. Copy into the local reference layout
|
||||||
|
|
||||||
Scoped copy — only the new test's mp4s. Loop over each `<model_id>` extracted
|
Scoped copy — only the new test's artefacts. Single command works for both
|
||||||
in step 1:
|
artefact types because `_iter_reference_files` walks `.mp4` and `.pt`:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python fastvideo/tests/ssim/reference_videos_cli.py copy-local \
|
python fastvideo/tests/ssim/reference_videos_cli.py copy-local \
|
||||||
@@ -186,12 +259,13 @@ python fastvideo/tests/ssim/reference_videos_cli.py copy-local \
|
|||||||
```
|
```
|
||||||
|
|
||||||
(The `--generated-dir` points at the device-folder root inside the
|
(The `--generated-dir` points at the device-folder root inside the
|
||||||
downloaded tree; `copy-local` walks all `<model>/<backend>/*.mp4`
|
downloaded tree; `copy-local` walks all `<model>/<backend>/*.{mp4,pt}`
|
||||||
underneath it. Since the Modal run was scoped to a single test file via
|
underneath it. Since the Modal run was scoped to a single test file via
|
||||||
`--test-files`, only that test's model(s) are present — so the copy is
|
`--test-files`, only that test's model(s) are present — so the copy is
|
||||||
implicitly per-test.)
|
implicitly per-test.)
|
||||||
|
|
||||||
Result: `fastvideo/tests/ssim/reference_videos/default/L40S_reference_videos/<model_id>/<backend>/<prompt>.mp4`.
|
Result for pixel: `fastvideo/tests/ssim/reference_videos/default/L40S_reference_videos/<model_id>/<backend>/<prompt>.mp4`.
|
||||||
|
Result for latent: same path with `.pt` extension.
|
||||||
|
|
||||||
### 6. Upload to HF — scoped per model_id, with overwrite guard
|
### 6. Upload to HF — scoped per model_id, with overwrite guard
|
||||||
|
|
||||||
@@ -224,33 +298,54 @@ it will auto-download the refs they just uploaded.
|
|||||||
## Failure modes and how to handle them
|
## Failure modes and how to handle them
|
||||||
|
|
||||||
- **`HF_API_KEY` unset.** Stop before step 2. The Modal run needs it (passed
|
- **`HF_API_KEY` unset.** Stop before step 2. The Modal run needs it (passed
|
||||||
via `--hf-api-key`), and step 6 needs it for upload.
|
via `--hf-api-key`), and step 6 needs it for upload. If the user
|
||||||
- **Modal run fails before generation.** No mp4s on the volume — nothing to
|
ran `hf auth login` instead of exporting an env var, read the cached
|
||||||
download. Fix the test locally (`pytest fastvideo/tests/ssim/<test_file>`)
|
token via `huggingface_hub.get_token()` and forward it to Modal as
|
||||||
|
`--hf-api-key="$CACHED_TOKEN"`.
|
||||||
|
- **Modal run fails before generation.** No artefacts on the volume — nothing
|
||||||
|
to download. Fix the test locally (`pytest fastvideo/tests/ssim/<test_file>`)
|
||||||
and retry from step 2.
|
and retry from step 2.
|
||||||
- **`./generated_videos_modal/default/L40S_reference_videos/` missing after
|
- **`./generated_videos_modal/default/L40S_reference_videos/` missing after
|
||||||
`modal volume get`.** The run didn't produce videos (most likely the test
|
`modal volume get`.** The run didn't produce artefacts (most likely the
|
||||||
crashed before writing, or `REQUIRED_GPUS` exceeded the partition capacity
|
test crashed before writing, or `REQUIRED_GPUS` exceeded the partition
|
||||||
— see Modal logs).
|
capacity — see Modal logs).
|
||||||
|
- **Latent test crashed with FSDP / inference_mode error
|
||||||
|
(`RuntimeError: Inference tensors do not track version counter`).** The
|
||||||
|
test must pass `init_kwargs_override={"use_fsdp_inference": False}` when
|
||||||
|
`sp_size == 1` — see `test_stable_audio_similarity.py` for the pattern.
|
||||||
|
Fix in the test, push, retry.
|
||||||
- **Upload guard fires (files already exist).** The test name / model id
|
- **Upload guard fires (files already exist).** The test name / model id
|
||||||
collides with something already on HF. Verify the user actually wants to
|
collides with something already on HF. Verify the user actually wants to
|
||||||
replace existing refs; if so, re-run the upload with `--force`. If not,
|
replace existing refs; if so, re-run the upload with `--force`. If not,
|
||||||
rename the model id in `*_MODEL_TO_PARAMS` and re-seed.
|
rename the model id in `*_MODEL_TO_PARAMS` and re-seed.
|
||||||
- **Quality looks wrong in step 4.** Abort. The mp4s stay on disk for
|
- **Quality looks wrong in step 4.** Abort. The artefacts stay on disk for
|
||||||
inspection. The fix is usually in the test's params (resolution, steps,
|
inspection. The fix is usually in the test's params (resolution, steps,
|
||||||
seed) — edit the test, then re-run the skill.
|
seed) — edit the test, then re-run the skill.
|
||||||
|
- For latent: also check `slice_spec.kind` matches the latent rank
|
||||||
|
(`corner_3x3_first_frame` requires 5-D, `audio_first_8_timesteps`
|
||||||
|
requires 3-D); a rank/kind mismatch raises in `_extract_expected_slice`.
|
||||||
|
|
||||||
## Design notes (for future skill maintainers)
|
## Design notes (for future skill maintainers)
|
||||||
|
|
||||||
- The skill deliberately runs on Modal, **not** locally, because the CI
|
- The skill deliberately runs on Modal, **not** locally, because the CI
|
||||||
runner is L40S. Seeding from a different GPU SKU produces refs that CI's
|
runner is L40S. Seeding from a different GPU SKU produces refs that CI's
|
||||||
L40S runs can't match (SSIM drifts across SKUs).
|
L40S runs can't match (pixel SSIM drifts across SKUs; latent cosine has
|
||||||
|
tighter cross-SKU bf16 drift but the configured tolerances assume
|
||||||
|
same-SKU seed → same-SKU verify).
|
||||||
- The skill is default-tier only. `full_quality` refs are seeded by a
|
- The skill is default-tier only. `full_quality` refs are seeded by a
|
||||||
separate, deliberate operation — they double runtime and aren't what CI
|
separate, deliberate operation — they double runtime and aren't what CI
|
||||||
gates on.
|
gates on.
|
||||||
- The overwrite guard in `reference_videos_cli.py upload` is default-on
|
- The overwrite guard in `reference_videos_cli.py upload` is default-on
|
||||||
specifically because this skill exists. Re-seeding is a distinct operation
|
specifically because this skill exists. Re-seeding is a distinct operation
|
||||||
that requires explicit `--force`.
|
that requires explicit `--force`.
|
||||||
|
- Both artefact types share the same Modal flow: the orchestrator sets
|
||||||
|
`--skip-reference-download` + `--no-fail-fast`, runs pytest, the test's
|
||||||
|
helper writes the artefact (`.mp4` via `imageio` for pixel,
|
||||||
|
`save_latent_reference` → `torch.save` for latent) BEFORE the
|
||||||
|
missing-reference assertion raises. `_sync_generated_videos_to_volume` in
|
||||||
|
`ssim_test.py` does a `shutil.copytree` of the whole `generated_videos/`
|
||||||
|
tree, picking up `.mp4`, `.pt`, and the `*_ssim.json` / `*_latent.json`
|
||||||
|
metric files alongside.
|
||||||
|
|
||||||
## References
|
## References
|
||||||
|
|
||||||
@@ -259,10 +354,17 @@ it will auto-download the refs they just uploaded.
|
|||||||
`--skip-reference-download`, `--no-fail-fast`.
|
`--skip-reference-download`, `--no-fail-fast`.
|
||||||
- `fastvideo/tests/ssim/reference_videos_cli.py` — `copy-local`, `upload`
|
- `fastvideo/tests/ssim/reference_videos_cli.py` — `copy-local`, `upload`
|
||||||
(with `--model-id`, `--force`), `download`, `ensure` subcommands.
|
(with `--model-id`, `--force`), `download`, `ensure` subcommands.
|
||||||
|
Extension allowlist is `REFERENCE_EXTENSIONS = VIDEO_EXTENSIONS +
|
||||||
|
LATENT_EXTENSIONS` (`.pt`).
|
||||||
- `fastvideo/tests/ssim/README.md` — reference layout, HF repo conventions.
|
- `fastvideo/tests/ssim/README.md` — reference layout, HF repo conventions.
|
||||||
- `fastvideo/tests/ssim/inference_similarity_utils.py` —
|
- `fastvideo/tests/ssim/inference_similarity_utils.py` — pixel helpers
|
||||||
`run_text_to_video_similarity_test` + `_build_init_kwargs`: what each test
|
(`run_text_to_video_similarity_test`,
|
||||||
config passes to `VideoGenerator.from_pretrained`.
|
`run_image_to_video_similarity_test`, `build_init_kwargs`).
|
||||||
|
- `fastvideo/tests/ssim/latent_similarity_utils.py` — latent helper
|
||||||
|
(`run_text_to_latent_similarity_test`), slice spec dispatch
|
||||||
|
(`_extract_expected_slice`), reference schema
|
||||||
|
(`save_latent_reference` / `load_latent_reference`),
|
||||||
|
`LATENT_REFERENCE_FORMAT_VERSION`.
|
||||||
|
|
||||||
## Changelog
|
## Changelog
|
||||||
|
|
||||||
@@ -271,3 +373,4 @@ it will auto-download the refs they just uploaded.
|
|||||||
| 2026-04-17 | Initial version (Modal sync-to-volume flow). |
|
| 2026-04-17 | Initial version (Modal sync-to-volume flow). |
|
||||||
| 2026-04-21 | Rewrite: single-test scope, explicit user-review pause, per-`model_id` upload, HF overwrite guard. Dropped `scripts/seed_ssim.sh`. |
|
| 2026-04-21 | Rewrite: single-test scope, explicit user-review pause, per-`model_id` upload, HF overwrite guard. Dropped `scripts/seed_ssim.sh`. |
|
||||||
| 2026-04-21 | Post-first-run fixes: `modal volume get` needs `--force` when parent exists; download tree has an extra `generated_videos/` level so `--generated-dir` must reflect it. |
|
| 2026-04-21 | Post-first-run fixes: `modal volume get` needs `--force` when parent exists; download tree has an extra `generated_videos/` level so `--generated-dir` must reflect it. |
|
||||||
|
| 2026-05-01 | Latent (`*.pt`) artefact support: artefact-type detection in step 1, type-aware verification (visual eyeball for mp4, numerics dump for pt) in step 4, FSDP+inference_mode failure-mode added, design notes for the unified Modal flow. Triggered by PR #1253 (LTX-2 latent migration + Stable Audio latent test). |
|
||||||
|
|||||||
@@ -65,6 +65,7 @@ _FROM_PRETRAINED_CONVENIENCE_KWARGS = frozenset({
|
|||||||
"pin_cpu_memory",
|
"pin_cpu_memory",
|
||||||
"enable_torch_compile",
|
"enable_torch_compile",
|
||||||
"torch_compile_kwargs",
|
"torch_compile_kwargs",
|
||||||
|
"output_type",
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
@@ -601,10 +602,20 @@ class VideoGenerator:
|
|||||||
thread = threading.Thread(target=execute_forward_thread)
|
thread = threading.Thread(target=execute_forward_thread)
|
||||||
thread.start()
|
thread.start()
|
||||||
latent_batch_size = _infer_latent_batch_size(batch)
|
latent_batch_size = _infer_latent_batch_size(batch)
|
||||||
samples = torch.empty(
|
# When ``output_type == "latent"`` the forward output has latent
|
||||||
(latent_batch_size, 3, sampling_param.num_frames, sampling_param.height, sampling_param.width),
|
# shape (e.g. ``[B, C_latent, T_latent, H_latent, W_latent]``)
|
||||||
device='cpu',
|
# rather than the pre-allocation's pixel shape. Skip the pinned
|
||||||
pin_memory=fastvideo_args.pin_cpu_memory)
|
# ~50 MB buffer entirely; we always fall through to the
|
||||||
|
# ``samples = output_batch.output.cpu()`` branch below in that
|
||||||
|
# mode. ``skip_pixel_prealloc`` also gates the slow-path warning.
|
||||||
|
skip_pixel_prealloc = fastvideo_args.output_type == "latent"
|
||||||
|
if skip_pixel_prealloc:
|
||||||
|
samples = torch.empty(0, device='cpu')
|
||||||
|
else:
|
||||||
|
samples = torch.empty(
|
||||||
|
(latent_batch_size, 3, sampling_param.num_frames, sampling_param.height, sampling_param.width),
|
||||||
|
device='cpu',
|
||||||
|
pin_memory=fastvideo_args.pin_cpu_memory)
|
||||||
thread.join()
|
thread.join()
|
||||||
|
|
||||||
if thread_error["error"] is not None:
|
if thread_error["error"] is not None:
|
||||||
@@ -619,29 +630,44 @@ class VideoGenerator:
|
|||||||
if output_batch.output.shape == samples.shape:
|
if output_batch.output.shape == samples.shape:
|
||||||
samples.copy_(output_batch.output)
|
samples.copy_(output_batch.output)
|
||||||
else:
|
else:
|
||||||
logger.warning("Output shape %s does not match expected shape %s; use slow path", output_batch.output.shape,
|
if not skip_pixel_prealloc:
|
||||||
samples.shape)
|
logger.warning("Output shape %s does not match expected shape %s; use slow path",
|
||||||
|
output_batch.output.shape, samples.shape)
|
||||||
samples = output_batch.output.cpu()
|
samples = output_batch.output.cpu()
|
||||||
logging_info = output_batch.logging_info
|
logging_info = output_batch.logging_info
|
||||||
|
|
||||||
gen_time = time.perf_counter() - start_time
|
gen_time = time.perf_counter() - start_time
|
||||||
logger.info("Generated successfully in %.2f seconds", gen_time)
|
logger.info("Generated successfully in %.2f seconds", gen_time)
|
||||||
|
|
||||||
# Process outputs (skip the make_grid loop for audio-only, where
|
# Three mutually-exclusive output modes determine whether (a) we
|
||||||
# `samples` is a 1×3×1×8×8 placeholder no caller will use).
|
# build an RGB frame buffer and (b) what file we write to disk:
|
||||||
|
#
|
||||||
|
# 1. `output_type == "latent"` — VAE is bypassed in DecodingStage
|
||||||
|
# and `samples` holds raw latents (arbitrary channel count).
|
||||||
|
# The RGB grid / uint8 / mp4 / png pipeline below cannot
|
||||||
|
# consume those, so we skip it entirely and let callers work
|
||||||
|
# with the latent tensor directly via `result["samples"]`.
|
||||||
|
# 2. Audio-only workload — `samples` is a 1×3×1×8×8 placeholder
|
||||||
|
# no caller will use; skip the grid loop and save a `.wav`.
|
||||||
|
# 3. Pixel video / image — the historical happy path.
|
||||||
|
is_latent_output = fastvideo_args.output_type == "latent"
|
||||||
audio_only = bool(output_batch.extra.get("audio_only"))
|
audio_only = bool(output_batch.extra.get("audio_only"))
|
||||||
frames: list[np.ndarray] = []
|
|
||||||
if not audio_only:
|
frames: list[np.ndarray] | None
|
||||||
|
if is_latent_output or audio_only:
|
||||||
|
frames = None if is_latent_output else []
|
||||||
|
else:
|
||||||
videos = rearrange(samples, "b c t h w -> t b c h w")
|
videos = rearrange(samples, "b c t h w -> t b c h w")
|
||||||
|
frames = []
|
||||||
for x in videos:
|
for x in videos:
|
||||||
x = torchvision.utils.make_grid(x, nrow=6)
|
x = torchvision.utils.make_grid(x, nrow=6)
|
||||||
x = x.permute(1, 2, 0).squeeze(-1)
|
x = x.permute(1, 2, 0).squeeze(-1)
|
||||||
x = (x * 255).to(torch.uint8)
|
x = (x * 255).to(torch.uint8)
|
||||||
frames.append(x.cpu().numpy())
|
frames.append(x.cpu().numpy())
|
||||||
|
|
||||||
# Save output if requested
|
save_to_disk = batch.save_video and not is_latent_output
|
||||||
if batch.save_video:
|
if save_to_disk:
|
||||||
if output_batch.extra.get("audio_only"):
|
if audio_only:
|
||||||
# Audio-only workload: write a standalone .wav rather than
|
# Audio-only workload: write a standalone .wav rather than
|
||||||
# muxing the audio into a placeholder mp4 (which forces
|
# muxing the audio into a placeholder mp4 (which forces
|
||||||
# ffmpeg to round 8x8 placeholder frames up to 16x16).
|
# ffmpeg to round 8x8 placeholder frames up to 16x16).
|
||||||
@@ -654,9 +680,11 @@ class VideoGenerator:
|
|||||||
logger.info("Saved audio to %s", output_path)
|
logger.info("Saved audio to %s", output_path)
|
||||||
elif self._is_image_workload():
|
elif self._is_image_workload():
|
||||||
# Image workloads (t2i, i2i, …): save the first frame as PNG.
|
# Image workloads (t2i, i2i, …): save the first frame as PNG.
|
||||||
|
assert frames is not None # implied by save_to_disk and not audio_only
|
||||||
imageio.imwrite(output_path, frames[0])
|
imageio.imwrite(output_path, frames[0])
|
||||||
logger.info("Saved image to %s", output_path)
|
logger.info("Saved image to %s", output_path)
|
||||||
else:
|
else:
|
||||||
|
assert frames is not None # implied by save_to_disk and not audio_only
|
||||||
imageio.mimsave(output_path, frames, fps=batch.fps, format="mp4")
|
imageio.mimsave(output_path, frames, fps=batch.fps, format="mp4")
|
||||||
logger.info("Saved video to %s", output_path)
|
logger.info("Saved video to %s", output_path)
|
||||||
audio = output_batch.extra.get("audio")
|
audio = output_batch.extra.get("audio")
|
||||||
@@ -680,7 +708,7 @@ class VideoGenerator:
|
|||||||
"trajectory": output_batch.trajectory_latents,
|
"trajectory": output_batch.trajectory_latents,
|
||||||
"trajectory_timesteps": output_batch.trajectory_timesteps,
|
"trajectory_timesteps": output_batch.trajectory_timesteps,
|
||||||
"trajectory_decoded": output_batch.trajectory_decoded,
|
"trajectory_decoded": output_batch.trajectory_decoded,
|
||||||
"video_path": output_path if batch.save_video else None,
|
"video_path": output_path if save_to_disk else None,
|
||||||
"peak_memory_mb": output_batch.extra.get("peak_memory_mb"),
|
"peak_memory_mb": output_batch.extra.get("peak_memory_mb"),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -33,6 +33,15 @@ class StableAudioDecodingStage(PipelineStage):
|
|||||||
pc = fastvideo_args.pipeline_config
|
pc = fastvideo_args.pipeline_config
|
||||||
latents = batch.latents
|
latents = batch.latents
|
||||||
|
|
||||||
|
# Latent regression path: hand back the un-decoded denoised latent
|
||||||
|
# so the LatentSimilarityUtils harness can compare on pre-VAE
|
||||||
|
# numerics. Mirrors the bypass in `pipelines/stages/decoding.py`
|
||||||
|
# used by the video DiTs. Skips the `.to(device)` VAE move so we
|
||||||
|
# don't pay decoder load cost on this shortcut path.
|
||||||
|
if fastvideo_args.output_type == "latent":
|
||||||
|
batch.output = latents.detach().cpu()
|
||||||
|
return batch
|
||||||
|
|
||||||
# VAE may be CPU-parked under `vae_cpu_offload=True`.
|
# VAE may be CPU-parked under `vae_cpu_offload=True`.
|
||||||
from fastvideo.distributed.parallel_state import get_local_torch_device
|
from fastvideo.distributed.parallel_state import get_local_torch_device
|
||||||
self.vae = self.vae.to(get_local_torch_device())
|
self.vae = self.vae.to(get_local_torch_device())
|
||||||
|
|||||||
@@ -485,6 +485,10 @@ def _prepare_ssim_workspace(
|
|||||||
./build.sh
|
./build.sh
|
||||||
cd ..
|
cd ..
|
||||||
uv pip install git+https://github.com/microsoft/MoGe.git
|
uv pip install git+https://github.com/microsoft/MoGe.git
|
||||||
|
# Stable Audio Open 1.0 inference deps (optional in basic install,
|
||||||
|
# required by `StableAudioDenoisingStage`; consumed by
|
||||||
|
# `test_stable_audio_similarity.py`).
|
||||||
|
uv pip install k_diffusion einops_exts alias_free_torch torchsde
|
||||||
export HF_HOME='/root/data/.cache'
|
export HF_HOME='/root/data/.cache'
|
||||||
hf auth login --token "$HF_API_KEY"
|
hf auth login --token "$HF_API_KEY"
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -132,7 +132,7 @@ def _assert_similarity(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _build_init_kwargs(
|
def build_init_kwargs(
|
||||||
base_params: dict[str, object],
|
base_params: dict[str, object],
|
||||||
) -> dict[str, object]:
|
) -> dict[str, object]:
|
||||||
init_kwargs: dict[str, object] = {
|
init_kwargs: dict[str, object] = {
|
||||||
@@ -167,7 +167,7 @@ def _build_init_kwargs(
|
|||||||
return init_kwargs
|
return init_kwargs
|
||||||
|
|
||||||
|
|
||||||
def _build_generation_kwargs(
|
def build_generation_kwargs(
|
||||||
base_params: dict[str, object],
|
base_params: dict[str, object],
|
||||||
num_inference_steps: int,
|
num_inference_steps: int,
|
||||||
output_dir: str,
|
output_dir: str,
|
||||||
@@ -222,11 +222,11 @@ def run_text_to_video_similarity_test(
|
|||||||
base_params = params_map[model_id]
|
base_params = params_map[model_id]
|
||||||
num_inference_steps = int(base_params["num_inference_steps"])
|
num_inference_steps = int(base_params["num_inference_steps"])
|
||||||
|
|
||||||
init_kwargs = _build_init_kwargs(base_params)
|
init_kwargs = build_init_kwargs(base_params)
|
||||||
if init_kwargs_override:
|
if init_kwargs_override:
|
||||||
init_kwargs.update(init_kwargs_override)
|
init_kwargs.update(init_kwargs_override)
|
||||||
|
|
||||||
generation_kwargs = _build_generation_kwargs(
|
generation_kwargs = build_generation_kwargs(
|
||||||
base_params,
|
base_params,
|
||||||
num_inference_steps,
|
num_inference_steps,
|
||||||
output_dir,
|
output_dir,
|
||||||
@@ -297,11 +297,11 @@ def run_image_to_video_similarity_test(
|
|||||||
base_params = params_map[model_id]
|
base_params = params_map[model_id]
|
||||||
num_inference_steps = int(base_params["num_inference_steps"])
|
num_inference_steps = int(base_params["num_inference_steps"])
|
||||||
|
|
||||||
init_kwargs = _build_init_kwargs(base_params)
|
init_kwargs = build_init_kwargs(base_params)
|
||||||
if init_kwargs_override:
|
if init_kwargs_override:
|
||||||
init_kwargs.update(init_kwargs_override)
|
init_kwargs.update(init_kwargs_override)
|
||||||
|
|
||||||
generation_kwargs = _build_generation_kwargs(
|
generation_kwargs = build_generation_kwargs(
|
||||||
base_params,
|
base_params,
|
||||||
num_inference_steps,
|
num_inference_steps,
|
||||||
output_dir,
|
output_dir,
|
||||||
|
|||||||
@@ -0,0 +1,494 @@
|
|||||||
|
# SPDX-License-Identifier: Apache-2.0
|
||||||
|
"""Latent-space regression helpers for numerically fragile SSIM tests.
|
||||||
|
|
||||||
|
Motivation
|
||||||
|
----------
|
||||||
|
Pixel-space SSIM is a poor regression signal for distilled / few-step
|
||||||
|
models (e.g. LTX-2 distilled): a single mis-rounded bf16 accumulator in
|
||||||
|
the VAE decoder can drive mean SSIM from ~0.95 to ~0.50 without any real
|
||||||
|
quality regression.
|
||||||
|
|
||||||
|
Inspired by diffusers' "small signature slice + bounded full-tensor
|
||||||
|
distance" testing philosophy, applied here to the **pre-VAE latent**
|
||||||
|
rather than the decoded pixel/audio output:
|
||||||
|
|
||||||
|
* `tests/pipelines/ltx2/test_ltx2.py` (diffusers) compares
|
||||||
|
``output_type='pt'`` (pixel) slices via
|
||||||
|
``torch.allclose(generated_slice, expected_slice, atol=1e-4)``;
|
||||||
|
* `tests/pipelines/stable_audio/test_stable_audio.py` (diffusers)
|
||||||
|
compares decoded audio samples via
|
||||||
|
``np.abs(expected - actual).max() < 1.5e-3``;
|
||||||
|
* `tests/pipelines/cogvideo/test_cogvideox.py` (diffusers) compares
|
||||||
|
full pixel video tensors via
|
||||||
|
``numpy_cosine_similarity_distance(...) < 1e-3``.
|
||||||
|
|
||||||
|
Diffusers does *not* assert on latents directly — that is a FastVideo
|
||||||
|
adaptation. Distilled few-step pipelines amplify per-step bf16 noise
|
||||||
|
enough that VAE-decoded comparisons are unreliable across our
|
||||||
|
heterogeneous CI pool, so we move the assertion upstream of the VAE.
|
||||||
|
|
||||||
|
Design
|
||||||
|
------
|
||||||
|
* Inference is run with ``output_type='latent'`` so ``DecodingStage``
|
||||||
|
hands back the un-decoded latent on ``result["samples"]``.
|
||||||
|
* The reference artefact is a ``.pt`` bundle (tensor + metadata) hosted
|
||||||
|
on the same HF dataset as the mp4 references, selected by
|
||||||
|
``<GPU>_reference_videos/<model_id>/<backend>/<prompt>.pt``.
|
||||||
|
* Two assertions are performed:
|
||||||
|
1. A small signature slice (default video: ``latent[0, :, 0, :3, :3]``;
|
||||||
|
audio: ``latent[0, :, :8]``) is compared via cosine distance with
|
||||||
|
a loose tolerance. Primary pass/fail gate.
|
||||||
|
2. The full latent is compared via cosine distance with a slightly
|
||||||
|
tighter tolerance, guarding against shape-correct but globally
|
||||||
|
drifted outputs.
|
||||||
|
* Tolerances default to 5e-3 (slice) and 1e-2 (full). diffusers uses
|
||||||
|
``1e-3`` against deterministic CPU dummy components; we relax for
|
||||||
|
cross-GPU-arch bf16 differences on the rented CI pool
|
||||||
|
(A40/L40S/H100/B200).
|
||||||
|
|
||||||
|
The helper intentionally reuses ``build_init_kwargs`` /
|
||||||
|
``build_generation_kwargs`` from :mod:`inference_similarity_utils` so
|
||||||
|
model params (vae tiling, sp_size, flow shift, …) flow through a single
|
||||||
|
source of truth.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from logging import Logger
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import torch
|
||||||
|
from torch.nn.functional import cosine_similarity
|
||||||
|
|
||||||
|
from fastvideo import VideoGenerator
|
||||||
|
from fastvideo.tests.ssim.inference_similarity_utils import (
|
||||||
|
attention_backend,
|
||||||
|
build_generation_kwargs,
|
||||||
|
build_init_kwargs,
|
||||||
|
shutdown_executor,
|
||||||
|
)
|
||||||
|
from fastvideo.tests.ssim.reference_utils import (
|
||||||
|
build_generated_output_dir,
|
||||||
|
build_reference_folder_path,
|
||||||
|
select_ssim_params,
|
||||||
|
)
|
||||||
|
|
||||||
|
LATENT_REFERENCE_EXTENSION = ".pt"
|
||||||
|
LATENT_REFERENCE_FORMAT_VERSION = 1
|
||||||
|
|
||||||
|
DEFAULT_SLICE_SPEC: dict[str, Any] = {
|
||||||
|
"kind": "corner_3x3_first_frame",
|
||||||
|
"version": 1,
|
||||||
|
}
|
||||||
|
|
||||||
|
AUDIO_FIRST_8_TIMESTEPS_SPEC: dict[str, Any] = {
|
||||||
|
"kind": "audio_first_8_timesteps",
|
||||||
|
"version": 1,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_expected_slice(
|
||||||
|
latent: torch.Tensor,
|
||||||
|
spec: dict[str, Any],
|
||||||
|
) -> torch.Tensor:
|
||||||
|
"""Return a 1-D fp32 signature slice from ``latent`` per ``spec``.
|
||||||
|
|
||||||
|
Dispatch by ``spec["kind"]``:
|
||||||
|
|
||||||
|
* ``"corner_3x3_first_frame"`` — 5-D video latent ``[B, C, T, H, W]``;
|
||||||
|
returns ``latent[0, :, 0, :3, :3]`` flattened (length = C * 9).
|
||||||
|
* ``"audio_first_8_timesteps"`` — 3-D audio latent ``[B, C, T]``;
|
||||||
|
returns ``latent[0, :, :8]`` flattened (length = C * 8).
|
||||||
|
"""
|
||||||
|
kind = spec.get("kind", "corner_3x3_first_frame")
|
||||||
|
if kind == "corner_3x3_first_frame":
|
||||||
|
if latent.dim() != 5:
|
||||||
|
raise ValueError(
|
||||||
|
f"corner_3x3_first_frame requires 5-D latent [B,C,T,H,W]; "
|
||||||
|
f"got shape {tuple(latent.shape)}")
|
||||||
|
_, _, t, h, w = latent.shape
|
||||||
|
if t < 1 or h < 3 or w < 3:
|
||||||
|
raise ValueError(
|
||||||
|
"corner_3x3_first_frame requires T>=1, H>=3, W>=3; got "
|
||||||
|
f"shape {tuple(latent.shape)}")
|
||||||
|
return (latent[0, :, 0, :3, :3]
|
||||||
|
.detach()
|
||||||
|
.to(torch.float32)
|
||||||
|
.reshape(-1)
|
||||||
|
.contiguous())
|
||||||
|
if kind == "audio_first_8_timesteps":
|
||||||
|
if latent.dim() != 3:
|
||||||
|
raise ValueError(
|
||||||
|
f"audio_first_8_timesteps requires 3-D latent [B,C,T]; "
|
||||||
|
f"got shape {tuple(latent.shape)}")
|
||||||
|
_, _, t = latent.shape
|
||||||
|
if t < 8:
|
||||||
|
raise ValueError(
|
||||||
|
"audio_first_8_timesteps requires T>=8; got "
|
||||||
|
f"shape {tuple(latent.shape)}")
|
||||||
|
return (latent[0, :, :8]
|
||||||
|
.detach()
|
||||||
|
.to(torch.float32)
|
||||||
|
.reshape(-1)
|
||||||
|
.contiguous())
|
||||||
|
raise ValueError(f"Unknown slice kind: {kind!r}")
|
||||||
|
|
||||||
|
|
||||||
|
def _cosine_distance(a: torch.Tensor, b: torch.Tensor) -> float:
|
||||||
|
"""Return ``1 - cos(a, b)`` as a Python float, operating in fp32."""
|
||||||
|
a32 = a.detach().to(torch.float32).reshape(-1)
|
||||||
|
b32 = b.detach().to(torch.float32).reshape(-1)
|
||||||
|
if a32.shape != b32.shape:
|
||||||
|
raise ValueError(
|
||||||
|
f"Cosine shape mismatch: {tuple(a32.shape)} vs {tuple(b32.shape)}"
|
||||||
|
)
|
||||||
|
sim = cosine_similarity(a32.unsqueeze(0), b32.unsqueeze(0), dim=1).item()
|
||||||
|
return 1.0 - float(sim)
|
||||||
|
|
||||||
|
|
||||||
|
def save_latent_reference(
|
||||||
|
path: str,
|
||||||
|
latent: torch.Tensor,
|
||||||
|
*,
|
||||||
|
metadata: dict[str, Any],
|
||||||
|
slice_spec: dict[str, Any] | None = None,
|
||||||
|
) -> None:
|
||||||
|
"""Persist a latent bundle to ``path``.
|
||||||
|
|
||||||
|
Storage format (dict pickled via ``torch.save``):
|
||||||
|
|
||||||
|
* ``latent``: full latent as fp16 on cpu
|
||||||
|
* ``shape``: original shape (list)
|
||||||
|
* ``dtype_original``: str
|
||||||
|
* ``expected_slice``: fp32 1-D signature slice
|
||||||
|
* ``slice_spec``: dict describing how the slice was built
|
||||||
|
* ``metadata``: caller-provided context (prompt, backend, steps, …)
|
||||||
|
* ``format_version``: int
|
||||||
|
|
||||||
|
fp16 is lossy but bounded; it keeps ref artefacts small (~a few MB
|
||||||
|
per prompt) while preserving enough dynamic range for cosine-based
|
||||||
|
regression. Slice values stay fp32 because the primary assertion is
|
||||||
|
computed against them.
|
||||||
|
"""
|
||||||
|
spec = slice_spec if slice_spec is not None else DEFAULT_SLICE_SPEC
|
||||||
|
parent = os.path.dirname(path)
|
||||||
|
if parent:
|
||||||
|
os.makedirs(parent, exist_ok=True)
|
||||||
|
|
||||||
|
latent_cpu = latent.detach().to("cpu")
|
||||||
|
payload: dict[str, Any] = {
|
||||||
|
"latent": latent_cpu.to(torch.float16),
|
||||||
|
"shape": list(latent_cpu.shape),
|
||||||
|
"dtype_original": str(latent_cpu.dtype),
|
||||||
|
"expected_slice": _extract_expected_slice(latent_cpu, spec),
|
||||||
|
"slice_spec": spec,
|
||||||
|
"metadata": metadata,
|
||||||
|
"format_version": LATENT_REFERENCE_FORMAT_VERSION,
|
||||||
|
}
|
||||||
|
torch.save(payload, path)
|
||||||
|
|
||||||
|
|
||||||
|
def load_latent_reference(path: str) -> dict[str, Any]:
|
||||||
|
"""Inverse of :func:`save_latent_reference` — always loads to cpu.
|
||||||
|
|
||||||
|
Enforces ``format_version == LATENT_REFERENCE_FORMAT_VERSION`` so a
|
||||||
|
schema change forces a deliberate reseed instead of silently
|
||||||
|
misinterpreting old artefacts.
|
||||||
|
"""
|
||||||
|
# ``weights_only=False`` is required because the payload is a dict of
|
||||||
|
# tensors + plain-Python metadata (slice_spec, prompt, ...). The trust
|
||||||
|
# boundary is the controlled HF dataset configured via
|
||||||
|
# FASTVIDEO_SSIM_REFERENCE_HF_REPO (default
|
||||||
|
# FastVideo/ssim-reference-videos), which is org-write-gated.
|
||||||
|
payload = torch.load(path, map_location="cpu", weights_only=False)
|
||||||
|
fmt = payload.get("format_version") if isinstance(payload, dict) else None
|
||||||
|
if fmt != LATENT_REFERENCE_FORMAT_VERSION:
|
||||||
|
raise ValueError(
|
||||||
|
f"Latent reference at {path!r} has format_version={fmt!r}; "
|
||||||
|
f"expected {LATENT_REFERENCE_FORMAT_VERSION}. Re-seed via the "
|
||||||
|
"test that produces this artefact, then re-upload through "
|
||||||
|
"fastvideo/tests/ssim/reference_videos_cli.py upload.")
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
def write_latent_similarity_results(
|
||||||
|
output_dir: str,
|
||||||
|
metrics: dict[str, float],
|
||||||
|
*,
|
||||||
|
reference_path: str,
|
||||||
|
generated_path: str,
|
||||||
|
num_inference_steps: int,
|
||||||
|
prompt: str,
|
||||||
|
model_id: str,
|
||||||
|
attention_backend_name: str,
|
||||||
|
slice_spec: dict[str, Any],
|
||||||
|
slice_cosine_threshold: float,
|
||||||
|
full_cosine_threshold: float,
|
||||||
|
passed: bool,
|
||||||
|
) -> bool:
|
||||||
|
"""Persist latent regression metrics next to the generated artefact.
|
||||||
|
|
||||||
|
Mirrors :func:`fastvideo.tests.utils.write_ssim_results` so downstream
|
||||||
|
CI tooling can scrape one schema for both pixel and latent runs.
|
||||||
|
The filename is ``steps{N}_{prompt[:100]}_latent.json``.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
os.makedirs(output_dir, exist_ok=True)
|
||||||
|
prompt_prefix = prompt[:100].strip()
|
||||||
|
filename = f"steps{num_inference_steps}_{prompt_prefix}_latent.json"
|
||||||
|
target = os.path.join(output_dir, filename)
|
||||||
|
payload = {
|
||||||
|
"metrics": metrics,
|
||||||
|
"reference_latent": reference_path,
|
||||||
|
"generated_latent": generated_path,
|
||||||
|
"model_id": model_id,
|
||||||
|
"attention_backend": attention_backend_name,
|
||||||
|
"slice_spec": slice_spec,
|
||||||
|
"thresholds": {
|
||||||
|
"slice_cosine": slice_cosine_threshold,
|
||||||
|
"full_cosine": full_cosine_threshold,
|
||||||
|
},
|
||||||
|
"passed": passed,
|
||||||
|
"parameters": {
|
||||||
|
"num_inference_steps": num_inference_steps,
|
||||||
|
"prompt": prompt,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
with open(target, "w", encoding="utf-8") as handle:
|
||||||
|
json.dump(payload, handle, indent=2, sort_keys=True)
|
||||||
|
return True
|
||||||
|
except OSError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _assert_latent_similarity(
|
||||||
|
*,
|
||||||
|
logger: Logger,
|
||||||
|
gen_latent: torch.Tensor,
|
||||||
|
reference_path: str,
|
||||||
|
slice_cosine_threshold: float,
|
||||||
|
full_cosine_threshold: float,
|
||||||
|
model_id: str,
|
||||||
|
attention_backend_name: str,
|
||||||
|
output_dir: str,
|
||||||
|
generated_path: str,
|
||||||
|
num_inference_steps: int,
|
||||||
|
prompt: str,
|
||||||
|
) -> dict[str, float]:
|
||||||
|
ref = load_latent_reference(reference_path)
|
||||||
|
expected_slice = ref["expected_slice"]
|
||||||
|
ref_full = ref["latent"].to(torch.float32)
|
||||||
|
ref_shape = tuple(ref.get("shape", ref_full.shape))
|
||||||
|
slice_spec = ref.get("slice_spec", DEFAULT_SLICE_SPEC)
|
||||||
|
|
||||||
|
if tuple(gen_latent.shape) != ref_shape:
|
||||||
|
raise AssertionError(
|
||||||
|
f"Generated latent shape {tuple(gen_latent.shape)} does not "
|
||||||
|
f"match reference shape {ref_shape} for {model_id} with "
|
||||||
|
f"backend {attention_backend_name}")
|
||||||
|
|
||||||
|
gen_slice = _extract_expected_slice(gen_latent, slice_spec)
|
||||||
|
slice_cos = _cosine_distance(gen_slice, expected_slice)
|
||||||
|
# The reference full tensor was fp16-quantized at seed time. Round-trip
|
||||||
|
# the generated tensor through fp16 so both sides share the same
|
||||||
|
# quantization floor and the cosine distance is symmetric. This does
|
||||||
|
# NOT affect ``slice_cos`` because the reference slice is persisted in
|
||||||
|
# fp32 (see :func:`save_latent_reference`).
|
||||||
|
gen_full_matched = gen_latent.to(torch.float16).to(torch.float32)
|
||||||
|
full_cos = _cosine_distance(gen_full_matched, ref_full)
|
||||||
|
max_abs_diff = float(
|
||||||
|
(gen_full_matched - ref_full).abs().max().item())
|
||||||
|
|
||||||
|
metrics: dict[str, float] = {
|
||||||
|
"slice_cosine_distance": slice_cos,
|
||||||
|
"full_cosine_distance": full_cos,
|
||||||
|
"max_abs_diff": max_abs_diff,
|
||||||
|
}
|
||||||
|
logger.info(
|
||||||
|
"Latent regression metrics for %s/%s: %s",
|
||||||
|
model_id,
|
||||||
|
attention_backend_name,
|
||||||
|
metrics,
|
||||||
|
)
|
||||||
|
|
||||||
|
failures: list[str] = []
|
||||||
|
if slice_cos > slice_cosine_threshold:
|
||||||
|
failures.append(
|
||||||
|
f"slice cosine {slice_cos:.6e} > threshold "
|
||||||
|
f"{slice_cosine_threshold:.6e}")
|
||||||
|
if full_cos > full_cosine_threshold:
|
||||||
|
failures.append(
|
||||||
|
f"full cosine {full_cos:.6e} > threshold "
|
||||||
|
f"{full_cosine_threshold:.6e}")
|
||||||
|
|
||||||
|
passed = not failures
|
||||||
|
write_latent_similarity_results(
|
||||||
|
output_dir,
|
||||||
|
metrics,
|
||||||
|
reference_path=reference_path,
|
||||||
|
generated_path=generated_path,
|
||||||
|
num_inference_steps=num_inference_steps,
|
||||||
|
prompt=prompt,
|
||||||
|
model_id=model_id,
|
||||||
|
attention_backend_name=attention_backend_name,
|
||||||
|
slice_spec=slice_spec,
|
||||||
|
slice_cosine_threshold=slice_cosine_threshold,
|
||||||
|
full_cosine_threshold=full_cosine_threshold,
|
||||||
|
passed=passed,
|
||||||
|
)
|
||||||
|
|
||||||
|
if failures:
|
||||||
|
raise AssertionError(
|
||||||
|
f"Latent regression exceeded tolerance for {model_id} with "
|
||||||
|
f"backend {attention_backend_name}: {'; '.join(failures)}. "
|
||||||
|
f"Full metrics: {metrics}")
|
||||||
|
|
||||||
|
return metrics
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_latent_from_result(result: Any) -> torch.Tensor:
|
||||||
|
"""Pull a fp32 cpu latent tensor (5-D video or 3-D audio) from
|
||||||
|
``generate_video`` output.
|
||||||
|
"""
|
||||||
|
if not isinstance(result, dict):
|
||||||
|
raise RuntimeError(
|
||||||
|
"VideoGenerator.generate_video returned unexpected payload "
|
||||||
|
f"(type={type(result)!r}); expected dict with 'samples'.")
|
||||||
|
samples = result.get("samples")
|
||||||
|
if samples is None:
|
||||||
|
raise RuntimeError(
|
||||||
|
"VideoGenerator did not return latent samples. Ensure "
|
||||||
|
"output_type='latent' and return_frames=True for this call.")
|
||||||
|
if not isinstance(samples, torch.Tensor):
|
||||||
|
raise RuntimeError(
|
||||||
|
f"Expected torch.Tensor samples; got type={type(samples)!r}.")
|
||||||
|
gen_latent = samples.detach().to(torch.float32).cpu()
|
||||||
|
if gen_latent.dim() not in (3, 5):
|
||||||
|
raise RuntimeError(
|
||||||
|
"Expected 5-D video latent (B,C,T,H,W) or 3-D audio latent "
|
||||||
|
f"(B,C,T); got shape {tuple(gen_latent.shape)}")
|
||||||
|
return gen_latent
|
||||||
|
|
||||||
|
|
||||||
|
def run_text_to_latent_similarity_test(
|
||||||
|
*,
|
||||||
|
logger: Logger,
|
||||||
|
script_dir: str,
|
||||||
|
device_reference_folder: str,
|
||||||
|
prompt: str,
|
||||||
|
attention_backend_name: str,
|
||||||
|
model_id: str,
|
||||||
|
default_params_map: dict[str, dict[str, object]],
|
||||||
|
full_quality_params_map: dict[str, dict[str, object]],
|
||||||
|
slice_cosine_threshold: float = 5e-3,
|
||||||
|
full_cosine_threshold: float = 1e-2,
|
||||||
|
init_kwargs_override: dict[str, object] | None = None,
|
||||||
|
generation_kwargs_override: dict[str, object] | None = None,
|
||||||
|
slice_spec: dict[str, Any] | None = None,
|
||||||
|
) -> dict[str, float]:
|
||||||
|
"""Run T2V (or T2A) inference with ``output_type='latent'`` and
|
||||||
|
compare to a reference latent.
|
||||||
|
|
||||||
|
Returns the computed metrics dict on success. Raises
|
||||||
|
``AssertionError`` if any cosine tolerance is exceeded and
|
||||||
|
``FileNotFoundError`` if the reference artefact is missing.
|
||||||
|
"""
|
||||||
|
spec = slice_spec if slice_spec is not None else DEFAULT_SLICE_SPEC
|
||||||
|
with attention_backend(attention_backend_name):
|
||||||
|
output_dir = build_generated_output_dir(
|
||||||
|
script_dir,
|
||||||
|
device_reference_folder,
|
||||||
|
model_id,
|
||||||
|
attention_backend_name,
|
||||||
|
)
|
||||||
|
prompt_prefix = prompt[:100].strip()
|
||||||
|
output_latent_name = f"{prompt_prefix}{LATENT_REFERENCE_EXTENSION}"
|
||||||
|
os.makedirs(output_dir, exist_ok=True)
|
||||||
|
|
||||||
|
params_map = select_ssim_params(
|
||||||
|
default_params_map,
|
||||||
|
full_quality_params_map,
|
||||||
|
)
|
||||||
|
base_params = params_map[model_id]
|
||||||
|
num_inference_steps = int(base_params["num_inference_steps"])
|
||||||
|
|
||||||
|
init_kwargs = build_init_kwargs(base_params)
|
||||||
|
if init_kwargs_override:
|
||||||
|
init_kwargs.update(init_kwargs_override)
|
||||||
|
# Always wins: the helper exists specifically to compare on latents,
|
||||||
|
# so an override can never silently turn it back into a pixel run.
|
||||||
|
init_kwargs["output_type"] = "latent"
|
||||||
|
|
||||||
|
generation_kwargs = build_generation_kwargs(
|
||||||
|
base_params,
|
||||||
|
num_inference_steps,
|
||||||
|
output_dir,
|
||||||
|
)
|
||||||
|
# We serialize latents ourselves; skip the RGB encoder path.
|
||||||
|
generation_kwargs["save_video"] = False
|
||||||
|
generation_kwargs["return_frames"] = True
|
||||||
|
if generation_kwargs_override:
|
||||||
|
generation_kwargs.update(generation_kwargs_override)
|
||||||
|
|
||||||
|
generator: VideoGenerator | None = None
|
||||||
|
try:
|
||||||
|
generator = VideoGenerator.from_pretrained(
|
||||||
|
model_path=base_params["model_path"],
|
||||||
|
**init_kwargs,
|
||||||
|
)
|
||||||
|
result = generator.generate_video(prompt, **generation_kwargs)
|
||||||
|
finally:
|
||||||
|
shutdown_executor(generator)
|
||||||
|
|
||||||
|
gen_latent = _extract_latent_from_result(result)
|
||||||
|
|
||||||
|
generated_latent_path = os.path.join(output_dir, output_latent_name)
|
||||||
|
save_latent_reference(
|
||||||
|
generated_latent_path,
|
||||||
|
gen_latent,
|
||||||
|
metadata={
|
||||||
|
"prompt": prompt,
|
||||||
|
"model_id": model_id,
|
||||||
|
"attention_backend": attention_backend_name,
|
||||||
|
"num_inference_steps": num_inference_steps,
|
||||||
|
},
|
||||||
|
slice_spec=spec,
|
||||||
|
)
|
||||||
|
logger.info("Saved generated latent to %s", generated_latent_path)
|
||||||
|
|
||||||
|
reference_folder = build_reference_folder_path(
|
||||||
|
script_dir,
|
||||||
|
device_reference_folder,
|
||||||
|
model_id,
|
||||||
|
attention_backend_name,
|
||||||
|
)
|
||||||
|
if not os.path.exists(reference_folder):
|
||||||
|
raise FileNotFoundError(
|
||||||
|
f"Reference folder does not exist: {reference_folder}\n"
|
||||||
|
f"To download references, run:\n"
|
||||||
|
f" python fastvideo/tests/ssim/reference_videos_cli.py download")
|
||||||
|
|
||||||
|
reference_latent_path = os.path.join(
|
||||||
|
reference_folder,
|
||||||
|
output_latent_name,
|
||||||
|
)
|
||||||
|
if not os.path.exists(reference_latent_path):
|
||||||
|
raise FileNotFoundError(
|
||||||
|
"Reference latent missing for prompt/backend: "
|
||||||
|
f"{reference_latent_path}")
|
||||||
|
|
||||||
|
return _assert_latent_similarity(
|
||||||
|
logger=logger,
|
||||||
|
gen_latent=gen_latent,
|
||||||
|
reference_path=reference_latent_path,
|
||||||
|
slice_cosine_threshold=slice_cosine_threshold,
|
||||||
|
full_cosine_threshold=full_cosine_threshold,
|
||||||
|
model_id=model_id,
|
||||||
|
attention_backend_name=attention_backend_name,
|
||||||
|
output_dir=output_dir,
|
||||||
|
generated_path=generated_latent_path,
|
||||||
|
num_inference_steps=num_inference_steps,
|
||||||
|
prompt=prompt,
|
||||||
|
)
|
||||||
@@ -13,6 +13,12 @@ from pathlib import Path
|
|||||||
|
|
||||||
|
|
||||||
VIDEO_EXTENSIONS = (".mp4", ".avi", ".mov", ".mkv", ".webm", ".flv")
|
VIDEO_EXTENSIONS = (".mp4", ".avi", ".mov", ".mkv", ".webm", ".flv")
|
||||||
|
# Additional artefact types stored under the same reference folders. Latent
|
||||||
|
# tensors (`.pt`) back the latent-slice regression tests used by flaky
|
||||||
|
# pixel-space models (e.g. LTX-2 distilled). Keeping them in the same upload
|
||||||
|
# flow means seeding a new test only requires one HF round-trip.
|
||||||
|
LATENT_EXTENSIONS = (".pt",)
|
||||||
|
REFERENCE_EXTENSIONS = VIDEO_EXTENSIONS + LATENT_EXTENSIONS
|
||||||
HF_TOKEN_ENV_KEYS = ("HF_API_KEY", "HUGGINGFACE_HUB_TOKEN", "HF_TOKEN")
|
HF_TOKEN_ENV_KEYS = ("HF_API_KEY", "HUGGINGFACE_HUB_TOKEN", "HF_TOKEN")
|
||||||
HF_REPO_ENV_KEY = "FASTVIDEO_SSIM_REFERENCE_HF_REPO"
|
HF_REPO_ENV_KEY = "FASTVIDEO_SSIM_REFERENCE_HF_REPO"
|
||||||
HF_REPO_TYPE_ENV_KEY = "FASTVIDEO_SSIM_REFERENCE_HF_REPO_TYPE"
|
HF_REPO_TYPE_ENV_KEY = "FASTVIDEO_SSIM_REFERENCE_HF_REPO_TYPE"
|
||||||
@@ -42,9 +48,14 @@ def _default_repo_type() -> str:
|
|||||||
return os.environ.get(HF_REPO_TYPE_ENV_KEY, DEFAULT_REPO_TYPE)
|
return os.environ.get(HF_REPO_TYPE_ENV_KEY, DEFAULT_REPO_TYPE)
|
||||||
|
|
||||||
|
|
||||||
def _iter_video_files(root: Path) -> Iterable[Path]:
|
def _iter_reference_files(root: Path) -> Iterable[Path]:
|
||||||
|
"""Yield video and latent (.pt) references under `root`.
|
||||||
|
|
||||||
|
Used by copy-local and the "has local references" marker probe so that
|
||||||
|
latent-only tests (no mp4) still satisfy readiness checks.
|
||||||
|
"""
|
||||||
for path in root.rglob("*"):
|
for path in root.rglob("*"):
|
||||||
if path.is_file() and path.suffix.lower() in VIDEO_EXTENSIONS:
|
if path.is_file() and path.suffix.lower() in REFERENCE_EXTENSIONS:
|
||||||
yield path
|
yield path
|
||||||
|
|
||||||
|
|
||||||
@@ -139,7 +150,7 @@ def _has_local_reference_videos(base_dir: Path, quality_tier: str) -> bool:
|
|||||||
return False
|
return False
|
||||||
tier_root = _reference_tier_root(base_dir, quality_tier)
|
tier_root = _reference_tier_root(base_dir, quality_tier)
|
||||||
for ref_dir in _discover_reference_dirs(tier_root):
|
for ref_dir in _discover_reference_dirs(tier_root):
|
||||||
for _ in _iter_video_files(ref_dir):
|
for _ in _iter_reference_files(ref_dir):
|
||||||
return True
|
return True
|
||||||
return False
|
return False
|
||||||
|
|
||||||
@@ -173,14 +184,14 @@ def copy_generated_to_reference(
|
|||||||
raise FileNotFoundError(f"Generated directory not found: {generated_dir}")
|
raise FileNotFoundError(f"Generated directory not found: {generated_dir}")
|
||||||
|
|
||||||
copied = 0
|
copied = 0
|
||||||
for video_file in _iter_video_files(generated_dir):
|
for ref_file in _iter_reference_files(generated_dir):
|
||||||
rel = video_file.relative_to(generated_dir)
|
rel = ref_file.relative_to(generated_dir)
|
||||||
dst = reference_dir / rel
|
dst = reference_dir / rel
|
||||||
if dry_run:
|
if dry_run:
|
||||||
print(f"[dry-run] {video_file} -> {dst}")
|
print(f"[dry-run] {ref_file} -> {dst}")
|
||||||
else:
|
else:
|
||||||
dst.parent.mkdir(parents=True, exist_ok=True)
|
dst.parent.mkdir(parents=True, exist_ok=True)
|
||||||
shutil.copy2(video_file, dst)
|
shutil.copy2(ref_file, dst)
|
||||||
print(f"Copied: {rel}")
|
print(f"Copied: {rel}")
|
||||||
copied += 1
|
copied += 1
|
||||||
return copied
|
return copied
|
||||||
|
|||||||
@@ -1,11 +1,25 @@
|
|||||||
# SPDX-License-Identifier: Apache-2.0
|
# SPDX-License-Identifier: Apache-2.0
|
||||||
"""SSIM-based similarity test for LTX-2 distilled text-to-video.
|
"""Latent-slice regression test for LTX-2 distilled text-to-video.
|
||||||
|
|
||||||
Parameters derived from examples/inference/basic/basic_ltx2_distilled.py,
|
Pixel-space SSIM is not a useful signal for this model: 4 distilled
|
||||||
with resolution + num_inference_steps reduced to keep GPU CI runtime
|
steps + bf16 attention + tiled VAE decode produce outputs that pass
|
||||||
bounded. Full-quality variant (via ``--ssim-full-quality``) falls back
|
visual QA but occupy a very wide region in pixel space.
|
||||||
to the ``ltx2_distilled`` preset defaults.
|
|
||||||
|
Inspired by diffusers' slice-vs-full regression philosophy — see
|
||||||
|
``diffusers/tests/pipelines/ltx2/test_ltx2.py`` (compares pixel slices
|
||||||
|
via ``torch.allclose(..., atol=1e-4)``) and
|
||||||
|
``diffusers/tests/pipelines/cogvideo/test_cogvideox.py`` (full pixel
|
||||||
|
tensors via ``numpy_cosine_similarity_distance(...) < 1e-3``).
|
||||||
|
Diffusers itself does NOT compare latents; we apply the same "small
|
||||||
|
signature slice + bounded full-tensor distance" idea to the **pre-VAE
|
||||||
|
latent** because distilled few-step pipelines amplify per-step bf16
|
||||||
|
noise enough that VAE-decoded comparisons are unreliable.
|
||||||
|
|
||||||
|
Parameters are kept identical to the original SSIM run so that
|
||||||
|
reference artefacts generated on Modal L40S remain bit-compatible with
|
||||||
|
production inference.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
@@ -14,7 +28,9 @@ from fastvideo.api.sampling_param import SamplingParam
|
|||||||
from fastvideo.logger import init_logger
|
from fastvideo.logger import init_logger
|
||||||
from fastvideo.tests.ssim.inference_similarity_utils import (
|
from fastvideo.tests.ssim.inference_similarity_utils import (
|
||||||
resolve_inference_device_reference_folder,
|
resolve_inference_device_reference_folder,
|
||||||
run_text_to_video_similarity_test,
|
)
|
||||||
|
from fastvideo.tests.ssim.latent_similarity_utils import (
|
||||||
|
run_text_to_latent_similarity_test,
|
||||||
)
|
)
|
||||||
|
|
||||||
logger = init_logger(__name__)
|
logger = init_logger(__name__)
|
||||||
@@ -71,6 +87,13 @@ LTX2_DISTILLED_TEST_PROMPTS = [
|
|||||||
"deadpan, absurd, and quietly tragic.",
|
"deadpan, absurd, and quietly tragic.",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
# Tolerances chosen on top of diffusers' ``1e-3`` defaults. LTX-2 distilled
|
||||||
|
# amplifies per-step numerical noise, and FastVideo's CI pool spans L40S /
|
||||||
|
# A40 / H100 so cross-architecture bf16 drift must be absorbed. Values can
|
||||||
|
# be tightened after an initial stable window of reference refreshes.
|
||||||
|
SLICE_COSINE_DISTANCE_THRESHOLD = 5e-3
|
||||||
|
FULL_COSINE_DISTANCE_THRESHOLD = 1e-2
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("prompt", LTX2_DISTILLED_TEST_PROMPTS)
|
@pytest.mark.parametrize("prompt", LTX2_DISTILLED_TEST_PROMPTS)
|
||||||
@pytest.mark.parametrize("attention_backend_name", ["FLASH_ATTN"])
|
@pytest.mark.parametrize("attention_backend_name", ["FLASH_ATTN"])
|
||||||
@@ -80,7 +103,7 @@ def test_ltx2_distilled_inference_similarity(
|
|||||||
attention_backend_name: str,
|
attention_backend_name: str,
|
||||||
model_id: str,
|
model_id: str,
|
||||||
) -> None:
|
) -> None:
|
||||||
run_text_to_video_similarity_test(
|
run_text_to_latent_similarity_test(
|
||||||
logger=logger,
|
logger=logger,
|
||||||
script_dir=os.path.dirname(os.path.abspath(__file__)),
|
script_dir=os.path.dirname(os.path.abspath(__file__)),
|
||||||
device_reference_folder=device_reference_folder,
|
device_reference_folder=device_reference_folder,
|
||||||
@@ -89,5 +112,6 @@ def test_ltx2_distilled_inference_similarity(
|
|||||||
model_id=model_id,
|
model_id=model_id,
|
||||||
default_params_map=LTX2_DISTILLED_MODEL_TO_PARAMS,
|
default_params_map=LTX2_DISTILLED_MODEL_TO_PARAMS,
|
||||||
full_quality_params_map=FULL_QUALITY_LTX2_DISTILLED_MODEL_TO_PARAMS,
|
full_quality_params_map=FULL_QUALITY_LTX2_DISTILLED_MODEL_TO_PARAMS,
|
||||||
min_acceptable_ssim=0.60,
|
slice_cosine_threshold=SLICE_COSINE_DISTANCE_THRESHOLD,
|
||||||
|
full_cosine_threshold=FULL_COSINE_DISTANCE_THRESHOLD,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,111 @@
|
|||||||
|
# SPDX-License-Identifier: Apache-2.0
|
||||||
|
"""Latent-slice regression test for Stable Audio Open 1.0 text-to-audio.
|
||||||
|
|
||||||
|
Companion to ``test_ltx2_similarity.py`` — applies the same latent
|
||||||
|
cosine-distance philosophy to 3-D audio latents ``[B, 64, T_latent]``.
|
||||||
|
|
||||||
|
Why latent-space and not waveform-space SSIM:
|
||||||
|
- ``dpmpp-3m-sde`` (k-diffusion) accumulates per-step bf16 noise; the
|
||||||
|
Oobleck VAE then magnifies any residual drift into the time-domain
|
||||||
|
waveform. A few mis-rounded accumulators drive sample-wise diff well
|
||||||
|
past audible thresholds without indicating a real regression.
|
||||||
|
- Diffusers' own
|
||||||
|
``tests/pipelines/stable_audio/test_stable_audio.py`` compares
|
||||||
|
decoded audio samples via
|
||||||
|
``np.abs(expected - actual).max() < 1.5e-3``; that bound holds for
|
||||||
|
CPU dummy components but does not survive cross-architecture bf16
|
||||||
|
on our CI pool (L40S/A40/H100/B200).
|
||||||
|
- Comparing the **pre-VAE latent** moves the assertion upstream of
|
||||||
|
the dominant noise source.
|
||||||
|
|
||||||
|
Slice spec: ``audio_first_8_timesteps`` returns
|
||||||
|
``latent[0, :, :8]`` (= 64 channels × 8 latent timesteps = 512
|
||||||
|
elements). The full latent ``[1, 64, 1024]`` for SA-1.0 is also
|
||||||
|
compared via cosine distance.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from fastvideo.api.sampling_param import SamplingParam
|
||||||
|
from fastvideo.logger import init_logger
|
||||||
|
from fastvideo.tests.ssim.inference_similarity_utils import (
|
||||||
|
resolve_inference_device_reference_folder,
|
||||||
|
)
|
||||||
|
from fastvideo.tests.ssim.latent_similarity_utils import (
|
||||||
|
AUDIO_FIRST_8_TIMESTEPS_SPEC,
|
||||||
|
run_text_to_latent_similarity_test,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = init_logger(__name__)
|
||||||
|
|
||||||
|
REQUIRED_GPUS = 1
|
||||||
|
|
||||||
|
device_reference_folder = resolve_inference_device_reference_folder(logger)
|
||||||
|
|
||||||
|
STABLE_AUDIO_PARAMS = {
|
||||||
|
"num_gpus": 1,
|
||||||
|
"model_path": "FastVideo/stable-audio-open-1.0-Diffusers",
|
||||||
|
# 8/8/1 are the SA-1.0 SamplingParam defaults; the values are
|
||||||
|
# placeholders unused by the audio pipeline but must satisfy the
|
||||||
|
# shared InputValidationStage divisible-by-8 check.
|
||||||
|
"height": 8,
|
||||||
|
"width": 8,
|
||||||
|
"num_frames": 1,
|
||||||
|
"num_inference_steps": 25,
|
||||||
|
"guidance_scale": 7.0,
|
||||||
|
"seed": 1024,
|
||||||
|
"sp_size": 1,
|
||||||
|
"tp_size": 1,
|
||||||
|
"fps": 24,
|
||||||
|
}
|
||||||
|
|
||||||
|
_SA_FULL_DEFAULTS = SamplingParam.from_pretrained(STABLE_AUDIO_PARAMS["model_path"])
|
||||||
|
STABLE_AUDIO_FULL_QUALITY_PARAMS = {
|
||||||
|
**STABLE_AUDIO_PARAMS,
|
||||||
|
"num_inference_steps": _SA_FULL_DEFAULTS.num_inference_steps,
|
||||||
|
"guidance_scale": _SA_FULL_DEFAULTS.guidance_scale,
|
||||||
|
"seed": _SA_FULL_DEFAULTS.seed,
|
||||||
|
}
|
||||||
|
|
||||||
|
STABLE_AUDIO_MODEL_TO_PARAMS = {
|
||||||
|
"stable-audio-open-1.0-Diffusers": STABLE_AUDIO_PARAMS,
|
||||||
|
}
|
||||||
|
FULL_QUALITY_STABLE_AUDIO_MODEL_TO_PARAMS = {
|
||||||
|
"stable-audio-open-1.0-Diffusers": STABLE_AUDIO_FULL_QUALITY_PARAMS,
|
||||||
|
}
|
||||||
|
|
||||||
|
STABLE_AUDIO_TEST_PROMPTS = [
|
||||||
|
"Lo-fi hip hop instrumental with vinyl crackle and gentle piano.",
|
||||||
|
]
|
||||||
|
|
||||||
|
SLICE_COSINE_DISTANCE_THRESHOLD = 5e-3
|
||||||
|
FULL_COSINE_DISTANCE_THRESHOLD = 1e-2
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("prompt", STABLE_AUDIO_TEST_PROMPTS)
|
||||||
|
@pytest.mark.parametrize("attention_backend_name", ["TORCH_SDPA"])
|
||||||
|
@pytest.mark.parametrize("model_id", list(STABLE_AUDIO_MODEL_TO_PARAMS.keys()))
|
||||||
|
def test_stable_audio_inference_similarity(
|
||||||
|
prompt: str,
|
||||||
|
attention_backend_name: str,
|
||||||
|
model_id: str,
|
||||||
|
) -> None:
|
||||||
|
run_text_to_latent_similarity_test(
|
||||||
|
logger=logger,
|
||||||
|
script_dir=os.path.dirname(os.path.abspath(__file__)),
|
||||||
|
device_reference_folder=device_reference_folder,
|
||||||
|
prompt=prompt,
|
||||||
|
attention_backend_name=attention_backend_name,
|
||||||
|
model_id=model_id,
|
||||||
|
default_params_map=STABLE_AUDIO_MODEL_TO_PARAMS,
|
||||||
|
full_quality_params_map=FULL_QUALITY_STABLE_AUDIO_MODEL_TO_PARAMS,
|
||||||
|
slice_cosine_threshold=SLICE_COSINE_DISTANCE_THRESHOLD,
|
||||||
|
full_cosine_threshold=FULL_COSINE_DISTANCE_THRESHOLD,
|
||||||
|
slice_spec=AUDIO_FIRST_8_TIMESTEPS_SPEC,
|
||||||
|
# FSDP + @torch.inference_mode in StableAudioDenoisingStage hits
|
||||||
|
# "Inference tensors do not track version counter" on single-GPU
|
||||||
|
# unshard. SA-1.0 fits on one B200 anyway.
|
||||||
|
init_kwargs_override={"use_fsdp_inference": False},
|
||||||
|
)
|
||||||
Reference in New Issue
Block a user