Compare commits

...
Author SHA1 Message Date
Jinzhe Pan 3a7925c674 [ci] Add /test pre-commit slash command
- /test pre-commit triggers pre-commit via workflow_call
- Posts commit status to PR head SHA so check appears on PR page
- Mergify check-success~=pre-commit condition can now be satisfied
2026-03-31 08:18:06 -04:00
Jinzhe Pan 81fd80c8ee [ci] Add TEST_SCOPE routing for clean single-test execution (#1203) 2026-03-31 19:40:59 +08:00
Jinzhe Pan ff22439f28 [ci] Fix fork PR checkout for /test and Full Suite triggers (#1202) 2026-03-31 13:42:13 +08:00
Jinzhe Pan de0de04212 [ci] Replace Merge Queue with auto-merge — reduce CI complexity (#1200) 2026-03-31 10:09:33 +08:00
mergify[bot] 7f2c3e1f64 [ci](mergify): upgrade configuration to current format (#1194)
Co-authored-by: mergify[bot] <37929162+mergify[bot]@users.noreply.github.com>
2026-03-31 00:47:48 +08:00
Jinzhe Pan ab55e57c22 [ci] Fix Merge Queue requeue and draft PR pre-commit skip (#1197) 2026-03-30 22:35:13 +08:00
Jinzhe Pan 46f6b43a53 [ci] Fix Merge Queue immediate dequeue (#1196) 2026-03-30 21:33:45 +08:00
Jinzhe Pan 833a33b663 [ci] CI follow-up: gate checks, issue label unification, draft PR skip (#1193) 2026-03-30 20:19:46 +08:00
Jinzhe Pan 9ea1307cd4 [ci] Add approval and pre-commit checks to merge protections (#1190)
## Summary

Follow-up to #1187. Two small changes:

1. **Merge Protections expanded** — adds `#approved-reviews-by>=1` and `check-success~=pre-commit` to `merge_protections` so the Mergify check shows a unified requirements checklist on every PR (title format + approval + pre-commit), instead of only showing the title format.

2. **Buildkite pipeline comment fix** — updates the outdated Full Suite section comment from "Triggered by adding the 'ready' label via GitHub Actions → Buildkite API" to reflect the new Merge Queue trigger path.
2026-03-30 05:14:49 +00:00
Jinzhe Pan be35003cb1 [ci] Merge Queue, label system overhaul, and slash commands (2/2) (#1187) 2026-03-30 08:22:35 +08:00
Jinzhe Pan 26bd4db253 [ci] CI infrastructure cleanup and workflow reorganization (1/2) (#1186) 2026-03-29 17:01:09 -07:00
Jinzhe PanandWill Lin e294ca011c [feat]: overhaul SSIM test infrastructure — partition scheduling, helper migration, CI fixes (#1185)
Co-authored-by: Will Lin <wlsaidhi@gmail.com>
2026-03-29 23:59:25 +00:00
alexzms 2085a4fc4a [bugfix]: fix VAE temporal tiling blend corruption in tiled_encode (#1181) 2026-03-29 23:12:42 +00:00
Jinzhe Pan c0c8e39c04 Revert "[feat] Job Runner UI" (#1188) 2026-03-29 16:46:27 +08:00
164 changed files with 4451 additions and 15159 deletions
+50 -15
View File
@@ -2,17 +2,45 @@ env:
IMAGE_VERSION: "py3.12-latest"
BUILDKITE_CLEAN_CHECKOUT: true
notify:
- github_commit_status:
context: "fastcheck-passed"
if: build.env("TEST_SCOPE") == "fastcheck" || build.env("TEST_SCOPE") == null
- github_commit_status:
context: "full-suite-passed"
if: build.env("TEST_SCOPE") == "full"
steps:
# ============================================================
- label: ":dart: Direct Test (${TEST_TYPE})"
if: build.env("TEST_SCOPE") == "direct"
command: "timeout 90m .buildkite/scripts/pr_test.sh"
retry:
automatic:
- exit_status: 128
limit: 3
- exit_status: -1
limit: 2
agents:
queue: "default"
# ============================================================
# Fastcheck: Runs on every PR (~10-15 min parallel)
# Core component validation: encoders, VAEs, transformers,
# CUDA kernels, and unit tests.
# ============================================================
- label: "Trigger Fastcheck"
plugins:
- monorepo-diff#v1.4.0:
diff: 'git fetch origin "$BUILDKITE_PULL_REQUEST_BASE_BRANCH" && git diff --name-only origin/"$BUILDKITE_PULL_REQUEST_BASE_BRANCH"...HEAD'
watch:
- label: "Trigger Fastcheck"
if: build.env("TEST_SCOPE") == "fastcheck" || build.env("TEST_SCOPE") == null
retry:
automatic:
- exit_status: 128
limit: 3
- exit_status: -1
limit: 2
plugins:
- monorepo-diff#v1.4.0:
diff: 'git fetch origin "${BUILDKITE_PULL_REQUEST_BASE_BRANCH:-main}" && git diff --name-only "origin/${BUILDKITE_PULL_REQUEST_BASE_BRANCH:-main}...HEAD"'
watch:
- path:
- "fastvideo/models/encoders/**"
- "fastvideo/models/loader/**"
@@ -80,17 +108,24 @@ steps:
queue: "default"
# ============================================================
# Full Suite: Runs only when FULL_SUITE=true
# Triggered by adding the 'ready' label to a PR (via GitHub
# Actions → Buildkite API). Includes integration tests, SSIM
# regression, training pipelines, and performance benchmarks.
# Full Suite: Runs when TEST_SCOPE=full
# Triggered by adding the 'ready' label (via ci-trigger-full-suite.yml)
# or on-demand via /test full slash command.
# Includes integration tests, SSIM regression, training pipelines,
# and performance benchmarks.
# ============================================================
- label: "Trigger Full Suite"
if: build.env("FULL_SUITE") == "true"
plugins:
- monorepo-diff#v1.4.0:
diff: 'git fetch origin "$BUILDKITE_PULL_REQUEST_BASE_BRANCH" && git diff --name-only origin/"$BUILDKITE_PULL_REQUEST_BASE_BRANCH"...HEAD'
watch:
- label: "Trigger Full Suite"
if: build.env("TEST_SCOPE") == "full"
retry:
automatic:
- exit_status: 128
limit: 3
- exit_status: -1
limit: 2
plugins:
- monorepo-diff#v1.4.0:
diff: 'git fetch origin "${BUILDKITE_PULL_REQUEST_BASE_BRANCH:-main}" && git diff --name-only "origin/${BUILDKITE_PULL_REQUEST_BASE_BRANCH:-main}...HEAD"'
watch:
- path:
- "fastvideo/**/*.py"
- "pyproject.toml"
+5 -1
View File
@@ -59,7 +59,11 @@ if [ -z "${TEST_TYPE:-}" ]; then
fi
log "Test type: $TEST_TYPE"
MODAL_ENV="BUILDKITE_REPO=$BUILDKITE_REPO BUILDKITE_COMMIT=$BUILDKITE_COMMIT BUILDKITE_PULL_REQUEST=$BUILDKITE_PULL_REQUEST IMAGE_VERSION=$IMAGE_VERSION"
EFFECTIVE_PR=${BUILDKITE_PULL_REQUEST:-false}
if [ "$EFFECTIVE_PR" = "false" ] && [ -n "${PR_NUMBER:-}" ]; then
EFFECTIVE_PR=$PR_NUMBER
fi
MODAL_ENV="BUILDKITE_REPO=$BUILDKITE_REPO BUILDKITE_COMMIT=$BUILDKITE_COMMIT BUILDKITE_PULL_REQUEST=$EFFECTIVE_PR IMAGE_VERSION=$IMAGE_VERSION"
case "$TEST_TYPE" in
"encoder")
+19
View File
@@ -1,3 +1,22 @@
<!--
PR TITLE: Must start with a type tag, e.g.:
[feat] Add new model [bugfix] Fix VAE tiling [refactor] Restructure pipeline
[perf] Optimize kernel [ci] Update tests [docs] Add guide
[misc] Cleanup configs [new-model] Port Flux2
MERGE WORKFLOW:
1. Ensure pre-commit passes and you have at least 1 approval
2. Comment /merge (or add the "ready" label) to enter the Merge Queue
3. Full Test Suite runs automatically on a staging branch → auto-merge on success
ON-DEMAND TESTING (write access required):
/test full — Full Test Suite /test ssim — SSIM regression
/test training — Training pipeline /test encoder — Encoder tests
/test transformer — Transformer tests /test vae — VAE tests
/test kernel — CUDA kernel tests /test unit — Unit tests
See docs/contributing/pull_requests.md for all 17 test commands
-->
## Purpose
<!-- What does this PR do? Link the related issue if applicable. -->
+187 -121
View File
@@ -1,168 +1,191 @@
merge_protections:
- name: PR merge requirements
if:
- base = main
success_conditions:
- "title~=(?i)^\\[(feat|feature|bugfix|fix|refactor|perf|ci|doc|docs|misc|chore|kernel|new.?model)\\]"
- check-success~=pre-commit
- check-success=fastcheck-passed
pull_request_rules:
# ============================================================
# Auto-labeling by file path
# Type labels (from PR title prefix)
# ============================================================
- name: label attention changes
- name: "label type: feat"
conditions:
- "title~=(?i)^\\[(feat|feature)\\]"
- -closed
actions:
label:
add: ["type: feat"]
- name: "label type: bugfix"
conditions:
- "title~=(?i)^\\[(bug)?fix\\]"
- -closed
actions:
label:
add: ["type: bugfix"]
- name: "label type: refactor"
conditions:
- "title~=(?i)^\\[refactor\\]"
- -closed
actions:
label:
add: ["type: refactor"]
- name: "label type: perf"
conditions:
- "title~=(?i)^\\[perf\\]"
- -closed
actions:
label:
add: ["type: perf"]
- name: "label type: ci"
conditions:
- "title~=(?i)^\\[ci\\]"
- -closed
actions:
label:
add: ["type: ci"]
- name: "label type: docs"
conditions:
- "title~=(?i)^\\[(doc|docs)\\]"
- -closed
actions:
label:
add: ["type: docs"]
- name: "label type: misc"
conditions:
- "title~=(?i)^\\[(misc|chore)\\]"
- -closed
actions:
label:
add: ["type: misc"]
- name: "label type: new-model"
conditions:
- "title~=(?i)^\\[new.?model\\]"
- -closed
actions:
label:
add: ["type: new-model"]
# ============================================================
# Scope labels (from changed files)
# ============================================================
- name: "label scope: training"
conditions:
- or:
- files~=^fastvideo/attention/
- files~=^csrc/attn/
- files~=^fastvideo/train/
- files~=^fastvideo/training/
- files~=^fastvideo/distillation/
- files~=^examples/train/
- files~=^examples/training/
- files~=^examples/distill/
- -closed
- label!=stale
actions:
label:
add: [attention]
add: ["scope: training"]
- name: label encoder changes
- name: "label scope: inference"
conditions:
- files~=^fastvideo/models/encoders/
- or:
- files~=^fastvideo/pipelines/basic/
- files~=^fastvideo/pipelines/stages/
- files~=^fastvideo/pipelines/samplers/
- files~=^fastvideo/entrypoints/
- files~=^fastvideo/worker/
- files~=^fastvideo/configs/sample/
- files~=^fastvideo/configs/pipelines/
- files~=^examples/inference/
- -closed
- label!=stale
actions:
label:
add: [encoder]
add: ["scope: inference"]
- name: label vae changes
- name: "label scope: attention"
conditions:
- files~=^fastvideo/models/vaes/
- files~=^fastvideo/attention/
- -closed
- label!=stale
actions:
label:
add: [vae]
add: ["scope: attention"]
- name: label transformer/DiT changes
- name: "label scope: kernel"
conditions:
- files~=^fastvideo/models/dits/
- or:
- files~=^fastvideo-kernel/
- files~=^csrc/
- -closed
- label!=stale
actions:
label:
add: [transformer]
add: ["scope: kernel"]
- name: label training changes
- name: "label scope: data"
conditions:
- files~=^fastvideo/training/
- or:
- files~=^fastvideo/dataset/
- files~=^fastvideo/pipelines/preprocess/
- files~=^examples/preprocessing/
- -closed
- label!=stale
actions:
label:
add: [training]
add: ["scope: data"]
- name: label pipeline changes
- name: "label scope: infra"
conditions:
- files~=^fastvideo/pipelines/
- or:
- files~=^\.github/
- files~=^\.buildkite/
- files~=^fastvideo/tests/
- files~=^docker/
- -closed
- label!=stale
actions:
label:
add: [pipeline]
add: ["scope: infra"]
- name: label distributed changes
- name: "label scope: distributed"
conditions:
- files~=^fastvideo/distributed/
- -closed
- label!=stale
actions:
label:
add: [distributed]
add: ["scope: distributed"]
- name: label kernel changes
- name: "label scope: docs"
conditions:
- files~=^docs/
- -closed
actions:
label:
add: ["scope: docs"]
- name: "label scope: ui"
conditions:
- files~=^ui/
- -closed
actions:
label:
add: ["scope: ui"]
- name: "label scope: model"
conditions:
- or:
- files~=^csrc/
- files~=^fastvideo-kernel/
- files~=^fastvideo/models/
- files~=^fastvideo/layers/
- files~=^fastvideo/configs/models/
- -closed
- label!=stale
actions:
label:
add: [kernel]
- name: label data/dataset changes
conditions:
- or:
- files~=^fastvideo/dataset/
- files~=^fastvideo/data_preprocess/
- -closed
- label!=stale
actions:
label:
add: [data]
- name: label CLI/entrypoint changes
conditions:
- files~=^fastvideo/entrypoints/
- -closed
- label!=stale
actions:
label:
add: [cli]
- name: label documentation changes
conditions:
- or:
- files~=^docs/
- files~=\.md$
- -closed
- label!=stale
actions:
label:
add: [documentation]
- name: label CI changes
conditions:
- or:
- files~=^\.github/
- files~=^\.buildkite/
- files~=^docker/
- -closed
- label!=stale
actions:
label:
add: [ci]
- name: label test changes
conditions:
- or:
- files~=^fastvideo/tests/
- files~=^tests/
- -closed
- label!=stale
actions:
label:
add: [tests]
- name: label ComfyUI changes
conditions:
- files~=^comfyui/
- -closed
- label!=stale
actions:
label:
add: [comfyui]
- name: label layer changes
conditions:
- files~=^fastvideo/layers/
- -closed
- label!=stale
actions:
label:
add: [layers]
- name: label LoRA changes
conditions:
- or:
- files~=^fastvideo/layers/lora/
- files~=^fastvideo/tests/lora/
- title~=(?i)lora
- -closed
- label!=stale
actions:
label:
add: [lora]
add: ["scope: model"]
# ============================================================
# Pre-commit failure help comment
@@ -197,6 +220,7 @@ pull_request_rules:
For future commits, `pre-commit` will run automatically on changed files before each commit.
# ============================================================
# Merge conflict detection
# ============================================================
@@ -230,14 +254,17 @@ pull_request_rules:
remove: [needs-rebase]
# ============================================================
# Auto-rebase stale PRs
# Auto-merge and auto-rebase
# ============================================================
- name: auto-merge when ready and approved
- name: auto-merge when ready and all checks pass
conditions:
- label=ready
- "title~=(?i)^\\[(feat|feature|bugfix|fix|refactor|perf|ci|doc|docs|misc|chore|kernel|new.?model)\\]"
- "#approved-reviews-by>=1"
- check-success~=pre-commit
- check-success=fastcheck-passed
- check-success=full-suite-passed
- -conflict
- -closed
- -draft
@@ -245,11 +272,50 @@ pull_request_rules:
merge:
method: squash
- name: auto-rebase on ready label
- name: auto-rebase when ready and Full Suite passed
conditions:
- label=ready
- "#approved-reviews-by>=1"
- check-success=full-suite-passed
- -conflict
- -closed
- -draft
actions:
rebase: {}
- name: remove ready label on Full Suite failure
conditions:
- label=ready
- check-failure=full-suite-passed
actions:
label:
remove: [ready]
# ============================================================
# PR title format help
# ============================================================
- name: comment on invalid PR title format
conditions:
- -closed
- -draft
- "-title~=(?i)^\\[(feat|feature|bugfix|fix|refactor|perf|ci|doc|docs|misc|chore|kernel|new.?model)\\]"
actions:
comment:
message: |
## ⚠️ PR title format required
Your PR title must start with a type tag in brackets. Examples:
- `[feat] Add new model support`
- `[bugfix] Fix VAE tiling corruption`
- `[refactor] Restructure training pipeline`
- `[perf] Optimize attention kernel`
- `[ci] Update test infrastructure`
- `[docs] Add inference guide`
- `[misc] Clean up configs`
- `[new-model] Port Flux2 to FastVideo`
Valid tags: `feat`, `feature`, `bugfix`, `fix`, `refactor`, `perf`, `ci`, `doc`, `docs`, `misc`, `chore`, `kernel`, `new-model`
Please update your PR title and the merge protection check will pass automatically.
-249
View File
@@ -1,249 +0,0 @@
import argparse
import json
import os
import subprocess
import sys
import time
import requests
def parse_arguments():
"""Parse command line arguments"""
parser = argparse.ArgumentParser(description='Run tests on RunPod GPU')
parser.add_argument('--gpu-type', type=str, help='GPU type to use')
parser.add_argument('--gpu-count',
type=int,
help='Number of GPUs to use',
default=1)
parser.add_argument('--test-command', type=str, help='Test command to run')
parser.add_argument('--disk-size',
type=int,
default=20,
help='Container disk size in GB (default: 20)')
parser.add_argument('--volume-size',
type=int,
default=20,
help='Persistent volume size in GB (default: 20)')
parser.add_argument(
'--image',
type=str,
required=True,
help='Docker image to use')
return parser.parse_args()
args = parse_arguments()
API_KEY = os.environ['RUNPOD_API_KEY']
RUN_ID = os.environ['GITHUB_RUN_ID']
JOB_ID = os.environ['JOB_ID']
PODS_API = "https://rest.runpod.io/v1/pods"
HEADERS = {
"Content-Type": "application/json",
"Authorization": f"Bearer {API_KEY}"
}
def create_pod():
"""Create a RunPod instance"""
# Ensure image name is lowercase (Docker requirement)
image_name = args.image.lower()
print(f"Using specified image: {image_name}")
docker_start_cmd = [
"bash",
"-c",
"apt update;DEBIAN_FRONTEND=noninteractive apt-get install openssh-server -y;mkdir -p ~/.ssh;cd $_;chmod 700 ~/.ssh;echo \"$PUBLIC_KEY\" >> authorized_keys;chmod 700 authorized_keys;service ssh start;sleep infinity"
]
print(f"Creating RunPod instance with GPU: {args.gpu_type}...")
payload = {
"name": f"fastvideo-{JOB_ID}-{RUN_ID}",
"containerDiskInGb": args.disk_size,
"volumeInGb": args.volume_size,
"gpuTypeIds": [args.gpu_type],
"gpuCount": args.gpu_count,
"imageName": image_name,
"allowedCudaVersions": ["12.4"],
"dockerStartCmd": docker_start_cmd
}
response = requests.post(PODS_API, headers=HEADERS, json=payload)
response_data = response.json()
print(f"Response: {json.dumps(response_data, indent=2)}")
return response_data["id"]
def wait_for_pod(pod_id):
"""Wait for pod to be in RUNNING state and fully ready with SSH access"""
print("Waiting for RunPod to be ready...")
# First wait for RUNNING status
max_attempts = 10
attempts = 0
while attempts < max_attempts:
response = requests.get(f"{PODS_API}/{pod_id}", headers=HEADERS)
pod_data = response.json()
status = pod_data["desiredStatus"]
if status == "RUNNING":
print("RunPod is running! Now waiting for ports to be assigned...")
break
print(
f"Current status: {status}, waiting... (attempt {attempts+1}/{max_attempts})"
)
time.sleep(2)
attempts += 1
if attempts >= max_attempts:
raise TimeoutError(
"Timed out waiting for RunPod to reach RUNNING state")
# Wait for ports to be assigned
max_attempts = 50
attempts = 0
while attempts < max_attempts:
response = requests.get(f"{PODS_API}/{pod_id}", headers=HEADERS)
pod_data = response.json()
port_mappings = pod_data.get("portMappings")
if (port_mappings is not None and "22" in port_mappings
and pod_data.get("publicIp", "") != ""):
print("RunPod is ready with SSH access!")
print(f"SSH IP: {pod_data['publicIp']}")
print(f"SSH Port: {port_mappings['22']}")
break
print(
f"Waiting for SSH port and public IP to be available... (attempt {attempts+1}/{max_attempts})"
)
time.sleep(20)
attempts += 1
if attempts >= max_attempts:
raise TimeoutError("Timed out waiting for RunPod SSH access")
def execute_command(pod_id):
"""Execute command on the pod via SSH using system SSH client"""
print(f"Running command: {args.test_command}")
response = requests.get(f"{PODS_API}/{pod_id}", headers=HEADERS)
pod_data = response.json()
ssh_ip = pod_data["publicIp"]
ssh_port = pod_data["portMappings"]["22"]
# Copy the repository to the pod using scp
repo_dir = os.path.abspath(os.getcwd())
repo_name = os.path.basename(repo_dir)
print(f"Copying repository from {repo_dir} to RunPod...")
tar_command = [
"tar", "-czf", "/tmp/repo.tar.gz", "-C",
os.path.dirname(repo_dir), repo_name
]
subprocess.run(tar_command, check=True)
# Copy the tarball to the pod
scp_command = [
"scp", "-o", "StrictHostKeyChecking=no", "-o",
"UserKnownHostsFile=/dev/null", "-o", "ServerAliveInterval=60", "-o",
"ServerAliveCountMax=10", "-P",
str(ssh_port), "/tmp/repo.tar.gz", f"root@{ssh_ip}:/tmp/"
]
subprocess.run(scp_command, check=True)
# For custom image, we can use the pre-configured environment
setup_steps = [
"tar -xzf /tmp/repo.tar.gz --no-same-owner -C /workspace/",
f"cd /workspace/{repo_name}",
"source $HOME/.local/bin/env && source /opt/venv/bin/activate",
args.test_command
]
remote_command = " && ".join(setup_steps)
ssh_command = [
"ssh", "-o", "StrictHostKeyChecking=no", "-o",
"UserKnownHostsFile=/dev/null", "-o", "ServerAliveInterval=60", "-o",
"ServerAliveCountMax=10", "-p",
str(ssh_port), f"root@{ssh_ip}", remote_command
]
print(f"Connecting to {ssh_ip}:{ssh_port}...")
try:
process = subprocess.Popen(ssh_command,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
universal_newlines=True,
bufsize=0)
stdout_lines = []
print("Command output:")
for line in iter(process.stdout.readline, ''):
print(line.strip())
stdout_lines.append(line)
process.wait()
return_code = process.returncode
success = return_code == 0
stdout_str = "".join(stdout_lines)
if success:
print("Command executed successfully")
else:
print(f"Command failed with exit code {return_code}")
result = {
"success": success,
"return_code": return_code,
"stdout": stdout_str,
"stderr": ""
}
return result
except Exception as e:
print(f"Error executing SSH command: {str(e)}")
result = {"success": False, "error": str(e), "stdout": "", "stderr": ""}
return result
def terminate_pod(pod_id):
"""Terminate the pod"""
print("Terminating RunPod...")
requests.delete(f"{PODS_API}/{pod_id}", headers=HEADERS)
print(f"Terminated pod {pod_id}")
def main():
pod_id = None
try:
pod_id = create_pod()
wait_for_pod(pod_id)
result = execute_command(pod_id)
if result.get("error") is not None:
print(f"Error executing command: {result['error']}")
sys.exit(1)
if not result.get("success", False):
print(
"Tests failed - check the output above for details on which tests failed"
)
sys.exit(1)
finally:
if pod_id:
terminate_pod(pod_id)
if __name__ == "__main__":
main()
-90
View File
@@ -1,90 +0,0 @@
import json
import os
import sys
import uuid
import requests
API_KEY = os.environ['RUNPOD_API_KEY']
RUN_ID = os.environ.get('GITHUB_RUN_ID', str(uuid.uuid4()))
PODS_API = "https://rest.runpod.io/v1/pods"
HEADERS = {
"Content-Type": "application/json",
"Authorization": f"Bearer {API_KEY}"
}
def get_job_ids():
"""Parse job IDs from environment variable"""
job_ids_str = os.environ.get('JOB_IDS')
try:
job_ids = json.loads(job_ids_str)
if not isinstance(job_ids, list):
print("Error: JOB_IDS is not a list.")
sys.exit(1)
return job_ids
except json.JSONDecodeError as e:
print(f"Error parsing JOB_IDS: {e}")
sys.exit(1)
def cleanup_pods():
"""Find and terminate RunPod instances"""
print(f"Run ID: {RUN_ID}")
single_job_id = os.environ.get('JOB_ID')
if single_job_id:
job_ids = [single_job_id]
print(f"Job ID: {single_job_id}")
else:
job_ids = get_job_ids()
print(f"Job IDs: {job_ids}")
# Get all pods associated with RunPod API_KEY
try:
response = requests.get(PODS_API, headers=HEADERS)
response.raise_for_status()
pods = response.json()
except requests.exceptions.RequestException as e:
print(f"Error getting pods: {e}")
sys.exit(1)
# Find and terminate pods created by this workflow run
terminated_pods = []
for pod in pods:
pod_name = pod.get("name", "")
pod_id = pod.get("id")
# Check if this pod was created by one of our jobs
if any(f"{job_id}-{RUN_ID}" in pod_name for job_id in job_ids):
print(f"Found pod: {pod_id} ({pod_name})")
try:
print(f"Terminating pod {pod_id}...")
term_response = requests.delete(f"{PODS_API}/{pod_id}",
headers=HEADERS)
term_response.raise_for_status()
terminated_pods.append(pod_id)
print(f"Successfully terminated pod {pod_id}")
except requests.exceptions.RequestException as e:
print(f"Error terminating pod {pod_id}: {e}")
sys.exit(1)
if terminated_pods:
if single_job_id:
print(f"Terminated pod: {terminated_pods[0]}")
else:
print(f"Terminated {len(terminated_pods)} pods: {terminated_pods}")
else:
if single_job_id:
print(f"No pod found matching pattern: {single_job_id}-{RUN_ID}")
else:
print("No pods found to terminate.")
def main():
cleanup_pods()
if __name__ == "__main__":
main()
@@ -3,8 +3,6 @@ name: pre-commit
on:
pull_request:
branches: [main]
push:
branches: [main]
workflow_call:
concurrency:
@@ -16,6 +14,7 @@ permissions:
jobs:
pre-commit:
if: github.event_name == 'workflow_call' || github.event.pull_request.draft != true
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
+230
View File
@@ -0,0 +1,230 @@
name: Slash Commands
on:
issue_comment:
types: [created]
permissions:
contents: read
pull-requests: write
jobs:
handle-merge:
if: >-
github.event.issue.pull_request != null
&& startsWith(github.event.comment.body, '/merge')
runs-on: ubuntu-latest
steps:
- name: Check write permission
id: perm
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
with:
script: |
const { data: perm } = await github.rest.repos.getCollaboratorPermissionLevel({
owner: context.repo.owner,
repo: context.repo.repo,
username: context.payload.comment.user.login,
});
const hasWrite = ['admin', 'write'].includes(perm.permission);
if (!hasWrite) {
core.setFailed(`User ${context.payload.comment.user.login} lacks write permission (has: ${perm.permission}).`);
}
core.setOutput('has_write', String(hasWrite));
- name: Add ready label and react
if: steps.perm.outputs.has_write == 'true'
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
with:
script: |
const owner = context.repo.owner;
const repo = context.repo.repo;
const prNumber = context.payload.issue.number;
// Remove ready first to allow re-trigger (labeled event fires on add, not if already present)
try { await github.rest.issues.removeLabel({ owner, repo, issue_number: prNumber, name: 'ready' }); } catch {}
await github.rest.issues.addLabels({ owner, repo, issue_number: prNumber, labels: ['ready'] });
await github.rest.reactions.createForIssueComment({
owner, repo,
comment_id: context.payload.comment.id,
content: 'rocket',
});
parse-command:
if: >-
github.event.issue.pull_request != null
&& startsWith(github.event.comment.body, '/test')
runs-on: ubuntu-latest
outputs:
test_type: ${{ steps.parse.outputs.test_type }}
test_scope: ${{ steps.parse.outputs.test_scope }}
full_suite: ${{ steps.parse.outputs.full_suite }}
pr_sha: ${{ steps.pr.outputs.sha }}
pr_branch: ${{ steps.pr.outputs.branch }}
has_write: ${{ steps.perm.outputs.has_write }}
steps:
- name: Check write permission
id: perm
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
with:
script: |
const { data: perm } = await github.rest.repos.getCollaboratorPermissionLevel({
owner: context.repo.owner,
repo: context.repo.repo,
username: context.payload.comment.user.login,
});
const hasWrite = ['admin', 'write'].includes(perm.permission);
core.setOutput('has_write', String(hasWrite));
if (!hasWrite) {
core.info(`User ${context.payload.comment.user.login} lacks write permission — ignoring.`);
}
- name: Parse /test command
id: parse
if: steps.perm.outputs.has_write == 'true'
shell: bash
env:
COMMENT: ${{ github.event.comment.body }}
run: |
set -euo pipefail
TEST_NAME=$(echo "$COMMENT" | grep -oP '(?<=/test\s)\S+' | head -1 || true)
VALID="encoder vae transformer kernel unit ssim training lora-inference lora-training distillation self-forcing vsa vmoba performance api full fastcheck pre-commit"
if [ -z "$TEST_NAME" ] || ! echo "$VALID" | grep -qw "$TEST_NAME"; then
echo "Unknown test: '$TEST_NAME'. Valid: $VALID"
exit 1
fi
declare -A MAP=(
[encoder]=encoder [vae]=vae [transformer]=transformer
[kernel]=kernel_tests [unit]=unit_test
[ssim]=ssim [training]=training
[lora-inference]=inference_lora [lora-training]=training_lora
[distillation]=distillation_dmd [self-forcing]=self_forcing
[vsa]=training_vsa [vmoba]=inference_vmoba
[performance]=performance [api]=api_server
)
if [ "$TEST_NAME" = "full" ]; then
{
echo "test_type=all"
echo "test_scope=full"
echo "full_suite=true"
} >> "$GITHUB_OUTPUT"
elif [ "$TEST_NAME" = "fastcheck" ]; then
{
echo "test_type=fastcheck"
echo "test_scope=fastcheck"
echo "full_suite=false"
} >> "$GITHUB_OUTPUT"
elif [ "$TEST_NAME" = "pre-commit" ]; then
{
echo "test_type="
echo "test_scope=precommit"
echo "full_suite=false"
} >> "$GITHUB_OUTPUT"
else
{
echo "test_type=${MAP[$TEST_NAME]}"
echo "test_scope=direct"
echo "full_suite=false"
} >> "$GITHUB_OUTPUT"
fi
- name: Get PR details
id: pr
if: steps.perm.outputs.has_write == 'true'
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
with:
script: |
const { data: pr } = await github.rest.pulls.get({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: context.payload.issue.number,
});
core.setOutput('sha', pr.head.sha);
core.setOutput('branch', pr.head.ref);
pre-commit:
needs: parse-command
if: >-
needs.parse-command.outputs.has_write == 'true'
&& needs.parse-command.outputs.test_scope == 'precommit'
uses: ./.github/workflows/ci-precommit.yml
post-precommit-status:
needs: [parse-command, pre-commit]
if: always() && needs.parse-command.outputs.test_scope == 'precommit'
runs-on: ubuntu-latest
steps:
- name: Post commit status
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
env:
PR_SHA: ${{ needs.parse-command.outputs.pr_sha }}
RESULT: ${{ needs.pre-commit.result }}
with:
script: |
const state = process.env.RESULT === 'success' ? 'success' : 'failure';
await github.rest.repos.createCommitStatus({
owner: context.repo.owner,
repo: context.repo.repo,
sha: process.env.PR_SHA,
state,
context: 'pre-commit',
description: `Triggered via /test pre-commit (${state})`,
});
trigger-buildkite:
needs: parse-command
if: >-
needs.parse-command.outputs.has_write == 'true'
&& needs.parse-command.outputs.test_type != ''
runs-on: ubuntu-latest
steps:
- name: React to comment
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
with:
script: |
await github.rest.reactions.createForIssueComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: context.payload.comment.id,
content: 'rocket',
});
- name: Trigger Buildkite
env:
BUILDKITE_API_TOKEN: ${{ secrets.BUILDKITE_API_TOKEN }}
PR_SHA: ${{ needs.parse-command.outputs.pr_sha }}
PR_BRANCH: ${{ needs.parse-command.outputs.pr_branch }}
PR_NUMBER: ${{ github.event.issue.number }}
TEST_SCOPE: ${{ needs.parse-command.outputs.test_scope }}
FULL_SUITE: ${{ needs.parse-command.outputs.full_suite }}
TEST_TYPE: ${{ needs.parse-command.outputs.test_type }}
BK_ORG: ${{ vars.BUILDKITE_ORG_SLUG }}
BK_PIPELINE: ${{ vars.BUILDKITE_PIPELINE_SLUG }}
run: |
curl -sS --fail-with-body -X POST \
"https://api.buildkite.com/v2/organizations/${BK_ORG}/pipelines/${BK_PIPELINE}/builds" \
-H "Authorization: Bearer $BUILDKITE_API_TOKEN" \
-H "Content-Type: application/json" \
--data-raw "$(jq -n \
--arg commit "$PR_SHA" \
--arg branch "$PR_BRANCH" \
--arg message "/test ${TEST_TYPE} on PR #${PR_NUMBER}" \
--argjson pr_id "$PR_NUMBER" \
--arg test_scope "$TEST_SCOPE" \
--arg full_suite "$FULL_SUITE" \
--arg test_type "$TEST_TYPE" \
--arg pr_number "$PR_NUMBER" \
'{
commit: $commit,
branch: $branch,
message: $message,
ignore_pipeline_branch_filters: true,
pull_request_id: $pr_id,
pull_request_base_branch: "main",
env: {
TEST_SCOPE: $test_scope,
FULL_SUITE: $full_suite,
TEST_TYPE: $test_type,
PR_NUMBER: $pr_number
}
}')"
@@ -0,0 +1,83 @@
name: Trigger Full Suite
on:
pull_request:
types: [labeled, synchronize]
permissions:
contents: read
pull-requests: read
concurrency:
group: full-suite-${{ github.event.pull_request.number }}
cancel-in-progress: true
jobs:
trigger:
if: >-
(github.event.action == 'labeled' && github.event.label.name == 'ready')
|| github.event.action == 'synchronize'
runs-on: ubuntu-latest
steps:
- name: Check ready label
id: check
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
with:
script: |
const { data: pr } = await github.rest.pulls.get({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: context.payload.pull_request.number,
});
const hasReady = pr.labels.some(l => l.name === 'ready');
core.setOutput('has_ready', String(hasReady));
if (!hasReady) core.info('No ready label — skipping Full Suite trigger.');
- name: Cancel previous Buildkite builds
if: steps.check.outputs.has_ready == 'true'
env:
BUILDKITE_API_TOKEN: ${{ secrets.BUILDKITE_API_TOKEN }}
PR_BRANCH: ${{ github.event.pull_request.head.ref }}
run: |
# Find running builds for this branch with TEST_SCOPE=full and cancel them
builds=$(curl -sS -H "Authorization: Bearer $BUILDKITE_API_TOKEN" \
"https://api.buildkite.com/v2/organizations/${{ vars.BUILDKITE_ORG_SLUG }}/pipelines/${{ vars.BUILDKITE_PIPELINE_SLUG }}/builds?branch=${PR_BRANCH}&state=running,scheduled" \
| jq -r '.[] | select(.env.TEST_SCOPE == "full") | .number')
for build_num in $builds; do
echo "Cancelling Buildkite build #$build_num"
curl -sS -X PUT -H "Authorization: Bearer $BUILDKITE_API_TOKEN" \
"https://api.buildkite.com/v2/organizations/${{ vars.BUILDKITE_ORG_SLUG }}/pipelines/${{ vars.BUILDKITE_PIPELINE_SLUG }}/builds/${build_num}/cancel"
done
- name: Trigger Buildkite Full Suite
if: steps.check.outputs.has_ready == 'true'
env:
BUILDKITE_API_TOKEN: ${{ secrets.BUILDKITE_API_TOKEN }}
PR_SHA: ${{ github.event.pull_request.head.sha }}
PR_BRANCH: ${{ github.event.pull_request.head.ref }}
PR_NUMBER: ${{ github.event.pull_request.number }}
BK_ORG: ${{ vars.BUILDKITE_ORG_SLUG }}
BK_PIPELINE: ${{ vars.BUILDKITE_PIPELINE_SLUG }}
run: |
curl -sS --fail-with-body -X POST \
"https://api.buildkite.com/v2/organizations/${BK_ORG}/pipelines/${BK_PIPELINE}/builds" \
-H "Authorization: Bearer $BUILDKITE_API_TOKEN" \
-H "Content-Type: application/json" \
--data-raw "$(jq -n \
--arg commit "$PR_SHA" \
--arg branch "$PR_BRANCH" \
--arg message "Full Suite for PR #${PR_NUMBER}" \
--argjson pr_id "$PR_NUMBER" \
'{
commit: $commit,
branch: $branch,
message: $message,
ignore_pipeline_branch_filters: true,
pull_request_id: $pr_id,
pull_request_base_branch: "main",
env: {
TEST_SCOPE: "full",
FULL_SUITE: "true",
PR_NUMBER: ($pr_id | tostring)
}
}')"
@@ -22,16 +22,28 @@ jobs:
const labels = [];
const rules = [
{ keywords: ['training', 'finetune', 'fine-tune', 'lora', 'fsdp', 'distill'], label: 'training' },
{ keywords: ['inference', 'generate', 'pipeline', 'slow', 'latency'], label: 'inference' },
{ keywords: ['attention', 'vsa', 'flash', 'sta', 'vmoba', 'sparse attn'], label: 'attention' },
// scope labels (shared with PR labeling via Mergify)
// Mapping: label → repo directories
// scope: training → fastvideo/train/, fastvideo/training/, fastvideo/distillation/
// scope: inference → fastvideo/pipelines/, fastvideo/entrypoints/, fastvideo/worker/
// scope: attention → fastvideo/attention/
// scope: kernel → fastvideo-kernel/, csrc/
// scope: model → fastvideo/models/, fastvideo/layers/, fastvideo/configs/models/
// scope: data → fastvideo/dataset/, fastvideo/pipelines/preprocess/
// scope: distributed → fastvideo/distributed/
// scope: docs → docs/
{ keywords: ['training', 'finetune', 'fine-tune', 'lora', 'fsdp', 'distill'], label: 'scope: training' },
{ keywords: ['inference', 'generate', 'pipeline', 'slow', 'latency'], label: 'scope: inference' },
{ keywords: ['attention', 'vsa', 'flash', 'sta', 'vmoba', 'sparse attn'], label: 'scope: attention' },
{ keywords: ['kernel', 'csrc', 'cuda kernel', 'thunderkittens'], label: 'scope: kernel' },
{ keywords: ['wan', 'hunyuan', 'mochi', 'ltx', 'cogvideo', 'flux', 'sd3', 'cosmos'], label: 'scope: model' },
{ keywords: ['dataset', 'dataloader', 'preprocessing', 'preprocess'], label: 'scope: data' },
{ keywords: ['distributed', 'sequence parallel', 'fsdp', 'tensor parallel', 'multi-node', 'multi-gpu'], label: 'scope: distributed' },
{ keywords: ['docs', 'documentation', 'tutorial', 'example'], label: 'scope: docs' },
// issue-only labels (cross-module, no single repo directory)
{ keywords: ['install', 'setup', 'pip', 'cuda', 'uv ', 'import error', 'modulenotfound'], label: 'installation' },
{ keywords: ['docs', 'documentation', 'tutorial', 'example'], label: 'documentation' },
{ keywords: ['memory', 'oom', 'out of memory', 'gpu memory', 'vram'], label: 'performance' },
{ keywords: ['wan', 'hunyuan', 'mochi', 'ltx', 'cogvideo'], label: 'model-specific' },
{ keywords: ['windows', 'macos', 'mac os', 'apple', 'mps'], label: 'platform' },
{ keywords: ['comfyui', 'comfy'], label: 'comfyui' },
{ keywords: ['kernel', 'csrc', 'cuda kernel', 'thunderkittens'], label: 'kernel' },
{ keywords: ['windows', 'macos', 'mac os', 'apple', 'mps', 'rocm', 'amd', 'npu'], label: 'platform' },
];
for (const rule of rules) {
@@ -32,7 +32,7 @@ permissions:
jobs:
build-python-3-10:
if: ${{ github.event.inputs.python_3_10 == 'true' }}
uses: ./.github/workflows/build-image-template.yml
uses: ./.github/workflows/_template-build-image.yml
with:
python_version: '3.10'
dockerfile_path: docker/Dockerfile.python3.10
@@ -41,7 +41,7 @@ jobs:
build-python-3-11:
if: ${{ github.event.inputs.python_3_11 == 'true' }}
uses: ./.github/workflows/build-image-template.yml
uses: ./.github/workflows/_template-build-image.yml
with:
python_version: '3.11'
dockerfile_path: docker/Dockerfile.python3.11
@@ -50,7 +50,7 @@ jobs:
build-python-3-12:
if: ${{ github.event.inputs.python_3_12 == 'true' }}
uses: ./.github/workflows/build-image-template.yml
uses: ./.github/workflows/_template-build-image.yml
with:
python_version: '3.12'
dockerfile_path: docker/Dockerfile.python3.12
@@ -59,9 +59,9 @@ jobs:
build-python-3-12-cuda-12-9:
if: ${{ github.event.inputs.python_3_12_cuda_12_9 == 'true' }}
uses: ./.github/workflows/build-image-template.yml
uses: ./.github/workflows/_template-build-image.yml
with:
python_version: '3.12'
dockerfile_path: docker/Dockerfile.python3.12.cuda12.9.1
tag_suffix: py3.12-cuda12.9.1
secrets: inherit
secrets: inherit
@@ -7,14 +7,14 @@ on:
- 'docs/**'
- 'mkdocs.yml'
- 'requirements-mkdocs.txt'
- '.github/workflows/docs.yml'
- '.github/workflows/infra-docs.yml'
pull_request:
branches: [ main ]
paths:
- 'docs/**'
- 'mkdocs.yml'
- 'requirements-mkdocs.txt'
- '.github/workflows/docs.yml'
- '.github/workflows/infra-docs.yml'
permissions:
contents: read
-334
View File
@@ -1,334 +0,0 @@
name: PR Test
on:
push:
branches: [main]
paths:
- "fastvideo/**/*.py"
- ".github/workflows/pr-test.yml"
pull_request:
branches: [main]
types: [opened, ready_for_review, synchronize, reopened]
paths:
- "fastvideo/**/*.py"
- ".github/workflows/pr-test.yml"
- "pyproject.toml"
- "docker/Dockerfile.python3.12"
- "csrc/**"
workflow_dispatch:
inputs:
run_encoder_test:
description: "Run encoder-test"
required: false
default: false
type: boolean
run_vae_test:
description: "Run vae-test"
required: false
default: false
type: boolean
run_transformer_test:
description: "Run transformer-test"
required: false
default: false
type: boolean
run_ssim_test:
description: "Run ssim-test"
required: false
default: false
type: boolean
run_training_test:
description: "Run training-test"
required: false
default: false
type: boolean
run_training_test_VSA:
description: "Run training-test-VSA"
required: false
default: false
type: boolean
run_precision_test_VSA:
description: "Run precision-test-VSA"
required: false
default: false
type: boolean
run_unit_test:
description: "Run unit-test"
required: false
default: false
type: boolean
env:
PYTHONUNBUFFERED: "1"
concurrency:
group: pr-test-${{ github.ref }}
cancel-in-progress: true
jobs:
change-filter:
runs-on: ubuntu-latest
if: ${{ github.event.pull_request.draft == false || github.event_name == 'workflow_dispatch' }}
outputs:
encoder-test: ${{ steps.filter.outputs.encoder-test }}
vae-test: ${{ steps.filter.outputs.vae-test }}
transformer-test: ${{ steps.filter.outputs.transformer-test }}
training-test: ${{ steps.filter.outputs.training-test }}
training-test-VSA: ${{ steps.filter.outputs.training-test-VSA }}
precision-test-VSA: ${{ steps.filter.outputs.precision-test-VSA }}
unit-test: ${{ steps.filter.outputs.unit-test }}
steps:
- uses: actions/checkout@v4
- uses: dorny/paths-filter@v3
id: filter
with:
filters: |
# Define reusable path patterns
common-paths: &common-paths
- 'pyproject.toml'
- 'docker/Dockerfile.python3.10'
- 'docker/Dockerfile.python3.11'
- 'docker/Dockerfile.python3.12'
vsa-kernel-paths: &vsa-kernel-paths
- 'csrc/attn/video_sparse_attn/**'
- 'csrc/attn/video_sparse_attn/tk/**'
- 'csrc/attn/video_sparse_attn/setup.py'
- 'csrc/attn/video_sparse_attn/config_vsa.py'
- 'csrc/attn/video_sparse_attn/vsa.cpp'
vsa-paths: &vsa-paths
- 'fastvideo/**'
- *common-paths
- *vsa-kernel-paths
# Actual tests
encoder-test:
- 'fastvideo/models/encoders/**'
- 'fastvideo/models/loader/**'
- 'fastvideo/tests/encoders/**'
- *common-paths
vae-test:
- 'fastvideo/models/vaes/**'
- 'fastvideo/models/loader/**'
- 'fastvideo/tests/vaes/**'
- *common-paths
transformer-test:
- 'fastvideo/models/dits/**'
- 'fastvideo/models/loader/**'
- 'fastvideo/tests/transformers/**'
- 'fastvideo/layers/**'
- 'fastvideo/attention/**'
- *common-paths
training-test:
- 'fastvideo/**'
- *common-paths
training-test-VSA:
- 'fastvideo/**'
- *common-paths
- *vsa-kernel-paths
precision-test-VSA:
- *common-paths
- *vsa-kernel-paths
unit-test:
- 'fastvideo/**'
- *common-paths
encoder-test:
needs: change-filter
if: >-
(github.event_name != 'workflow_dispatch' && needs.change-filter.outputs.encoder-test == 'true') ||
(github.event_name == 'workflow_dispatch' && github.event.inputs.run_encoder_test == 'true')
uses: ./.github/workflows/runpod-test.yml
with:
job_id: "encoder-test"
gpu_type: "NVIDIA A40"
gpu_count: 1
volume_size: 100
image: "ghcr.io/${{ github.repository }}/fastvideo-dev:py3.12-latest"
test_command: "uv pip install -e .[test] && pytest ./fastvideo/tests/encoders -s"
timeout_minutes: 30
secrets:
RUNPOD_API_KEY: ${{ secrets.RUNPOD_API_KEY }}
RUNPOD_PRIVATE_KEY: ${{ secrets.RUNPOD_PRIVATE_KEY }}
vae-test:
needs: change-filter
if: >-
(github.event_name != 'workflow_dispatch' && needs.change-filter.outputs.vae-test == 'true') ||
(github.event_name == 'workflow_dispatch' && github.event.inputs.run_vae_test == 'true')
uses: ./.github/workflows/runpod-test.yml
with:
job_id: "vae-test"
gpu_type: "NVIDIA A40"
gpu_count: 1
volume_size: 100
image: "ghcr.io/${{ github.repository }}/fastvideo-dev:py3.12-latest"
test_command: "uv pip install -e .[test] && pytest ./fastvideo/tests/vaes -s"
timeout_minutes: 30
secrets:
RUNPOD_API_KEY: ${{ secrets.RUNPOD_API_KEY }}
RUNPOD_PRIVATE_KEY: ${{ secrets.RUNPOD_PRIVATE_KEY }}
transformer-test:
needs: change-filter
if: >-
(github.event_name != 'workflow_dispatch' && needs.change-filter.outputs.transformer-test == 'true') ||
(github.event_name == 'workflow_dispatch' && github.event.inputs.run_transformer_test == 'true')
uses: ./.github/workflows/runpod-test.yml
with:
job_id: "transformer-test"
gpu_type: "NVIDIA L40S"
gpu_count: 1
volume_size: 100
image: "ghcr.io/${{ github.repository }}/fastvideo-dev:py3.12-latest"
test_command: "uv pip install -e .[test] && pytest ./fastvideo/tests/transformers -s"
timeout_minutes: 30
secrets:
RUNPOD_API_KEY: ${{ secrets.RUNPOD_API_KEY }}
RUNPOD_PRIVATE_KEY: ${{ secrets.RUNPOD_PRIVATE_KEY }}
ssim-test:
needs: change-filter
if: >-
github.event_name != 'workflow_dispatch' || (github.event_name == 'workflow_dispatch' && github.event.inputs.run_ssim_test == 'true')
strategy:
fail-fast: false
matrix:
python-version: [
# {version: "3.10", tag: "latest"},
# {version: "3.11", tag: "py3.11-latest"},
{version: "3.12", tag: "py3.12-latest"}
]
uses: ./.github/workflows/runpod-test.yml
with:
job_id: "ssim-test-py${{ matrix.python-version.version }}"
gpu_type: "NVIDIA A40"
gpu_count: 2
volume_size: 200
disk_size: 200
image: "ghcr.io/${{ github.repository }}/fastvideo-dev:${{ matrix.python-version.tag }}"
test_command: "uv pip install -e .[test] && pytest ./fastvideo/tests/ssim -vs"
timeout_minutes: 60
secrets:
RUNPOD_API_KEY: ${{ secrets.RUNPOD_API_KEY }}
RUNPOD_PRIVATE_KEY: ${{ secrets.RUNPOD_PRIVATE_KEY }}
training-test:
needs: change-filter
if: >-
(github.event_name != 'workflow_dispatch' && needs.change-filter.outputs.training-test == 'true') ||
(github.event_name == 'workflow_dispatch' && github.event.inputs.run_training_test == 'true')
uses: ./.github/workflows/runpod-test.yml
with:
job_id: "training-test"
gpu_type: "NVIDIA A40"
gpu_count: 4
volume_size: 100
disk_size: 100
image: "ghcr.io/${{ github.repository }}/fastvideo-dev:py3.12-latest"
test_command: "wandb login $WANDB_API_KEY && uv pip install -e .[test] && pytest ./fastvideo/tests/training/Vanilla -srP"
timeout_minutes: 30
secrets:
RUNPOD_API_KEY: ${{ secrets.RUNPOD_API_KEY }}
RUNPOD_PRIVATE_KEY: ${{ secrets.RUNPOD_PRIVATE_KEY }}
WANDB_API_KEY: ${{ secrets.WANDB_API_KEY }}
training-test-VSA:
needs: change-filter
if: >-
(github.event_name != 'workflow_dispatch' && needs.change-filter.outputs.training-test-VSA == 'true') ||
(github.event_name == 'workflow_dispatch' && github.event.inputs.run_training_test_VSA == 'true')
uses: ./.github/workflows/runpod-test.yml
with:
job_id: "training-test-VSA"
gpu_type: "NVIDIA H100 NVL"
gpu_count: 2
volume_size: 100
disk_size: 100
image: "ghcr.io/${{ github.repository }}/fastvideo-dev:py3.12-latest"
test_command: "wandb login $WANDB_API_KEY && uv pip install -e .[test] && pytest ./fastvideo/tests/training/VSA -srP"
timeout_minutes: 30
secrets:
RUNPOD_API_KEY: ${{ secrets.RUNPOD_API_KEY }}
RUNPOD_PRIVATE_KEY: ${{ secrets.RUNPOD_PRIVATE_KEY }}
WANDB_API_KEY: ${{ secrets.WANDB_API_KEY }}
precision-test-VSA:
needs: change-filter
if: >-
(github.event_name != 'workflow_dispatch' && needs.change-filter.outputs.precision-test-VSA == 'true') ||
(github.event_name == 'workflow_dispatch' && github.event.inputs.run_precision_test_VSA == 'true')
uses: ./.github/workflows/runpod-test.yml
with:
job_id: "precision-test-VSA"
gpu_type: "NVIDIA H100 NVL"
gpu_count: 1
volume_size: 100
disk_size: 100
image: "ghcr.io/${{ github.repository }}/fastvideo-dev:py3.12-latest"
test_command: "uv pip install -e .[test] && python csrc/attn/tests/test_vsa.py"
timeout_minutes: 30
secrets:
RUNPOD_API_KEY: ${{ secrets.RUNPOD_API_KEY }}
RUNPOD_PRIVATE_KEY: ${{ secrets.RUNPOD_PRIVATE_KEY }}
unit-test:
needs: change-filter
if: >-
(github.event_name != 'workflow_dispatch' && needs.change-filter.outputs.unit-test == 'true') ||
(github.event_name == 'workflow_dispatch' && github.event.inputs.run_unit_test == 'true')
uses: ./.github/workflows/runpod-test.yml
with:
job_id: "unit-test"
gpu_type: "NVIDIA L40S"
gpu_count: 1
volume_size: 100
disk_size: 100
image: "ghcr.io/${{ github.repository }}/fastvideo-dev:py3.12-latest"
test_command: "uv pip install -e .[test] && pytest ./fastvideo/dataset/ -vs && pytest ./fastvideo/workflow/ -vs && pytest ./fastvideo/entrypoints/ -vs"
timeout_minutes: 30
secrets:
RUNPOD_API_KEY: ${{ secrets.RUNPOD_API_KEY }}
RUNPOD_PRIVATE_KEY: ${{ secrets.RUNPOD_PRIVATE_KEY }}
# nightly-test:
# if: >-
# (github.event_name == 'workflow_dispatch' && github.event.inputs.run_nightly_test == 'true')
# uses: ./.github/workflows/runpod-test.yml
# with:
# job_id: "nightly-test"
# gpu_type: "NVIDIA A40"
# gpu_count: 4
# volume_size: 100
# disk_size: 100
# image: "ghcr.io/${{ github.repository }}/fastvideo-dev:py3.12-latest"
# test_command: "wandb login $WANDB_API_KEY && uv pip install -e .[test] && pytest ./fastvideo/tests/nightly/test_e2e_overfit_single_sample.py -vs"
# timeout_minutes: 30
# secrets:
# RUNPOD_API_KEY: ${{ secrets.RUNPOD_API_KEY }}
# RUNPOD_PRIVATE_KEY: ${{ secrets.RUNPOD_PRIVATE_KEY }}
# WANDB_API_KEY: ${{ secrets.WANDB_API_KEY }}
runpod-cleanup:
# Add other jobs to this list as you create them
needs: [encoder-test, vae-test, transformer-test, ssim-test, training-test, training-test-VSA, precision-test-VSA]
if: ${{ always() && ((github.event_name != 'workflow_dispatch' && github.event.pull_request.draft == false) || github.event_name == 'workflow_dispatch') }}
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.10"
- name: Install dependencies
run: pip install requests
- name: Cleanup all RunPod instances
env:
JOB_IDS: '["encoder-test", "vae-test", "transformer-test", "ssim-test-py3.10", "ssim-test-py3.11", "ssim-test-py3.12", "training-test", "training-test-VSA", "precision-test-VSA"]'
RUNPOD_API_KEY: ${{ secrets.RUNPOD_API_KEY }}
GITHUB_RUN_ID: ${{ github.run_id }}
run: python .github/scripts/runpod_cleanup.py
-94
View File
@@ -1,94 +0,0 @@
name: RunPod Test
on:
workflow_call:
inputs:
job_id:
required: true
type: string
description: "Unique identifier for this test job"
gpu_type:
required: true
type: string
description: "GPU type to use (e.g. NVIDIA A40, NVIDIA L40S)"
gpu_count:
required: true
type: number
description: "Number of GPUs to use"
volume_size:
required: false
type: number
default: 20
description: "Volume size in GB"
disk_size:
required: false
type: number
default: 20
description: "Disk size in GB"
image:
required: true
type: string
description: "Docker image to use"
test_command:
required: true
type: string
description: "Command to run tests"
timeout_minutes:
required: false
type: number
default: 30
description: "Timeout in minutes"
secrets:
RUNPOD_API_KEY:
required: true
RUNPOD_PRIVATE_KEY:
required: true
WANDB_API_KEY:
required: false
jobs:
run-test:
runs-on: ubuntu-latest
environment: runpod-runners
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Set up SSH key
run: |
mkdir -p ~/.ssh
echo "${{ secrets.RUNPOD_PRIVATE_KEY }}" > ~/.ssh/id_rsa
chmod 600 ~/.ssh/id_rsa
ssh-keygen -y -f ~/.ssh/id_rsa > ~/.ssh/id_rsa.pub
- name: Install dependencies
run: pip install requests
- name: Run tests on RunPod
env:
JOB_ID: ${{ inputs.job_id }}
RUNPOD_API_KEY: ${{ secrets.RUNPOD_API_KEY }}
GITHUB_RUN_ID: ${{ github.run_id }}
WANDB_API_KEY: ${{ secrets.WANDB_API_KEY }}
timeout-minutes: ${{ inputs.timeout_minutes }}
run: >-
python .github/scripts/runpod_api.py
--gpu-type "${{ inputs.gpu_type }}"
--gpu-count ${{ inputs.gpu_count }}
--volume-size ${{ inputs.volume_size }}
--disk-size ${{ inputs.disk_size }}
--image "${{ inputs.image }}"
--test-command "${{ inputs.test_command }}"
- name: Terminate RunPod Instances
if: ${{ always() }}
env:
RUNPOD_API_KEY: ${{ secrets.RUNPOD_API_KEY }}
GITHUB_RUN_ID: ${{ github.run_id }}
JOB_ID: ${{ inputs.job_id }}
run: python .github/scripts/runpod_cleanup.py
-31
View File
@@ -1,31 +0,0 @@
name: Run Tests
on:
push:
branches: [ main ]
pull_request:
branches: [ main ]
jobs:
test:
runs-on: ubuntu-latest
steps:
- name: Check out repository
uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.12' # or any version you need
- name: Install dependencies
run: |
python -m pip install --upgrade pip setuptools wheel
pip install torch
pip install packaging ninja
pip install -e .
pip install pytest
- name: Run Pytest
run: |
pytest --ignore csrc/attn/test
-26
View File
@@ -1,26 +0,0 @@
name: Trigger Full Test Suite
on:
pull_request:
types: [labeled]
jobs:
trigger-buildkite:
if: >-
github.repository == 'hao-ai-lab/FastVideo'
&& github.event.label.name == 'ready'
runs-on: ubuntu-latest
permissions:
pull-requests: read
steps:
- name: Trigger Buildkite build with FULL_SUITE
uses: buildkite/trigger-pipeline-action@v2.4.1
with:
buildkite_api_access_token: ${{ secrets.BUILDKITE_API_TOKEN }}
pipeline: "${{ vars.BUILDKITE_ORG_SLUG }}/${{ vars.BUILDKITE_PIPELINE_SLUG }}"
branch: "${{ github.event.pull_request.head.ref }}"
commit: "${{ github.event.pull_request.head.sha }}"
message: "Full suite for PR #${{ github.event.pull_request.number }}"
send_pull_request: "true"
pull_request_base_branch: "${{ github.event.pull_request.base.ref }}"
build_env_vars: '{"FULL_SUITE": "true"}'
-257
View File
@@ -1,257 +0,0 @@
name: Publish Video Sparse Attention Kernel to PyPI on Version Change
on:
push:
branches:
- main
paths:
- "csrc/attn/video_sparse_attn/setup.py"
workflow_dispatch:
jobs:
check-version-change:
runs-on: ubuntu-latest
outputs:
version-changed: ${{ steps.check-version.outputs.changed }}
new-version: ${{ steps.check-version.outputs.new-version }}
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 2
- name: Check if version changed
id: check-version
run: |
cd csrc/attn/video_sparse_attn
# Get current commit's version
NEW_VERSION=$(grep -oP 'VERSION\s*=\s*"\K[^"]+' setup.py)
echo "New version: $NEW_VERSION"
# Get previous version from git history
OLD_VERSION=$(git show HEAD~1:./setup.py | grep -oP 'VERSION\s*=\s*"\K[^"]+' || echo "0.0.0")
echo "Old version: $OLD_VERSION"
if [ "$NEW_VERSION" != "$OLD_VERSION" ]; then
echo "Version changed from $OLD_VERSION to $NEW_VERSION"
echo "changed=true" >> $GITHUB_OUTPUT
echo "new-version=$NEW_VERSION" >> $GITHUB_OUTPUT
else
echo "Version did not change"
echo "changed=false" >> $GITHUB_OUTPUT
fi
build_wheels:
name: Build Wheel
needs: check-version-change
if: ${{ needs.check-version-change.outputs.version-changed == 'true' || github.event_name == 'workflow_dispatch' }}
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
# Using ubuntu-20.04 instead of 22.04 for more compatibility (glibc). Ideally we'd use the
# manylinux docker image, but I haven't figured out how to install CUDA on manylinux.
os: [ubuntu-22.04]
python-version: ['3.10', '3.11', '3.12', '3.13']
# For version reference https://pytorch.org/get-started/previous-versions/
torch-cuda:
- torch-version: '2.5.1'
cuda-version: '12.4.1'
torch-cuda-short: 'cu124'
- torch-version: '2.6.0'
cuda-version: '12.6.3'
torch-cuda-short: 'cu126'
- torch-version: '2.7.1'
cuda-version: '12.8.0'
torch-cuda-short: 'cu128'
steps:
- name: Free up disk space
run: |
echo "Initial disk space:"
df -h
# Remove large directories
sudo rm -rf /usr/share/dotnet
sudo rm -rf /usr/local/lib/android
sudo rm -rf /opt/ghc
sudo rm -rf /usr/local/share/boost
sudo rm -rf /usr/share/swift
sudo rm -rf /usr/local/lib/node_modules
sudo rm -rf /usr/local/share/powershell
sudo rm -rf /usr/share/rust
sudo rm -rf /usr/local/.ghcup
# Remove cached files
sudo rm -rf /var/lib/apt/lists/*
sudo rm -rf /var/cache/apt/archives/*
echo "Disk space after cleanup:"
df -h
- name: Checkout
uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
- name: Install CUDA ${{ matrix.torch-cuda.cuda-version }}
uses: Jimver/cuda-toolkit@v0.2.21
id: cuda-toolkit
with:
cuda: ${{ matrix.torch-cuda.cuda-version }}
linux-local-args: '["--toolkit"]'
method: 'network'
- name: Install dependencies (GCC, Clang, CUDA Paths, Git)
run: |
sudo apt update
sudo apt install -y git patchelf gcc-11 g++-11 clang-11
sudo update-alternatives --install /usr/bin/gcc gcc /usr/bin/gcc-11 100 --slave /usr/bin/g++ g++ /usr/bin/g++-11
# Allow Git to Access Safe Directory
git config --global --add safe.directory /__w/FastVideo/FastVideo
# Set CUDA environment variables
export CUDA_HOME=/usr/local/cuda-${{ matrix.torch-cuda.cuda-version }}
export PATH=${CUDA_HOME}/bin:${PATH}
export LD_LIBRARY_PATH=${CUDA_HOME}/lib64:$LD_LIBRARY_PATH
# Verify installation
gcc --version
g++ --version
clang-11 --version
nvcc --version
- name: Install PyTorch ${{ matrix.torch-cuda.torch-version }}+cu${{ matrix.torch-cuda.cuda-version }}
run: |
pip install --upgrade pip
# With python 3.13 and torch 2.5.1, unless we update typing-extensions, we get error
# AttributeError: attribute '__default__' of 'typing.ParamSpec' objects is not writable
pip install typing-extensions==4.12.2
# We want to figure out the CUDA version to download pytorch
# e.g. we can have system CUDA version being 11.7 but if torch==1.12 then we need to download the wheel from cu116
# see https://github.com/pytorch/pytorch/blob/main/RELEASE.md#release-compatibility-matrix
pip install --no-cache-dir torch==${{ matrix.torch-cuda.torch-version }} --index-url https://download.pytorch.org/whl/${{matrix.torch-cuda.torch-cuda-short}}
nvcc --version
python --version
python -c "import torch; print('PyTorch:', torch.__version__)"
python -c "import torch; print('CUDA:', torch.version.cuda)"
python -c "from torch.utils import cpp_extension; print (cpp_extension.CUDA_HOME)"
- name: Build wheel
run: |
export PYTHONPATH=$GITHUB_WORKSPACE:$PYTHONPATH
# We want setuptools >= 49.6.0 otherwise we can't compile the extension if system CUDA version is 11.7 and pytorch cuda version is 11.6
# https://github.com/pytorch/pytorch/blob/664058fa83f1d8eede5d66418abff6e20bd76ca8/torch/utils/cpp_extension.py#L810
# However this still fails so I'm using a newer version of setuptools
pip install setuptools
pip install ninja packaging wheel
cd csrc/attn/video_sparse_attn # Move into the correct folder
git submodule update --init --recursive # Ensure ThunderKittens submodule is initialized
python setup.py bdist_wheel --dist-dir=dist
- name: Rename wheel file
run: |
cd csrc/attn/video_sparse_attn
CUDA_SHORT_VERSION=$(echo ${{ matrix.torch-cuda.cuda-version }} | cut -d. -f1,2 | sed 's/\.//g')
TORCH_SHORT_VERSION=$(echo ${{ matrix.torch-cuda.torch-version }} | cut -d. -f1,2)
# Get the correct version format
tmpname=cu${CUDA_SHORT_VERSION}torch${TORCH_SHORT_VERSION}
wheel_name=$(ls dist/*whl | xargs -n 1 basename | sed "s/-/+$tmpname-/2")
# Rename with version information
ls dist/*whl |xargs -I {} mv {} dist/${wheel_name}
echo "wheel_name=${wheel_name}" >> $GITHUB_ENV
- name: Upload wheel artifact
uses: actions/upload-artifact@v4
with:
name: ${{ env.wheel_name }}
path: csrc/attn/video_sparse_attn/dist/*.whl
retention-days: 90
publish_package:
name: Publish package
needs: [build_wheels, check-version-change]
if: ${{ needs.check-version-change.outputs.version-changed == 'true' || github.event_name == 'workflow_dispatch' }}
runs-on: ubuntu-22.04
permissions:
id-token: write # Needed for OIDC Trusted Publishing
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.10'
- name: Install CUDA 12.4.1
uses: Jimver/cuda-toolkit@v0.2.21
id: cuda-toolkit
with:
cuda: 12.4.1
linux-local-args: '["--toolkit"]'
method: 'network'
sub-packages: '["nvcc"]'
- name: Install dependencies (GCC, Clang, CUDA Paths, Git)
run: |
sudo apt update
sudo apt install -y git patchelf gcc-11 g++-11 clang-11
sudo update-alternatives --install /usr/bin/gcc gcc /usr/bin/gcc-11 100 --slave /usr/bin/g++ g++ /usr/bin/g++-11
# Allow Git to Access Safe Directory
git config --global --add safe.directory /__w/FastVideo/FastVideo
# Set CUDA environment variables
export CUDA_HOME=/usr/local/cuda-12.4.1
export PATH=${CUDA_HOME}/bin:${PATH}
export LD_LIBRARY_PATH=${CUDA_HOME}/lib64:$LD_LIBRARY_PATH
# Verify installation
gcc --version
g++ --version
clang-11 --version
nvcc --version
- name: Install PyTorch 2.5.1+cu12.4.1
run: |
pip install --upgrade pip
# With python 3.13 and torch 2.5.1, unless we update typing-extensions, we get error
# AttributeError: attribute '__default__' of 'typing.ParamSpec' objects is not writable
pip install typing-extensions==4.12.2
# We want to figure out the CUDA version to download pytorch
# e.g. we can have system CUDA version being 11.7 but if torch==1.12 then we need to download the wheel from cu116
# see https://github.com/pytorch/pytorch/blob/main/RELEASE.md#release-compatibility-matrix
export TORCH_CUDA_VERSION=124
pip install --no-cache-dir torch==2.5.1 --index-url https://download.pytorch.org/whl/cu${TORCH_CUDA_VERSION}
nvcc --version
python --version
python -c "import torch; print('PyTorch:', torch.__version__)"
python -c "import torch; print('CUDA:', torch.version.cuda)"
python -c "from torch.utils import cpp_extension; print (cpp_extension.CUDA_HOME)"
- name: Build source distribution
run: |
export PYTHONPATH=$GITHUB_WORKSPACE:$PYTHONPATH
# We want setuptools >= 49.6.0 otherwise we can't compile the extension if system CUDA version is 11.7 and pytorch cuda version is 11.6
# https://github.com/pytorch/pytorch/blob/664058fa83f1d8eede5d66418abff6e20bd76ca8/torch/utils/cpp_extension.py#L810
# However this still fails so I'm using a newer version of setuptools
pip install setuptools
pip install ninja packaging wheel
cd csrc/attn/video_sparse_attn # Move into the correct folder
git submodule update --init --recursive # Ensure ThunderKittens submodule is initialized
python setup.py sdist --dist-dir=dist
- name: Publish release distributions to PyPI
uses: pypa/gh-action-pypi-publish@release/v1
with:
packages-dir: csrc/attn/video_sparse_attn/dist/
+2 -1
View File
@@ -85,8 +85,9 @@ docs/distillation/examples/
dmd_t2v_output/
preprocess_output_text/
# Next.js / Node artifacts under ui/: see ui/.gitignore
.claude/
.codex/
.sisyphus/
openspec/
fastvideo/tests/ssim/reference_videos/**
+2 -4
View File
@@ -18,10 +18,8 @@ exclude: |
fastvideo/train\.py|
fastvideo/utils/.*|
examples/.*|
.github/workflows/fastvideo-publish.yml|
.github/workflows/sta-publish.yml|
.github/workflows/vsa-publish.yml|
.github/workflows/build-image-template.yml|
.github/workflows/publish-fastvideo.yml|
.github/workflows/_template-build-image.yml|
docs/source/inference/support_matrix.md
)
repos:
-1
View File
@@ -1 +0,0 @@
3.12
+1 -1
View File
@@ -36,4 +36,4 @@ Then open your browser to: http://localhost:8000
## Automatic Deployment
Documentation is automatically built and deployed to GitHub Pages when changes are pushed to the `main` branch via the `.github/workflows/docs.yml` workflow.
Documentation is automatically built and deployed to GitHub Pages when changes are pushed to the `main` branch via the `.github/workflows/infra-docs.yml` workflow.
+306
View File
@@ -0,0 +1,306 @@
# CI Architecture
## Overview
FastVideo uses a three-tier CI pipeline designed to keep feedback fast on every push while
protecting `main` through a full GPU regression suite before any merge.
```
PR push
│
├─► Tier 1: Pre-commit (~2 min)
│ GitHub Actions / ubuntu-latest
│ yapf, ruff, mypy, codespell, pymarkdown, actionlint, check-filenames
│
└─► Tier 2: Fastcheck (~10-20 min, path-filtered)
Buildkite / Modal GPU instances
Only runs tests for paths you changed
│ (developer comments /merge or maintainer adds 'ready' label)
▼
Tier 3: Full Suite (~60-90 min)
Buildkite / Modal GPU instances
All integration, SSIM, training, and performance tests
Runs on the PR branch directly
│
pass ──► Mergify auto-squash-merges to main, branch deleted
fail ──► Mergify removes 'ready' label; fix and /merge again
```
---
## CI Tiers
### Tier 1: Pre-commit (every PR push)
| Attribute | Value |
|-----------|-------|
| Triggered by | Every push to any PR branch, plus pushes to `main` |
| Runs on | GitHub Actions, `ubuntu-latest` |
| Duration | ~2 minutes |
**Checks run** (from `.pre-commit-config.yaml`, stage: `manual`):
| Hook | What it checks |
|------|---------------|
| `yapf` | Python code formatting |
| `ruff` | Python linting and auto-fixable style issues |
| `codespell` | Spelling errors in code and docs |
| `pymarkdown` | Markdown formatting |
| `actionlint` | GitHub Actions workflow syntax |
| `mypy` | Static type checking (Python 3.10 target) |
| `check-filenames` | No spaces in tracked filenames |
A failure here means code style or type issues. Run `pre-commit run --all-files` locally to
replicate CI results before pushing.
---
### Tier 2: Fastcheck (path-filtered, every PR push)
| Attribute | Value |
|-----------|-------|
| Triggered by | Every push; the monorepo-diff plugin skips tests for unchanged paths |
| Runs on | Buildkite, Modal GPU instances |
| Duration | ~10-20 minutes per test (run in parallel) |
**Tests and their path triggers:**
| Buildkite label | `TEST_TYPE` | Triggers when you change |
|-----------------|-------------|--------------------------|
| Encoder Tests | `encoder` | `fastvideo/models/encoders/**`, `fastvideo/models/loader/**`, `fastvideo/tests/encoders/**`, `pyproject.toml`, `docker/Dockerfile.python3.12` |
| VAE Tests | `vae` | `fastvideo/models/vaes/**`, `fastvideo/models/loader/**`, `fastvideo/tests/vaes/**`, `pyproject.toml`, `docker/Dockerfile.python3.12` |
| Transformer Tests | `transformer` | `fastvideo/models/dits/**`, `fastvideo/models/loader/**`, `fastvideo/tests/transformers/**`, `fastvideo/layers/**`, `fastvideo/attention/**`, `pyproject.toml`, `docker/Dockerfile.python3.12` |
| Kernel Tests | `kernel_tests` | `fastvideo-kernel/**`, `pyproject.toml`, `docker/Dockerfile.python3.12` |
| Unit Tests | `unit_test` | `fastvideo/**`, `.buildkite/**`, `.github/**`, `pyproject.toml`, `docker/Dockerfile.python3.12` |
A Fastcheck failure means a component-level regression. Check the Buildkite build log for the
failing test's output.
---
### Tier 3: Full Test Suite (triggered by `ready` label)
| Attribute | Value |
|-----------|-------|
| Triggered by | Adding the `ready` label to the PR (via `/merge` command), or a `/test full` command |
| Runs on | Buildkite, Modal GPU instances |
| Duration | 60-90 minutes total (tests run in parallel, path-filtered) |
**Tests:**
| Buildkite label | `TEST_TYPE` | Timeout |
|-----------------|-------------|---------|
| SSIM Tests | `ssim` | 90 min |
| LoRA Inference Tests | `inference_lora` | 20 min |
| Training Tests | `training` | 15 min |
| Distillation DMD Tests | `distillation_dmd` | 15 min |
| Self-Forcing Tests | `self_forcing` | 15 min |
| LoRA Training Tests | `training_lora` | 15 min |
| Training Tests VSA | `training_vsa` | 15 min |
| Inference Tests VMoBA | `inference_vmoba` | 15 min |
| Performance Tests | `performance` | 30 min |
| API Server Tests | `api_server` | 30 min |
A Full Suite failure removes the `ready` label automatically. A Mergify comment links to
the Buildkite build. Fix the regression, push, and comment `/merge` again.
---
## Auto-merge Flow
Mergify prevents untested code from landing on `main` by gating squash-merge on the Full
Suite passing directly on the PR branch.
**How it works:**
1. A developer comments `/merge` on an approved PR (or a maintainer adds the `ready` label).
2. The `ready` label triggers `ci-trigger-full-suite.yml`, which calls the Buildkite API to
run the Full Suite on the PR branch itself.
3. While the Full Suite runs, Mergify also auto-rebases the PR branch against `main` if it
is behind and has no conflicts.
4. Once the Full Suite posts `full-suite-passed`, Mergify checks all **merge conditions**:
- `pre-commit` check is green
- `fastcheck-passed` check is green
- `full-suite-passed` check is green
- At least 1 approved review (`#approved-reviews-by>=1`)
- PR title starts with a valid `[type]` tag
- PR is not a draft
- No merge conflicts
5. If all conditions pass, Mergify squash-merges to `main` automatically. The branch is
deleted after merge.
6. If the Full Suite fails, Mergify removes the `ready` label and posts a comment linking to
the Buildkite build. The developer fixes the issue, pushes, and comments `/merge` again.
**Merge conditions summary:**
| Condition | Meaning |
|-----------|---------|
| `check-success~=pre-commit` | Tier 1 pre-commit must be green |
| `check-success=fastcheck-passed` | Tier 2 Fastcheck must be green |
| `check-success=full-suite-passed` | Tier 3 Full Suite must be green |
| `#approved-reviews-by>=1` | At least one approved review |
| `title~=(?i)^\[(feat|bugfix|...)` | PR title has a valid type tag |
| `-draft` | PR is not in draft state |
| `-conflict` | No merge conflicts with base branch |
| `-closed` | PR is still open |
---
## Label System
Labels are applied automatically. You don't need to set them manually.
### Type Labels (from PR title prefix)
Applied by Mergify based on the `[tag]` at the start of the PR title.
| Label | Matched title prefix | Meaning |
|-------|---------------------|---------|
| `type: feat` | `[feat]` or `[feature]` | New feature or capability |
| `type: bugfix` | `[bugfix]` or `[fix]` | Bug fix |
| `type: refactor` | `[refactor]` | Code restructuring, no behavior change |
| `type: perf` | `[perf]` | Performance improvement |
| `type: ci` | `[ci]` | CI/CD or tooling changes |
| `type: docs` | `[doc]` or `[docs]` | Documentation only |
| `type: misc` | `[misc]` or `[chore]` | Housekeeping, dependency bumps |
| `type: new-model` | `[new-model]` | Adding a new model |
### Scope Labels (from changed files)
Applied by Mergify based on which paths you modified. Multiple scope labels can be added.
| Label | File paths that trigger it |
|-------|---------------------------|
| `scope: training` | `fastvideo/train/`, `fastvideo/training/`, `fastvideo/distillation/`, `examples/train/`, `examples/training/`, `examples/distill/` |
| `scope: inference` | `fastvideo/pipelines/basic/`, `fastvideo/pipelines/stages/`, `fastvideo/pipelines/samplers/`, `fastvideo/entrypoints/`, `fastvideo/worker/`, `fastvideo/configs/sample/`, `fastvideo/configs/pipelines/`, `examples/inference/` |
| `scope: attention` | `fastvideo/attention/` |
| `scope: kernel` | `fastvideo-kernel/`, `csrc/` |
| `scope: data` | `fastvideo/dataset/`, `fastvideo/pipelines/preprocess/`, `examples/preprocessing/` |
| `scope: infra` | `.github/`, `.buildkite/`, `fastvideo/tests/`, `docker/` |
| `scope: distributed` | `fastvideo/distributed/` |
| `scope: docs` | `docs/` |
| `scope: ui` | `ui/` |
| `scope: model` | `fastvideo/models/`, `fastvideo/layers/`, `fastvideo/configs/models/` |
### Process Labels
| Label | Who sets it | Meaning |
|-------|-------------|---------|
| `ready` | Developer (`/merge` command) or maintainer | Triggers Full Suite and enables auto-merge |
| `needs-rebase` | Mergify (automatic) | PR has merge conflicts; rebase needed |
| `do-not-merge` | Maintainer | Blocks queue entry regardless of other conditions |
---
## PR Title Format
All PR titles targeting `main` must start with a bracketed type tag. This is enforced by a
Mergify merge protection rule and is required before a PR can be squash-merged.
**Format:**
```
[type] Short description
```
**Valid type tags:**
`feat`, `feature`, `bugfix`, `fix`, `refactor`, `perf`, `ci`, `doc`, `docs`, `misc`, `chore`,
`kernel`, `new-model`
**Valid examples:**
```
[feat] Add causal Wan pipeline
[bugfix] Fix VAE temporal tiling corruption
[refactor] Restructure training framework
[perf] Optimize FlashAttention kernel dispatch
[docs] Add inference guide for LoRA
[new-model] Port HunyuanVideo 1.5 to FastVideo
```
**Invalid examples (will block merge):**
```
Add causal Wan pipeline ← missing type tag
FEAT: Add pipeline ← wrong format
feat: Add pipeline ← square brackets required
```
If your title is invalid, Mergify posts a comment explaining the required format and the
merge protection check will remain failed until you update the title.
---
## Slash Commands
Slash commands let contributors and maintainers trigger CI actions directly from PR comments.
**Write permission to the repository is required.**
The command is recognized within a few seconds. The workflow reacts with a 🚀 emoji to confirm.
### `/merge`
```
/merge
```
Adds the `ready` label to the PR, which triggers the Full Suite on your PR branch and
enables Mergify to auto-squash-merge once all conditions pass.
The command first removes the `ready` label if it is already present, then re-adds it. This
ensures the `labeled` event fires and a fresh Full Suite build is started even on a re-try.
### `/test <name>`
```
/test <name>
```
Triggers a specific Buildkite test or suite on the current PR branch.
| Command | Runs | Maps to `TEST_TYPE` |
|---------|------|---------------------|
| `/test encoder` | Encoder Tests (Fastcheck) | `encoder` |
| `/test vae` | VAE Tests (Fastcheck) | `vae` |
| `/test transformer` | Transformer Tests (Fastcheck) | `transformer` |
| `/test kernel` | Kernel Tests (Fastcheck) | `kernel_tests` |
| `/test unit` | Unit Tests (Fastcheck) | `unit_test` |
| `/test ssim` | SSIM regression tests | `ssim` |
| `/test training` | Training pipeline tests | `training` |
| `/test lora-inference` | LoRA inference tests | `inference_lora` |
| `/test lora-training` | LoRA training tests | `training_lora` |
| `/test distillation` | DMD distillation tests | `distillation_dmd` |
| `/test self-forcing` | Self-Forcing tests | `self_forcing` |
| `/test vsa` | VSA training tests | `training_vsa` |
| `/test vmoba` | VMoBA inference tests | `inference_vmoba` |
| `/test performance` | Performance benchmarks | `performance` |
| `/test api` | API server integration tests | `api_server` |
| `/test full` | Entire Full Suite | all (with `TEST_SCOPE=full`) |
| `/test fastcheck` | Entire Fastcheck suite | fastcheck (with `TEST_SCOPE=fastcheck`) |
---
## Auto Branch Cleanup
After a PR is squash-merged to `main`, Mergify automatically deletes the head branch.
Protected branches (`main`, `master`, `release/*`) are never deleted.
---
## Workflow File Reference
| Filename | Trigger | What it does |
|----------|---------|-------------|
| `ci-precommit.yml` | Every push / PR against `main` | Runs pre-commit hooks (yapf, ruff, mypy, codespell, pymarkdown, actionlint, check-filenames) |
| `ci-trigger-full-suite.yml` | `ready` label added to a PR | Calls Buildkite API to run Full Suite on the PR branch |
| `ci-slash-commands.yml` | PR comment starting with `/merge` or `/test` | Handles slash commands; adds `ready` label or triggers Buildkite |
| `community-issue-labeler.yml` | Issue opened or edited | Auto-labels issues by keyword matching against title and body |
| `community-welcome.yml` | First contribution | Posts a welcome comment for first-time contributors |
| `community-stale.yml` | Scheduled | Marks and closes stale issues and PRs |
| `infra-build-image.yml` | Manual (`workflow_dispatch`) | Builds Docker images for CI |
| `infra-docs.yml` | Changes to `docs/` merged to `main` | Builds and deploys documentation to GitHub Pages |
| `publish-fastvideo.yml` | Version bump | Publishes `fastvideo` package to PyPI |
| `publish-kernel.yml` | Version bump | Publishes `fastvideo-kernel` package to PyPI |
| `publish-comfyui.yml` | Version bump | Publishes ComfyUI node package to PyPI |
+163 -33
View File
@@ -1,65 +1,195 @@
# RunPod Development Environment
# 📦 Developing FastVideo on RunPod
RunPod gives you on-demand cloud GPUs for FastVideo development. It's useful when you need a beefy GPU to test training runs, benchmark inference, or reproduce results without waiting for shared cluster time.
You can easily use the FastVideo Pod Template on [RunPod](https://www.runpod.io) for development or experimentation.
## Prerequisites
## Creating a new pod
- A [RunPod](https://www.runpod.io) account with billing configured
- An SSH key pair. If you don't have one, generate it with `ssh-keygen -t ed25519`
- Your public key (`~/.ssh/id_ed25519.pub`) ready to paste into RunPod
## Step 1: Create a Pod
**1. Verify your account**
Make sure you're logged into the right RunPod account before spending credits.
- Make sure you are using the correct RunPod account.
![RunPod Account Selection](../../assets/images/runpod_account.png)
- Use "Additional Filters" to select CUDA 12.8.
**2. Filter by CUDA version**
Use "Additional Filters" to select CUDA 12.8.
![RunPod CUDA selection](../../assets/images/runpod_cuda.png)
- Click "Deploy" and Pick a single A40 or RTX 4090 GPU.
**3. Select a GPU**
Click "Deploy" and pick a GPU. See [GPU Recommendations](#gpu-recommendations) below for guidance on which GPU to choose.
![RunPod GPU Selection](../../assets/images/runpod_deploy.png)
- Select the "FastVideo" or "fastvideo-dev" Pod Template.
**4. Pick the FastVideo template**
Select the "FastVideo" or "fastvideo-dev" Pod Template. This pulls the pre-built image that includes all dependencies, Flash Attention, and a ready-to-use `uv` environment.
![RunPod Pod Template Selection](../../assets/images/runpod_create.png)
- Set the Pod name to "`<name>-<FastVideo>-<date>`".
**5. Name your pod**
Use a memorable name like `yourname-fastvideo-2026-03-28`. This helps if you have multiple pods running.
**6. Add a persistent volume (recommended)**
Attach a network volume to `/root/.cache` or `/models` for storing downloaded model weights. Models can be 10-50 GB each, and re-downloading them every session wastes time and bandwidth.
**7. Deploy**
Click Deploy. The pod takes a few minutes to start while the image pulls. You'll see it transition to "Running" in your dashboard.
## Step 2: Connect via SSH
Once the pod is running, find the "SSH over exposed TCP" connection string in the pod dashboard.
- Finally, once the pod is deployed (will take a few minutes as the image is being pulled), you can SSH into it using "SSH exposed over TCP". You'll need to use the matching private ssh key you provided.
![RunPod SSH](../../assets/images/runpod_ssh.png)
## Working with the pod
After SSH'ing into your pod, you'll find the correct `uv` environment already activated and you should be in /FastVideo/ directory. Make sure to use /FastVideo/ for all your work.
To pull in the latest changes from the GitHub repo:
Connect with:
```bash
cd /FastVideo
git pull
ssh root@<pod-ip> -p <port> -i ~/.ssh/id_ed25519
```
Run your development workflows as usual:
RunPod also supports VS Code Remote SSH if you prefer an IDE.
```bash
# Run linters
pre-commit run --all-files
### Custom template (advanced)
# Run tests
pytest tests/
```
Make sure to push your changes back to the GitHub repo as nothing will be saved to the pod when it is terminated.
After you are done with your work, you can terminate the pod by clicking the "Terminate" and "Delete" buttons. Remember if the pod is not completely deleted, Runpod will keep charging you for it.
## Extra Information:
If you need to customize the pod template this section has some useful information. For the most part you can leave the defaults of the FastVideo Pod Template.
When creating your pod template, use this image:
If you're setting up a pod from scratch instead of the FastVideo template, use this image:
```
ghcr.io/hao-ai-lab/fastvideo/fastvideo-dev:py3.12-latest
```
Paste Container Start Command to support SSH ([RunPod Docs](https://docs.runpod.io/pods/configuration/use-ssh)):
And paste this as the Container Start Command to enable SSH ([RunPod docs](https://docs.runpod.io/pods/configuration/use-ssh)):
```bash
bash -c "apt update;DEBIAN_FRONTEND=noninteractive apt-get install openssh-server -y;mkdir -p ~/.ssh;cd $_;chmod 700 ~/.ssh;echo \"$PUBLIC_KEY\" >> authorized_keys;chmod 700 authorized_keys;service ssh start;sleep infinity"
```
![RunPod template configuration](../../assets/images/runpod_template.png)
## Step 3: Set Up FastVideo
After SSH'ing in, the `uv` virtual environment at `/opt/venv` is already activated (configured in `.bashrc` and `.profile`). You land in the `/FastVideo` directory.
**Clone or pull the repo**
If the pod already has the FastVideo repo:
```bash
cd /FastVideo
git pull
```
If starting from a blank pod:
```bash
git clone https://github.com/hao-ai-lab/FastVideo.git /FastVideo
cd /FastVideo
```
**Install the package**
```bash
uv pip install -e .[dev]
```
The Docker image already includes Flash Attention and most heavy dependencies, so this is fast.
**Build the custom kernels (optional)**
VSA and STA attention kernels aren't in the Docker image by default. Build them if you're working on attention backends or need maximum inference performance:
```bash
cd /FastVideo/fastvideo-kernel
./build.sh
```
The build script detects your GPU architecture automatically. An A100 or H100 takes about 5-10 minutes.
**Verify the setup**
```bash
cd /FastVideo
python -c "import fastvideo; print('OK')"
pytest tests/ -q --no-header
```
## Development Workflow
### Editing code on RunPod
Two common approaches:
**Option A: Edit on RunPod directly**
Use VS Code Remote SSH or `vim`/`nano` on the pod. Commit and push when you're ready:
```bash
cd /FastVideo
git add .
git commit -m "your change"
git push
```
**Option B: Edit locally, sync to RunPod**
Work in your local repo, then pull on the pod:
```bash
# On RunPod:
cd /FastVideo
git pull
```
This keeps your local tools (editor, linters) intact while running GPU workloads on the pod.
### Running linters and tests
```bash
# Lint
pre-commit run --all-files
# Full test suite
pytest tests/
# Just package tests
pytest fastvideo/tests/ -v
```
### Storing models
If you attached a persistent volume, point your model downloads there:
```bash
export HF_HOME=/models/huggingface
export TRANSFORMERS_CACHE=/models/huggingface
```
Add these to `/root/.bashrc` so they persist across SSH sessions. The volume survives pod termination, so you only download models once.
### Terminating the pod
When you're done, push any commits you want to keep. RunPod does not save pod storage after termination.
Go to your RunPod dashboard, click "Terminate", then "Delete". A pod that's stopped but not deleted still charges you for storage. Fully delete it to stop all charges.
## GPU Recommendations
| GPU | VRAM | Good for |
|-----|------|----------|
| RTX 4090 | 24 GB | Inference testing, small model fine-tuning, quick iteration |
| A40 | 48 GB | Mid-size training runs, 480p video generation |
| A100 (40 GB) | 40 GB | Multi-GPU inference, training with sequence parallelism |
| A100 (80 GB) | 80 GB | Large model training, 720p+ video generation |
| H100 | 80 GB | Heavy training, benchmarking, kernel development |
For most development work, a single RTX 4090 or A40 is sufficient and cost-effective. Use A100/H100 when you need to reproduce training results at scale or test multi-GPU features.
+218
View File
@@ -0,0 +1,218 @@
# Contributing via Pull Requests
This guide walks through the PR workflow: title format, labels, CI pipeline, and getting
your changes merged.
---
## PR Title Format (Required)
Every PR targeting `main` must start with a type tag in square brackets. This is checked by
Mergify before any merge is allowed.
**Format:**
```
[type] Short description of the change
```
**Valid type tags:**
| Tag | When to use |
|-----|-------------|
| `[feat]` or `[feature]` | New feature or capability |
| `[bugfix]` or `[fix]` | Bug fix |
| `[refactor]` | Code restructuring with no behavior change |
| `[perf]` | Performance improvement |
| `[ci]` | CI/CD or build tooling changes |
| `[doc]` or `[docs]` | Documentation only |
| `[misc]` or `[chore]` | Housekeeping, dependency bumps, minor cleanup |
| `[kernel]` | CUDA kernel changes in `fastvideo-kernel/` |
| `[new-model]` | Adding a new model or pipeline |
**Examples:**
```
[feat] Add causal Wan 2.2 I2V pipeline
[bugfix] Fix VAE temporal tiling corruption on H100
[refactor] Restructure distributed attention dispatch
[docs] Add LoRA finetuning guide
[new-model] Port HunyuanVideo 1.5 to FastVideo
```
If your title is missing the tag, Mergify will post a comment listing the valid formats.
Update the title and the check will re-evaluate automatically.
---
## Labels
Labels are applied automatically based on your PR title and the files you changed. You don't
need to set them manually.
**Type label** — set from the `[tag]` in your title:
`type: feat`, `type: bugfix`, `type: refactor`, `type: perf`, `type: ci`, `type: docs`,
`type: misc`, `type: new-model`
**Scope labels** — set from which files you modified (multiple labels can apply):
`scope: training`, `scope: inference`, `scope: attention`, `scope: kernel`, `scope: data`,
`scope: infra`, `scope: distributed`, `scope: docs`, `scope: ui`, `scope: model`
**Process labels** — set during review and merge:
| Label | Who sets it | Meaning |
|-------|-------------|---------|
| `ready` | You (`/merge` comment) or a maintainer | Triggers Full Suite and enables auto-merge |
| `needs-rebase` | Mergify (automatic) | Your PR has conflicts; rebase against `main` |
| `do-not-merge` | Maintainer | Blocks merge regardless of CI status |
---
## CI Pipeline
Three tiers run automatically on every PR.
**Tier 1: Pre-commit (~2 min) — runs on every push**
GitHub Actions checks formatting, linting, type correctness, and spelling using pre-commit
hooks: yapf, ruff, mypy, codespell, pymarkdown, actionlint, and check-filenames.
**Tier 2: Fastcheck (~10-20 min) — runs on every push, path-filtered**
Buildkite runs GPU tests only for the components you changed. If you only modified
`fastvideo/models/vaes/`, only VAE Tests run. Tests run in parallel.
**Tier 3: Full Suite (~60-90 min) — triggered by the `ready` label**
When you comment `/merge` (or a maintainer adds the `ready` label), Buildkite runs the
complete test suite on your PR branch: SSIM regression, LoRA inference and training,
distillation, self-forcing, VSA, VMoBA, performance benchmarks, and API server tests.
---
## Getting Your PR Merged
**Step-by-step:**
1. Open a PR with a title that starts with a valid `[type]` tag.
2. Push your changes. Pre-commit and Fastcheck run automatically.
3. Fix any pre-commit failures locally (`pre-commit run --all-files`) and push again.
4. Wait for at least one approving review.
5. Once approved and pre-commit is green, comment `/merge` on the PR.
6. The `ready` label is added, which triggers the Full Suite on your PR branch.
7. Mergify also auto-rebases your branch against `main` if it is behind and conflict-free.
8. If all Full Suite tests pass and all merge conditions are met (approval, valid title,
pre-commit green, fastcheck green, no draft, no conflicts), Mergify squash-merges to
`main` automatically. Your branch is deleted.
9. If a Full Suite test fails, Mergify removes the `ready` label and posts a comment with a
link to the Buildkite build. Fix the issue, push, and comment `/merge` again.
!!! note
Only contributors with write permission to the repository can trigger slash commands.
If you're an external contributor, ask a maintainer to run `/merge` or add the `ready`
label for you.
---
## Running Tests On Demand
Comment on your PR to trigger specific tests independently of the auto-merge flow.
**Trigger the entire Full Suite:**
```
/test full
```
**Trigger the Fastcheck suite:**
```
/test fastcheck
```
**Trigger individual tests:**
```
/test encoder # Encoder component tests
/test vae # VAE component tests
/test transformer # Transformer / DiT tests
/test kernel # CUDA kernel tests
/test unit # Unit tests
/test ssim # SSIM regression tests
/test training # Training pipeline tests
/test lora-inference # LoRA inference tests
/test lora-training # LoRA training tests
/test distillation # DMD distillation tests
/test self-forcing # Self-Forcing distillation tests
/test vsa # VSA training tests
/test vmoba # VMoBA inference tests
/test performance # Performance benchmarks
/test api # API server integration tests
```
The workflow reacts with a 🚀 emoji to confirm the command was received.
---
## Troubleshooting
### Pre-commit fails
Run locally to reproduce and auto-fix:
```bash
# Install pre-commit if needed
uv pip install pre-commit
pre-commit install
# Run all checks on all files
pre-commit run --all-files
```
Common quick fixes:
- **yapf**: `yapf -i <file>` (Python formatting)
- **ruff**: `ruff check --fix <file>` (linting)
- **codespell**: `codespell --write-changes <file>` (spelling)
### PR title format check fails
Update your title to start with a valid type tag. The Mergify merge protection check
re-evaluates automatically after you save the title.
Valid tags: `feat`, `feature`, `bugfix`, `fix`, `refactor`, `perf`, `ci`, `doc`, `docs`,
`misc`, `chore`, `kernel`, `new-model`
### My PR has merge conflicts (`needs-rebase` label)
Rebase against `main` and force-push:
```bash
git fetch origin main
git rebase origin/main
# Resolve any conflicts, then:
git push --force-with-lease
```
Mergify removes the `needs-rebase` label automatically once conflicts are resolved.
### Full Suite failed after `/merge`
The Full Suite found a regression. Mergify removes the `ready` label and posts a comment
linking to the Buildkite build. Check the failing step's output for assertion errors or
tracebacks.
Common causes:
- Test failures caused by your code changes
- Missing dependency in `pyproject.toml`
- GPU memory issue (some tests require specific hardware like L40S or H100)
- Kernel build failure (if you changed `fastvideo-kernel/`)
After fixing, push and comment `/merge` again.
### I'm an external contributor without write permission
You can't use slash commands directly. After your PR is approved, ask a maintainer to
comment `/merge` or add the `ready` label.
+109 -22
View File
@@ -23,13 +23,18 @@ SSIM tests are located in `fastvideo/tests/ssim`. These tests generate videos us
```
fastvideo/tests/ssim/
├── <GPU>_reference_videos/ # Reference videos organized by GPU type (e.g., L40S_reference_videos)
│ ├── <Model_Name>/
│ │ ├── <Backend>/ # e.g., FLASH_ATTN, TORCH_SDPA
│ │ │ └── <Video_File>
├── reference_videos/
│ ├── default/
│ │ └── <GPU>_reference_videos/
│ │ ├── <Model_Name>/
│ │ │ ├── <Backend>/ # e.g., FLASH_ATTN, TORCH_SDPA
│ │ │ │ └── <Video_File>
│ └── full_quality/
│ └── <GPU>_reference_videos/
├── test_causal_similarity.py
├── test_inference_similarity.py
├── update_reference_videos.sh
├── test_wan_t2v_similarity.py
├── test_wan_i2v_similarity.py
├── reference_videos_cli.py
└── ...
```
@@ -37,7 +42,7 @@ fastvideo/tests/ssim/
To add a new SSIM test, follow these steps:
1. **Create or Update a Test File**: You can add a new test function to an existing file (like `test_inference_similarity.py`) or create a new one if testing a distinct category of models.
1. **Create or Update a Test File**: Prefer model-specific files (for example `test_wan_t2v_similarity.py`) and create a new one when testing a distinct model or pipeline.
2. **Define Model Parameters**: Define the configuration for the model you want to test. This includes model path, dimensions, inference steps, and other generation parameters. **Note:** Consider using lower `num_inference_steps` or reduced resolution (e.g., 480p instead of 720p) to keep test execution time reasonable, provided it doesn't compromise the test's ability to detect regression.
@@ -81,10 +86,15 @@ To add a new SSIM test, follow these steps:
```
4. **Reference Videos**:
* When running the test for the first time (or when updating the reference), the test will fail because the reference video is missing. The generated video will be saved in `fastvideo/tests/ssim/generated_videos`.
* When running the test for the first time (or when updating the reference), the test will fail because the reference video is missing. The generated video will be saved in `fastvideo/tests/ssim/generated_videos/<quality-tier>/<GPU>_reference_videos`.
* Inspect the generated video to ensure it meets quality expectations.
* Move the generated video to the appropriate reference folder: `fastvideo/tests/ssim/<GPU>_reference_videos/<Model>/<Backend>/`.
* You can use the helper script `update_reference_videos.sh` to automate copying videos from `generated_videos` to `L40S_reference_videos`. Note: Check the script to ensure paths match your environment (it defaults to `L40S_reference_videos`).
* Move the generated video to the appropriate quality/GPU reference folder:
`fastvideo/tests/ssim/reference_videos/<quality-tier>/<GPU>_reference_videos/<Model>/<Backend>/`.
* You can use the helper CLI to copy generated videos into a reference folder:
`python fastvideo/tests/ssim/reference_videos_cli.py copy-local --quality-tier default --reference-dir fastvideo/tests/ssim/reference_videos/default/L40S_reference_videos`
* Upload/download can target both quality tiers and specific GPU folders:
`python fastvideo/tests/ssim/reference_videos_cli.py upload --quality-tier all`
`python fastvideo/tests/ssim/reference_videos_cli.py download --quality-tier full_quality --device-folder H200_reference_videos`
### Running Tests Locally
@@ -96,36 +106,113 @@ pytest fastvideo/tests/ssim/ -vs
Ensure you have the necessary GPUs available as defined in your test parameters.
## Modal Workflow
## CI Integration
FastVideo uses [Modal](https://modal.com/) for running tests in a CI environment. The workflow scripts are located in `fastvideo/tests/modal/`.
FastVideo uses [Modal](https://modal.com/) for running tests in a CI environment. The
workflow scripts are located in `fastvideo/tests/modal/`.
### Buildkite Pipeline
Tests are orchestrated by Buildkite (`.buildkite/pipeline.yml`) and executed on Modal GPU
instances. The pipeline runs in two modes:
**Fastcheck** — runs on every PR push, path-filtered. Only tests for the components you
changed are triggered. Tests run in parallel.
**Full Suite** — runs when a PR enters the Merge Queue (or when triggered manually via
`/test full`). Covers SSIM regression, training, distillation, inference, and performance.
### `pr_test.py`
The main entry point for CI tests is `fastvideo/tests/modal/pr_test.py`. This script defines Modal functions that execute the pytest suites on specific hardware (e.g., L40S, H100).
The main entry point for CI tests is `fastvideo/tests/modal/pr_test.py`. This script defines
Modal functions that execute the pytest suites on specific hardware (e.g., L40S, H100).
### Updating Modal Configuration
If you add a new test that requires:
* **Different GPU Hardware**: You may need to change the `@app.function(gpu=...)` decorator.
* **Longer Execution Time**: Increase the `timeout` parameter.
* **New Environment Variables/Secrets**: Add them to `secrets=[...]` or the image environment. For example, if your model is gated on Hugging Face, ensure `HF_API_KEY` is passed.
* **New Environment Variables/Secrets**: Add them to `secrets=[...]` or the image
environment. For example, if your model is gated on Hugging Face, ensure `HF_API_KEY`
is passed.
For SSIM tests, the `run_ssim_tests` function in `pr_test.py` currently runs:
For SSIM tests, use `fastvideo/tests/modal/ssim_test.py`:
```python
@app.function(gpu="L40S:2", image=image, timeout=2700, secrets=[modal.Secret.from_dict({"HF_API_KEY": os.environ.get("HF_API_KEY", "")})])
def run_ssim_tests():
run_test("hf auth login --token $HF_API_KEY && pytest ./fastvideo/tests/ssim -vs")
```bash
python -m modal run fastvideo/tests/modal/ssim_test.py::run_ssim_tests
```
If your new test file is inside `fastvideo/tests/ssim`, it will automatically be picked up by this command. However, ensure that the `gpu="L40S:2"` configuration is sufficient for your model. If your model requires more GPUs (e.g., 4 or 8), you might need to create a separate Modal function or update the existing one.
Target specific SSIM files/models:
```bash
python -m modal run fastvideo/tests/modal/ssim_test.py::run_ssim_tests \
--test-files test_wan_t2v_similarity.py \
--model-ids Wan2.1-T2V-1.3B-Diffusers
```
If HF token env vars are not set (`HF_API_KEY` / `HUGGINGFACE_HUB_TOKEN` /
`HF_TOKEN`), the local entrypoint fails fast. To export raw `generated_videos`
from Modal to the shared volume:
```bash
python -m modal run fastvideo/tests/modal/ssim_test.py::run_ssim_tests \
--sync-generated-to-volume
```
The raw export path is quality-tiered:
* default params: `ssim_generated_videos/default/<subdir>/generated_videos`
* full-quality params: `ssim_generated_videos/full_quality/<subdir>/generated_videos`
The printed `modal volume get` command also downloads into a quality-specific
local directory under `./generated_videos_modal/<quality-tier>`.
To turn downloaded Modal outputs into local reference videos, use the matching
quality tier with `copy-local`, for example:
```bash
python fastvideo/tests/ssim/reference_videos_cli.py copy-local \
--quality-tier full_quality \
--generated-dir ./generated_videos_modal/full_quality/L40S_reference_videos \
--device-folder L40S_reference_videos
```
### Workflow Scripts
The shell script that triggers these tests in the CI pipeline is located at `.buildkite/scripts/pr_test.sh`. If you add a new test category (e.g., a new folder outside of `ssim`), you will need to:
The shell script that triggers tests in CI is `.buildkite/scripts/pr_test.sh`. If you add
a new test category (e.g., a new folder outside of `ssim`), you will need to:
1. Add a new function in `fastvideo/tests/modal/pr_test.py`.
2. Add a new case in `.buildkite/scripts/pr_test.sh` to handle the new test type.
!!! note
If you are a maintainer, you'll need to finally manually update the workflow script in Buildkite. Otherwise, a maintainer will help you update.
If you are a maintainer, update the workflow script in Buildkite after merging. Otherwise,
ask a maintainer for help.
## Triggering Tests via Slash Commands
Maintainers and contributors with write access can trigger individual test suites directly
from a PR comment. The workflow reacts with a 🚀 emoji to confirm the command was received.
```
/test ssim # SSIM regression tests
/test training # Training pipeline tests
/test lora-training # LoRA training tests
/test lora-inference # LoRA inference tests
/test distillation # DMD distillation tests
/test self-forcing # Self-Forcing tests
/test vsa # VSA training tests
/test vmoba # VMoBA inference tests
/test performance # Performance benchmarks
/test api # API server integration tests
/test encoder # Encoder component tests (Fastcheck)
/test vae # VAE component tests (Fastcheck)
/test transformer # Transformer / DiT tests (Fastcheck)
/test kernel # CUDA kernel tests (Fastcheck)
/test unit # Unit tests (Fastcheck)
/test full # Entire Full Suite
/test fastcheck # Entire Fastcheck suite
```
See [CI Architecture](ci_architecture.md) for the complete reference.
@@ -0,0 +1,78 @@
# HunyuanVideo T2V overfitting test config.
#
# Overfits on 3 short videos (480x832, 77 frames) to verify the
# training pipeline works end-to-end.
#
# Preprocess data first:
# CUDA_VISIBLE_DEVICES=0 python scripts/preprocess_hunyuan_overfit.py
#
# Run:
# bash examples/train/run.sh examples/train/configs/overfit_hunyuan_t2v.yaml
models:
student:
_target_: fastvideo.train.models.hunyuan.HunyuanModel
init_from: hunyuanvideo-community/HunyuanVideo
trainable: true
enable_gradient_checkpointing_type: full
method:
_target_: fastvideo.train.methods.fine_tuning.finetune.FineTuneMethod
training:
distributed:
num_gpus: 8
sp_size: 1
tp_size: 1
hsdp_replicate_dim: 1
hsdp_shard_dim: 8
data:
data_path: data/hunyuan_overfit_preprocessed
dataloader_num_workers: 0
train_batch_size: 1
training_cfg_rate: 0.0
seed: 42
num_latent_t: 20
num_height: 480
num_width: 832
num_frames: 77
optimizer:
learning_rate: 5.0e-5
betas: [0.9, 0.999]
weight_decay: 0.0
lr_scheduler: constant
lr_warmup_steps: 0
loop:
max_train_steps: 300
gradient_accumulation_steps: 1
checkpoint:
output_dir: outputs/hunyuan_overfit
training_state_checkpointing_steps: 50
checkpoints_total_limit: 2
tracker:
project_name: fastvideo_hunyuan
run_name: hunyuan_overfit
model:
precondition_outputs: true
enable_gradient_checkpointing_type: full
callbacks:
grad_clip:
_target_: fastvideo.train.callbacks.grad_clip.GradNormClipCallback
max_grad_norm: 1.0
validation:
_target_: fastvideo.train.callbacks.validation.ValidationCallback
pipeline_target: fastvideo.pipelines.basic.hunyuan.hunyuan_pipeline.HunyuanVideoPipeline
dataset_file: data/hunyuan_overfit_preprocessed/validation_prompts.json
every_steps: 50
sampling_steps: [50]
guidance_scale: 1.0
pipeline:
flow_shift: 7
+1 -1
View File
@@ -23,7 +23,7 @@ classifiers = [
]
dependencies = [
"torch>=2.5.0",
"triton>=2.0.0; sys_platform == 'linux'",
"triton>=2.0.0",
]
[project.urls]
+9 -57
View File
@@ -52,27 +52,16 @@ class VideoGenerator:
customization options, similar to popular frameworks like HF Diffusers.
"""
def __init__(
self,
fastvideo_args: FastVideoArgs,
executor_class: type[Executor],
log_stats: bool,
*,
log_queue=None,
):
def __init__(self, fastvideo_args: FastVideoArgs, executor_class: type[Executor], log_stats: bool):
"""
Initialize the video generator.
Args:
fastvideo_args: The inference arguments
executor_class: The executor class to use for inference
log_stats: Whether to log statistics
log_queue: Optional multiprocessing.Queue to forward worker logs to
"""
self.fastvideo_args = fastvideo_args
self.executor = executor_class(
fastvideo_args, log_queue=log_queue
)
self.executor = executor_class(fastvideo_args)
@classmethod
def from_pretrained(cls, model_path: str, **kwargs) -> "VideoGenerator":
@@ -91,25 +80,18 @@ class VideoGenerator:
"""
# If users also provide some kwargs, it will override the FastVideoArgs and PipelineConfig.
kwargs['model_path'] = model_path
log_queue = kwargs.pop("log_queue", None)
fastvideo_args = FastVideoArgs.from_kwargs(**kwargs)
return cls.from_fastvideo_args(fastvideo_args, log_queue=log_queue)
return cls.from_fastvideo_args(fastvideo_args)
@classmethod
def from_fastvideo_args(
cls,
fastvideo_args: FastVideoArgs,
*,
log_queue=None,
) -> "VideoGenerator":
def from_fastvideo_args(cls, fastvideo_args: FastVideoArgs) -> "VideoGenerator":
"""
Create a video generator with the specified arguments.
Args:
fastvideo_args: The inference arguments
log_queue: Optional multiprocessing.Queue to forward worker logs to
Returns:
The created video generator
"""
@@ -121,7 +103,6 @@ class VideoGenerator:
fastvideo_args=fastvideo_args,
executor_class=executor_class,
log_stats=False, # TODO: implement
log_queue=log_queue,
)
def generate_video(
@@ -159,34 +140,6 @@ class VideoGenerator:
A metadata dictionary for single-prompt generation, or a list of
metadata dictionaries for prompt-file batch generation.
"""
log_queue = kwargs.pop("log_queue", None)
if log_queue:
self.executor.set_log_queue(log_queue)
try:
return self._generate_video_impl(
prompt=prompt,
sampling_param=sampling_param,
mouse_cond=mouse_cond,
keyboard_cond=keyboard_cond,
grid_sizes=grid_sizes,
**kwargs,
)
finally:
if log_queue:
self.executor.clear_log_queue()
def _generate_video_impl(
self,
prompt: str | None = None,
sampling_param: SamplingParam | None = None,
mouse_cond: torch.Tensor | None = None,
keyboard_cond: torch.Tensor | None = None,
grid_sizes: tuple[int, int, int] | list[int] | torch.Tensor
| None = None,
**kwargs,
) -> dict[str, Any] | list[np.ndarray] | list[dict[str, Any]]:
"""Internal implementation of generate_video."""
# Handle batch processing from text file
if sampling_param is None:
sampling_param = SamplingParam.from_pretrained(self.fastvideo_args.model_path)
@@ -203,9 +156,8 @@ class VideoGenerator:
if self.fastvideo_args.prompt_txt is not None or sampling_param.prompt_path is not None:
prompt_txt_path = sampling_param.prompt_path or self.fastvideo_args.prompt_txt
if not prompt_txt_path or not os.path.exists(prompt_txt_path):
raise FileNotFoundError(
f"Prompt text file not found: {prompt_txt_path}")
if not os.path.exists(prompt_txt_path):
raise FileNotFoundError(f"Prompt text file not found: {prompt_txt_path}")
# Read prompts from file
with open(prompt_txt_path, encoding='utf-8') as f:
+6 -1
View File
@@ -364,10 +364,15 @@ class ParallelTiledVAE(ABC):
if i > 0:
tile = tile[:, :, 1:, :, :]
row.append(tile)
blend_latent_num_frames = max(
1,
self.blend_num_frames // self.temporal_compression_ratio,
)
result_row = []
for i, tile in enumerate(row):
if i > 0:
tile = self.blend_t(row[i - 1], tile, self.blend_num_frames)
tile = self.blend_t(row[i - 1], tile,
blend_latent_num_frames)
result_row.append(
tile[:, :, :tile_latent_stride_num_frames, :, :])
else:
@@ -0,0 +1,206 @@
# SPDX-License-Identifier: Apache-2.0
"""Preprocess HunyuanVideo overfit data into parquet format.
Encodes videos with HunyuanVideo VAE and captions with dual text
encoders (LLaMA + CLIP) into the t2v parquet schema expected by
the training framework.
Usage:
CUDA_VISIBLE_DEVICES=0 python scripts/preprocess_hunyuan_overfit.py
"""
import glob
import json
import os
import cv2
import numpy as np
import pyarrow as pa
import pyarrow.parquet as pq
import torch
from safetensors.torch import load_file as safetensors_load_file
from fastvideo.configs.models.vaes import HunyuanVAEConfig
from fastvideo.configs.pipelines.hunyuan import (
clip_preprocess_text,
clip_postprocess_text,
llama_preprocess_text,
llama_postprocess_text,
)
from fastvideo.dataset.dataloader.schema import pyarrow_schema_t2v
from fastvideo.models.vaes.hunyuanvae import AutoencoderKLHunyuanVideo
from fastvideo.utils import maybe_download_model
# --- Config ---
NUM_FRAMES = 77 # 4k+1 for temporal compression ratio 4
MAX_HEIGHT = 480
MAX_WIDTH = 832
TRAIN_FPS = 16.0
DATA_DIR = "data/hunyuan_overfit"
OUTPUT_DIR = "data/hunyuan_overfit_preprocessed"
def load_video(path: str, num_frames: int) -> torch.Tensor:
"""Load video as [1, C, T, H, W] in [-1, 1]."""
cap = cv2.VideoCapture(path)
frames: list[np.ndarray] = []
while len(frames) < num_frames:
ret, frame = cap.read()
if not ret:
break
frame = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
frames.append(frame)
cap.release()
if len(frames) < num_frames:
# Repeat last frame to fill
while len(frames) < num_frames:
frames.append(frames[-1])
frames = frames[:num_frames]
video = np.stack(frames, axis=0)
video = torch.from_numpy(video).float()
video = video / 127.5 - 1.0 # [0,255] -> [-1,1]
video = video.permute(3, 0, 1, 2).unsqueeze(0) # [1,C,T,H,W]
return video
def main() -> None:
device = torch.device("cuda:0")
model_path = maybe_download_model("hunyuanvideo-community/HunyuanVideo")
vae_path = os.path.join(model_path, "vae")
os.makedirs(OUTPUT_DIR, exist_ok=True)
# Load captions
with open(os.path.join(DATA_DIR, "videos2caption.json")) as f:
caption_data = json.load(f)
# --- Load VAE ---
print("Loading HunyuanVideo VAE...")
vae_config = HunyuanVAEConfig()
vae_config.load_encoder = True
vae_config.load_decoder = False
vae = AutoencoderKLHunyuanVideo(vae_config)
sf_files = glob.glob(os.path.join(vae_path, "*.safetensors"))
loaded = {}
for sf_file in sf_files:
loaded.update(safetensors_load_file(sf_file))
vae.load_state_dict(loaded, strict=False)
vae = vae.to(device=device, dtype=torch.float16).eval()
vae.use_parallel_tiling = False
print(f"VAE loaded ({sum(p.numel() for p in vae.parameters())/1e6:.0f}M)")
# --- Load text encoders ---
print("Loading LLaMA text encoder...")
from transformers import AutoTokenizer, CLIPTextModel, LlamaModel
from fastvideo.configs.pipelines.hunyuan import (LlamaConfig, CLIPTextConfig)
llama_cfg = LlamaConfig()
llama_tok = AutoTokenizer.from_pretrained(os.path.join(model_path, "tokenizer"))
llama_enc = LlamaModel.from_pretrained(
os.path.join(model_path, "text_encoder"),
torch_dtype=torch.float16,
).to(device).eval()
llama_tok_kwargs = dict(llama_cfg.tokenizer_kwargs)
print("Loading CLIP text encoder...")
clip_cfg = CLIPTextConfig()
clip_tok = AutoTokenizer.from_pretrained(os.path.join(model_path, "tokenizer_2"))
clip_enc = CLIPTextModel.from_pretrained(
os.path.join(model_path, "text_encoder_2"),
torch_dtype=torch.float16,
).to(device).eval()
clip_tok_kwargs = dict(clip_cfg.tokenizer_kwargs)
# --- Process each video ---
records = []
for item in caption_data:
video_name = item["path"]
caption = item["cap"][0]
video_path = os.path.join(DATA_DIR, "videos", video_name)
print(f"\nProcessing: {video_name}")
print(f" Caption: {caption[:80]}...")
# Encode video
video = load_video(video_path, NUM_FRAMES).to(device=device, dtype=torch.float16)
print(f" Video shape: {video.shape}")
with torch.no_grad():
latent_dist = vae.encode(video)
# Cast to fp32 — dataloader hardcodes np.float32
latent = latent_dist.mean.squeeze(0).float().cpu()
print(f" Latent shape: {latent.shape}")
# Encode text with LLaMA
llama_text = llama_preprocess_text(caption)
with torch.no_grad():
llama_inputs = llama_tok(llama_text, **llama_tok_kwargs).to(device)
llama_out = llama_enc(**llama_inputs, output_hidden_states=True)
llama_embeds = llama_postprocess_text(llama_out).squeeze(0) # [seq, dim]
# Encode text with CLIP
clip_text = clip_preprocess_text(caption)
with torch.no_grad():
clip_inputs = clip_tok(clip_text, **clip_tok_kwargs).to(device)
clip_out = clip_enc(**clip_inputs)
clip_pooled = clip_postprocess_text(clip_out).squeeze(0) # [dim]
# Combine: [pooled_clip_row, llama_embeds]
llama_dim = llama_embeds.shape[-1]
pooled_row = torch.zeros(llama_dim, device=device, dtype=torch.float16)
pooled_row[:clip_pooled.shape[-1]] = clip_pooled
text_embedding = torch.cat(
[pooled_row.unsqueeze(0), llama_embeds],
dim=0,
).float().cpu() # [seq+1, dim]
print(f" Text embedding shape: {text_embedding.shape}")
record = {
"id": video_name,
"vae_latent_bytes": latent.numpy().tobytes(),
"vae_latent_shape": list(latent.shape),
"vae_latent_dtype": str(latent.dtype).replace("torch.", ""),
"text_embedding_bytes": text_embedding.numpy().tobytes(),
"text_embedding_shape": list(text_embedding.shape),
"text_embedding_dtype": str(text_embedding.dtype).replace("torch.", ""),
"file_name": video_name,
"caption": caption,
"media_type": "video",
"width": MAX_WIDTH,
"height": MAX_HEIGHT,
"num_frames": NUM_FRAMES,
"duration_sec": NUM_FRAMES / TRAIN_FPS,
"fps": TRAIN_FPS,
}
records.append(record)
# Clean up encoders
del llama_enc, llama_tok, clip_enc, clip_tok, vae
# Write parquet
table = pa.table(
{k: [r[k] for r in records]
for k in records[0]},
schema=pyarrow_schema_t2v,
)
output_path = os.path.join(OUTPUT_DIR, "data_00000.parquet")
pq.write_table(table, output_path)
print(f"\nWrote {len(records)} records to {output_path}")
# Write validation prompts for callback
# Wrap in "data" key — ValidationDataset expects field="data"
# Use "caption" field — ValidationDataset aliases it to "prompt"
val_prompts = {"data": [{"caption": item["cap"][0]} for item in caption_data]}
val_path = os.path.join(OUTPUT_DIR, "validation_prompts.json")
with open(val_path, "w") as f:
json.dump(val_prompts, f, indent=2)
print(f"Wrote validation prompts to {val_path}")
print("\nDone! Use data_path: " + OUTPUT_DIR + " in training config.")
if __name__ == "__main__":
main()
+14 -129
View File
@@ -83,7 +83,7 @@ from fastvideo.configs.sample.wan import (
)
from fastvideo.configs.sample.sd35 import SD35SamplingParam
from fastvideo.fastvideo_args import FastVideoArgs, WorkloadType
from fastvideo.fastvideo_args import WorkloadType
from fastvideo.logger import init_logger
from fastvideo.utils import (maybe_download_model_index, verify_model_config_and_directory)
@@ -135,7 +135,6 @@ class ConfigInfo:
sampling_param_cls: type[SamplingParam] | None
pipeline_config_cls: type[PipelineConfig]
workload_types: tuple[WorkloadType, ...]
# The central registry mapping a model name to its configuration information
@@ -151,21 +150,15 @@ _MODEL_NAME_DETECTORS: list[tuple[str, Callable[[str], bool]]] = []
def register_configs(
sampling_param_cls: type[SamplingParam] | None,
pipeline_config_cls: type[PipelineConfig],
workload_types: tuple[WorkloadType, ...],
hf_model_paths: list[str] | None = None,
model_detectors: list[Callable[[str], bool]] | None = None,
) -> None:
"""Register config classes for a model family.
workload_types declares which UI workload options this config supports.
Use () for configs not exposed as workload options.
"""
"""Register config classes for a model family."""
model_id = str(len(_CONFIG_REGISTRY))
_CONFIG_REGISTRY[model_id] = ConfigInfo(
sampling_param_cls=sampling_param_cls,
pipeline_config_cls=pipeline_config_cls,
workload_types=workload_types,
)
if hf_model_paths:
@@ -242,7 +235,6 @@ def _register_configs() -> None:
register_configs(
sampling_param_cls=LTX2BaseSamplingParam,
pipeline_config_cls=LTX2T2VConfig,
workload_types=(WorkloadType.T2V,),
hf_model_paths=[
"Lightricks/LTX-2",
"FastVideo/LTX2-base",
@@ -256,7 +248,6 @@ def _register_configs() -> None:
register_configs(
sampling_param_cls=LTX2DistilledSamplingParam,
pipeline_config_cls=LTX2T2VConfig,
workload_types=(WorkloadType.T2V,),
hf_model_paths=[
"FastVideo/LTX2-Distilled-Diffusers",
],
@@ -269,7 +260,6 @@ def _register_configs() -> None:
register_configs(
sampling_param_cls=Hunyuan15_480P_SamplingParam,
pipeline_config_cls=Hunyuan15T2V480PConfig,
workload_types=(WorkloadType.T2V,),
hf_model_paths=[
"hunyuanvideo-community/HunyuanVideo-1.5-Diffusers-480p_t2v",
],
@@ -285,7 +275,6 @@ def _register_configs() -> None:
register_configs(
sampling_param_cls=Hunyuan15_480P_StepDistilled_I2V_SamplingParam,
pipeline_config_cls=Hunyuan15I2V480PStepDistilledConfig,
workload_types=(WorkloadType.I2V,),
hf_model_paths=[
"hunyuanvideo-community/HunyuanVideo-1.5-Diffusers-480p_i2v_step_distilled",
],
@@ -293,7 +282,6 @@ def _register_configs() -> None:
register_configs(
sampling_param_cls=Hunyuan15_720P_SamplingParam,
pipeline_config_cls=Hunyuan15T2V720PConfig,
workload_types=(WorkloadType.T2V,),
hf_model_paths=[
"hunyuanvideo-community/HunyuanVideo-1.5-Diffusers-720p_t2v",
],
@@ -301,7 +289,6 @@ def _register_configs() -> None:
register_configs(
sampling_param_cls=Hunyuan15_720P_Distilled_I2V_SamplingParam,
pipeline_config_cls=Hunyuan15I2V720PConfig,
workload_types=(WorkloadType.I2V,),
hf_model_paths=[
"hunyuanvideo-community/HunyuanVideo-1.5-Diffusers-720p_i2v_distilled",
],
@@ -309,18 +296,13 @@ def _register_configs() -> None:
register_configs(
sampling_param_cls=Hunyuan15_SR_1080P_SamplingParam,
pipeline_config_cls=Hunyuan15SR1080PConfig,
workload_types=(),
hf_model_paths=[
"weizhou03/HunyuanVideo-1.5-Diffusers-1080p",
"weizhou03/HunyuanVideo-1.5-Diffusers-1080p-2SR"
],
hf_model_paths=["weizhou03/HunyuanVideo-1.5-Diffusers-1080p", "weizhou03/HunyuanVideo-1.5-Diffusers-1080p-2SR"],
)
# Hunyuan (excludes gamecraft, hyworld, and versioned models)
register_configs(
sampling_param_cls=HunyuanSamplingParam,
pipeline_config_cls=HunyuanConfig,
workload_types=(WorkloadType.T2V,),
hf_model_paths=[
"hunyuanvideo-community/HunyuanVideo",
],
@@ -332,7 +314,6 @@ def _register_configs() -> None:
register_configs(
sampling_param_cls=FastHunyuanSamplingParam,
pipeline_config_cls=FastHunyuanConfig,
workload_types=(WorkloadType.T2V,),
hf_model_paths=[
"FastVideo/FastHunyuan-diffusers",
],
@@ -342,7 +323,6 @@ def _register_configs() -> None:
register_configs(
sampling_param_cls=HYWorld_SamplingParam,
pipeline_config_cls=HYWorldConfig,
workload_types=(),
hf_model_paths=[
"FastVideo/HY-WorldPlay-Bidirectional-Diffusers",
],
@@ -353,7 +333,6 @@ def _register_configs() -> None:
register_configs(
sampling_param_cls=HunyuanGameCraftSamplingParam,
pipeline_config_cls=HunyuanGameCraftPipelineConfig,
workload_types=(WorkloadType.I2V,),
hf_model_paths=[
"FastVideo/HunyuanGameCraft-Diffusers",
],
@@ -363,7 +342,6 @@ def _register_configs() -> None:
register_configs(
sampling_param_cls=LingBotWorld_SamplingParam,
pipeline_config_cls=LingBotWorldI2V480PConfig,
workload_types=(WorkloadType.I2V,),
hf_model_paths=[
"FastVideo/LingBot-World-Base-Cam-Diffusers",
],
@@ -374,7 +352,6 @@ def _register_configs() -> None:
register_configs(
sampling_param_cls=None,
pipeline_config_cls=PipelineConfig,
workload_types=(WorkloadType.T2V,),
hf_model_paths=[
"kandinskylab/Kandinsky-5.0-T2V-Lite-sft-5s-Diffusers",
],
@@ -383,38 +360,19 @@ def _register_configs() -> None:
],
)
# LongCat (T2V, I2V, VC use same config; workload varies by path)
# LongCat
register_configs(
sampling_param_cls=None,
pipeline_config_cls=LongCatT2V480PConfig,
workload_types=(WorkloadType.T2V,),
hf_model_paths=["FastVideo/LongCat-Video-T2V-Diffusers"],
model_detectors=[
lambda path: "longcat" in path.lower()
and "i2v" not in path.lower()
and "imagetovideo" not in path.lower()
and "vc" not in path.lower()
and "videocontinuation" not in path.lower(),
hf_model_paths=[
"FastVideo/LongCat-Video-T2V-Diffusers",
"FastVideo/LongCat-Video-I2V-Diffusers",
"FastVideo/LongCat-Video-VC-Diffusers",
],
)
register_configs(
sampling_param_cls=None,
pipeline_config_cls=LongCatT2V480PConfig,
workload_types=(WorkloadType.I2V,),
hf_model_paths=["FastVideo/LongCat-Video-I2V-Diffusers"],
model_detectors=[
lambda path: "longcatimagetovideo" in path.lower()
or ("longcat" in path.lower() and "i2v" in path.lower()),
],
)
register_configs(
sampling_param_cls=None,
pipeline_config_cls=LongCatT2V480PConfig,
workload_types=(),
hf_model_paths=["FastVideo/LongCat-Video-VC-Diffusers"],
model_detectors=[
lambda path: "longcatvideocontinuation" in path.lower()
or ("longcat" in path.lower() and "vc" in path.lower()),
lambda path: "longcatimagetovideo" in path.lower(),
lambda path: "longcatvideocontinuation" in path.lower(),
lambda path: "longcat" in path.lower(),
],
)
@@ -422,7 +380,6 @@ def _register_configs() -> None:
register_configs(
sampling_param_cls=MatrixGame2_SamplingParam,
pipeline_config_cls=MatrixGameI2V480PConfig,
workload_types=(WorkloadType.I2V,),
hf_model_paths=[
"FastVideo/Matrix-Game-2.0-Base-Diffusers",
"FastVideo/Matrix-Game-2.0-GTA-Diffusers",
@@ -437,7 +394,6 @@ def _register_configs() -> None:
register_configs(
sampling_param_cls=Cosmos25SamplingParamBase,
pipeline_config_cls=Cosmos25Config,
workload_types=(WorkloadType.T2V,),
hf_model_paths=[
"KyleShao/Cosmos-Predict2.5-2B-Diffusers",
],
@@ -454,7 +410,6 @@ def _register_configs() -> None:
register_configs(
sampling_param_cls=Cosmos_Predict2_2B_Video2World_SamplingParam,
pipeline_config_cls=CosmosConfig,
workload_types=(WorkloadType.T2V,),
hf_model_paths=[
"nvidia/Cosmos-Predict2-2B-Video2World",
],
@@ -468,7 +423,6 @@ def _register_configs() -> None:
register_configs(
sampling_param_cls=TurboDiffusionT2V_1_3B_SamplingParam,
pipeline_config_cls=TurboDiffusionT2V_1_3B_Config,
workload_types=(WorkloadType.T2V,),
hf_model_paths=[
"loayrashid/TurboWan2.1-T2V-1.3B-Diffusers",
],
@@ -477,7 +431,6 @@ def _register_configs() -> None:
register_configs(
sampling_param_cls=TurboDiffusionT2V_14B_SamplingParam,
pipeline_config_cls=TurboDiffusionT2V_14B_Config,
workload_types=(WorkloadType.T2V,),
hf_model_paths=[
"loayrashid/TurboWan2.1-T2V-14B-Diffusers",
],
@@ -485,7 +438,6 @@ def _register_configs() -> None:
register_configs(
sampling_param_cls=TurboDiffusionI2V_A14B_SamplingParam,
pipeline_config_cls=TurboDiffusionI2V_A14B_Config,
workload_types=(WorkloadType.I2V,),
hf_model_paths=[
"loayrashid/TurboWan2.2-I2V-A14B-Diffusers",
],
@@ -495,7 +447,6 @@ def _register_configs() -> None:
register_configs(
sampling_param_cls=WanT2V_1_3B_SamplingParam,
pipeline_config_cls=WanT2V480PConfig,
workload_types=(WorkloadType.T2V,),
hf_model_paths=[
"Wan-AI/Wan2.1-T2V-1.3B-Diffusers",
],
@@ -504,7 +455,6 @@ def _register_configs() -> None:
register_configs(
sampling_param_cls=WanT2V_14B_SamplingParam,
pipeline_config_cls=WanT2V720PConfig,
workload_types=(WorkloadType.T2V,),
hf_model_paths=[
"Wan-AI/Wan2.1-T2V-14B-Diffusers",
"FastVideo/Wan2.1-VSA-T2V-14B-720P-Diffusers",
@@ -513,7 +463,6 @@ def _register_configs() -> None:
register_configs(
sampling_param_cls=WanI2V_14B_480P_SamplingParam,
pipeline_config_cls=WanI2V480PConfig,
workload_types=(WorkloadType.I2V,),
hf_model_paths=[
"Wan-AI/Wan2.1-I2V-14B-480P-Diffusers",
],
@@ -522,7 +471,6 @@ def _register_configs() -> None:
register_configs(
sampling_param_cls=WanI2V_14B_720P_SamplingParam,
pipeline_config_cls=WanI2V720PConfig,
workload_types=(WorkloadType.I2V,),
hf_model_paths=[
"Wan-AI/Wan2.1-I2V-14B-720P-Diffusers",
],
@@ -530,7 +478,6 @@ def _register_configs() -> None:
register_configs(
sampling_param_cls=Wan2_1_Fun_1_3B_InP_SamplingParam,
pipeline_config_cls=WanI2V480PConfig,
workload_types=(WorkloadType.I2V,),
hf_model_paths=[
"weizhou03/Wan2.1-Fun-1.3B-InP-Diffusers",
],
@@ -538,7 +485,6 @@ def _register_configs() -> None:
register_configs(
sampling_param_cls=Wan2_1_Fun_1_3B_Control_SamplingParam,
pipeline_config_cls=WANV2VConfig,
workload_types=(),
hf_model_paths=[
"IRMChen/Wan2.1-Fun-1.3B-Control-Diffusers",
],
@@ -546,7 +492,6 @@ def _register_configs() -> None:
register_configs(
sampling_param_cls=FastWanT2V480P_SamplingParam,
pipeline_config_cls=FastWan2_1_T2V_480P_Config,
workload_types=(WorkloadType.T2V,),
hf_model_paths=[
"FastVideo/FastWan2.1-T2V-1.3B-Diffusers",
"FastVideo/FastWan2.1-T2V-14B-480P-Diffusers",
@@ -556,7 +501,6 @@ def _register_configs() -> None:
register_configs(
sampling_param_cls=Wan2_2_TI2V_5B_SamplingParam,
pipeline_config_cls=Wan2_2_TI2V_5B_Config,
workload_types=(WorkloadType.T2V, WorkloadType.I2V),
hf_model_paths=[
"Wan-AI/Wan2.2-TI2V-5B-Diffusers",
],
@@ -564,7 +508,6 @@ def _register_configs() -> None:
register_configs(
sampling_param_cls=Wan2_2_TI2V_5B_SamplingParam,
pipeline_config_cls=FastWan2_2_TI2V_5B_Config,
workload_types=(WorkloadType.T2V, WorkloadType.I2V),
hf_model_paths=[
"FastVideo/FastWan2.2-TI2V-5B-FullAttn-Diffusers",
"FastVideo/FastWan2.2-TI2V-5B-Diffusers",
@@ -573,7 +516,6 @@ def _register_configs() -> None:
register_configs(
sampling_param_cls=Wan2_2_T2V_A14B_SamplingParam,
pipeline_config_cls=Wan2_2_T2V_A14B_Config,
workload_types=(WorkloadType.T2V,),
hf_model_paths=[
"Wan-AI/Wan2.2-T2V-A14B-Diffusers",
],
@@ -581,7 +523,6 @@ def _register_configs() -> None:
register_configs(
sampling_param_cls=Wan2_2_I2V_A14B_SamplingParam,
pipeline_config_cls=Wan2_2_I2V_A14B_Config,
workload_types=(WorkloadType.I2V,),
hf_model_paths=[
"Wan-AI/Wan2.2-I2V-A14B-Diffusers",
],
@@ -589,31 +530,17 @@ def _register_configs() -> None:
register_configs(
sampling_param_cls=SelfForcingWan2_1_T2V_1_3B_480P_SamplingParam,
pipeline_config_cls=SelfForcingWanT2V480PConfig,
workload_types=(WorkloadType.T2V,),
hf_model_paths=[
"wlsaidhi/SFWan2.1-T2V-1.3B-Diffusers",
],
model_detectors=[lambda path: "wancausaldmdpipeline" in path.lower()],
)
# SFWan2.2: T2V and I2V variants by path
register_configs(
sampling_param_cls=SelfForcingWan2_2_T2V_A14B_480P_SamplingParam,
pipeline_config_cls=SelfForcingWan2_2_T2V480PConfig,
workload_types=(WorkloadType.T2V,),
hf_model_paths=["rand0nmr/SFWan2.2-T2V-A14B-Diffusers"],
model_detectors=[
lambda path: ("sfwan2.2" in path.lower() or "sfwan2_2" in path.lower())
and "i2v" not in path.lower(),
],
)
register_configs(
sampling_param_cls=SelfForcingWan2_2_T2V_A14B_480P_SamplingParam,
pipeline_config_cls=SelfForcingWan2_2_T2V480PConfig,
workload_types=(WorkloadType.I2V,),
hf_model_paths=["FastVideo/SFWan2.2-I2V-A14B-Preview-Diffusers"],
model_detectors=[
lambda path: ("sfwan2.2" in path.lower() or "sfwan2_2" in path.lower())
and "i2v" in path.lower(),
hf_model_paths=[
"rand0nmr/SFWan2.2-T2V-A14B-Diffusers",
"FastVideo/SFWan2.2-I2V-A14B-Preview-Diffusers",
],
)
@@ -621,7 +548,6 @@ def _register_configs() -> None:
register_configs(
sampling_param_cls=SD35SamplingParam,
pipeline_config_cls=SD35Config,
workload_types=(WorkloadType.T2I,),
hf_model_paths=[
"stabilityai/stable-diffusion-3.5-medium",
],
@@ -709,52 +635,11 @@ def get_sampling_param_cls_for_name(pipeline_name_or_path: str) -> Any | None:
_register_configs()
def get_registered_model_paths() -> list[str]:
"""Return all registered HuggingFace model paths.
Useful for UIs and tooling that need to enumerate supported models.
"""
return sorted(_MODEL_HF_PATH_TO_NAME.keys())
def get_registered_models_with_workloads(
workload_type: str | None = None,
) -> list[dict[str, Any]]:
"""Return models with workload metadata, optionally filtered by workload.
Args:
workload_type: If set (e.g. "t2v", "i2v", "t2i"), only return models
that support this workload. If None, return all with workload_types.
Returns:
List of dicts with keys: id, label, workload_types.
"""
result: list[dict[str, Any]] = []
for path in sorted(_MODEL_HF_PATH_TO_NAME.keys()):
model_id = _MODEL_HF_PATH_TO_NAME[path]
config_info = _CONFIG_REGISTRY.get(model_id)
if config_info is None:
continue
workload_values = [w.value for w in config_info.workload_types]
if workload_type is not None and workload_type.lower() not in workload_values:
continue
label = path.split("/")[-1].replace("-", " ").replace("_", " ")
result.append({
"id": path,
"label": label,
"workload_types": workload_values,
})
return result
__all__ = [
"ConfigInfo",
"ModelInfo",
"get_model_info",
"get_pipeline_config_cls_from_name",
"get_registered_model_paths",
"get_registered_models_with_workloads",
"get_sampling_param_cls_for_name",
"get_pipeline_config_classes",
]
+17 -24
View File
@@ -21,9 +21,7 @@ os.environ["MASTER_ADDR"] = "localhost"
os.environ["MASTER_PORT"] = "29503"
BASE_MODEL_PATH = "hunyuanvideo-community/HunyuanVideo"
MODEL_PATH = maybe_download_model(BASE_MODEL_PATH,
local_dir=os.path.join("data", BASE_MODEL_PATH) # store in the large /workspace disk on Runpod
)
MODEL_PATH = maybe_download_model(BASE_MODEL_PATH, local_dir=os.path.join("data", BASE_MODEL_PATH))
TEXT_ENCODER_PATH = os.path.join(MODEL_PATH, "text_encoder_2")
TOKENIZER_PATH = os.path.join(MODEL_PATH, "tokenizer_2")
@@ -34,13 +32,12 @@ def test_clip_encoder():
Tests compatibility between two different implementations for loading text encoders:
1. load_text_encoder from fastvideo.models.hunyuan.text_encoder
2. TextEncoderLoader from fastvideo.models.loader
The test verifies that both implementations:
- Load models with the same weights and parameters
- Produce nearly identical outputs for the same input prompts
"""
args = FastVideoArgs(model_path="openai/clip-vit-large-patch14",
pipeline_config=HunyuanConfig())
args = FastVideoArgs(model_path="openai/clip-vit-large-patch14", pipeline_config=HunyuanConfig())
device = torch.device("cuda:0" if torch.cuda.is_available() else "cpu")
logger.info("Loading models from %s", args.model_path)
@@ -55,6 +52,7 @@ def test_clip_encoder():
model1 = CLIPTextModel.from_pretrained(TEXT_ENCODER_PATH).to(torch.float16).to(device).eval()
from fastvideo.models.loader.component_loader import TextEncoderLoader
loader = TextEncoderLoader()
model2 = loader.load(TEXT_ENCODER_PATH, args)
@@ -90,10 +88,7 @@ def test_clip_encoder():
tokenizer = AutoTokenizer.from_pretrained(TOKENIZER_PATH)
# Test with some sample prompts
prompts = [
"a photo of a cat", "a beautiful landscape with mountains",
"an astronaut riding a horse on the moon"
]
prompts = ["a photo of a cat", "a beautiful landscape with mountains", "an astronaut riding a horse on the moon"]
logger.info("Testing CLIP text encoder with sample prompts")
@@ -102,14 +97,11 @@ def test_clip_encoder():
logger.info("Testing prompt: '%s'", prompt)
# Tokenize the prompt
tokens = tokenizer(prompt,
padding="max_length",
max_length=77,
truncation=True,
return_tensors="pt").to(device)
tokens = tokenizer(prompt, padding="max_length", max_length=77, truncation=True, return_tensors="pt").to(
device
)
# Get embeddings from our implementation
outputs1 = model1(input_ids=tokens.input_ids,
output_hidden_states=True)
outputs1 = model1(input_ids=tokens.input_ids, output_hidden_states=True)
logger.info("Testing model2")
print("--------------------------------")
@@ -118,24 +110,25 @@ def test_clip_encoder():
outputs2 = model2(
input_ids=tokens.input_ids,
# attention_mask=tokens.attention_mask,
output_hidden_states=True)
output_hidden_states=True,
)
# Compare last hidden states
last_hidden_state1 = outputs1.last_hidden_state[
tokens.attention_mask == 1]
last_hidden_state2 = outputs2.last_hidden_state[
tokens.attention_mask == 1]
last_hidden_state1 = outputs1.last_hidden_state[tokens.attention_mask == 1]
last_hidden_state2 = outputs2.last_hidden_state[tokens.attention_mask == 1]
# print("last_hidden_state1", last_hidden_state1)
# print("last_hidden_state2", last_hidden_state2)
assert last_hidden_state1.shape == last_hidden_state2.shape, \
assert last_hidden_state1.shape == last_hidden_state2.shape, (
f"Hidden state shapes don't match: {last_hidden_state1.shape} vs {last_hidden_state2.shape}"
)
# Compare pooler outputs
pooler_output1 = outputs1.pooler_output
pooler_output2 = outputs2.pooler_output
assert pooler_output1.shape == pooler_output2.shape, \
assert pooler_output1.shape == pooler_output2.shape, (
f"Pooler output shapes don't match: {pooler_output1.shape} vs {pooler_output2.shape}"
)
assert_close(pooler_output1, pooler_output2, atol=1e-2, rtol=1e-3)
assert_close(last_hidden_state1, last_hidden_state2, atol=1e-2, rtol=1e-3)
+22 -28
View File
@@ -15,15 +15,14 @@ from fastvideo.utils import maybe_download_model
from fastvideo.configs.models.encoders import LlamaConfig
from torch.distributed.tensor import DTensor
from torch.testing import assert_close
logger = init_logger(__name__)
os.environ["MASTER_ADDR"] = "localhost"
os.environ["MASTER_PORT"] = "29503"
BASE_MODEL_PATH = "hunyuanvideo-community/HunyuanVideo"
MODEL_PATH = maybe_download_model(BASE_MODEL_PATH,
local_dir=os.path.join("data", BASE_MODEL_PATH) # store in the large /workspace disk on Runpod
)
MODEL_PATH = maybe_download_model(BASE_MODEL_PATH, local_dir=os.path.join("data", BASE_MODEL_PATH))
TEXT_ENCODER_PATH = os.path.join(MODEL_PATH, "text_encoder")
TOKENIZER_PATH = os.path.join(MODEL_PATH, "tokenizer")
@@ -34,13 +33,12 @@ def test_llama_encoder():
Tests compatibility between two different implementations for loading text encoders:
1. load_text_encoder from fastvideo.models.hunyuan.text_encoder
2. TextEncoderLoader from fastvideo.models.loader
The test verifies that both implementations:
- Load models with the same weights and parameters
- Produce nearly identical outputs for the same input prompts
"""
args = FastVideoArgs(model_path="meta-llama/Llama-2-7b-hf",
pipeline_config=HunyuanConfig())
args = FastVideoArgs(model_path="meta-llama/Llama-2-7b-hf", pipeline_config=HunyuanConfig())
device = torch.device("cuda:0" if torch.cuda.is_available() else "cpu")
@@ -68,15 +66,15 @@ def test_llama_encoder():
logger.info("Model1 has %d parameters", len(params1))
logger.info("Model2 has %d parameters", len(params2))
# check if embed_tokens are the same
device = model1.embed_tokens.weight.device
assert torch.allclose(model1.embed_tokens.weight,
model2.embed_tokens.weight.to_local().to(device) if isinstance(model2.embed_tokens.weight, DTensor) else model2.embed_tokens.weight.to(device))
weights = [
"layers.{}.input_layernorm.weight",
"layers.{}.post_attention_layernorm.weight"
]
assert torch.allclose(
model1.embed_tokens.weight,
model2.embed_tokens.weight.to_local().to(device)
if isinstance(model2.embed_tokens.weight, DTensor)
else model2.embed_tokens.weight.to(device),
)
weights = ["layers.{}.input_layernorm.weight", "layers.{}.post_attention_layernorm.weight"]
for layer_idx in range(hf_config.num_hidden_layers):
for w in weights:
name1 = w.format(layer_idx)
@@ -121,33 +119,29 @@ def test_llama_encoder():
logger.info("Testing prompt: '%s'", prompt)
# Tokenize the prompt
tokens = tokenizer(prompt,
padding="max_length",
max_length=512,
truncation=True,
return_tensors="pt").to(device)
tokens = tokenizer(prompt, padding="max_length", max_length=512, truncation=True, return_tensors="pt").to(
device
)
# Get outputs from our implementation
# filter out padding input_ids
# tokens.input_ids = tokens.input_ids[tokens.attention_mask==1]
# tokens.attention_mask = tokens.attention_mask[tokens.attention_mask==1]
outputs1 = model1(input_ids=tokens.input_ids,
output_hidden_states=True)
outputs1 = model1(input_ids=tokens.input_ids, output_hidden_states=True)
print("--------------------------------")
logger.info("Testing model2")
# Get outputs from HuggingFace implementation
with set_forward_context(current_timestep=0, attn_metadata=None):
outputs2 = model2(input_ids=tokens.input_ids,
attention_mask=tokens.attention_mask,
output_hidden_states=True)
outputs2 = model2(
input_ids=tokens.input_ids, attention_mask=tokens.attention_mask, output_hidden_states=True
)
# Compare last hidden states
last_hidden_state1 = outputs1.last_hidden_state[
tokens.attention_mask == 1]
last_hidden_state2 = outputs2.last_hidden_state[
tokens.attention_mask == 1]
last_hidden_state1 = outputs1.last_hidden_state[tokens.attention_mask == 1]
last_hidden_state2 = outputs2.last_hidden_state[tokens.attention_mask == 1]
assert last_hidden_state1.shape == last_hidden_state2.shape, \
assert last_hidden_state1.shape == last_hidden_state2.shape, (
f"Hidden state shapes don't match: {last_hidden_state1.shape} vs {last_hidden_state2.shape}"
)
assert_close(last_hidden_state1, last_hidden_state2, atol=1e-1, rtol=1e-4)
+475 -153
View File
@@ -1,7 +1,11 @@
import ast
import datetime
import glob
import os
import re
import shutil
import subprocess
import sys
from dataclasses import dataclass
from typing import Any
@@ -10,7 +14,7 @@ import modal
app = modal.App()
model_vol = modal.Volume.from_name("hf-model-weights")
image_version = os.getenv("IMAGE_VERSION")
image_version = os.getenv("IMAGE_VERSION", "latest")
image_tag = f"ghcr.io/hao-ai-lab/fastvideo/fastvideo-dev:{image_version}"
print(f"Using image: {image_tag}")
@@ -24,30 +28,29 @@ image = (
"libssl-dev",
"ffmpeg",
)
.run_commands(
"curl --proto '=https' --tlsv1.2 -sSf "
"https://sh.rustup.rs | sh -s -- -y --default-toolchain stable"
)
.run_commands("curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain stable")
.run_commands("echo 'source ~/.cargo/env' >> ~/.bashrc")
.env({
"PATH": "/root/.cargo/bin:$PATH",
"BUILDKITE_REPO": os.environ.get("BUILDKITE_REPO", ""),
"BUILDKITE_COMMIT": os.environ.get("BUILDKITE_COMMIT", ""),
"BUILDKITE_PULL_REQUEST": os.environ.get(
"BUILDKITE_PULL_REQUEST", ""
),
"IMAGE_VERSION": os.environ.get("IMAGE_VERSION", ""),
})
.env(
{
"PATH": "/root/.cargo/bin:$PATH",
"BUILDKITE_REPO": os.environ.get("BUILDKITE_REPO", ""),
"BUILDKITE_COMMIT": os.environ.get("BUILDKITE_COMMIT", ""),
"BUILDKITE_PULL_REQUEST": os.environ.get("BUILDKITE_PULL_REQUEST", ""),
"IMAGE_VERSION": os.environ.get("IMAGE_VERSION", ""),
}
)
)
SSIM_NUM_GPUS = 4
SSIM_TERMINATE_TIMEOUT_S = 30
HF_TOKEN_ENV_KEYS = ("HF_API_KEY", "HUGGINGFACE_HUB_TOKEN", "HF_TOKEN")
RAW_GENERATED_VOLUME_ROOT = "ssim_generated_videos"
DEFAULT_OUTPUT_QUALITY_TIER = "default"
FULL_OUTPUT_QUALITY_TIER = "full_quality"
MODAL_DEVICE_REFERENCE_FOLDER = "L40S_reference_videos"
SSIM_COMMON_KWARGS = dict(
image=image,
timeout=5400,
secrets=[modal.Secret.from_dict(
{"HF_API_KEY": os.environ.get("HF_API_KEY", "")}
)],
volumes={"/root/data": model_vol},
)
@@ -105,6 +108,194 @@ class _PartitionResult:
exit_code: int
def _split_csv_values(csv_values: str) -> set[str]:
return {value.strip() for value in csv_values.split(",") if value.strip()}
def _run_git_command(args: list[str]) -> str:
try:
result = subprocess.run(
["git", *args],
check=True,
capture_output=True,
text=True,
)
except subprocess.CalledProcessError as error:
stderr = (error.stderr or "").strip()
raise RuntimeError(f"Failed to run git {' '.join(args)}: {stderr or error}") from error
return result.stdout.strip()
def _normalize_git_repo_url(git_repo: str) -> str:
if git_repo.startswith("git@github.com:"):
return "https://github.com/" + git_repo[len("git@github.com:") :]
if git_repo.startswith("ssh://git@github.com/"):
return "https://github.com/" + git_repo[len("ssh://git@github.com/") :]
return git_repo
def _resolve_git_repo(git_repo: str) -> str:
if git_repo.strip():
return _normalize_git_repo_url(git_repo.strip())
env_repo = os.environ.get("BUILDKITE_REPO", "").strip()
if env_repo:
return _normalize_git_repo_url(env_repo)
discovered_repo = _run_git_command(["config", "--get", "remote.origin.url"])
if discovered_repo:
return _normalize_git_repo_url(discovered_repo)
raise RuntimeError("Could not resolve git repo URL. Pass --git-repo or set BUILDKITE_REPO.")
def _resolve_git_commit(git_commit: str) -> str:
if git_commit.strip():
return git_commit.strip()
env_commit = os.environ.get("BUILDKITE_COMMIT", "").strip()
if env_commit:
return env_commit
discovered_commit = _run_git_command(["rev-parse", "HEAD"])
if discovered_commit:
return discovered_commit
raise RuntimeError("Could not resolve git commit. Pass --git-commit or set BUILDKITE_COMMIT.")
def _resolve_pull_request(pr_number: str) -> str:
if pr_number.strip():
return pr_number.strip()
env_pr = os.environ.get("BUILDKITE_PULL_REQUEST", "").strip()
if env_pr:
return env_pr
return "false"
def _resolve_hf_api_key(
hf_api_key: str,
) -> str:
if hf_api_key.strip():
return hf_api_key.strip()
for key in HF_TOKEN_ENV_KEYS:
value = os.environ.get(key, "").strip()
if value:
return value
raise RuntimeError(
"Hugging Face token is required. Set HF_API_KEY, HUGGINGFACE_HUB_TOKEN, or HF_TOKEN; or pass --hf-api-key."
)
def _sanitize_path_fragment(value: str) -> str:
cleaned = re.sub(r"[^A-Za-z0-9_.-]+", "_", value.strip())
return cleaned.strip("._-")
def _resolve_generated_volume_subdir(
requested_subdir: str,
git_commit: str,
) -> str:
if requested_subdir.strip():
normalized = requested_subdir.strip().strip("/")
if not normalized:
raise RuntimeError("generated_volume_subdir must not be empty.")
return normalized
short_commit = _sanitize_path_fragment(git_commit[:12]) or "unknown"
timestamp = datetime.datetime.utcnow().strftime("%Y%m%d_%H%M%S")
return f"{timestamp}_{short_commit}"
def _resolve_output_quality_tier(ssim_full_quality: bool) -> str:
if ssim_full_quality:
return FULL_OUTPUT_QUALITY_TIER
return DEFAULT_OUTPUT_QUALITY_TIER
def _build_generated_volume_relative_path(
*,
generated_volume_subdir: str,
quality_tier: str,
) -> str:
return os.path.join(
RAW_GENERATED_VOLUME_ROOT,
quality_tier,
generated_volume_subdir,
"generated_videos",
)
def _build_local_generated_download_dir(quality_tier: str) -> str:
return os.path.join(".", "generated_videos_modal", quality_tier)
def _print_local_reference_copy_command(quality_tier: str) -> None:
generated_dir = os.path.join(
_build_local_generated_download_dir(quality_tier),
MODAL_DEVICE_REFERENCE_FOLDER,
)
print(
"To update local references from the downloaded Modal outputs, run:\n"
" python fastvideo/tests/ssim/reference_videos_cli.py copy-local "
f"--quality-tier {quality_tier} "
f"--generated-dir {generated_dir} "
f"--device-folder {MODAL_DEVICE_REFERENCE_FOLDER}"
)
def _count_video_files(root: str) -> int:
count = 0
for current_root, _, filenames in os.walk(root):
for filename in filenames:
if filename.lower().endswith((".mp4", ".avi", ".mov", ".mkv", ".webm", ".flv")):
count += 1
return count
def _sync_generated_videos_to_volume(
repo_root: str,
generated_volume_subdir: str,
quality_tier: str,
) -> str | None:
generated_root = os.path.join(
repo_root,
"fastvideo",
"tests",
"ssim",
"generated_videos",
quality_tier,
)
if not os.path.isdir(generated_root):
print(
f"No generated_videos directory found for quality tier {quality_tier}; skipping raw generated video export."
)
return None
relative_dst = _build_generated_volume_relative_path(
generated_volume_subdir=generated_volume_subdir,
quality_tier=quality_tier,
)
absolute_dst = os.path.join("/root/data", relative_dst)
if os.path.exists(absolute_dst):
shutil.rmtree(absolute_dst)
os.makedirs(os.path.dirname(absolute_dst), exist_ok=True)
shutil.copytree(generated_root, absolute_dst)
model_vol.commit()
num_videos = _count_video_files(absolute_dst)
print(f"Raw generated videos exported to Modal volume path: {relative_dst} ({num_videos} video files).")
print(
"Download command:\n"
f" modal volume get hf-model-weights {relative_dst} "
f"{_build_local_generated_download_dir(quality_tier)}"
)
_print_local_reference_copy_command(quality_tier)
return relative_dst
def _extract_required_gpus(filepath: str) -> int:
"""Read REQUIRED_GPUS from a test file. Defaults to 1."""
with open(filepath, encoding="utf-8") as file:
@@ -126,10 +317,7 @@ def _extract_model_ids(filepath: str) -> list[str]:
value_node = None
if isinstance(node, ast.Assign):
target_names = [
target.id for target in node.targets
if isinstance(target, ast.Name)
]
target_names = [target.id for target in node.targets if isinstance(target, ast.Name)]
value_node = node.value
elif isinstance(node, ast.AnnAssign):
if isinstance(node.target, ast.Name):
@@ -138,8 +326,7 @@ def _extract_model_ids(filepath: str) -> list[str]:
if not target_names or value_node is None:
continue
if not any(name.endswith("MODEL_TO_PARAMS")
for name in target_names):
if not any(name.endswith("MODEL_TO_PARAMS") for name in target_names):
continue
if not isinstance(value_node, ast.Dict):
continue
@@ -159,35 +346,70 @@ def _extract_model_ids(filepath: str) -> list[str]:
return unique_model_ids
def _discover_ssim_tasks(ssim_dir: str) -> list[SSIMTask]:
def _discover_ssim_tasks(
ssim_dir: str,
*,
selected_test_files: set[str] | None = None,
selected_model_ids: set[str] | None = None,
) -> list[SSIMTask]:
tasks = []
task_id = 0
selected_test_files = selected_test_files or set()
selected_test_file_names = {os.path.basename(test_file) for test_file in selected_test_files}
selected_model_ids = selected_model_ids or set()
test_files = sorted(glob.glob(os.path.join(ssim_dir, "test_*.py")))
matched_test_files = set()
matched_model_ids = set()
for filepath in test_files:
file_name = os.path.basename(filepath)
rel_path = f"./fastvideo/tests/ssim/{file_name}"
if selected_test_files and (
filepath not in selected_test_files
and rel_path not in selected_test_files
and file_name not in selected_test_file_names
):
continue
matched_test_files.add(file_name)
required_gpus = _extract_required_gpus(filepath)
if required_gpus < 1 or required_gpus > SSIM_NUM_GPUS:
raise ValueError(
f"{filepath} requires {required_gpus} GPUs, "
f"but scheduler supports up to {SSIM_NUM_GPUS}."
)
rel_path = f"./fastvideo/tests/ssim/{os.path.basename(filepath)}"
raise ValueError(f"{filepath} requires {required_gpus} GPUs, but scheduler supports up to {SSIM_NUM_GPUS}.")
model_ids = _extract_model_ids(filepath)
if model_ids:
for model_id in model_ids:
tasks.append(SSIMTask(
if selected_model_ids and model_id not in selected_model_ids:
continue
matched_model_ids.add(model_id)
tasks.append(
SSIMTask(
task_id=task_id,
test_file=rel_path,
required_gpus=required_gpus,
model_id=model_id,
)
)
task_id += 1
else:
if selected_model_ids:
continue
tasks.append(
SSIMTask(
task_id=task_id,
test_file=rel_path,
required_gpus=required_gpus,
model_id=model_id,
))
task_id += 1
else:
tasks.append(SSIMTask(
task_id=task_id,
test_file=rel_path,
required_gpus=required_gpus,
))
)
)
task_id += 1
if selected_test_files:
unmatched_files = sorted(selected_test_file_names - matched_test_files)
if unmatched_files:
raise RuntimeError("Requested SSIM test file(s) not found: " + ", ".join(unmatched_files))
if selected_model_ids:
unmatched_model_ids = sorted(selected_model_ids - matched_model_ids)
if unmatched_model_ids:
raise RuntimeError("Requested SSIM model_id(s) not found: " + ", ".join(unmatched_model_ids))
return sorted(tasks, key=lambda task: task.sort_key)
@@ -207,26 +429,24 @@ def _build_checkout_command(git_commit: str, pr_number: str | None) -> str:
try:
pr_id = int(pr_number)
except ValueError as error:
raise RuntimeError(
f"Invalid BUILDKITE_PULL_REQUEST value: {pr_number}"
) from error
return (
"git fetch --prune origin "
f"refs/pull/{pr_id}/head && "
"git checkout FETCH_HEAD"
)
raise RuntimeError(f"Invalid BUILDKITE_PULL_REQUEST value: {pr_number}") from error
return f"git fetch --prune origin refs/pull/{pr_id}/head && git checkout FETCH_HEAD"
return f"git checkout {shlex.quote(git_commit)}"
def _prepare_ssim_workspace() -> tuple[str, list[SSIMTask]]:
def _prepare_ssim_workspace(
*,
git_repo: str,
git_commit: str,
pr_number: str,
hf_api_key: str,
selected_test_files: set[str] | None = None,
selected_model_ids: set[str] | None = None,
) -> tuple[str, list[SSIMTask]]:
import shlex
import subprocess
git_repo = os.environ.get("BUILDKITE_REPO")
git_commit = os.environ.get("BUILDKITE_COMMIT")
pr_number = os.environ.get("BUILDKITE_PULL_REQUEST")
if not git_repo or not git_commit:
raise RuntimeError("BUILDKITE_REPO and BUILDKITE_COMMIT must be set.")
if not hf_api_key.strip():
raise RuntimeError("HF API key is required to prepare SSIM workspace.")
checkout_command = _build_checkout_command(git_commit, pr_number)
repo_root = "/FastVideo"
@@ -235,7 +455,14 @@ def _prepare_ssim_workspace() -> tuple[str, list[SSIMTask]]:
set -euo pipefail
source $HOME/.local/bin/env
source /opt/venv/bin/activate
cd {shlex.quote(repo_root)}
if [ -d {shlex.quote(repo_root)}/.git ]; then
cd {shlex.quote(repo_root)}
git remote set-url origin {shlex.quote(git_repo)} || true
git fetch --prune origin
else
git clone {shlex.quote(git_repo)} {shlex.quote(repo_root)}
cd {shlex.quote(repo_root)}
fi
{checkout_command}
git submodule update --init --recursive
cd fastvideo-kernel
@@ -249,17 +476,22 @@ def _prepare_ssim_workspace() -> tuple[str, list[SSIMTask]]:
["/bin/bash", "-lc", command],
capture_output=True,
text=True,
env={
**os.environ,
"HF_API_KEY": hf_api_key,
},
)
if result.returncode != 0:
print(result.stdout)
print(result.stderr)
raise RuntimeError(
"Workspace setup failed with "
f"exit code {result.returncode}"
)
raise RuntimeError(f"Workspace setup failed with exit code {result.returncode}")
ssim_dir = os.path.join(repo_root, "fastvideo", "tests", "ssim")
tasks = _discover_ssim_tasks(ssim_dir)
tasks = _discover_ssim_tasks(
ssim_dir,
selected_test_files=selected_test_files,
selected_model_ids=selected_model_ids,
)
if not tasks:
raise RuntimeError("No SSIM test files found.")
return repo_root, tasks
@@ -271,22 +503,14 @@ def _spawn_ssim_task(
assigned_gpu_ids: list[str],
log_dir: str,
task_index: int,
pytest_extra_args: list[str],
) -> _RunningTask:
import shlex
import subprocess
safe_test_name = re.sub(
r"[^A-Za-z0-9_.-]+", "_", task.test_name
)
log_path = os.path.join(
log_dir, f"{task_index:03d}_{safe_test_name}.log"
)
command = (
"set -euo pipefail && "
"source $HOME/.local/bin/env && "
"source /opt/venv/bin/activate && "
f"pytest {shlex.quote(task.test_file)} -vs"
)
safe_test_name = re.sub(r"[^A-Za-z0-9_.-]+", "_", task.test_name)
log_path = os.path.join(log_dir, f"{task_index:03d}_{safe_test_name}.log")
pytest_command = shlex.join(["pytest", task.test_file, "-vs", *pytest_extra_args])
command = f"set -euo pipefail && source $HOME/.local/bin/env && source /opt/venv/bin/activate && {pytest_command}"
env = os.environ.copy()
env["HF_HOME"] = "/root/data/.cache"
# MultiprocExecutor returns CUDA tensors through mp pipes (CUDA IPC).
@@ -327,9 +551,7 @@ def _finalize_running_task(
) -> None:
running_task.log_handle.close()
available_gpu_ids.extend(running_task.gpu_ids)
available_gpu_ids.sort(
key=lambda gpu_id: gpu_order.get(gpu_id, len(gpu_order))
)
available_gpu_ids.sort(key=lambda gpu_id: gpu_order.get(gpu_id, len(gpu_order)))
results[running_task.task.task_id] = _TaskResult(
task=running_task.task,
status=status,
@@ -402,9 +624,30 @@ def _get_visible_gpu_ids() -> list[str]:
return gpu_ids
def _build_pytest_extra_args(
*,
ssim_full_quality: bool,
ssim_reference_repo: str,
skip_ssim_reference_download: bool,
pytest_k: str,
) -> list[str]:
args = []
if ssim_full_quality:
args.append("--ssim-full-quality")
if ssim_reference_repo.strip():
args.extend(["--ssim-reference-repo", ssim_reference_repo.strip()])
if skip_ssim_reference_download:
args.append("--skip-ssim-reference-download")
if pytest_k.strip():
args.extend(["-k", pytest_k.strip()])
return args
def _schedule_ssim_tasks(
repo_root: str,
tasks: list[SSIMTask],
pytest_extra_args: list[str],
fail_fast: bool = True,
) -> dict[int, _TaskResult]:
import tempfile
import time
@@ -412,16 +655,10 @@ def _schedule_ssim_tasks(
pending_tasks = list(tasks)
running_tasks = []
available_gpu_ids = _get_visible_gpu_ids()
gpu_order = {
gpu_id: index for index, gpu_id in enumerate(available_gpu_ids)
}
max_required_gpus = max(
(task.required_gpus for task in pending_tasks), default=0
)
gpu_order = {gpu_id: index for index, gpu_id in enumerate(available_gpu_ids)}
max_required_gpus = max((task.required_gpus for task in pending_tasks), default=0)
if max_required_gpus > len(available_gpu_ids):
cuda_visible_devices = os.environ.get(
"CUDA_VISIBLE_DEVICES", "<unset>"
)
cuda_visible_devices = os.environ.get("CUDA_VISIBLE_DEVICES", "<unset>")
raise RuntimeError(
"SSIM task requires "
f"{max_required_gpus} GPUs but only "
@@ -443,19 +680,17 @@ def _schedule_ssim_tasks(
break
task = pending_tasks.pop(next_task_index)
assigned_gpu_ids = available_gpu_ids[:task.required_gpus]
del available_gpu_ids[:task.required_gpus]
assigned_gpu_ids = available_gpu_ids[: task.required_gpus]
del available_gpu_ids[: task.required_gpus]
running_task = _spawn_ssim_task(
task=task,
repo_root=repo_root,
assigned_gpu_ids=assigned_gpu_ids,
log_dir=log_dir,
task_index=task.task_id,
pytest_extra_args=pytest_extra_args,
)
print(
f"Started {task.test_name} on GPUs "
f"{','.join(assigned_gpu_ids)}"
)
print(f"Started {task.test_name} on GPUs {','.join(assigned_gpu_ids)}")
running_tasks.append(running_task)
completed_tasks = []
@@ -474,11 +709,8 @@ def _schedule_ssim_tasks(
gpu_order=gpu_order,
)
running_tasks.remove(running_task)
print(
f"Finished {running_task.task.test_name} with "
f"exit code {returncode}"
)
if returncode != 0 and not fail_fast_triggered:
print(f"Finished {running_task.task.test_name} with exit code {returncode}")
if returncode != 0 and fail_fast and not fail_fast_triggered:
fail_fast_triggered = True
if fail_fast_triggered and running_tasks:
@@ -517,22 +749,18 @@ def _collect_task_summaries(
for task in tasks:
result = results[task.task_id]
log_content = None
if (
result.status == "failed"
and result.log_path
and os.path.exists(result.log_path)
):
with open(
result.log_path, encoding="utf-8"
) as f:
if result.status == "failed" and result.log_path and os.path.exists(result.log_path):
with open(result.log_path, encoding="utf-8") as f:
log_content = f.read()
summaries.append(_TaskSummary(
test_name=task.test_name,
required_gpus=task.required_gpus,
status=result.status,
returncode=result.returncode,
log_content=log_content,
))
summaries.append(
_TaskSummary(
test_name=task.test_name,
required_gpus=task.required_gpus,
status=result.status,
returncode=result.returncode,
log_content=log_content,
)
)
return summaries
@@ -550,18 +778,13 @@ def _print_combined_results(
if result is None:
continue
for s in result.task_summaries:
label = (
f"[P{result.partition_index}] "
f"{s.test_name}"
)
label = f"[P{result.partition_index}] {s.test_name}"
if s.status == "passed":
passed.append(label)
elif s.status == "failed":
failed.append(label)
if first_failure is None:
first_failure = (
result.partition_index, s
)
first_failure = (result.partition_index, s)
elif s.status == "terminated":
terminated.append(label)
elif s.status == "skipped":
@@ -585,14 +808,8 @@ def _print_combined_results(
status = "cancelled"
else:
count = len(result.task_summaries)
status = (
"passed" if result.exit_code == 0
else "failed"
)
print(
f" Partition {i}: {status} "
f"({count} tasks)"
)
status = "passed" if result.exit_code == 0 else "failed"
print(f" Partition {i}: {status} ({count} tasks)")
print(f" passed: {len(passed)}")
print(f" failed: {len(failed)}")
print(f" terminated: {len(terminated)}")
@@ -605,40 +822,72 @@ def _print_combined_results(
if skipped:
print(f"Skipped: {', '.join(skipped)}")
has_failures = bool(
failed or terminated or skipped
or any(r is None for r in partition_results)
)
has_failures = bool(failed or terminated or skipped or any(r is None for r in partition_results))
return 1 if has_failures else 0
@app.function(gpu=f"L40S:{SSIM_NUM_GPUS}", **SSIM_COMMON_KWARGS)
def run_ssim_partition(
partition_index: int,
num_partitions: int,
git_repo: str,
git_commit: str,
pr_number: str = "false",
hf_api_key: str = "",
test_files_csv: str = "",
model_ids_csv: str = "",
ssim_full_quality: bool = False,
ssim_reference_repo: str = "",
skip_ssim_reference_download: bool = False,
pytest_k: str = "",
sync_generated_to_volume: bool = False,
generated_volume_subdir: str = "",
fail_fast: bool = True,
) -> _PartitionResult:
repo_root, tasks = _prepare_ssim_workspace()
partition = _partition_tasks(tasks, partition_index)
selected_test_files = _split_csv_values(test_files_csv)
selected_model_ids = _split_csv_values(model_ids_csv)
repo_root, tasks = _prepare_ssim_workspace(
git_repo=git_repo,
git_commit=git_commit,
pr_number=pr_number,
hf_api_key=hf_api_key,
selected_test_files=selected_test_files,
selected_model_ids=selected_model_ids,
)
partition = _partition_tasks(tasks, partition_index, num_partitions)
if not partition:
print(
f"Partition {partition_index}: "
"no tasks assigned."
)
print(f"Partition {partition_index}: no tasks assigned.")
return _PartitionResult(
partition_index=partition_index,
task_summaries=[],
exit_code=0,
)
print(
f"Partition {partition_index}: running "
f"{len(partition)}/{len(tasks)} tasks"
print(f"Partition {partition_index}: running {len(partition)}/{len(tasks)} tasks")
pytest_extra_args = _build_pytest_extra_args(
ssim_full_quality=ssim_full_quality,
ssim_reference_repo=ssim_reference_repo,
skip_ssim_reference_download=skip_ssim_reference_download,
pytest_k=pytest_k,
)
results = _schedule_ssim_tasks(repo_root, partition)
summaries = _collect_task_summaries(
partition, results
)
has_failures = any(
s.status != "passed" for s in summaries
results = _schedule_ssim_tasks(
repo_root,
partition,
pytest_extra_args=pytest_extra_args,
fail_fast=fail_fast,
)
summaries = _collect_task_summaries(partition, results)
has_failures = any(s.status != "passed" for s in summaries)
if sync_generated_to_volume:
quality_tier = _resolve_output_quality_tier(ssim_full_quality)
resolved_subdir = _resolve_generated_volume_subdir(
generated_volume_subdir,
git_commit,
)
_sync_generated_videos_to_volume(
repo_root,
resolved_subdir,
quality_tier,
)
return _PartitionResult(
partition_index=partition_index,
task_summaries=summaries,
@@ -646,18 +895,91 @@ def run_ssim_partition(
)
@app.local_entrypoint()
def run_ssim_tests():
import sys
NUM_PARTITIONS = 2
future_0 = run_ssim_partition.spawn(partition_index=0)
future_1 = run_ssim_partition.spawn(partition_index=1)
results: list[_PartitionResult | None] = [
future_0.get(),
future_1.get(),
@app.local_entrypoint()
def run_ssim_tests(
git_repo: str = "",
git_commit: str = "",
pr_number: str = "",
hf_api_key: str = "",
test_files: str = "",
model_ids: str = "",
full_quality: bool = False,
reference_repo: str = "",
skip_reference_download: bool = False,
pytest_k: str = "",
sync_generated_to_volume: bool = False,
generated_volume_subdir: str = "",
no_fail_fast: bool = False,
):
resolved_git_repo = _resolve_git_repo(git_repo)
resolved_git_commit = _resolve_git_commit(git_commit)
resolved_pr_number = _resolve_pull_request(pr_number)
resolved_hf_api_key = _resolve_hf_api_key(hf_api_key)
print(f"Running SSIM on repo: {resolved_git_repo}")
print(f"Using commit: {resolved_git_commit}")
if resolved_pr_number and resolved_pr_number != "false":
print(f"Using PR ref: {resolved_pr_number}")
if test_files.strip():
print(f"Selected test files: {test_files}")
if model_ids.strip():
print(f"Selected model ids: {model_ids}")
if pytest_k.strip():
print(f"Using pytest -k filter: {pytest_k}")
quality_tier = _resolve_output_quality_tier(full_quality)
if sync_generated_to_volume:
resolved_subdir = _resolve_generated_volume_subdir(
generated_volume_subdir,
resolved_git_commit,
)
print(
"Raw generated videos will be saved to Modal volume path: "
f"{_build_generated_volume_relative_path(generated_volume_subdir=resolved_subdir, quality_tier=quality_tier)}"
)
else:
resolved_subdir = ""
common_kwargs = dict(
git_repo=resolved_git_repo,
git_commit=resolved_git_commit,
pr_number=resolved_pr_number,
hf_api_key=resolved_hf_api_key,
test_files_csv=test_files,
model_ids_csv=model_ids,
ssim_full_quality=full_quality,
ssim_reference_repo=reference_repo,
skip_ssim_reference_download=skip_reference_download,
pytest_k=pytest_k,
sync_generated_to_volume=sync_generated_to_volume,
generated_volume_subdir=resolved_subdir,
fail_fast=not no_fail_fast,
)
futures = [
run_ssim_partition.spawn(
partition_index=i,
num_partitions=NUM_PARTITIONS,
**common_kwargs,
)
for i in range(NUM_PARTITIONS)
]
results: list[_PartitionResult | None] = [f.get() for f in futures]
exit_code = _print_combined_results(results)
if sync_generated_to_volume:
download_src = _build_generated_volume_relative_path(
generated_volume_subdir=resolved_subdir,
quality_tier=quality_tier,
)
local_download_dir = _build_local_generated_download_dir(quality_tier)
print(
"To download raw generated videos locally, run:\n"
f" modal volume get hf-model-weights {download_src} "
f"{local_download_dir}"
)
_print_local_reference_copy_command(quality_tier)
if exit_code != 0:
sys.exit(exit_code)
print("All SSIM tasks passed.")
@@ -3,8 +3,8 @@ from pathlib import Path
from huggingface_hub import snapshot_download
import subprocess
import sys
from fastvideo.tests.ssim.test_inference_similarity import compute_video_ssim_torchvision
import shutil
from fastvideo.tests.utils import compute_video_ssim_torchvision
# Import the training pipeline
sys.path.append(str(Path(__file__).parent.parent.parent.parent.parent))
@@ -181,4 +181,4 @@ def test_e2e_overfit_single_sample():
if __name__ == "__main__":
test_e2e_overfit_single_sample()
test_e2e_overfit_single_sample()
@@ -4,7 +4,7 @@ from huggingface_hub import snapshot_download
import shutil
import subprocess
import sys
from fastvideo.tests.ssim.test_inference_similarity import compute_video_ssim_torchvision
from fastvideo.tests.utils import compute_video_ssim_torchvision
# Import the training pipeline
sys.path.append(str(Path(__file__).parent.parent.parent.parent.parent))
@@ -170,4 +170,4 @@ def test_e2e_overfit_single_sample():
if __name__ == "__main__":
test_e2e_overfit_single_sample()
test_e2e_overfit_single_sample()
@@ -4,7 +4,7 @@ from huggingface_hub import snapshot_download
import shutil
import subprocess
import sys
from fastvideo.tests.ssim.test_inference_similarity import compute_video_ssim_torchvision
from fastvideo.tests.utils import compute_video_ssim_torchvision
# Import the training pipeline
sys.path.append(str(Path(__file__).parent.parent.parent.parent.parent))
@@ -175,4 +175,4 @@ def test_e2e_overfit_single_sample():
if __name__ == "__main__":
test_e2e_overfit_single_sample()
test_e2e_overfit_single_sample()
+55
View File
@@ -0,0 +1,55 @@
# SSIM Directory Guidelines
## Scope
These instructions apply to everything under `fastvideo/tests/ssim/`.
## Purpose
SSIM tests are GPU-backed end-to-end quality regression checks. They generate
videos and compare them against device-specific reference videos.
## Key Files
- `test_*.py`: SSIM regression tests by model/task.
- `conftest.py`: optional filtering via `FASTVIDEO_SSIM_MODEL_ID`.
- `reference_videos/<quality-tier>/<GPU>_reference_videos/`: local cache of
references split by quality tier and GPU type.
- `generated_videos/<quality-tier>/<GPU>_reference_videos/`: local outputs and
`*_ssim.json` artifacts (git-ignored).
- `reference_videos_cli.py`: copy/download/upload/ensure reference videos
(including HF sync).
## Run Commands
- Full SSIM suite: `pytest fastvideo/tests/ssim/ -vs`
- Full SSIM with full-quality params:
`pytest fastvideo/tests/ssim/ -vs --ssim-full-quality`
- Single test file:
`pytest fastvideo/tests/ssim/test_wan_t2v_similarity.py -vs`
- Single model split:
`FASTVIDEO_SSIM_MODEL_ID=<model_id> pytest fastvideo/tests/ssim/test_wan_t2v_similarity.py -vs`
- Modal orchestrator (CI-style scheduling):
`modal run fastvideo/tests/modal/ssim_test.py`
## Authoring Rules
- Name files `test_<feature>_similarity.py`.
- Set `REQUIRED_GPUS = <int>` near the top of each test module.
- For multi-model suites, keep configs in `*_MODEL_TO_PARAMS` dictionaries.
The Modal scheduler auto-discovers these keys and runs one subprocess per
model id.
- Keep runs deterministic when possible (fixed prompts/seeds/frames/backend).
- Persist metrics with `write_ssim_results(...)`.
- Use `pytest.skip(...)` for unsupported hardware, missing assets, or
insufficient GPU count.
## Updating Reference Videos
1. Run the target SSIM test and inspect generated outputs + scores.
2. Update references only for intentional behavior changes.
3. Copy local generated outputs into the target reference folder:
`python fastvideo/tests/ssim/reference_videos_cli.py copy-local ...`
4. Upload updated reference folders to HF:
`python fastvideo/tests/ssim/reference_videos_cli.py upload ...`
5. In the PR, explain why references changed and which GPU type generated
them.
## PR Expectations
- Include exact test commands and pass/fail evidence.
- If thresholds change, include before/after SSIM numbers and rationale.
- If references change, call out the source commit/model/backend.
@@ -1,11 +0,0 @@
{
"mean_ssim": 0.9805062346988254,
"min_ssim": 0.9636241793632507,
"max_ssim": 0.9951534271240234,
"reference_video": "/mnt/fast-disks/hao_lab/loay/FastVideo/fastvideo/tests/ssim/L40S_reference_videos/TurboWan2.2-I2V-A14B-Diffusers/SLA_ATTN/An astronaut hatching from an egg, on the surface of the moon, the darkness and depth of space reali.mp4",
"generated_video": "/mnt/fast-disks/hao_lab/loay/FastVideo/fastvideo/tests/ssim/generated_videos/TurboWan2.2-I2V-A14B-Diffusers/SLA_ATTN/An astronaut hatching from an egg, on the surface of the moon, the darkness and depth of space reali.mp4",
"parameters": {
"num_inference_steps": 4,
"prompt": "An astronaut hatching from an egg, on the surface of the moon, the darkness and depth of space realised in the background. High quality, ultrarealistic detail and breath-taking movie-like camera shot."
}
}
+47 -3
View File
@@ -1,8 +1,52 @@
The reference videos in the `*_reference_videos` directory are used as part of an e2e test to ensure consistency in video generation quality across code changes. `test_inference_similarity.py` compares newly generated videos against these references using Structural Similarity Index (SSIM) metrics to detect any regressions in visual quality across code changes.
The reference videos are used as part of e2e SSIM regression tests.
Wan inference coverage now lives in `test_wan_t2v_similarity.py` and
`test_wan_i2v_similarity.py` alongside the other model-specific SSIM files.
These tests compare newly generated videos against references to detect quality
regressions.
`A40_reference_videos` are generated on A40s and so on.
reference layout:
- `reference_videos/default/<GPU>_reference_videos/<model>/<backend>/...`
- `reference_videos/full_quality/<GPU>_reference_videos/<model>/<backend>/...`
run `bash update_reference_videos.sh` from inside the `fastvideo/tests/ssim/` directory after running `test_inference_similarity.py` to update reference videos. Note: make sure to update the path to the corresponding device.
`A40_reference_videos` are generated on A40s and so on. (legacy default layout
`<ssim_dir>/<GPU>_reference_videos/...` is still read as fallback.)
Before SSIM tests run, missing reference videos are auto-downloaded from a
public HF repo (configured by `FASTVIDEO_SSIM_REFERENCE_HF_REPO`, default:
`FastVideo/ssim-reference-videos`).
Use the CLI:
- `python fastvideo/tests/ssim/reference_videos_cli.py --help`
- `python fastvideo/tests/ssim/reference_videos_cli.py copy-local --help`
- `python fastvideo/tests/ssim/reference_videos_cli.py copy-local --quality-tier default --device-folder H200_reference_videos`
- `python fastvideo/tests/ssim/reference_videos_cli.py copy-local --quality-tier full_quality --device-folder H200_reference_videos`
- `python fastvideo/tests/ssim/reference_videos_cli.py download --help`
- `python fastvideo/tests/ssim/reference_videos_cli.py upload --help`
- `python fastvideo/tests/ssim/reference_videos_cli.py download --quality-tier all`
- `python fastvideo/tests/ssim/reference_videos_cli.py upload --quality-tier all`
- `python fastvideo/tests/ssim/reference_videos_cli.py download --quality-tier full_quality --device-folder H200_reference_videos`
- `python fastvideo/tests/ssim/reference_videos_cli.py upload --quality-tier full_quality --device-folder H200_reference_videos`
For `upload`, the tool reads HF token from `HF_API_KEY` /
`HUGGINGFACE_HUB_TOKEN` / `HF_TOKEN` and fails fast if none are set.
run `pytest fastvideo/tests/ssim/ -vs --ssim-full-quality` to use the
`*_FULL_QUALITY_PARAMS` configs (default run keeps the original shortened test
configs).
to override the HF reference repo at test time:
`pytest fastvideo/tests/ssim/ -vs --ssim-reference-repo <org/repo>`
to skip auto-download of missing refs:
`pytest fastvideo/tests/ssim/ -vs --skip-ssim-reference-download`
generated videos are written under:
- default params: `generated_videos/default/<GPU>_reference_videos/<model_id>/<ATTENTION_BACKEND>/`
- full-quality params: `generated_videos/full_quality/<GPU>_reference_videos/<model_id>/<ATTENTION_BACKEND>/`
HF repo layout mirrors quality + GPU split:
- `reference_videos/default/<GPU>_reference_videos/...`
- `reference_videos/full_quality/<GPU>_reference_videos/...`
reference videos were generated on commit `4aeabbc629e0edf91477e80e795e7bb1823c71cb`
causal videos were generated on commit b318063c0a4618f1d5d99ea82ca67a06aad0d19d
+55
View File
@@ -1,5 +1,60 @@
import os
from fastvideo.tests.ssim.reference_utils import (
FULL_QUALITY_ENV_VAR,
get_output_quality_tier,
)
from fastvideo.tests.ssim.reference_videos_cli import (
HF_REPO_ENV_KEY,
ensure_reference_videos_available,
)
def pytest_addoption(parser):
parser.addoption(
"--ssim-full-quality",
action="store_true",
default=False,
help=(
"Use *_FULL_QUALITY_PARAMS for SSIM tests. "
"Default keeps the original CI-friendly params."
),
)
parser.addoption(
"--ssim-reference-repo",
default=os.environ.get(HF_REPO_ENV_KEY, ""),
help=(
"HF repo id for SSIM reference videos "
f"(overrides {HF_REPO_ENV_KEY})."
),
)
parser.addoption(
"--skip-ssim-reference-download",
action="store_true",
default=False,
help="Skip auto-download of missing SSIM reference videos from HF.",
)
def pytest_configure(config):
if config.getoption("--ssim-full-quality"):
os.environ[FULL_QUALITY_ENV_VAR] = "1"
repo_id = config.getoption("--ssim-reference-repo")
if repo_id:
os.environ[HF_REPO_ENV_KEY] = repo_id
skip_download = config.getoption("--skip-ssim-reference-download")
skip_download = skip_download or os.environ.get(
"FASTVIDEO_SSIM_SKIP_REFERENCE_DOWNLOAD", ""
).strip().lower() in {"1", "true", "yes", "on"}
if not skip_download:
ensure_reference_videos_available(
repo_id=repo_id or None,
quality_tier=get_output_quality_tier(),
)
def pytest_collection_modifyitems(config, items):
"""Optionally keep only tests with a matching model_id parameter."""
@@ -0,0 +1,340 @@
# SPDX-License-Identifier: Apache-2.0
from __future__ import annotations
import os
from contextlib import contextmanager
from logging import Logger
from typing import Iterator
from fastvideo import VideoGenerator
from fastvideo.tests.ssim.reference_utils import (
build_generated_output_dir,
build_reference_folder_path,
get_cuda_device_name,
resolve_device_reference_folder,
select_ssim_params,
)
from fastvideo.tests.utils import compute_video_ssim_torchvision, write_ssim_results
from fastvideo.worker.multiproc_executor import MultiprocExecutor
DEVICE_MAPPINGS = (
("A40", "A40"),
("L40S", "L40S"),
("H100", "H100"),
("H200", "H200"),
)
@contextmanager
def attention_backend(backend: str) -> Iterator[None]:
previous = os.environ.get("FASTVIDEO_ATTENTION_BACKEND")
os.environ["FASTVIDEO_ATTENTION_BACKEND"] = backend
try:
yield
finally:
if previous is None:
os.environ.pop("FASTVIDEO_ATTENTION_BACKEND", None)
else:
os.environ["FASTVIDEO_ATTENTION_BACKEND"] = previous
def shutdown_executor(generator: VideoGenerator | None) -> None:
if generator is None:
return
if isinstance(generator.executor, MultiprocExecutor):
generator.executor.shutdown()
def resolve_inference_device_reference_folder(logger: Logger) -> str:
device_name = get_cuda_device_name()
device_reference_folder = resolve_device_reference_folder(
DEVICE_MAPPINGS,
device_name=device_name,
logger=logger,
)
if device_reference_folder is None:
raise ValueError(f"Unsupported device for ssim tests: {device_name}")
return device_reference_folder
def _find_reference_video(reference_folder: str, prompt: str) -> str:
prompt_prefix = prompt[:100].strip()
for filename in os.listdir(reference_folder):
if filename.endswith(".mp4") and prompt_prefix in filename:
return os.path.join(reference_folder, filename)
raise FileNotFoundError("Reference video missing")
def _assert_similarity(
*,
logger: Logger,
output_dir: str,
output_video_name: str,
reference_folder: str,
prompt: str,
num_inference_steps: int,
min_acceptable_ssim: float,
model_id: str,
attention_backend_name: str,
) -> None:
if not os.path.exists(reference_folder):
logger.error("Reference folder missing: %s", reference_folder)
error_msg = (
f"Reference video folder does not exist: {reference_folder}\n"
f"To download reference videos, run:\n"
f" python fastvideo/tests/ssim/reference_videos_cli.py download"
)
raise FileNotFoundError(error_msg)
try:
reference_video_path = _find_reference_video(reference_folder, prompt)
except FileNotFoundError as error:
logger.error(
"Reference video not found for prompt: %s with backend: %s",
prompt,
attention_backend_name,
)
raise error
generated_video_path = os.path.join(output_dir, output_video_name)
logger.info(
"Computing SSIM between %s and %s",
reference_video_path,
generated_video_path,
)
ssim_values = compute_video_ssim_torchvision(
reference_video_path,
generated_video_path,
use_ms_ssim=True,
)
mean_ssim = ssim_values[0]
logger.info("SSIM mean value: %s", mean_ssim)
logger.info("Writing SSIM results to directory: %s", output_dir)
success = write_ssim_results(
output_dir,
ssim_values,
reference_video_path,
generated_video_path,
num_inference_steps,
prompt,
)
if not success:
logger.error("Failed to write SSIM results to file")
assert mean_ssim >= min_acceptable_ssim, (
f"SSIM value {mean_ssim} is below threshold {min_acceptable_ssim} "
f"for {model_id} with backend {attention_backend_name}"
)
def _build_init_kwargs(
base_params: dict[str, object],
) -> dict[str, object]:
init_kwargs: dict[str, object] = {
"num_gpus": base_params["num_gpus"],
"sp_size": base_params.get("sp_size", 1),
"tp_size": base_params.get("tp_size", 1),
"use_fsdp_inference": True,
"dit_cpu_offload": False,
"dit_layerwise_offload": False,
}
if "flow_shift" in base_params:
init_kwargs["flow_shift"] = base_params["flow_shift"]
if base_params.get("vae_sp"):
init_kwargs["vae_sp"] = True
init_kwargs["vae_tiling"] = True
if "text-encoder-precision" in base_params:
init_kwargs["text_encoder_precisions"] = base_params["text-encoder-precision"]
if base_params.get("ltx2_vae_tiling"):
init_kwargs["ltx2_vae_tiling"] = True
init_kwargs["ltx2_vae_spatial_tile_size_in_pixels"] = base_params.get(
"ltx2_vae_spatial_tile_size_in_pixels", 512
)
init_kwargs["ltx2_vae_spatial_tile_overlap_in_pixels"] = base_params.get(
"ltx2_vae_spatial_tile_overlap_in_pixels", 64
)
init_kwargs["ltx2_vae_temporal_tile_size_in_frames"] = base_params.get(
"ltx2_vae_temporal_tile_size_in_frames", 64
)
init_kwargs["ltx2_vae_temporal_tile_overlap_in_frames"] = base_params.get(
"ltx2_vae_temporal_tile_overlap_in_frames", 24
)
return init_kwargs
def _build_generation_kwargs(
base_params: dict[str, object],
num_inference_steps: int,
output_dir: str,
) -> dict[str, object]:
generation_kwargs: dict[str, object] = {
"num_inference_steps": num_inference_steps,
"output_path": output_dir,
"height": base_params["height"],
"width": base_params["width"],
"num_frames": base_params["num_frames"],
"seed": base_params["seed"],
}
if "guidance_scale" in base_params:
generation_kwargs["guidance_scale"] = base_params["guidance_scale"]
if "embedded_cfg_scale" in base_params:
generation_kwargs["embedded_cfg_scale"] = base_params["embedded_cfg_scale"]
if "fps" in base_params:
generation_kwargs["fps"] = base_params["fps"]
if "neg_prompt" in base_params:
generation_kwargs["neg_prompt"] = base_params["neg_prompt"]
return generation_kwargs
def run_text_to_video_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]],
min_acceptable_ssim: float,
init_kwargs_override: dict[str, object] | None = None,
generation_kwargs_override: dict[str, object] | None = None,
) -> None:
with attention_backend(attention_backend_name):
output_dir = build_generated_output_dir(
script_dir,
device_reference_folder,
model_id,
attention_backend_name,
)
output_video_name = f"{prompt[:100].strip()}.mp4"
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)
generation_kwargs = _build_generation_kwargs(
base_params,
num_inference_steps,
output_dir,
)
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,
)
generator.generate_video(prompt, **generation_kwargs)
finally:
shutdown_executor(generator)
assert os.path.exists(output_dir), f"Output video was not generated at {output_dir}"
reference_folder = build_reference_folder_path(
script_dir,
device_reference_folder,
model_id,
attention_backend_name,
)
_assert_similarity(
logger=logger,
output_dir=output_dir,
output_video_name=output_video_name,
reference_folder=reference_folder,
prompt=prompt,
num_inference_steps=num_inference_steps,
min_acceptable_ssim=min_acceptable_ssim,
model_id=model_id,
attention_backend_name=attention_backend_name,
)
def run_image_to_video_similarity_test(
*,
logger: Logger,
script_dir: str,
device_reference_folder: str,
prompt: str,
image_path: 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]],
min_acceptable_ssim: float,
init_kwargs_override: dict[str, object] | None = None,
generation_kwargs_override: dict[str, object] | None = None,
) -> None:
with attention_backend(attention_backend_name):
output_dir = build_generated_output_dir(
script_dir,
device_reference_folder,
model_id,
attention_backend_name,
)
output_video_name = f"{prompt[:100].strip()}.mp4"
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)
generation_kwargs = _build_generation_kwargs(
base_params,
num_inference_steps,
output_dir,
)
generation_kwargs["image_path"] = image_path
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,
)
generator.generate_video(prompt, **generation_kwargs)
finally:
shutdown_executor(generator)
assert os.path.exists(output_dir), f"Output video was not generated at {output_dir}"
reference_folder = build_reference_folder_path(
script_dir,
device_reference_folder,
model_id,
attention_backend_name,
)
_assert_similarity(
logger=logger,
output_dir=output_dir,
output_video_name=output_video_name,
reference_folder=reference_folder,
prompt=prompt,
num_inference_steps=num_inference_steps,
min_acceptable_ssim=min_acceptable_ssim,
model_id=model_id,
attention_backend_name=attention_backend_name,
)
+130
View File
@@ -0,0 +1,130 @@
# SPDX-License-Identifier: Apache-2.0
from __future__ import annotations
import os
from collections.abc import Sequence
from logging import Logger
from typing import TypeVar
import torch
DEVICE_REFERENCE_FOLDER_SUFFIX = "_reference_videos"
FULL_QUALITY_ENV_VAR = "FASTVIDEO_SSIM_FULL_QUALITY"
DEFAULT_OUTPUT_QUALITY_TIER = "default"
FULL_OUTPUT_QUALITY_TIER = "full_quality"
REFERENCE_VIDEOS_DIRNAME = "reference_videos"
DeviceMapping = tuple[str, str]
T = TypeVar("T")
def build_reference_folder_name(
device_prefix: str,
suffix: str = DEVICE_REFERENCE_FOLDER_SUFFIX,
) -> str:
return f"{device_prefix}{suffix}"
def get_cuda_device_name(index: int = 0) -> str:
if not torch.cuda.is_available():
return "CPU"
return torch.cuda.get_device_name(index)
def resolve_device_reference_folder(
device_mappings: Sequence[DeviceMapping],
*,
device_name: str | None = None,
fallback_device_prefix: str | None = None,
unknown_device_prefix: str | None = None,
suffix: str = DEVICE_REFERENCE_FOLDER_SUFFIX,
logger: Logger | None = None,
) -> str | None:
resolved_device_name = device_name or get_cuda_device_name()
for device_pattern, device_prefix in device_mappings:
if device_pattern in resolved_device_name:
return build_reference_folder_name(device_prefix, suffix)
if logger is not None:
if fallback_device_prefix is None:
logger.warning(
"Unsupported device for ssim tests: %s",
resolved_device_name,
)
else:
logger.warning(
"Unsupported device for ssim tests: %s; using %s references",
resolved_device_name,
fallback_device_prefix,
)
if unknown_device_prefix is not None:
return build_reference_folder_name(unknown_device_prefix, suffix)
if fallback_device_prefix is not None:
return build_reference_folder_name(fallback_device_prefix, suffix)
return None
def build_reference_folder_path(
script_dir: str,
device_reference_folder: str,
model_id: str,
attention_backend: str,
) -> str:
quality_tier = get_output_quality_tier()
tiered_reference_folder = os.path.join(
script_dir,
REFERENCE_VIDEOS_DIRNAME,
quality_tier,
device_reference_folder,
model_id,
attention_backend,
)
if os.path.exists(tiered_reference_folder):
return tiered_reference_folder
legacy_reference_folder = os.path.join(
script_dir,
device_reference_folder,
model_id,
attention_backend,
)
if quality_tier == DEFAULT_OUTPUT_QUALITY_TIER and os.path.exists(
legacy_reference_folder
):
return legacy_reference_folder
return tiered_reference_folder
def use_full_quality_configs() -> bool:
value = os.environ.get(FULL_QUALITY_ENV_VAR, "").strip().lower()
return value in {"1", "true", "yes", "on"}
def get_output_quality_tier() -> str:
if use_full_quality_configs():
return FULL_OUTPUT_QUALITY_TIER
return DEFAULT_OUTPUT_QUALITY_TIER
def build_generated_output_dir(
script_dir: str,
device_reference_folder: str,
model_id: str,
attention_backend: str,
) -> str:
return os.path.join(
script_dir,
"generated_videos",
get_output_quality_tier(),
device_reference_folder,
model_id,
attention_backend,
)
def select_ssim_params(default_params: T, full_quality_params: T) -> T:
if use_full_quality_configs():
return full_quality_params
return default_params
@@ -0,0 +1,637 @@
# SPDX-License-Identifier: Apache-2.0
from __future__ import annotations
import argparse
import contextlib
import os
import shutil
import sys
import tempfile
from collections.abc import Iterable, Sequence
from pathlib import Path
VIDEO_EXTENSIONS = (".mp4", ".avi", ".mov", ".mkv", ".webm", ".flv")
HF_TOKEN_ENV_KEYS = ("HF_API_KEY", "HUGGINGFACE_HUB_TOKEN", "HF_TOKEN")
HF_REPO_ENV_KEY = "FASTVIDEO_SSIM_REFERENCE_HF_REPO"
HF_REPO_TYPE_ENV_KEY = "FASTVIDEO_SSIM_REFERENCE_HF_REPO_TYPE"
DEFAULT_REPO_ID = "FastVideo/ssim-reference-videos"
DEFAULT_REPO_TYPE = "dataset"
DEFAULT_OUTPUT_QUALITY_TIER = "default"
FULL_OUTPUT_QUALITY_TIER = "full_quality"
ALL_OUTPUT_QUALITY_TIERS = "all"
REFERENCE_VIDEOS_DIRNAME = "reference_videos"
DEFAULT_DEVICE_REFERENCE_FOLDER = "L40S_reference_videos"
QUALITY_TIERS = (
DEFAULT_OUTPUT_QUALITY_TIER,
FULL_OUTPUT_QUALITY_TIER,
)
def _ssim_dir() -> Path:
return Path(__file__).resolve().parent
def _default_repo_id() -> str:
return os.environ.get(HF_REPO_ENV_KEY, DEFAULT_REPO_ID)
def _default_repo_type() -> str:
return os.environ.get(HF_REPO_TYPE_ENV_KEY, DEFAULT_REPO_TYPE)
def _iter_video_files(root: Path) -> Iterable[Path]:
for path in root.rglob("*"):
if path.is_file() and path.suffix.lower() in VIDEO_EXTENSIONS:
yield path
def _discover_reference_dirs(base_dir: Path) -> list[Path]:
if not base_dir.exists():
return []
dirs = [p for p in base_dir.iterdir() if p.is_dir() and p.name.endswith("_reference_videos")]
return sorted(dirs)
@contextlib.contextmanager
def _exclusive_download_lock(lock_path: Path):
"""Best-effort cross-process lock for reference video download/copy."""
lock_path.parent.mkdir(parents=True, exist_ok=True)
with lock_path.open("a", encoding="utf-8") as lock_file:
if os.name == "posix":
import fcntl
fcntl.flock(lock_file.fileno(), fcntl.LOCK_EX)
try:
yield
finally:
if os.name == "posix":
import fcntl
fcntl.flock(lock_file.fileno(), fcntl.LOCK_UN)
def _merge_downloaded_reference_content(
staging_dir: Path,
local_dir: Path,
) -> int:
copied_roots = 0
tier_root = staging_dir / REFERENCE_VIDEOS_DIRNAME
if tier_root.exists():
dst_tier_root = local_dir / REFERENCE_VIDEOS_DIRNAME
shutil.copytree(tier_root, dst_tier_root, dirs_exist_ok=True)
copied_roots += 1
# Legacy default layout support: <staging>/<GPU>_reference_videos/...
legacy_reference_dirs = _discover_reference_dirs(staging_dir)
for legacy_dir in legacy_reference_dirs:
dst_legacy_dir = local_dir / legacy_dir.name
shutil.copytree(legacy_dir, dst_legacy_dir, dirs_exist_ok=True)
copied_roots += 1
return copied_roots
def _resolve_device_reference_folder(
*,
explicit_device_folder: str | None,
reference_dir: Path | None,
) -> str:
if explicit_device_folder and explicit_device_folder.strip():
return explicit_device_folder.strip()
if reference_dir is not None and reference_dir.name.endswith("_reference_videos"):
return reference_dir.name
return DEFAULT_DEVICE_REFERENCE_FOLDER
def _reference_tier_root(base_dir: Path, quality_tier: str) -> Path:
return base_dir / REFERENCE_VIDEOS_DIRNAME / quality_tier
def _resolve_quality_tiers(quality_tier: str) -> list[str]:
if quality_tier == ALL_OUTPUT_QUALITY_TIERS:
return list(QUALITY_TIERS)
if quality_tier not in QUALITY_TIERS:
raise ValueError(f"Unsupported quality tier: {quality_tier}")
return [quality_tier]
def _discover_reference_dirs_for_tier(
base_dir: Path,
quality_tier: str,
) -> list[Path]:
tier_root = _reference_tier_root(base_dir, quality_tier)
tier_dirs = _discover_reference_dirs(tier_root)
if tier_dirs:
return tier_dirs
if quality_tier == DEFAULT_OUTPUT_QUALITY_TIER:
# Keep supporting legacy default refs at <ssim_dir>/<GPU>_reference_videos.
return _discover_reference_dirs(base_dir)
return []
def _has_local_reference_videos(base_dir: Path, quality_tier: str) -> bool:
# Check for completion marker file first
marker_path = base_dir / REFERENCE_VIDEOS_DIRNAME / quality_tier / f".download_complete_{quality_tier}"
if not marker_path.exists():
return False
# Also verify at least one .mp4 exists
for ref_dir in _discover_reference_dirs_for_tier(base_dir, quality_tier):
for _ in _iter_video_files(ref_dir):
return True
return False
def _get_hf_token() -> str | None:
for key in HF_TOKEN_ENV_KEYS:
value = os.environ.get(key, "").strip()
if value:
return value
return None
def _load_hf_sdk():
try:
from huggingface_hub import HfApi, snapshot_download
except ImportError as exc:
raise RuntimeError(
"huggingface_hub is required for download/upload.\nInstall with: pip install huggingface_hub"
) from exc
return HfApi, snapshot_download
def copy_generated_to_reference(
generated_dir: Path,
reference_dir: Path,
*,
dry_run: bool = False,
) -> int:
if not generated_dir.exists():
raise FileNotFoundError(f"Generated directory not found: {generated_dir}")
copied = 0
for video_file in _iter_video_files(generated_dir):
rel = video_file.relative_to(generated_dir)
dst = reference_dir / rel
if dry_run:
print(f"[dry-run] {video_file} -> {dst}")
else:
dst.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(video_file, dst)
print(f"Copied: {rel}")
copied += 1
return copied
def download_reference_videos(
*,
repo_id: str,
repo_type: str,
local_dir: Path,
quality_tiers: Sequence[str] | None = None,
device_folders: Sequence[str] | None = None,
token: str | None = None,
) -> None:
_, snapshot_download = _load_hf_sdk()
local_dir.mkdir(parents=True, exist_ok=True)
tiers = tuple(quality_tiers or QUALITY_TIERS)
for quality_tier in tiers:
if quality_tier not in QUALITY_TIERS:
raise ValueError(f"Unsupported quality tier: {quality_tier}")
allow_patterns: list[str] = []
for quality_tier in tiers:
tier_prefix = f"{REFERENCE_VIDEOS_DIRNAME}/{quality_tier}"
if device_folders:
allow_patterns.extend(f"{tier_prefix}/{folder}/**" for folder in device_folders)
else:
allow_patterns.append(f"{tier_prefix}/*_reference_videos/**")
# Keep compatibility for older HF layout where default refs lived at
# repo root as <GPU>_reference_videos.
if quality_tier == DEFAULT_OUTPUT_QUALITY_TIER:
if device_folders:
allow_patterns.extend(f"{folder}/**" for folder in device_folders)
else:
allow_patterns.append("*_reference_videos/**")
# Work around long-path issues in some huggingface_hub versions:
# avoid `local_dir` mode and download into a short cache dir instead,
# then merge files into local_dir.
with tempfile.TemporaryDirectory(prefix="fv2-ssim-cache-") as tmp_cache_dir:
snapshot_path = snapshot_download(
repo_id=repo_id,
repo_type=repo_type,
cache_dir=tmp_cache_dir,
token=token,
allow_patterns=allow_patterns,
)
staging_dir = Path(snapshot_path)
copied_roots = _merge_downloaded_reference_content(
staging_dir=staging_dir,
local_dir=local_dir,
)
if copied_roots == 0:
raise RuntimeError(
"HF download completed but no reference_videos content was found in downloaded artifacts."
)
for quality_tier in tiers:
marker_path = local_dir / REFERENCE_VIDEOS_DIRNAME / quality_tier / f".download_complete_{quality_tier}"
marker_path.parent.mkdir(parents=True, exist_ok=True)
marker_path.touch()
def upload_reference_videos(
*,
repo_id: str,
repo_type: str,
reference_dirs_by_tier: Sequence[tuple[str, Path]],
token: str,
private: bool,
) -> None:
HfApi, _ = _load_hf_sdk()
api = HfApi(token=token)
api.create_repo(
repo_id=repo_id,
repo_type=repo_type,
private=private,
exist_ok=True,
)
for quality_tier, reference_dir in reference_dirs_by_tier:
if not reference_dir.exists():
raise FileNotFoundError(f"Reference directory not found: {reference_dir}")
path_in_repo = f"{REFERENCE_VIDEOS_DIRNAME}/{quality_tier}/{reference_dir.name}"
print(f"Uploading {reference_dir.name} ({quality_tier}) to {repo_id} ...")
api.upload_folder(
repo_id=repo_id,
repo_type=repo_type,
folder_path=str(reference_dir),
path_in_repo=path_in_repo,
token=token,
)
def _resolve_upload_reference_dirs(
*,
base_dir: Path,
quality_tier: str,
explicit_reference_dirs: Sequence[str],
device_folders: Sequence[str] | None = None,
) -> list[tuple[str, Path]]:
tiers = _resolve_quality_tiers(quality_tier)
selected_device_folders = set(device_folders or [])
if explicit_reference_dirs:
if len(tiers) != 1:
raise RuntimeError("--reference-dir requires a single --quality-tier (default or full_quality).")
resolved_tier = tiers[0]
return [(resolved_tier, Path(p).resolve()) for p in explicit_reference_dirs]
resolved: list[tuple[str, Path]] = []
for tier in tiers:
for reference_dir in _discover_reference_dirs_for_tier(base_dir, tier):
if selected_device_folders and reference_dir.name not in selected_device_folders:
continue
resolved.append((tier, reference_dir.resolve()))
return resolved
def ensure_reference_videos_available(
*,
local_dir: Path | None = None,
repo_id: str | None = None,
repo_type: str | None = None,
quality_tier: str = DEFAULT_OUTPUT_QUALITY_TIER,
) -> bool:
"""Return True if downloaded from HF, False if already present locally."""
if quality_tier not in QUALITY_TIERS:
raise ValueError(f"Unsupported quality tier: {quality_tier}")
target_dir = local_dir or _ssim_dir()
lock_path = target_dir / ".reference_videos_download.lock"
with _exclusive_download_lock(lock_path):
if _has_local_reference_videos(target_dir, quality_tier):
print(f"Reference videos ({quality_tier}) already available at {target_dir}")
return False
resolved_repo_id = repo_id or _default_repo_id()
resolved_repo_type = repo_type or _default_repo_type()
if not resolved_repo_id:
raise RuntimeError(
f"No local reference videos found and no HF repo configured.\nSet {HF_REPO_ENV_KEY} or pass --repo-id."
)
print(f"Repo ID: {resolved_repo_id}")
print(f"Quality tier: {quality_tier}")
print(f"No local {quality_tier} reference videos found under {target_dir}. Starting download...")
try:
download_reference_videos(
repo_id=resolved_repo_id,
repo_type=resolved_repo_type,
local_dir=target_dir,
quality_tiers=[quality_tier],
)
print(f"Download completed for {quality_tier} reference videos.")
except Exception as exc:
print(f"ERROR: Failed to download {quality_tier} reference videos from {resolved_repo_id}.")
print(
f"Suggested command to retry: "
f"python fastvideo/tests/ssim/reference_videos_cli.py download "
f"--quality-tier {quality_tier}"
)
raise
if not _has_local_reference_videos(target_dir, quality_tier):
raise RuntimeError(f"HF download completed but no {quality_tier} *_reference_videos content found.")
return True
def _build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
prog="reference_videos_cli",
description=(
"Manage SSIM reference videos locally and on Hugging Face.\n"
"This tool can copy generated videos into reference folders,\n"
"download references from a public HF repo, and upload updates."
),
epilog=(
"Examples:\n"
" # Copy default generated videos into local L40S refs\n"
" python fastvideo/tests/ssim/reference_videos_cli.py copy-local \\\n"
" --quality-tier default \\\n"
" --device-folder L40S_reference_videos \\\n"
" --reference-dir fastvideo/tests/ssim/reference_videos/default/L40S_reference_videos\n\n"
" # Download both default and full-quality references from HF\n"
" python fastvideo/tests/ssim/reference_videos_cli.py download \\\n"
" --repo-id FastVideo/ssim-reference-videos\n\n"
" # Upload both tiers and all GPU folders (fails if token is unset)\n"
" python fastvideo/tests/ssim/reference_videos_cli.py upload \\\n"
" --repo-id FastVideo/ssim-reference-videos"
),
formatter_class=argparse.RawTextHelpFormatter,
)
subparsers = parser.add_subparsers(dest="command", required=True)
copy_parser = subparsers.add_parser(
"copy-local",
help="Copy generated videos into a local reference folder.",
)
copy_parser.add_argument(
"--quality-tier",
choices=(DEFAULT_OUTPUT_QUALITY_TIER, FULL_OUTPUT_QUALITY_TIER),
default=DEFAULT_OUTPUT_QUALITY_TIER,
help="Generated quality tier to copy from.",
)
copy_parser.add_argument(
"--generated-dir",
type=Path,
default=None,
help=("Source generated directory. Default: <ssim_dir>/generated_videos/<quality-tier>/<device-folder>"),
)
copy_parser.add_argument(
"--device-folder",
default=None,
help=("GPU folder name (e.g., H200_reference_videos) used to build default generated/reference paths."),
)
copy_parser.add_argument(
"--reference-dir",
type=Path,
default=None,
help="Destination reference directory.",
)
copy_parser.add_argument(
"--dry-run",
action="store_true",
help="Print copy operations without writing files.",
)
download_parser = subparsers.add_parser(
"download",
help="Download reference videos from Hugging Face.",
)
download_parser.add_argument(
"--repo-id",
default=_default_repo_id(),
help=f"HF repo id. Default: env {HF_REPO_ENV_KEY} or {DEFAULT_REPO_ID}",
)
download_parser.add_argument(
"--repo-type",
default=_default_repo_type(),
help=f"HF repo type. Default: env {HF_REPO_TYPE_ENV_KEY} or {DEFAULT_REPO_TYPE}",
)
download_parser.add_argument(
"--local-dir",
type=Path,
default=_ssim_dir(),
help=(
"Local SSIM directory where references are stored under "
"reference_videos/<quality-tier>/<GPU>_reference_videos."
),
)
download_parser.add_argument(
"--quality-tier",
choices=(
DEFAULT_OUTPUT_QUALITY_TIER,
FULL_OUTPUT_QUALITY_TIER,
ALL_OUTPUT_QUALITY_TIERS,
),
default=ALL_OUTPUT_QUALITY_TIERS,
help="Quality tier to download (default: all).",
)
download_parser.add_argument(
"--device-folder",
action="append",
default=[],
help="Specific reference folder to fetch (repeatable), e.g. L40S_reference_videos",
)
upload_parser = subparsers.add_parser(
"upload",
help="Upload local reference folders to Hugging Face.",
)
upload_parser.add_argument(
"--repo-id",
default=_default_repo_id(),
help=f"HF repo id. Default: env {HF_REPO_ENV_KEY} or {DEFAULT_REPO_ID}",
)
upload_parser.add_argument(
"--repo-type",
default=_default_repo_type(),
help=f"HF repo type. Default: env {HF_REPO_TYPE_ENV_KEY} or {DEFAULT_REPO_TYPE}",
)
upload_parser.add_argument(
"--reference-dir",
action="append",
default=[],
help=(
"Reference directory to upload (repeatable). "
"Requires single --quality-tier. "
"Default: discover folders under --base-dir for chosen tier(s)."
),
)
upload_parser.add_argument(
"--quality-tier",
choices=(
DEFAULT_OUTPUT_QUALITY_TIER,
FULL_OUTPUT_QUALITY_TIER,
ALL_OUTPUT_QUALITY_TIERS,
),
default=ALL_OUTPUT_QUALITY_TIERS,
help="Quality tier to upload (default: all).",
)
upload_parser.add_argument(
"--device-folder",
action="append",
default=[],
help=("Specific GPU reference folder to upload (repeatable), e.g. H200_reference_videos."),
)
upload_parser.add_argument(
"--base-dir",
type=Path,
default=_ssim_dir(),
help=("Base SSIM directory that contains reference_videos/<quality-tier>/<GPU>_reference_videos."),
)
upload_parser.add_argument(
"--private",
action="store_true",
help="Create/use a private repo instead of public.",
)
ensure_parser = subparsers.add_parser(
"ensure",
help="Download refs only when the selected local quality tier is missing.",
)
ensure_parser.add_argument(
"--repo-id",
default=_default_repo_id(),
help=f"HF repo id. Default: env {HF_REPO_ENV_KEY} or {DEFAULT_REPO_ID}",
)
ensure_parser.add_argument(
"--repo-type",
default=_default_repo_type(),
help=f"HF repo type. Default: env {HF_REPO_TYPE_ENV_KEY} or {DEFAULT_REPO_TYPE}",
)
ensure_parser.add_argument(
"--local-dir",
type=Path,
default=_ssim_dir(),
help=("Local SSIM directory that should contain reference_videos/<quality-tier>/<GPU>_reference_videos."),
)
ensure_parser.add_argument(
"--quality-tier",
choices=(
DEFAULT_OUTPUT_QUALITY_TIER,
FULL_OUTPUT_QUALITY_TIER,
),
default=DEFAULT_OUTPUT_QUALITY_TIER,
help="Quality tier to ensure locally.",
)
return parser
def main(argv: Sequence[str] | None = None) -> int:
parser = _build_parser()
args = parser.parse_args(argv)
if args.command == "copy-local":
device_folder = _resolve_device_reference_folder(
explicit_device_folder=args.device_folder,
reference_dir=args.reference_dir,
)
generated_dir = args.generated_dir
if generated_dir is None:
tier_root = _ssim_dir() / "generated_videos" / args.quality_tier
tier_device_dir = tier_root / device_folder
if tier_device_dir.exists():
generated_dir = tier_device_dir
else:
# Backward compatibility for pre-device-folder layout.
generated_dir = tier_root
reference_dir = (
args.reference_dir
if args.reference_dir is not None
else (_ssim_dir() / REFERENCE_VIDEOS_DIRNAME / args.quality_tier / device_folder)
)
copied = copy_generated_to_reference(
generated_dir=generated_dir,
reference_dir=reference_dir,
dry_run=args.dry_run,
)
print(f"Done. {'Would copy' if args.dry_run else 'Copied'} {copied} video files.")
if not args.dry_run and copied > 0:
marker = (
_ssim_dir() / REFERENCE_VIDEOS_DIRNAME / args.quality_tier / f".download_complete_{args.quality_tier}"
)
marker.parent.mkdir(parents=True, exist_ok=True)
marker.touch()
return 0
if args.command == "download":
quality_tiers = _resolve_quality_tiers(args.quality_tier)
download_reference_videos(
repo_id=args.repo_id,
repo_type=args.repo_type,
local_dir=args.local_dir,
quality_tiers=quality_tiers,
device_folders=args.device_folder or None,
)
print("Download complete for quality tier(s): " + ", ".join(quality_tiers) + ".")
return 0
if args.command == "upload":
token = _get_hf_token()
if token is None:
raise RuntimeError(
"Hugging Face API key is required for upload. Set HF_API_KEY, HUGGINGFACE_HUB_TOKEN, or HF_TOKEN."
)
reference_dirs_by_tier = _resolve_upload_reference_dirs(
base_dir=args.base_dir.resolve(),
quality_tier=args.quality_tier,
explicit_reference_dirs=args.reference_dir,
device_folders=args.device_folder or None,
)
if not reference_dirs_by_tier:
raise RuntimeError(
f"No *_reference_videos directories found for the selected quality tier(s) under {args.base_dir}"
)
upload_reference_videos(
repo_id=args.repo_id,
repo_type=args.repo_type,
reference_dirs_by_tier=reference_dirs_by_tier,
token=token,
private=args.private,
)
print("Upload complete.")
return 0
if args.command == "ensure":
downloaded = ensure_reference_videos_available(
local_dir=args.local_dir,
repo_id=args.repo_id,
repo_type=args.repo_type,
quality_tier=args.quality_tier,
)
if downloaded:
print(f"{args.quality_tier} reference videos were missing and have been downloaded.")
else:
print(f"{args.quality_tier} reference videos already exist locally.")
return 0
parser.error(f"Unknown command: {args.command}")
return 2
if __name__ == "__main__":
try:
raise SystemExit(main())
except KeyboardInterrupt:
print("Interrupted.", file=sys.stderr)
raise SystemExit(130)
+45 -121
View File
@@ -1,32 +1,22 @@
# SPDX-License-Identifier: Apache-2.0
import json
import os
import torch
import pytest
from fastvideo import VideoGenerator
from fastvideo.configs.sample.wan import (
SelfForcingWan2_1_T2V_1_3B_480P_SamplingParam,
)
from fastvideo.logger import init_logger
from fastvideo.tests.utils import compute_video_ssim_torchvision, write_ssim_results
from fastvideo.worker.multiproc_executor import MultiprocExecutor
from fastvideo.tests.ssim.inference_similarity_utils import (
resolve_inference_device_reference_folder,
run_text_to_video_similarity_test,
)
logger = init_logger(__name__)
REQUIRED_GPUS = 1
device_name = torch.cuda.get_device_name()
device_reference_folder_suffix = '_reference_videos'
if "A40" in device_name:
device_reference_folder = "A40" + device_reference_folder_suffix
elif "L40S" in device_name:
device_reference_folder = "L40S" + device_reference_folder_suffix
else:
# device_reference_folder = "L40S" + device_reference_folder_suffix
logger.warning(f"Unsupported device for ssim tests: {device_name}")
# raise ValueError(f"Unsupported device for ssim tests: {device_name}")
# Base parameters from the shell script
device_reference_folder = resolve_inference_device_reference_folder(logger)
SF_WAN_T2V_PARAMS = {
"num_gpus": 1,
@@ -40,119 +30,53 @@ SF_WAN_T2V_PARAMS = {
"tp_size": 1,
}
_SF_WAN_T2V_FULL_QUALITY_DEFAULTS = SelfForcingWan2_1_T2V_1_3B_480P_SamplingParam()
SF_WAN_T2V_FULL_QUALITY_PARAMS = {
"num_gpus": SF_WAN_T2V_PARAMS["num_gpus"],
"model_path": SF_WAN_T2V_PARAMS["model_path"],
"height": _SF_WAN_T2V_FULL_QUALITY_DEFAULTS.height,
"width": _SF_WAN_T2V_FULL_QUALITY_DEFAULTS.width,
"num_frames": SF_WAN_T2V_PARAMS["num_frames"],
"num_inference_steps": (_SF_WAN_T2V_FULL_QUALITY_DEFAULTS.num_inference_steps),
"guidance_scale": (_SF_WAN_T2V_FULL_QUALITY_DEFAULTS.guidance_scale),
"seed": _SF_WAN_T2V_FULL_QUALITY_DEFAULTS.seed,
"sp_size": SF_WAN_T2V_PARAMS["sp_size"],
"tp_size": SF_WAN_T2V_PARAMS["tp_size"],
"neg_prompt": (_SF_WAN_T2V_FULL_QUALITY_DEFAULTS.negative_prompt),
}
MODEL_TO_PARAMS = {
"SFWan2.1-T2V-1.3B-Diffusers": SF_WAN_T2V_PARAMS,
}
I2V_MODEL_TO_PARAMS = {
FULL_QUALITY_MODEL_TO_PARAMS = {
"SFWan2.1-T2V-1.3B-Diffusers": SF_WAN_T2V_FULL_QUALITY_PARAMS,
}
TEST_PROMPTS = [
"Will Smith casually eats noodles, his relaxed demeanor contrasting with the energetic background of a bustling street food market. The scene captures a mix of humor and authenticity. Mid-shot framing, vibrant lighting.",
# "A lone hiker stands atop a towering cliff, silhouetted against the vast horizon. The rugged landscape stretches endlessly beneath, its earthy tones blending into the soft blues of the sky. The scene captures the spirit of exploration and human resilience. High angle, dynamic framing, with soft natural lighting emphasizing the grandeur of nature."
"Will Smith casually eats noodles, his relaxed demeanor contrasting "
"with the energetic background of a bustling street food market. "
"The scene captures a mix of humor and authenticity. Mid-shot "
"framing, vibrant lighting.",
]
I2V_TEST_PROMPTS = [
"An astronaut hatching from an egg, on the surface of the moon, the darkness and depth of space realised in the background. High quality, ultrarealistic detail and breath-taking movie-like camera shot.",
]
I2V_IMAGE_PATHS = [
"https://huggingface.co/datasets/huggingface/documentation-images/resolve/main/diffusers/astronaut.jpg",
]
@pytest.mark.parametrize("prompt", TEST_PROMPTS)
@pytest.mark.parametrize("ATTENTION_BACKEND", ["FLASH_ATTN"])
@pytest.mark.parametrize("attention_backend_name", ["FLASH_ATTN"])
@pytest.mark.parametrize("model_id", list(MODEL_TO_PARAMS.keys()))
def test_causal_similarity(prompt, ATTENTION_BACKEND, model_id):
"""
Test that runs inference with different parameters and compares the output
to reference videos using SSIM.
"""
os.environ["FASTVIDEO_ATTENTION_BACKEND"] = ATTENTION_BACKEND
script_dir = os.path.dirname(os.path.abspath(__file__))
base_output_dir = os.path.join(script_dir, 'generated_videos', model_id)
output_dir = os.path.join(base_output_dir, ATTENTION_BACKEND)
output_video_name = f"{prompt[:100].strip()}.mp4"
os.makedirs(output_dir, exist_ok=True)
BASE_PARAMS = MODEL_TO_PARAMS[model_id]
num_inference_steps = BASE_PARAMS["num_inference_steps"]
init_kwargs = {
"num_gpus": BASE_PARAMS["num_gpus"],
"sp_size": BASE_PARAMS["sp_size"],
"tp_size": BASE_PARAMS["tp_size"],
"dit_cpu_offload": True,
}
if BASE_PARAMS.get("vae_sp"):
init_kwargs["vae_sp"] = True
init_kwargs["vae_tiling"] = True
#if "text-encoder-precision" in BASE_PARAMS:
# init_kwargs["text_encoder_precisions"] = BASE_PARAMS["text-encoder-precision"]
generation_kwargs = {
"num_inference_steps": num_inference_steps,
"output_path": output_dir,
"height": BASE_PARAMS["height"],
"width": BASE_PARAMS["width"],
"num_frames": BASE_PARAMS["num_frames"],
"seed": BASE_PARAMS["seed"],
}
if "neg_prompt" in BASE_PARAMS:
generation_kwargs["neg_prompt"] = BASE_PARAMS["neg_prompt"]
generator = VideoGenerator.from_pretrained(model_path=BASE_PARAMS["model_path"], **init_kwargs)
generator.generate_video(prompt, **generation_kwargs)
if isinstance(generator.executor, MultiprocExecutor):
generator.executor.shutdown()
assert os.path.exists(
output_dir), f"Output video was not generated at {output_dir}"
reference_folder = os.path.join(script_dir, device_reference_folder, model_id, ATTENTION_BACKEND)
if not os.path.exists(reference_folder):
logger.error("Reference folder missing")
raise FileNotFoundError(
f"Reference video folder does not exist: {reference_folder}")
# Find the matching reference video based on the prompt
reference_video_name = None
for filename in os.listdir(reference_folder):
if filename.endswith('.mp4') and prompt[:100].strip() in filename:
reference_video_name = filename
break
if not reference_video_name:
logger.error(f"Reference video not found for prompt: {prompt} with backend: {ATTENTION_BACKEND}")
raise FileNotFoundError(f"Reference video missing")
reference_video_path = os.path.join(reference_folder, reference_video_name)
generated_video_path = os.path.join(output_dir, output_video_name)
logger.info(
f"Computing SSIM between {reference_video_path} and {generated_video_path}"
def test_causal_similarity(
prompt: str,
attention_backend_name: str,
model_id: str,
) -> None:
run_text_to_video_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=MODEL_TO_PARAMS,
full_quality_params_map=FULL_QUALITY_MODEL_TO_PARAMS,
min_acceptable_ssim=0.98,
init_kwargs_override={"dit_cpu_offload": True},
)
ssim_values = compute_video_ssim_torchvision(reference_video_path,
generated_video_path,
use_ms_ssim=True)
mean_ssim = ssim_values[0]
logger.info(f"SSIM mean value: {mean_ssim}")
logger.info(f"Writing SSIM results to directory: {output_dir}")
success = write_ssim_results(output_dir, ssim_values, reference_video_path,
generated_video_path, num_inference_steps,
prompt)
if not success:
logger.error("Failed to write SSIM results to file")
min_acceptable_ssim = 0.98
assert mean_ssim >= min_acceptable_ssim, f"SSIM value {mean_ssim} is below threshold {min_acceptable_ssim} for {model_id} with backend {ATTENTION_BACKEND}"
@@ -6,7 +6,8 @@ Generates a video with deterministic seed and camera trajectory,
then compares against a device-specific reference video via MS-SSIM.
Reference videos must be pre-generated and stored under:
<device>_reference_videos/HunyuanGameCraft/<ATTENTION_BACKEND>/
reference_videos/<quality-tier>/<device>_reference_videos/HunyuanGameCraft/
<ATTENTION_BACKEND>/
To create initial reference videos, run this test once and copy the
generated videos into the appropriate reference folder.
@@ -17,7 +18,17 @@ import torch
import pytest
from fastvideo import VideoGenerator
from fastvideo.configs.sample.hunyuangamecraft import (
HunyuanGameCraftSamplingParam,
)
from fastvideo.logger import init_logger
from fastvideo.tests.ssim.reference_utils import (
build_generated_output_dir,
build_reference_folder_path,
get_cuda_device_name,
resolve_device_reference_folder,
select_ssim_params,
)
from fastvideo.tests.utils import (
compute_video_ssim_torchvision,
write_ssim_results,
@@ -31,20 +42,18 @@ REQUIRED_GPUS = 1
# ---------------------------------------------------------------------------
# Device-dependent reference folder
# ---------------------------------------------------------------------------
device_name = torch.cuda.get_device_name()
device_reference_folder_suffix = "_reference_videos"
if "A40" in device_name:
device_reference_folder = "A40" + device_reference_folder_suffix
elif "L40S" in device_name:
device_reference_folder = "L40S" + device_reference_folder_suffix
elif "A100" in device_name:
device_reference_folder = "A100" + device_reference_folder_suffix
elif "H100" in device_name:
device_reference_folder = "H100" + device_reference_folder_suffix
else:
device_reference_folder = "Unknown" + device_reference_folder_suffix
logger.warning(f"Unsupported device for ssim tests: {device_name}")
device_reference_folder = resolve_device_reference_folder(
(
("A40", "A40"),
("L40S", "L40S"),
("A100", "A100"),
("H100", "H100"),
("H200", "H200"),
),
device_name=get_cuda_device_name(),
unknown_device_prefix="Unknown",
logger=logger,
)
# ---------------------------------------------------------------------------
# Helpers – camera trajectory (self-contained, no official repo dependency)
@@ -82,6 +91,21 @@ GAMECRAFT_T2V_PARAMS = {
"negative_prompt": "",
}
_GAMECRAFT_FULL_QUALITY_DEFAULTS = HunyuanGameCraftSamplingParam()
GAMECRAFT_T2V_FULL_QUALITY_PARAMS = {
"num_gpus": GAMECRAFT_T2V_PARAMS["num_gpus"],
"model_path": GAMECRAFT_T2V_PARAMS["model_path"],
"height": _GAMECRAFT_FULL_QUALITY_DEFAULTS.height,
"width": _GAMECRAFT_FULL_QUALITY_DEFAULTS.width,
"num_frames": GAMECRAFT_T2V_PARAMS["num_frames"], # default num_frames: 33
"num_inference_steps": _GAMECRAFT_FULL_QUALITY_DEFAULTS.num_inference_steps,
"guidance_scale": _GAMECRAFT_FULL_QUALITY_DEFAULTS.guidance_scale,
"seed": _GAMECRAFT_FULL_QUALITY_DEFAULTS.seed,
"action": GAMECRAFT_T2V_PARAMS["action"],
"action_speed": GAMECRAFT_T2V_PARAMS["action_speed"],
"negative_prompt": _GAMECRAFT_FULL_QUALITY_DEFAULTS.negative_prompt,
}
GAMECRAFT_I2V_PARAMS = {
**GAMECRAFT_T2V_PARAMS,
"image_path": (
@@ -89,14 +113,24 @@ GAMECRAFT_I2V_PARAMS = {
"resolve/main/diffusers/astronaut.jpg"
),
}
GAMECRAFT_I2V_FULL_QUALITY_PARAMS = {
**GAMECRAFT_T2V_FULL_QUALITY_PARAMS,
"image_path": GAMECRAFT_I2V_PARAMS["image_path"],
}
MODEL_TO_PARAMS = {
"HunyuanGameCraft-T2V": GAMECRAFT_T2V_PARAMS,
}
FULL_QUALITY_MODEL_TO_PARAMS = {
"HunyuanGameCraft-T2V": GAMECRAFT_T2V_FULL_QUALITY_PARAMS,
}
I2V_MODEL_TO_PARAMS = {
"HunyuanGameCraft-I2V": GAMECRAFT_I2V_PARAMS,
}
FULL_QUALITY_I2V_MODEL_TO_PARAMS = {
"HunyuanGameCraft-I2V": GAMECRAFT_I2V_FULL_QUALITY_PARAMS,
}
TEST_PROMPTS = [
"A majestic ancient temple stands under a clear blue sky, its grandeur highlighted by towering Doric columns and intricate architectural details.",
@@ -119,13 +153,21 @@ def test_gamecraft_t2v_similarity(prompt, ATTENTION_BACKEND, model_id):
os.environ["FASTVIDEO_ATTENTION_BACKEND"] = ATTENTION_BACKEND
script_dir = os.path.dirname(os.path.abspath(__file__))
base_output_dir = os.path.join(script_dir, "generated_videos", model_id)
output_dir = os.path.join(base_output_dir, ATTENTION_BACKEND)
output_dir = build_generated_output_dir(
script_dir,
device_reference_folder,
model_id,
ATTENTION_BACKEND,
)
output_video_name = f"{prompt[:100].strip()}.mp4"
os.makedirs(output_dir, exist_ok=True)
BASE_PARAMS = MODEL_TO_PARAMS[model_id]
params_map = select_ssim_params(
MODEL_TO_PARAMS,
FULL_QUALITY_MODEL_TO_PARAMS,
)
BASE_PARAMS = params_map[model_id]
num_inference_steps = BASE_PARAMS["num_inference_steps"]
# Build camera trajectory
@@ -175,8 +217,11 @@ def test_gamecraft_t2v_similarity(prompt, ATTENTION_BACKEND, model_id):
)
# Compare to reference
reference_folder = os.path.join(
script_dir, device_reference_folder, model_id, ATTENTION_BACKEND
reference_folder = build_reference_folder_path(
script_dir,
device_reference_folder,
model_id,
ATTENTION_BACKEND,
)
if not os.path.exists(reference_folder):
@@ -244,13 +289,21 @@ def test_gamecraft_i2v_similarity(prompt, ATTENTION_BACKEND, model_id):
os.environ["FASTVIDEO_ATTENTION_BACKEND"] = ATTENTION_BACKEND
script_dir = os.path.dirname(os.path.abspath(__file__))
base_output_dir = os.path.join(script_dir, "generated_videos", model_id)
output_dir = os.path.join(base_output_dir, ATTENTION_BACKEND)
output_dir = build_generated_output_dir(
script_dir,
device_reference_folder,
model_id,
ATTENTION_BACKEND,
)
output_video_name = f"{prompt[:100].strip()}.mp4"
os.makedirs(output_dir, exist_ok=True)
BASE_PARAMS = I2V_MODEL_TO_PARAMS[model_id]
params_map = select_ssim_params(
I2V_MODEL_TO_PARAMS,
FULL_QUALITY_I2V_MODEL_TO_PARAMS,
)
BASE_PARAMS = params_map[model_id]
num_inference_steps = BASE_PARAMS["num_inference_steps"]
# Build camera trajectory
@@ -301,8 +354,11 @@ def test_gamecraft_i2v_similarity(prompt, ATTENTION_BACKEND, model_id):
)
# Compare to reference
reference_folder = os.path.join(
script_dir, device_reference_folder, model_id, ATTENTION_BACKEND
reference_folder = build_reference_folder_path(
script_dir,
device_reference_folder,
model_id,
ATTENTION_BACKEND,
)
if not os.path.exists(reference_folder):
@@ -1,389 +0,0 @@
# SPDX-License-Identifier: Apache-2.0
import json
import os
from contextlib import contextmanager
from typing import Iterator
import torch
import pytest
from fastvideo import VideoGenerator
from fastvideo.logger import init_logger
from fastvideo.tests.utils import compute_video_ssim_torchvision, write_ssim_results
from fastvideo.worker.multiproc_executor import MultiprocExecutor
logger = init_logger(__name__)
REQUIRED_GPUS = 2
@contextmanager
def _attention_backend(backend: str) -> Iterator[None]:
previous = os.environ.get("FASTVIDEO_ATTENTION_BACKEND")
os.environ["FASTVIDEO_ATTENTION_BACKEND"] = backend
try:
yield
finally:
if previous is None:
os.environ.pop("FASTVIDEO_ATTENTION_BACKEND", None)
else:
os.environ["FASTVIDEO_ATTENTION_BACKEND"] = previous
def _shutdown_executor(generator: VideoGenerator | None) -> None:
if generator is None:
return
if isinstance(generator.executor, MultiprocExecutor):
generator.executor.shutdown()
device_name = torch.cuda.get_device_name()
device_reference_folder_suffix = '_reference_videos'
if "A40" in device_name:
device_reference_folder = "A40" + device_reference_folder_suffix
elif "L40S" in device_name:
device_reference_folder = "L40S" + device_reference_folder_suffix
else:
# device_reference_folder = "L40S" + device_reference_folder_suffix
logger.warning(f"Unsupported device for ssim tests: {device_name}")
# raise ValueError(f"Unsupported device for ssim tests: {device_name}")
# Base parameters from the shell script
HUNYUAN_PARAMS = {
"num_gpus": 4,
"model_path": "FastVideo/FastHunyuan-diffusers",
"height": 720,
"width": 1280,
"num_frames": 45,
"num_inference_steps": 2,
"guidance_scale": 1,
"embedded_cfg_scale": 6,
"flow_shift": 17,
"seed": 1024,
"sp_size": 4,
"tp_size": 1,
"vae_sp": True,
"fps": 24,
}
WAN_T2V_PARAMS = {
"num_gpus": 2,
"model_path": "Wan-AI/Wan2.1-T2V-1.3B-Diffusers",
"height": 480,
"width": 832,
"num_frames": 45,
"num_inference_steps": 4,
"guidance_scale": 3,
"embedded_cfg_scale": 6,
"flow_shift": 7.0,
"seed": 1024,
"sp_size": 2,
"tp_size": 1,
"vae_sp": True,
"fps": 24,
"neg_prompt": "Bright tones, overexposed, static, blurred details, subtitles, style, works, paintings, images, static, overall gray, worst quality, low quality, JPEG compression residue, ugly, incomplete, extra fingers, poorly drawn hands, poorly drawn faces, deformed, disfigured, misshapen limbs, fused fingers, still picture, messy background, three legs, many people in the background, walking backwards",
"text-encoder-precision": ("fp32",)
}
WAN_I2V_PARAMS = {
"num_gpus": 2,
"model_path": "Wan-AI/Wan2.1-I2V-14B-480P-Diffusers",
"height": 480,
"width": 832,
"num_frames": 45,
"num_inference_steps": 2,
"guidance_scale": 5.0,
"embedded_cfg_scale": 6,
"flow_shift": 7.0,
"seed": 1024,
"sp_size": 2,
"tp_size": 1,
"vae_sp": True,
"fps": 24,
"neg_prompt": "Bright tones, overexposed, static, blurred details, subtitles, style, works, paintings, images, static, overall gray, worst quality, low quality, JPEG compression residue, ugly, incomplete, extra fingers, poorly drawn hands, poorly drawn faces, deformed, disfigured, misshapen limbs, fused fingers, still picture, messy background, three legs, many people in the background, walking backwards",
"text-encoder-precision": ("fp32",)
}
# LTX-2 distilled one-stage params (no refine/upscale)
# Official defaults: height=512, width=768, num_frames=121, fps=24, seed=10
# Using num_frames=41 for faster CI (still valid: 41 = 8×5 + 1)
LTX2_T2V_PARAMS = {
"num_gpus": 2,
"model_path": "FastVideo/LTX2-Distilled-Diffusers",
"height": 512,
"width": 768,
"num_frames": 41, # Shorter for CI; official default is 121
"num_inference_steps": 8, # Distilled uses 8 steps
"guidance_scale": 1.0, # No CFG for distilled
"embedded_cfg_scale": 6,
"seed": 1024,
"sp_size": 2,
"tp_size": 1,
"fps": 24,
"neg_prompt": (
"blurry, out of focus, overexposed, underexposed, low contrast, washed out colors, "
"excessive noise, grainy texture, poor lighting, flickering, motion blur, distorted "
"proportions, unnatural skin tones, deformed facial features, asymmetrical face, "
"missing facial features, extra limbs, disfigured hands, wrong hand count, artifacts "
"around text, inconsistent perspective, camera shake, incorrect depth of field, "
"background too sharp, background clutter, distracting reflections, harsh shadows, "
"inconsistent lighting direction, color banding, cartoonish rendering, 3D CGI look, "
"unrealistic materials, uncanny valley effect, incorrect ethnicity, wrong gender, "
"exaggerated expressions, wrong gaze direction, mismatched lip sync, silent or muted "
"audio, distorted voice, robotic voice, echo, background noise, off-sync audio, "
"incorrect dialogue, added dialogue, repetitive speech, jittery movement, awkward "
"pauses, incorrect timing, unnatural transitions, inconsistent framing, tilted camera, "
"flat lighting, inconsistent tone, cinematic oversaturation, stylized filters, or AI artifacts."
),
"ltx2_vae_tiling": True,
"ltx2_vae_spatial_tile_size_in_pixels": 512,
"ltx2_vae_spatial_tile_overlap_in_pixels": 64,
"ltx2_vae_temporal_tile_size_in_frames": 64,
"ltx2_vae_temporal_tile_overlap_in_frames": 24,
}
MODEL_TO_PARAMS = {
# "FastHunyuan-diffusers": HUNYUAN_PARAMS,
"Wan2.1-T2V-1.3B-Diffusers": WAN_T2V_PARAMS,
# "ltx2_diffusers": LTX2_T2V_PARAMS,
}
I2V_MODEL_TO_PARAMS = {
"Wan2.1-I2V-14B-480P-Diffusers": WAN_I2V_PARAMS,
}
TEST_PROMPTS = [
"Will Smith casually eats noodles, his relaxed demeanor contrasting with the energetic background of a bustling street food market. The scene captures a mix of humor and authenticity. Mid-shot framing, vibrant lighting.",
# "A lone hiker stands atop a towering cliff, silhouetted against the vast horizon. The rugged landscape stretches endlessly beneath, its earthy tones blending into the soft blues of the sky. The scene captures the spirit of exploration and human resilience. High angle, dynamic framing, with soft natural lighting emphasizing the grandeur of nature."
]
I2V_TEST_PROMPTS = [
"An astronaut hatching from an egg, on the surface of the moon, the darkness and depth of space realised in the background. High quality, ultrarealistic detail and breath-taking movie-like camera shot.",
]
I2V_IMAGE_PATHS = [
"https://huggingface.co/datasets/huggingface/documentation-images/resolve/main/diffusers/astronaut.jpg",
]
@pytest.mark.parametrize("prompt", I2V_TEST_PROMPTS)
@pytest.mark.parametrize("ATTENTION_BACKEND", ["FLASH_ATTN"])
@pytest.mark.parametrize("model_id", list(I2V_MODEL_TO_PARAMS.keys()))
def test_i2v_inference_similarity(prompt, ATTENTION_BACKEND, model_id):
"""
Test that runs inference with different parameters and compares the output
to reference videos using SSIM.
"""
assert len(I2V_TEST_PROMPTS) == len(I2V_IMAGE_PATHS), "Expect number of prompts equal to number of images"
with _attention_backend(ATTENTION_BACKEND):
script_dir = os.path.dirname(os.path.abspath(__file__))
base_output_dir = os.path.join(script_dir, 'generated_videos', model_id)
output_dir = os.path.join(base_output_dir, ATTENTION_BACKEND)
output_video_name = f"{prompt[:100].strip()}.mp4"
os.makedirs(output_dir, exist_ok=True)
BASE_PARAMS = I2V_MODEL_TO_PARAMS[model_id]
num_inference_steps = BASE_PARAMS["num_inference_steps"]
image_path = I2V_IMAGE_PATHS[I2V_TEST_PROMPTS.index(prompt)]
init_kwargs = {
"num_gpus": BASE_PARAMS["num_gpus"],
"flow_shift": BASE_PARAMS["flow_shift"],
"sp_size": BASE_PARAMS["sp_size"],
"tp_size": BASE_PARAMS["tp_size"],
}
if BASE_PARAMS.get("vae_sp"):
init_kwargs["vae_sp"] = True
init_kwargs["vae_tiling"] = True
if "text-encoder-precision" in BASE_PARAMS:
init_kwargs["text_encoder_precisions"] = BASE_PARAMS[
"text-encoder-precision"]
generation_kwargs = {
"num_inference_steps": num_inference_steps,
"output_path": output_dir,
"image_path": image_path,
"height": BASE_PARAMS["height"],
"width": BASE_PARAMS["width"],
"num_frames": BASE_PARAMS["num_frames"],
"guidance_scale": BASE_PARAMS["guidance_scale"],
"embedded_cfg_scale": BASE_PARAMS["embedded_cfg_scale"],
"seed": BASE_PARAMS["seed"],
"fps": BASE_PARAMS["fps"],
}
if "neg_prompt" in BASE_PARAMS:
generation_kwargs["neg_prompt"] = BASE_PARAMS["neg_prompt"]
generator: VideoGenerator | None = None
try:
generator = VideoGenerator.from_pretrained(
model_path=BASE_PARAMS["model_path"], **init_kwargs)
generator.generate_video(prompt, **generation_kwargs)
finally:
_shutdown_executor(generator)
assert os.path.exists(
output_dir), f"Output video was not generated at {output_dir}"
reference_folder = os.path.join(script_dir, device_reference_folder, model_id, ATTENTION_BACKEND)
if not os.path.exists(reference_folder):
logger.error("Reference folder missing")
raise FileNotFoundError(
f"Reference video folder does not exist: {reference_folder}")
# Find the matching reference video based on the prompt
reference_video_name = None
for filename in os.listdir(reference_folder):
if filename.endswith('.mp4') and prompt[:100].strip() in filename:
reference_video_name = filename
break
if not reference_video_name:
logger.error(f"Reference video not found for prompt: {prompt} with backend: {ATTENTION_BACKEND}")
raise FileNotFoundError(f"Reference video missing")
reference_video_path = os.path.join(reference_folder, reference_video_name)
generated_video_path = os.path.join(output_dir, output_video_name)
logger.info(
f"Computing SSIM between {reference_video_path} and {generated_video_path}"
)
ssim_values = compute_video_ssim_torchvision(reference_video_path,
generated_video_path,
use_ms_ssim=True)
mean_ssim = ssim_values[0]
logger.info(f"SSIM mean value: {mean_ssim}")
logger.info(f"Writing SSIM results to directory: {output_dir}")
success = write_ssim_results(output_dir, ssim_values, reference_video_path,
generated_video_path, num_inference_steps,
prompt)
if not success:
logger.error("Failed to write SSIM results to file")
min_acceptable_ssim = 0.97
assert mean_ssim >= min_acceptable_ssim, f"SSIM value {mean_ssim} is below threshold {min_acceptable_ssim} for {model_id} with backend {ATTENTION_BACKEND}"
@pytest.mark.parametrize("prompt", TEST_PROMPTS)
@pytest.mark.parametrize("ATTENTION_BACKEND", ["FLASH_ATTN", "TORCH_SDPA"])
@pytest.mark.parametrize("model_id", list(MODEL_TO_PARAMS.keys()))
def test_inference_similarity(prompt, ATTENTION_BACKEND, model_id):
"""
Test that runs inference with different parameters and compares the output
to reference videos using SSIM.
"""
with _attention_backend(ATTENTION_BACKEND):
script_dir = os.path.dirname(os.path.abspath(__file__))
base_output_dir = os.path.join(script_dir, 'generated_videos', model_id)
output_dir = os.path.join(base_output_dir, ATTENTION_BACKEND)
output_video_name = f"{prompt[:100].strip()}.mp4"
os.makedirs(output_dir, exist_ok=True)
BASE_PARAMS = MODEL_TO_PARAMS[model_id]
num_inference_steps = BASE_PARAMS["num_inference_steps"]
init_kwargs = {
"num_gpus": BASE_PARAMS["num_gpus"],
"sp_size": BASE_PARAMS["sp_size"],
"tp_size": BASE_PARAMS["tp_size"],
"use_fsdp_inference": True,
"dit_cpu_offload": False,
"dit_layerwise_offload": False,
}
if "flow_shift" in BASE_PARAMS:
init_kwargs["flow_shift"] = BASE_PARAMS["flow_shift"]
if BASE_PARAMS.get("vae_sp"):
init_kwargs["vae_sp"] = True
init_kwargs["vae_tiling"] = True
if "text-encoder-precision" in BASE_PARAMS:
init_kwargs["text_encoder_precisions"] = BASE_PARAMS[
"text-encoder-precision"]
# LTX2-specific VAE tiling parameters
if BASE_PARAMS.get("ltx2_vae_tiling"):
init_kwargs["ltx2_vae_tiling"] = True
init_kwargs["ltx2_vae_spatial_tile_size_in_pixels"] = BASE_PARAMS.get(
"ltx2_vae_spatial_tile_size_in_pixels", 512)
init_kwargs["ltx2_vae_spatial_tile_overlap_in_pixels"] = BASE_PARAMS.get(
"ltx2_vae_spatial_tile_overlap_in_pixels", 64)
init_kwargs["ltx2_vae_temporal_tile_size_in_frames"] = BASE_PARAMS.get(
"ltx2_vae_temporal_tile_size_in_frames", 64)
init_kwargs[
"ltx2_vae_temporal_tile_overlap_in_frames"] = BASE_PARAMS.get(
"ltx2_vae_temporal_tile_overlap_in_frames", 24)
generation_kwargs = {
"num_inference_steps": num_inference_steps,
"output_path": output_dir,
"height": BASE_PARAMS["height"],
"width": BASE_PARAMS["width"],
"num_frames": BASE_PARAMS["num_frames"],
"guidance_scale": BASE_PARAMS["guidance_scale"],
"embedded_cfg_scale": BASE_PARAMS["embedded_cfg_scale"],
"seed": BASE_PARAMS["seed"],
"fps": BASE_PARAMS["fps"],
}
if "neg_prompt" in BASE_PARAMS:
generation_kwargs["neg_prompt"] = BASE_PARAMS["neg_prompt"]
generator: VideoGenerator | None = None
try:
generator = VideoGenerator.from_pretrained(
model_path=BASE_PARAMS["model_path"], **init_kwargs)
generator.generate_video(prompt, **generation_kwargs)
finally:
_shutdown_executor(generator)
assert os.path.exists(
output_dir), f"Output video was not generated at {output_dir}"
reference_folder = os.path.join(script_dir, device_reference_folder, model_id, ATTENTION_BACKEND)
if not os.path.exists(reference_folder):
logger.error("Reference folder missing")
raise FileNotFoundError(
f"Reference video folder does not exist: {reference_folder}")
# Find the matching reference video based on the prompt
reference_video_name = None
for filename in os.listdir(reference_folder):
if filename.endswith('.mp4') and prompt[:100].strip() in filename:
reference_video_name = filename
break
if not reference_video_name:
logger.error(f"Reference video not found for prompt: {prompt} with backend: {ATTENTION_BACKEND}")
raise FileNotFoundError(f"Reference video missing")
reference_video_path = os.path.join(reference_folder, reference_video_name)
generated_video_path = os.path.join(output_dir, output_video_name)
logger.info(
f"Computing SSIM between {reference_video_path} and {generated_video_path}"
)
ssim_values = compute_video_ssim_torchvision(reference_video_path,
generated_video_path,
use_ms_ssim=True)
mean_ssim = ssim_values[0]
logger.info(f"SSIM mean value: {mean_ssim}")
logger.info(f"Writing SSIM results to directory: {output_dir}")
success = write_ssim_results(output_dir, ssim_values, reference_video_path,
generated_video_path, num_inference_steps,
prompt)
if not success:
logger.error("Failed to write SSIM results to file")
min_acceptable_ssim = 0.93
assert mean_ssim >= min_acceptable_ssim, f"SSIM value {mean_ssim} is below threshold {min_acceptable_ssim} for {model_id} with backend {ATTENTION_BACKEND}"
+108 -71
View File
@@ -10,13 +10,21 @@ Note: num_inference_steps is reduced to 4 for faster CI.
import os
import pytest
import torch
from fastvideo import VideoGenerator
from fastvideo.logger import init_logger
from fastvideo.models.dits.lingbotworld.cam_utils import prepare_camera_embedding
from fastvideo.tests.utils import compute_video_ssim_torchvision, write_ssim_results
import pytest
import torch
from fastvideo import VideoGenerator
from fastvideo.configs.sample.lingbotworld import LingBotWorld_SamplingParam
from fastvideo.logger import init_logger
from fastvideo.models.dits.lingbotworld.cam_utils import prepare_camera_embedding
from fastvideo.tests.ssim.reference_utils import (
build_generated_output_dir,
build_reference_folder_path,
get_cuda_device_name,
resolve_device_reference_folder,
select_ssim_params,
)
from fastvideo.tests.utils import compute_video_ssim_torchvision, write_ssim_results
logger = init_logger(__name__)
@@ -40,21 +48,20 @@ def _find_lingbotworld_examples_root() -> str | None:
return None
device_name = torch.cuda.get_device_name()
device_reference_folder_suffix = "_reference_videos"
if "A40" in device_name:
device_reference_folder = "A40" + device_reference_folder_suffix
elif "L40S" in device_name:
device_reference_folder = "L40S" + device_reference_folder_suffix
elif "H100" in device_name:
device_reference_folder = "H100" + device_reference_folder_suffix
else:
device_reference_folder = None
logger.warning("Unsupported device for ssim tests: %s", device_name)
device_name = get_cuda_device_name()
device_reference_folder = resolve_device_reference_folder(
(
("A40", "A40"),
("L40S", "L40S"),
("H100", "H100"),
("H200", "H200"),
),
device_name=device_name,
logger=logger,
)
LINGBOT_PARAMS = {
LINGBOT_PARAMS = {
"model_path": "FastVideo/LingBot-World-Base-Cam-Diffusers",
"num_gpus": 2,
"height": 256,
@@ -81,8 +88,28 @@ LINGBOT_PARAMS = {
"镜头晃动,画面闪烁,模糊,噪点,水印,签名,文字,变形,扭曲,液化,不合逻辑的结构,卡顿,"
"PPT幻灯片感,过暗,欠曝,低对比度,霓虹灯光感,过度锐化,3D渲染感,人物,行人,游客,身体,"
"皮肤,肢体,面部特征,汽车,电线"
),
}
),
}
_LINGBOT_FULL_QUALITY_DEFAULTS = LingBotWorld_SamplingParam()
LINGBOT_FULL_QUALITY_PARAMS = {
"model_path": LINGBOT_PARAMS["model_path"],
"num_gpus": LINGBOT_PARAMS["num_gpus"],
"height": _LINGBOT_FULL_QUALITY_DEFAULTS.height,
"width": _LINGBOT_FULL_QUALITY_DEFAULTS.width,
"num_frames": LINGBOT_PARAMS["num_frames"], # default num_frames: 125
"num_inference_steps": _LINGBOT_FULL_QUALITY_DEFAULTS.num_inference_steps,
"guidance_scale": _LINGBOT_FULL_QUALITY_DEFAULTS.guidance_scale,
"guidance_scale_2": _LINGBOT_FULL_QUALITY_DEFAULTS.guidance_scale_2,
"embedded_cfg_scale": LINGBOT_PARAMS["embedded_cfg_scale"],
"flow_shift": LINGBOT_PARAMS["flow_shift"],
"boundary_ratio": _LINGBOT_FULL_QUALITY_DEFAULTS.boundary_ratio,
"seed": _LINGBOT_FULL_QUALITY_DEFAULTS.seed,
"fps": _LINGBOT_FULL_QUALITY_DEFAULTS.fps,
"spatial_scale": LINGBOT_PARAMS["spatial_scale"],
"example_case": LINGBOT_PARAMS["example_case"],
"image_path": LINGBOT_PARAMS["image_path"],
"negative_prompt": _LINGBOT_FULL_QUALITY_DEFAULTS.negative_prompt,
}
TEST_PROMPTS = [
"The video presents a soaring journey through a fantasy jungle. The wind "
@@ -95,74 +122,80 @@ TEST_PROMPTS = [
@pytest.mark.parametrize("prompt", TEST_PROMPTS)
@pytest.mark.parametrize("ATTENTION_BACKEND", ["FLASH_ATTN"])
def test_lingbot_i2v_similarity(prompt: str, ATTENTION_BACKEND: str):
os.environ["FASTVIDEO_ATTENTION_BACKEND"] = ATTENTION_BACKEND
if device_reference_folder is None:
pytest.skip(f"Unsupported device for LingBot SSIM test: {device_name}")
if torch.cuda.device_count() < LINGBOT_PARAMS["num_gpus"]:
pytest.skip(
f"LingBot SSIM test requires {LINGBOT_PARAMS['num_gpus']} GPUs, "
f"but only {torch.cuda.device_count()} detected."
)
def test_lingbot_i2v_similarity(prompt: str, ATTENTION_BACKEND: str):
os.environ["FASTVIDEO_ATTENTION_BACKEND"] = ATTENTION_BACKEND
params = select_ssim_params(LINGBOT_PARAMS, LINGBOT_FULL_QUALITY_PARAMS)
if device_reference_folder is None:
pytest.skip(f"Unsupported device for LingBot SSIM test: {device_name}")
if torch.cuda.device_count() < params["num_gpus"]:
pytest.skip(
f"LingBot SSIM test requires {params['num_gpus']} GPUs, "
f"but only {torch.cuda.device_count()} detected."
)
examples_root = _find_lingbotworld_examples_root()
if examples_root is None:
pytest.skip(
"lingbotworld_examples not found under examples/inference/basic.")
action_path = os.path.join(examples_root, LINGBOT_PARAMS["example_case"])
action_path = os.path.join(examples_root, params["example_case"])
if not (os.path.exists(os.path.join(action_path, "poses.npy"))
and os.path.exists(os.path.join(action_path, "intrinsics.npy"))):
pytest.skip(f"Missing camera npy files under {action_path}")
c2ws_plucker_emb, aligned_num_frames = prepare_camera_embedding(
action_path=action_path,
num_frames=LINGBOT_PARAMS["num_frames"],
height=LINGBOT_PARAMS["height"],
width=LINGBOT_PARAMS["width"],
spatial_scale=LINGBOT_PARAMS["spatial_scale"],
)
c2ws_plucker_emb, aligned_num_frames = prepare_camera_embedding(
action_path=action_path,
num_frames=params["num_frames"],
height=params["height"],
width=params["width"],
spatial_scale=params["spatial_scale"],
)
script_dir = os.path.dirname(os.path.abspath(__file__))
model_id = "LingBot-World-Base-Cam-Diffusers"
output_dir = os.path.join(script_dir, "generated_videos", model_id,
ATTENTION_BACKEND)
output_dir = build_generated_output_dir(
script_dir,
device_reference_folder,
model_id,
ATTENTION_BACKEND,
)
output_video_name = f"{prompt[:100].strip()}.mp4"
os.makedirs(output_dir, exist_ok=True)
init_kwargs = {
"num_gpus": LINGBOT_PARAMS["num_gpus"],
"flow_shift": LINGBOT_PARAMS["flow_shift"],
"boundary_ratio": LINGBOT_PARAMS["boundary_ratio"],
"use_fsdp_inference": True,
"dit_cpu_offload": True,
init_kwargs = {
"num_gpus": params["num_gpus"],
"flow_shift": params["flow_shift"],
"boundary_ratio": params["boundary_ratio"],
"use_fsdp_inference": True,
"dit_cpu_offload": True,
"dit_layerwise_offload": False,
"text_encoder_cpu_offload": True,
"vae_cpu_offload": False,
"pin_cpu_memory": True,
}
generation_kwargs = {
"output_path": output_dir,
"image_path": LINGBOT_PARAMS["image_path"],
"height": LINGBOT_PARAMS["height"],
"width": LINGBOT_PARAMS["width"],
"num_frames": aligned_num_frames,
"num_inference_steps": LINGBOT_PARAMS["num_inference_steps"],
"guidance_scale": LINGBOT_PARAMS["guidance_scale"],
"guidance_scale_2": LINGBOT_PARAMS["guidance_scale_2"],
"embedded_cfg_scale": LINGBOT_PARAMS["embedded_cfg_scale"],
"seed": LINGBOT_PARAMS["seed"],
"fps": LINGBOT_PARAMS["fps"],
"negative_prompt": LINGBOT_PARAMS["negative_prompt"],
"c2ws_plucker_emb": c2ws_plucker_emb,
}
generation_kwargs = {
"output_path": output_dir,
"image_path": params["image_path"],
"height": params["height"],
"width": params["width"],
"num_frames": aligned_num_frames,
"num_inference_steps": params["num_inference_steps"],
"guidance_scale": params["guidance_scale"],
"guidance_scale_2": params["guidance_scale_2"],
"embedded_cfg_scale": params["embedded_cfg_scale"],
"seed": params["seed"],
"fps": params["fps"],
"negative_prompt": params["negative_prompt"],
"c2ws_plucker_emb": c2ws_plucker_emb,
}
generator: VideoGenerator | None = None
try:
generator = VideoGenerator.from_pretrained(
model_path=LINGBOT_PARAMS["model_path"], **init_kwargs)
generator.generate_video(prompt, **generation_kwargs)
generator = VideoGenerator.from_pretrained(
model_path=params["model_path"], **init_kwargs)
generator.generate_video(prompt, **generation_kwargs)
finally:
if generator is not None:
generator.shutdown()
@@ -171,8 +204,12 @@ def test_lingbot_i2v_similarity(prompt: str, ATTENTION_BACKEND: str):
assert os.path.exists(generated_video_path), (
f"Output video was not generated at {generated_video_path}")
reference_folder = os.path.join(script_dir, device_reference_folder, model_id,
ATTENTION_BACKEND)
reference_folder = build_reference_folder_path(
script_dir,
device_reference_folder,
model_id,
ATTENTION_BACKEND,
)
if not os.path.exists(reference_folder):
raise FileNotFoundError(
f"Reference video folder does not exist: {reference_folder}")
@@ -196,9 +233,9 @@ def test_lingbot_i2v_similarity(prompt: str, ATTENTION_BACKEND: str):
mean_ssim = ssim_values[0]
logger.info("SSIM mean value: %s", mean_ssim)
write_ssim_results(output_dir, ssim_values, reference_video_path,
generated_video_path,
LINGBOT_PARAMS["num_inference_steps"], prompt)
write_ssim_results(output_dir, ssim_values, reference_video_path,
generated_video_path,
params["num_inference_steps"], prompt)
min_acceptable_ssim = 0.90
assert mean_ssim >= min_acceptable_ssim, (
+137 -54
View File
@@ -20,7 +20,15 @@ import pytest
import torch
from fastvideo import VideoGenerator
from fastvideo.configs.sample.base import SamplingParam
from fastvideo.logger import init_logger
from fastvideo.tests.ssim.reference_utils import (
build_generated_output_dir,
build_reference_folder_path,
get_cuda_device_name,
resolve_device_reference_folder,
select_ssim_params,
)
from fastvideo.tests.utils import compute_video_ssim_torchvision, write_ssim_results
logger = init_logger(__name__)
@@ -28,17 +36,16 @@ logger = init_logger(__name__)
REQUIRED_GPUS = 1
# Device-specific reference folder
device_name = torch.cuda.get_device_name()
device_reference_folder_suffix = "_reference_videos"
if "A40" in device_name:
device_reference_folder = "A40" + device_reference_folder_suffix
elif "L40S" in device_name:
device_reference_folder = "L40S" + device_reference_folder_suffix
elif "H100" in device_name:
device_reference_folder = "H100" + device_reference_folder_suffix
else:
logger.warning(f"Unsupported device for ssim tests: {device_name}")
device_reference_folder = resolve_device_reference_folder(
(
("A40", "A40"),
("L40S", "L40S"),
("H100", "H100"),
("H200", "H200"),
),
device_name=get_cuda_device_name(),
logger=logger,
)
# Common negative prompt from example scripts
NEGATIVE_PROMPT = (
@@ -64,6 +71,21 @@ LONGCAT_T2V_PARAMS = {
"seed": 42,
"negative_prompt": NEGATIVE_PROMPT,
}
_LONGCAT_T2V_FULL_QUALITY_DEFAULTS = SamplingParam.from_pretrained(
LONGCAT_T2V_PARAMS["model_path"]
)
LONGCAT_T2V_FULL_QUALITY_PARAMS = {
"num_gpus": LONGCAT_T2V_PARAMS["num_gpus"],
"model_path": LONGCAT_T2V_PARAMS["model_path"],
"height": _LONGCAT_T2V_FULL_QUALITY_DEFAULTS.height,
"width": _LONGCAT_T2V_FULL_QUALITY_DEFAULTS.width,
"num_frames": LONGCAT_T2V_PARAMS["num_frames"], # default num_frames: 125
"num_inference_steps": _LONGCAT_T2V_FULL_QUALITY_DEFAULTS.num_inference_steps,
"guidance_scale": _LONGCAT_T2V_FULL_QUALITY_DEFAULTS.guidance_scale,
"fps": _LONGCAT_T2V_FULL_QUALITY_DEFAULTS.fps,
"seed": _LONGCAT_T2V_FULL_QUALITY_DEFAULTS.seed,
"negative_prompt": _LONGCAT_T2V_FULL_QUALITY_DEFAULTS.negative_prompt,
}
# =============================================================================
# LongCat I2V Parameters (from basic_longcat_i2v.py)
@@ -80,6 +102,21 @@ LONGCAT_I2V_PARAMS = {
"seed": 42,
"negative_prompt": NEGATIVE_PROMPT,
}
_LONGCAT_I2V_FULL_QUALITY_DEFAULTS = SamplingParam.from_pretrained(
LONGCAT_I2V_PARAMS["model_path"]
)
LONGCAT_I2V_FULL_QUALITY_PARAMS = {
"num_gpus": LONGCAT_I2V_PARAMS["num_gpus"],
"model_path": LONGCAT_I2V_PARAMS["model_path"],
"height": _LONGCAT_I2V_FULL_QUALITY_DEFAULTS.height,
"width": _LONGCAT_I2V_FULL_QUALITY_DEFAULTS.width,
"num_frames": LONGCAT_I2V_PARAMS["num_frames"], # default num_frames: 125
"num_inference_steps": _LONGCAT_I2V_FULL_QUALITY_DEFAULTS.num_inference_steps,
"guidance_scale": _LONGCAT_I2V_FULL_QUALITY_DEFAULTS.guidance_scale,
"fps": _LONGCAT_I2V_FULL_QUALITY_DEFAULTS.fps,
"seed": _LONGCAT_I2V_FULL_QUALITY_DEFAULTS.seed,
"negative_prompt": _LONGCAT_I2V_FULL_QUALITY_DEFAULTS.negative_prompt,
}
# =============================================================================
# LongCat VC Parameters (from basic_longcat_vc.py)
@@ -97,6 +134,22 @@ LONGCAT_VC_PARAMS = {
"num_cond_frames": 13,
"negative_prompt": NEGATIVE_PROMPT,
}
_LONGCAT_VC_FULL_QUALITY_DEFAULTS = SamplingParam.from_pretrained(
LONGCAT_VC_PARAMS["model_path"]
)
LONGCAT_VC_FULL_QUALITY_PARAMS = {
"num_gpus": LONGCAT_VC_PARAMS["num_gpus"],
"model_path": LONGCAT_VC_PARAMS["model_path"],
"height": _LONGCAT_VC_FULL_QUALITY_DEFAULTS.height,
"width": _LONGCAT_VC_FULL_QUALITY_DEFAULTS.width,
"num_frames": LONGCAT_VC_PARAMS["num_frames"], # default num_frames: 125
"num_inference_steps": _LONGCAT_VC_FULL_QUALITY_DEFAULTS.num_inference_steps,
"guidance_scale": _LONGCAT_VC_FULL_QUALITY_DEFAULTS.guidance_scale,
"fps": _LONGCAT_VC_FULL_QUALITY_DEFAULTS.fps,
"seed": _LONGCAT_VC_FULL_QUALITY_DEFAULTS.seed,
"num_cond_frames": LONGCAT_VC_PARAMS["num_cond_frames"],
"negative_prompt": _LONGCAT_VC_FULL_QUALITY_DEFAULTS.negative_prompt,
}
# Test prompts
T2V_TEST_PROMPTS = [
@@ -153,15 +206,22 @@ def test_longcat_t2v_similarity(prompt: str, ATTENTION_BACKEND: str):
"""
os.environ["FASTVIDEO_ATTENTION_BACKEND"] = ATTENTION_BACKEND
params = select_ssim_params(LONGCAT_T2V_PARAMS, LONGCAT_T2V_FULL_QUALITY_PARAMS)
script_dir = os.path.dirname(os.path.abspath(__file__))
model_id = "LongCat-Video-T2V"
output_dir = os.path.join(script_dir, "generated_videos", model_id, ATTENTION_BACKEND)
output_dir = build_generated_output_dir(
script_dir,
device_reference_folder,
model_id,
ATTENTION_BACKEND,
)
output_video_name = f"{prompt[:100].strip()}.mp4"
os.makedirs(output_dir, exist_ok=True)
init_kwargs = {
"num_gpus": LONGCAT_T2V_PARAMS["num_gpus"],
"num_gpus": params["num_gpus"],
"use_fsdp_inference": True,
"dit_cpu_offload": True,
"vae_cpu_offload": True,
@@ -171,18 +231,18 @@ def test_longcat_t2v_similarity(prompt: str, ATTENTION_BACKEND: str):
generation_kwargs = {
"output_path": output_dir,
"height": LONGCAT_T2V_PARAMS["height"],
"width": LONGCAT_T2V_PARAMS["width"],
"num_frames": LONGCAT_T2V_PARAMS["num_frames"],
"num_inference_steps": LONGCAT_T2V_PARAMS["num_inference_steps"],
"guidance_scale": LONGCAT_T2V_PARAMS["guidance_scale"],
"fps": LONGCAT_T2V_PARAMS["fps"],
"seed": LONGCAT_T2V_PARAMS["seed"],
"negative_prompt": LONGCAT_T2V_PARAMS["negative_prompt"],
"height": params["height"],
"width": params["width"],
"num_frames": params["num_frames"],
"num_inference_steps": params["num_inference_steps"],
"guidance_scale": params["guidance_scale"],
"fps": params["fps"],
"seed": params["seed"],
"negative_prompt": params["negative_prompt"],
}
generator = VideoGenerator.from_pretrained(
model_path=LONGCAT_T2V_PARAMS["model_path"], **init_kwargs
model_path=params["model_path"], **init_kwargs
)
generator.generate_video(prompt, **generation_kwargs)
generator.shutdown()
@@ -193,8 +253,11 @@ def test_longcat_t2v_similarity(prompt: str, ATTENTION_BACKEND: str):
)
# Find reference video
reference_folder = os.path.join(
script_dir, device_reference_folder, model_id, ATTENTION_BACKEND
reference_folder = build_reference_folder_path(
script_dir,
device_reference_folder,
model_id,
ATTENTION_BACKEND,
)
if not os.path.exists(reference_folder):
raise FileNotFoundError(
@@ -224,7 +287,7 @@ def test_longcat_t2v_similarity(prompt: str, ATTENTION_BACKEND: str):
write_ssim_results(
output_dir, ssim_values, reference_video_path, generated_video_path,
LONGCAT_T2V_PARAMS["num_inference_steps"], prompt
params["num_inference_steps"], prompt
)
min_acceptable_ssim = 0.90
@@ -244,10 +307,17 @@ def test_longcat_i2v_similarity(prompt: str, ATTENTION_BACKEND: str):
"""
os.environ["FASTVIDEO_ATTENTION_BACKEND"] = ATTENTION_BACKEND
params = select_ssim_params(LONGCAT_I2V_PARAMS, LONGCAT_I2V_FULL_QUALITY_PARAMS)
script_dir = os.path.dirname(os.path.abspath(__file__))
model_id = "LongCat-Video-I2V"
output_dir = os.path.join(script_dir, "generated_videos", model_id, ATTENTION_BACKEND)
output_dir = build_generated_output_dir(
script_dir,
device_reference_folder,
model_id,
ATTENTION_BACKEND,
)
output_video_name = f"{prompt[:100].strip()}.mp4"
os.makedirs(output_dir, exist_ok=True)
@@ -256,7 +326,7 @@ def test_longcat_i2v_similarity(prompt: str, ATTENTION_BACKEND: str):
image_path = _resolve_asset_path(I2V_IMAGE_PATHS[prompt_idx])
init_kwargs = {
"num_gpus": LONGCAT_I2V_PARAMS["num_gpus"],
"num_gpus": params["num_gpus"],
"use_fsdp_inference": True,
"dit_cpu_offload": True,
"vae_cpu_offload": True,
@@ -267,18 +337,18 @@ def test_longcat_i2v_similarity(prompt: str, ATTENTION_BACKEND: str):
generation_kwargs = {
"output_path": output_dir,
"image_path": image_path,
"height": LONGCAT_I2V_PARAMS["height"],
"width": LONGCAT_I2V_PARAMS["width"],
"num_frames": LONGCAT_I2V_PARAMS["num_frames"],
"num_inference_steps": LONGCAT_I2V_PARAMS["num_inference_steps"],
"guidance_scale": LONGCAT_I2V_PARAMS["guidance_scale"],
"fps": LONGCAT_I2V_PARAMS["fps"],
"seed": LONGCAT_I2V_PARAMS["seed"],
"negative_prompt": LONGCAT_I2V_PARAMS["negative_prompt"],
"height": params["height"],
"width": params["width"],
"num_frames": params["num_frames"],
"num_inference_steps": params["num_inference_steps"],
"guidance_scale": params["guidance_scale"],
"fps": params["fps"],
"seed": params["seed"],
"negative_prompt": params["negative_prompt"],
}
generator = VideoGenerator.from_pretrained(
model_path=LONGCAT_I2V_PARAMS["model_path"], **init_kwargs
model_path=params["model_path"], **init_kwargs
)
generator.generate_video(prompt, **generation_kwargs)
generator.shutdown()
@@ -289,8 +359,11 @@ def test_longcat_i2v_similarity(prompt: str, ATTENTION_BACKEND: str):
)
# Find reference video
reference_folder = os.path.join(
script_dir, device_reference_folder, model_id, ATTENTION_BACKEND
reference_folder = build_reference_folder_path(
script_dir,
device_reference_folder,
model_id,
ATTENTION_BACKEND,
)
if not os.path.exists(reference_folder):
raise FileNotFoundError(
@@ -320,7 +393,7 @@ def test_longcat_i2v_similarity(prompt: str, ATTENTION_BACKEND: str):
write_ssim_results(
output_dir, ssim_values, reference_video_path, generated_video_path,
LONGCAT_I2V_PARAMS["num_inference_steps"], prompt
params["num_inference_steps"], prompt
)
min_acceptable_ssim = 0.90
@@ -340,10 +413,17 @@ def test_longcat_vc_similarity(prompt: str, ATTENTION_BACKEND: str):
"""
os.environ["FASTVIDEO_ATTENTION_BACKEND"] = ATTENTION_BACKEND
params = select_ssim_params(LONGCAT_VC_PARAMS, LONGCAT_VC_FULL_QUALITY_PARAMS)
script_dir = os.path.dirname(os.path.abspath(__file__))
model_id = "LongCat-Video-VC"
output_dir = os.path.join(script_dir, "generated_videos", model_id, ATTENTION_BACKEND)
output_dir = build_generated_output_dir(
script_dir,
device_reference_folder,
model_id,
ATTENTION_BACKEND,
)
output_video_name = f"{prompt[:100].strip()}.mp4"
os.makedirs(output_dir, exist_ok=True)
@@ -355,7 +435,7 @@ def test_longcat_vc_similarity(prompt: str, ATTENTION_BACKEND: str):
pytest.skip(f"Input video not found at {video_path}")
init_kwargs = {
"num_gpus": LONGCAT_VC_PARAMS["num_gpus"],
"num_gpus": params["num_gpus"],
"use_fsdp_inference": False,
"dit_cpu_offload": False,
"vae_cpu_offload": True,
@@ -367,19 +447,19 @@ def test_longcat_vc_similarity(prompt: str, ATTENTION_BACKEND: str):
generation_kwargs = {
"output_path": output_dir,
"video_path": video_path,
"num_cond_frames": LONGCAT_VC_PARAMS["num_cond_frames"],
"height": LONGCAT_VC_PARAMS["height"],
"width": LONGCAT_VC_PARAMS["width"],
"num_frames": LONGCAT_VC_PARAMS["num_frames"],
"num_inference_steps": LONGCAT_VC_PARAMS["num_inference_steps"],
"guidance_scale": LONGCAT_VC_PARAMS["guidance_scale"],
"fps": LONGCAT_VC_PARAMS["fps"],
"seed": LONGCAT_VC_PARAMS["seed"],
"negative_prompt": LONGCAT_VC_PARAMS["negative_prompt"],
"num_cond_frames": params["num_cond_frames"],
"height": params["height"],
"width": params["width"],
"num_frames": params["num_frames"],
"num_inference_steps": params["num_inference_steps"],
"guidance_scale": params["guidance_scale"],
"fps": params["fps"],
"seed": params["seed"],
"negative_prompt": params["negative_prompt"],
}
generator = VideoGenerator.from_pretrained(
model_path=LONGCAT_VC_PARAMS["model_path"], **init_kwargs
model_path=params["model_path"], **init_kwargs
)
generator.generate_video(prompt, **generation_kwargs)
generator.shutdown()
@@ -390,8 +470,11 @@ def test_longcat_vc_similarity(prompt: str, ATTENTION_BACKEND: str):
)
# Find reference video
reference_folder = os.path.join(
script_dir, device_reference_folder, model_id, ATTENTION_BACKEND
reference_folder = build_reference_folder_path(
script_dir,
device_reference_folder,
model_id,
ATTENTION_BACKEND,
)
if not os.path.exists(reference_folder):
raise FileNotFoundError(
@@ -421,7 +504,7 @@ def test_longcat_vc_similarity(prompt: str, ATTENTION_BACKEND: str):
write_ssim_results(
output_dir, ssim_values, reference_video_path, generated_video_path,
LONGCAT_VC_PARAMS["num_inference_steps"], prompt
params["num_inference_steps"], prompt
)
min_acceptable_ssim = 0.90
@@ -5,8 +5,16 @@ import torch
import pytest
from fastvideo import VideoGenerator
from fastvideo.models.dits.matrixgame.utils import create_action_presets
from fastvideo.configs.sample.wan import MatrixGame2_SamplingParam
from fastvideo.logger import init_logger
from fastvideo.models.dits.matrixgame.utils import create_action_presets
from fastvideo.tests.ssim.reference_utils import (
build_generated_output_dir,
build_reference_folder_path,
get_cuda_device_name,
resolve_device_reference_folder,
select_ssim_params,
)
from fastvideo.tests.utils import (
compute_video_ssim_torchvision,
write_ssim_results,
@@ -17,17 +25,15 @@ logger = init_logger(__name__)
REQUIRED_GPUS = 1
device_name = torch.cuda.get_device_name()
device_reference_folder_suffix = "_reference_videos"
if "A40" in device_name:
device_reference_folder = "A40" + device_reference_folder_suffix
elif "L40S" in device_name:
device_reference_folder = "L40S" + device_reference_folder_suffix
else:
# device_reference_folder = "L40S" + device_reference_folder_suffix
logger.warning(f"Unsupported device for ssim tests: {device_name}")
# raise ValueError(f"Unsupported device for ssim tests: {device_name}")
device_reference_folder = resolve_device_reference_folder(
(
("A40", "A40"),
("L40S", "L40S"),
("H200", "H200"),
),
device_name=get_cuda_device_name(),
logger=logger,
)
# Base parameters from the shell script
@@ -41,11 +47,26 @@ MATRIXGAME_PARAMS = {
"seed": 1024,
"keyboard_dim": 4,
}
_MATRIXGAME_FULL_QUALITY_DEFAULTS = MatrixGame2_SamplingParam()
MATRIXGAME_FULL_QUALITY_PARAMS = {
"num_gpus": MATRIXGAME_PARAMS["num_gpus"],
"model_path": MATRIXGAME_PARAMS["model_path"],
"height": _MATRIXGAME_FULL_QUALITY_DEFAULTS.height,
"width": _MATRIXGAME_FULL_QUALITY_DEFAULTS.width,
"num_frames": MATRIXGAME_PARAMS["num_frames"], # default num_frames: 57
"num_inference_steps": _MATRIXGAME_FULL_QUALITY_DEFAULTS.num_inference_steps,
"guidance_scale": _MATRIXGAME_FULL_QUALITY_DEFAULTS.guidance_scale,
"seed": _MATRIXGAME_FULL_QUALITY_DEFAULTS.seed,
"keyboard_dim": MATRIXGAME_PARAMS["keyboard_dim"],
}
MODEL_TO_PARAMS = {
"Matrix-Game-2.0-Diffusers-Base": MATRIXGAME_PARAMS,
}
FULL_QUALITY_MODEL_TO_PARAMS = {
"Matrix-Game-2.0-Diffusers-Base": MATRIXGAME_FULL_QUALITY_PARAMS,
}
I2V_MODEL_TO_PARAMS = {}
@@ -70,19 +91,26 @@ def test_matrixgame_similarity(prompt, ATTENTION_BACKEND, model_id):
script_dir = os.path.dirname(os.path.abspath(__file__))
base_output_dir = os.path.join(script_dir, "generated_videos", model_id)
output_dir = os.path.join(base_output_dir, ATTENTION_BACKEND)
output_video_name = f"output.mp4"
output_dir = build_generated_output_dir(
script_dir,
device_reference_folder,
model_id,
ATTENTION_BACKEND,
)
output_video_name = "output.mp4"
os.makedirs(output_dir, exist_ok=True)
BASE_PARAMS = MODEL_TO_PARAMS[model_id]
params_map = select_ssim_params(
MODEL_TO_PARAMS,
FULL_QUALITY_MODEL_TO_PARAMS,
)
BASE_PARAMS = params_map[model_id]
num_inference_steps = BASE_PARAMS["num_inference_steps"]
# Create action conditions for MatrixGame
actions = create_action_presets(
BASE_PARAMS["num_frames"], keyboard_dim=BASE_PARAMS["keyboard_dim"],
seed=BASE_PARAMS["seed"]
BASE_PARAMS["num_frames"], keyboard_dim=BASE_PARAMS["keyboard_dim"], seed=BASE_PARAMS["seed"]
)
latent_frames = (BASE_PARAMS["num_frames"] - 1) // 4 + 1
grid_sizes = torch.tensor([latent_frames, 44, 80])
@@ -117,19 +145,18 @@ def test_matrixgame_similarity(prompt, ATTENTION_BACKEND, model_id):
if isinstance(generator.executor, MultiprocExecutor):
generator.executor.shutdown()
assert os.path.exists(output_dir), (
f"Output video was not generated at {output_dir}"
)
assert os.path.exists(output_dir), f"Output video was not generated at {output_dir}"
reference_folder = os.path.join(
script_dir, device_reference_folder, model_id, ATTENTION_BACKEND
reference_folder = build_reference_folder_path(
script_dir,
device_reference_folder,
model_id,
ATTENTION_BACKEND,
)
if not os.path.exists(reference_folder):
logger.error("Reference folder missing")
raise FileNotFoundError(
f"Reference video folder does not exist: {reference_folder}"
)
raise FileNotFoundError(f"Reference video folder does not exist: {reference_folder}")
# Find the matching reference video based on the prompt
reference_video_name = None
@@ -140,20 +167,14 @@ def test_matrixgame_similarity(prompt, ATTENTION_BACKEND, model_id):
break
if not reference_video_name:
logger.error(
f"Reference video not found for model: {model_id} with backend: {ATTENTION_BACKEND}"
)
logger.error(f"Reference video not found for model: {model_id} with backend: {ATTENTION_BACKEND}")
raise FileNotFoundError("Reference video missing")
reference_video_path = os.path.join(reference_folder, reference_video_name)
generated_video_path = os.path.join(output_dir, output_video_name)
logger.info(
f"Computing SSIM between {reference_video_path} and {generated_video_path}"
)
ssim_values = compute_video_ssim_torchvision(
reference_video_path, generated_video_path, use_ms_ssim=True
)
logger.info(f"Computing SSIM between {reference_video_path} and {generated_video_path}")
ssim_values = compute_video_ssim_torchvision(reference_video_path, generated_video_path, use_ms_ssim=True)
mean_ssim = ssim_values[0]
logger.info(f"SSIM mean value: {mean_ssim}")
+94 -176
View File
@@ -1,41 +1,42 @@
# SPDX-License-Identifier: Apache-2.0
from __future__ import annotations
import os
import shlex
import logging
import pytest
import torch
logger = logging.getLogger(__name__)
from fastvideo.configs.sample.sd35 import SD35SamplingParam
from fastvideo.logger import init_logger
from fastvideo.tests.ssim.inference_similarity_utils import (
run_text_to_video_similarity_test,
)
from fastvideo.tests.ssim.reference_utils import (
get_cuda_device_name,
resolve_device_reference_folder,
)
logger = init_logger(__name__)
REQUIRED_GPUS = 1
def _device_reference_folder() -> str:
"""Pick a reference folder name based on the current CUDA device."""
suffix = "_reference_videos"
device_name = torch.cuda.get_device_name(0)
if "A40" in device_name:
return "A40" + suffix
if "L40S" in device_name:
return "L40S" + suffix
if "H100" in device_name:
return "H100" + suffix
if "RTX 4090" in device_name or "4090" in device_name:
return "RTX4090" + suffix
logger.warning(
"Unsupported device for ssim tests: %s; using L40S references", device_name
)
return "L40S" + suffix
SD35_MODEL_PATH = os.getenv(
"SD35_MODEL_DIR",
"/FastVideo/official_weights/stabilityai__stable-diffusion-3.5-medium",
"stabilityai/stable-diffusion-3.5-medium",
)
device_reference_folder = resolve_device_reference_folder(
(
("A40", "A40"),
("L40S", "L40S"),
("H100", "H100"),
("H200", "H200"),
("RTX 4090", "RTX4090"),
("4090", "RTX4090"),
),
device_name=get_cuda_device_name(),
fallback_device_prefix="L40S",
logger=logger,
)
MODEL_ID = "stabilityai__stable-diffusion-3.5-medium"
@@ -44,163 +45,80 @@ TEST_PROMPTS = [
"a photo of a cat",
]
pytestmark = pytest.mark.filterwarnings(
"ignore:.*torch.jit.script_method.*:DeprecationWarning",
SD35_PARAMS = {
"num_gpus": 1,
"model_path": SD35_MODEL_PATH,
"sp_size": 1,
"tp_size": 1,
"height": 256,
"width": 256,
"num_frames": 1,
"fps": 1,
"num_inference_steps": 8,
"guidance_scale": 6.0,
"seed": 0,
"neg_prompt": "lowres, blurry",
}
_SD35_FULL_QUALITY_DEFAULTS = SD35SamplingParam()
SD35_FULL_QUALITY_PARAMS = {
"num_gpus": 1,
"model_path": SD35_MODEL_PATH,
"sp_size": 1,
"tp_size": 1,
"height": _SD35_FULL_QUALITY_DEFAULTS.height,
"width": _SD35_FULL_QUALITY_DEFAULTS.width,
"num_frames": SD35_PARAMS["num_frames"],
"fps": _SD35_FULL_QUALITY_DEFAULTS.fps,
"num_inference_steps": (_SD35_FULL_QUALITY_DEFAULTS.num_inference_steps),
"guidance_scale": _SD35_FULL_QUALITY_DEFAULTS.guidance_scale,
"seed": _SD35_FULL_QUALITY_DEFAULTS.seed,
"neg_prompt": _SD35_FULL_QUALITY_DEFAULTS.negative_prompt,
}
SD35_MODEL_TO_PARAMS = {
MODEL_ID: SD35_PARAMS,
}
SD35_FULL_QUALITY_MODEL_TO_PARAMS = {
MODEL_ID: SD35_FULL_QUALITY_PARAMS,
}
@pytest.mark.skipif(
not torch.cuda.is_available(),
reason="SD3.5 SSIM test requires CUDA",
)
@pytest.mark.skipif(not torch.cuda.is_available(), reason="SD3.5 SSIM test requires CUDA")
@pytest.mark.parametrize("ATTENTION_BACKEND", ["TORCH_SDPA"])
@pytest.mark.parametrize("prompt", TEST_PROMPTS)
def test_sd35_similarity(prompt: str, ATTENTION_BACKEND: str) -> None:
from fastvideo import VideoGenerator
from fastvideo.tests.utils import (
compute_video_ssim_torchvision,
write_ssim_results,
)
@pytest.mark.parametrize("attention_backend_name", ["TORCH_SDPA"])
@pytest.mark.parametrize("model_id", list(SD35_MODEL_TO_PARAMS.keys()))
def test_sd35_similarity(
prompt: str,
attention_backend_name: str,
model_id: str,
) -> None:
is_hf_repo = "/" in SD35_MODEL_PATH and not SD35_MODEL_PATH.startswith("/")
if not is_hf_repo and not os.path.isdir(SD35_MODEL_PATH):
pytest.skip(f"SD3.5 weights not found at {SD35_MODEL_PATH} (set SD35_MODEL_DIR to override)")
if not os.path.isdir(SD35_MODEL_PATH):
pytest.skip(
f"SD3.5 weights not found at {SD35_MODEL_PATH} (set SD35_MODEL_DIR to override)"
)
old_backend = os.environ.get("FASTVIDEO_ATTENTION_BACKEND")
old_transformers_verbosity = os.environ.get("TRANSFORMERS_VERBOSITY")
os.environ["FASTVIDEO_ATTENTION_BACKEND"] = ATTENTION_BACKEND
os.environ["TRANSFORMERS_VERBOSITY"] = "error"
try:
script_dir = os.path.dirname(os.path.abspath(__file__))
output_dir = os.path.join(
script_dir, "generated_videos", MODEL_ID, ATTENTION_BACKEND
)
os.makedirs(output_dir, exist_ok=True)
prompt_prefix = prompt[:100].strip()
output_video_name = f"{prompt_prefix}.mp4"
expected_video_path = os.path.join(output_dir, output_video_name)
for filename in os.listdir(output_dir):
if filename.endswith(".mp4") and filename.startswith(prompt_prefix):
try:
os.remove(os.path.join(output_dir, filename))
except FileNotFoundError:
pass
init_kwargs = {
"num_gpus": 1,
# Save a single-frame MP4 to reuse the existing video-SSIM harness.
run_text_to_video_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=SD35_MODEL_TO_PARAMS,
full_quality_params_map=SD35_FULL_QUALITY_MODEL_TO_PARAMS,
min_acceptable_ssim=0.98,
init_kwargs_override={
"workload_type": "t2v",
"sp_size": 1,
"tp_size": 1,
"dit_cpu_offload": False,
"dit_layerwise_offload": False,
"use_fsdp_inference": False,
"text_encoder_cpu_offload": False,
"vae_cpu_offload": False,
"image_encoder_cpu_offload": False,
"pin_cpu_memory": False,
"use_fsdp_inference": False,
}
num_inference_steps = 8
generation_kwargs = {
"output_path": output_dir,
"height": 256,
"width": 256,
"num_frames": 1,
"fps": 1,
"num_inference_steps": num_inference_steps,
"guidance_scale": 6.0,
"seed": 0,
"negative_prompt": "lowres, blurry",
},
generation_kwargs_override={
"save_video": True,
}
generator = VideoGenerator.from_pretrained(model_path=SD35_MODEL_PATH, **init_kwargs)
try:
generator.generate_video(prompt, **generation_kwargs)
finally:
generator.shutdown()
generated_video_path = None
if os.path.exists(expected_video_path):
generated_video_path = expected_video_path
else:
candidates = [
os.path.join(output_dir, f)
for f in os.listdir(output_dir)
if f.endswith(".mp4") and f.startswith(prompt_prefix)
]
if candidates:
generated_video_path = max(candidates, key=os.path.getmtime)
assert generated_video_path is not None and os.path.exists(generated_video_path), (
f"Output video was not generated under {output_dir} for prompt '{prompt}'"
)
device_reference_folder = _device_reference_folder()
reference_folder = os.path.join(script_dir, device_reference_folder, MODEL_ID, ATTENTION_BACKEND)
if not os.path.exists(reference_folder):
bless_cmd = (
f"mkdir -p {shlex.quote(reference_folder)} && "
f"cp {shlex.quote(generated_video_path)} {shlex.quote(reference_folder)}/"
)
pytest.fail(
f"Reference folder does not exist: {reference_folder}\n"
f"Generated video saved at: {generated_video_path}\n"
"To bless references, copy the generated mp4 into the reference folder with a matching name.\n"
f"Example:\n {bless_cmd}"
)
reference_video_path = os.path.join(reference_folder, output_video_name)
if not os.path.exists(reference_video_path):
reference_video_name = None
for filename in os.listdir(reference_folder):
if filename.endswith(".mp4") and filename.startswith(prompt_prefix):
reference_video_name = filename
break
if not reference_video_name:
bless_cmd = (
f"cp {shlex.quote(generated_video_path)} {shlex.quote(reference_folder)}/"
)
pytest.fail(
f"Reference video not found for prompt '{prompt}' under: {reference_folder}\n"
f"Expected name: {output_video_name}\n"
f"Generated video saved at: {generated_video_path}\n"
"To bless references, copy the generated mp4 into the reference folder.\n"
f"Example:\n {bless_cmd}"
)
reference_video_path = os.path.join(reference_folder, reference_video_name)
logger.info("Computing SSIM between %s and %s", reference_video_path, generated_video_path)
ssim_values = compute_video_ssim_torchvision(
reference_video_path, generated_video_path, use_ms_ssim=True
)
mean_ssim = ssim_values[0]
logger.info("SSIM mean value: %s", mean_ssim)
write_ssim_results(
output_dir,
ssim_values,
reference_video_path,
generated_video_path,
num_inference_steps,
prompt,
)
min_acceptable_ssim = 0.98
assert mean_ssim >= min_acceptable_ssim, (
f"SSIM value {mean_ssim} is below threshold {min_acceptable_ssim} "
f"for {MODEL_ID} with backend {ATTENTION_BACKEND}"
)
finally:
if old_backend is None:
os.environ.pop("FASTVIDEO_ATTENTION_BACKEND", None)
else:
os.environ["FASTVIDEO_ATTENTION_BACKEND"] = old_backend
if old_transformers_verbosity is None:
os.environ.pop("TRANSFORMERS_VERBOSITY", None)
else:
os.environ["TRANSFORMERS_VERBOSITY"] = old_transformers_verbosity
},
)
@@ -1,184 +1,144 @@
# SPDX-License-Identifier: Apache-2.0
"""
SSIM-based similarity test for TurboDiffusion inference.
TurboDiffusion uses the SLA (Sparse-Linear Attention) backend and RCM scheduler
for 1-4 step video generation.
"""
import os
import torch
import pytest
from fastvideo import VideoGenerator
from fastvideo.configs.sample.turbodiffusion import (
TurboDiffusionI2V_A14B_SamplingParam,
TurboDiffusionT2V_1_3B_SamplingParam,
)
from fastvideo.logger import init_logger
from fastvideo.tests.utils import compute_video_ssim_torchvision, write_ssim_results
from fastvideo.worker.multiproc_executor import MultiprocExecutor
from fastvideo.tests.ssim.inference_similarity_utils import (
DEVICE_MAPPINGS,
resolve_inference_device_reference_folder,
run_image_to_video_similarity_test,
run_text_to_video_similarity_test,
)
from fastvideo.tests.ssim.reference_utils import (
get_cuda_device_name,
resolve_device_reference_folder,
)
logger = init_logger(__name__)
REQUIRED_GPUS = 4
device_name = torch.cuda.get_device_name()
device_reference_folder_suffix = '_reference_videos'
if "A40" in device_name:
device_reference_folder = "A40" + device_reference_folder_suffix
elif "L40S" in device_name:
device_reference_folder = "L40S" + device_reference_folder_suffix
else:
# device_reference_folder = "L40S" + device_reference_folder_suffix
logger.warning(f"Unsupported device for ssim tests: {device_name}, using L40S references")
device_name = get_cuda_device_name()
device_reference_folder = resolve_device_reference_folder(
DEVICE_MAPPINGS,
device_name=device_name,
)
if device_reference_folder is None:
raise ValueError(f"Unsupported device for ssim tests: {device_name}")
# TurboDiffusion parameters (1-4 step generation with RCM scheduler + SLA attention)
TURBODIFFUSION_PARAMS = {
"num_gpus": 4,
"model_path": "loayrashid/TurboWan2.1-T2V-1.3B-Diffusers",
"height": 480,
"width": 832,
"num_frames": 81,
"num_inference_steps": 4, # TurboDiffusion uses 1-4 steps
"guidance_scale": 1.0, # No CFG for TurboDiffusion
"num_inference_steps": 4,
"guidance_scale": 1.0,
"seed": 42,
"sp_size": 4,
"tp_size": 1,
"fps": 24,
}
_TURBODIFFUSION_FULL_QUALITY_DEFAULTS = TurboDiffusionT2V_1_3B_SamplingParam()
TURBODIFFUSION_FULL_QUALITY_PARAMS = {
"num_gpus": TURBODIFFUSION_PARAMS["num_gpus"],
"model_path": TURBODIFFUSION_PARAMS["model_path"],
"height": _TURBODIFFUSION_FULL_QUALITY_DEFAULTS.height,
"width": _TURBODIFFUSION_FULL_QUALITY_DEFAULTS.width,
"num_frames": TURBODIFFUSION_PARAMS["num_frames"],
"num_inference_steps": (_TURBODIFFUSION_FULL_QUALITY_DEFAULTS.num_inference_steps),
"guidance_scale": (_TURBODIFFUSION_FULL_QUALITY_DEFAULTS.guidance_scale),
"seed": _TURBODIFFUSION_FULL_QUALITY_DEFAULTS.seed,
"sp_size": TURBODIFFUSION_PARAMS["sp_size"],
"tp_size": TURBODIFFUSION_PARAMS["tp_size"],
"fps": _TURBODIFFUSION_FULL_QUALITY_DEFAULTS.fps,
}
TURBODIFFUSION_MODEL_TO_PARAMS = {
"TurboWan2.1-T2V-1.3B-Diffusers": TURBODIFFUSION_PARAMS,
}
FULL_QUALITY_TURBODIFFUSION_MODEL_TO_PARAMS = {
"TurboWan2.1-T2V-1.3B-Diffusers": (TURBODIFFUSION_FULL_QUALITY_PARAMS),
}
TURBODIFFUSION_TEST_PROMPTS = [
"Will Smith casually eats noodles, his relaxed demeanor contrasting with the energetic background of a bustling street food market. The scene captures a mix of humor and authenticity. Mid-shot framing, vibrant lighting.",
"Will Smith casually eats noodles, his relaxed demeanor contrasting "
"with the energetic background of a bustling street food market. "
"The scene captures a mix of humor and authenticity. Mid-shot "
"framing, vibrant lighting.",
]
@pytest.mark.parametrize("prompt", TURBODIFFUSION_TEST_PROMPTS)
@pytest.mark.parametrize("model_id", list(TURBODIFFUSION_MODEL_TO_PARAMS.keys()))
def test_turbodiffusion_inference_similarity(prompt, model_id):
"""
Test that runs TurboDiffusion inference with SLA attention and RCM scheduler,
then compares the output to reference videos using SSIM.
"""
# TurboDiffusion requires SLA attention backend
ATTENTION_BACKEND = "SLA_ATTN"
os.environ["FASTVIDEO_ATTENTION_BACKEND"] = ATTENTION_BACKEND
script_dir = os.path.dirname(os.path.abspath(__file__))
base_output_dir = os.path.join(script_dir, 'generated_videos', model_id)
output_dir = os.path.join(base_output_dir, ATTENTION_BACKEND)
output_video_name = f"{prompt[:100].strip()}.mp4"
os.makedirs(output_dir, exist_ok=True)
BASE_PARAMS = TURBODIFFUSION_MODEL_TO_PARAMS[model_id]
num_inference_steps = BASE_PARAMS["num_inference_steps"]
init_kwargs = {
"num_gpus": BASE_PARAMS["num_gpus"],
"sp_size": BASE_PARAMS["sp_size"],
"tp_size": BASE_PARAMS["tp_size"],
"override_pipeline_cls_name": "TurboDiffusionPipeline",
}
generation_kwargs = {
"num_inference_steps": num_inference_steps,
"output_path": output_dir,
"height": BASE_PARAMS["height"],
"width": BASE_PARAMS["width"],
"num_frames": BASE_PARAMS["num_frames"],
"guidance_scale": BASE_PARAMS["guidance_scale"],
"seed": BASE_PARAMS["seed"],
"fps": BASE_PARAMS["fps"],
}
generator = VideoGenerator.from_pretrained(model_path=BASE_PARAMS["model_path"], **init_kwargs)
generator.generate_video(prompt, **generation_kwargs)
if isinstance(generator.executor, MultiprocExecutor):
generator.executor.shutdown()
assert os.path.exists(output_dir), f"Output video was not generated at {output_dir}"
reference_folder = os.path.join(
script_dir, device_reference_folder, model_id, ATTENTION_BACKEND
)
if not os.path.exists(reference_folder):
logger.error("Reference folder missing")
raise FileNotFoundError(
f"Reference video folder does not exist: {reference_folder}"
)
# Find the matching reference video based on the prompt
reference_video_name = None
for filename in os.listdir(reference_folder):
if filename.endswith('.mp4') and prompt[:100].strip() in filename:
reference_video_name = filename
break
if not reference_video_name:
logger.error(
f"Reference video not found for prompt: {prompt} with backend: {ATTENTION_BACKEND}"
)
raise FileNotFoundError(f"Reference video missing")
reference_video_path = os.path.join(reference_folder, reference_video_name)
generated_video_path = os.path.join(output_dir, output_video_name)
logger.info(
f"Computing SSIM between {reference_video_path} and {generated_video_path}"
)
ssim_values = compute_video_ssim_torchvision(
reference_video_path, generated_video_path, use_ms_ssim=True
)
mean_ssim = ssim_values[0]
logger.info(f"SSIM mean value: {mean_ssim}")
logger.info(f"Writing SSIM results to directory: {output_dir}")
success = write_ssim_results(
output_dir, ssim_values, reference_video_path,
generated_video_path, num_inference_steps, prompt
)
if not success:
logger.error("Failed to write SSIM results to file")
# TurboDiffusion uses fewer steps, may have slightly lower SSIM
min_acceptable_ssim = 0.95
assert mean_ssim >= min_acceptable_ssim, (
f"SSIM value {mean_ssim} is below threshold {min_acceptable_ssim} "
f"for {model_id} with backend {ATTENTION_BACKEND}"
@pytest.mark.parametrize(
"model_id",
list(TURBODIFFUSION_MODEL_TO_PARAMS.keys()),
)
def test_turbodiffusion_inference_similarity(
prompt: str,
model_id: str,
) -> None:
run_text_to_video_similarity_test(
logger=logger,
script_dir=os.path.dirname(os.path.abspath(__file__)),
device_reference_folder=device_reference_folder,
prompt=prompt,
attention_backend_name="SLA_ATTN",
model_id=model_id,
default_params_map=TURBODIFFUSION_MODEL_TO_PARAMS,
full_quality_params_map=(FULL_QUALITY_TURBODIFFUSION_MODEL_TO_PARAMS),
min_acceptable_ssim=0.95,
init_kwargs_override={
"override_pipeline_cls_name": "TurboDiffusionPipeline",
},
)
# TurboDiffusion I2V parameters (dual-model with RCM scheduler + SLA attention)
TURBODIFFUSION_I2V_PARAMS = {
"num_gpus": 4,
"model_path": "loayrashid/TurboWan2.2-I2V-A14B-Diffusers",
"height": 480,
"width": 832,
"num_frames": 45,
"num_inference_steps": 4, # TurboDiffusion uses 1-4 steps
"guidance_scale": 1.0, # No CFG for TurboDiffusion
"num_inference_steps": 4,
"guidance_scale": 1.0,
"seed": 42,
"sp_size": 4,
"tp_size": 1,
"fps": 24,
}
_TURBODIFFUSION_I2V_FULL_QUALITY_DEFAULTS = TurboDiffusionI2V_A14B_SamplingParam()
TURBODIFFUSION_I2V_FULL_QUALITY_PARAMS = {
"num_gpus": TURBODIFFUSION_I2V_PARAMS["num_gpus"],
"model_path": TURBODIFFUSION_I2V_PARAMS["model_path"],
"height": _TURBODIFFUSION_I2V_FULL_QUALITY_DEFAULTS.height,
"width": _TURBODIFFUSION_I2V_FULL_QUALITY_DEFAULTS.width,
"num_frames": TURBODIFFUSION_I2V_PARAMS["num_frames"],
"num_inference_steps": (_TURBODIFFUSION_I2V_FULL_QUALITY_DEFAULTS.num_inference_steps),
"guidance_scale": (_TURBODIFFUSION_I2V_FULL_QUALITY_DEFAULTS.guidance_scale),
"seed": _TURBODIFFUSION_I2V_FULL_QUALITY_DEFAULTS.seed,
"sp_size": TURBODIFFUSION_I2V_PARAMS["sp_size"],
"tp_size": TURBODIFFUSION_I2V_PARAMS["tp_size"],
"fps": _TURBODIFFUSION_I2V_FULL_QUALITY_DEFAULTS.fps,
}
TURBODIFFUSION_I2V_MODEL_TO_PARAMS = {
"TurboWan2.2-I2V-A14B-Diffusers": TURBODIFFUSION_I2V_PARAMS,
}
FULL_QUALITY_TURBODIFFUSION_I2V_MODEL_TO_PARAMS = {
"TurboWan2.2-I2V-A14B-Diffusers": (TURBODIFFUSION_I2V_FULL_QUALITY_PARAMS),
}
TURBODIFFUSION_I2V_TEST_PROMPTS = [
"An astronaut hatching from an egg, on the surface of the moon, the darkness and depth of space realised in the background. High quality, ultrarealistic detail and breath-taking movie-like camera shot.",
"An astronaut hatching from an egg, on the surface of the moon, "
"the darkness and depth of space realised in the background. "
"High quality, ultrarealistic detail and breath-taking "
"movie-like camera shot.",
]
TURBODIFFUSION_I2V_IMAGE_PATHS = [
@@ -187,114 +147,27 @@ TURBODIFFUSION_I2V_IMAGE_PATHS = [
@pytest.mark.parametrize("prompt", TURBODIFFUSION_I2V_TEST_PROMPTS)
@pytest.mark.parametrize("model_id", list(TURBODIFFUSION_I2V_MODEL_TO_PARAMS.keys()))
def test_turbodiffusion_i2v_inference_similarity(prompt, model_id):
"""
Test that runs TurboDiffusion I2V inference with dual-model switching,
then compares the output to reference videos using SSIM.
"""
# TurboDiffusion requires SLA attention backend
ATTENTION_BACKEND = "SLA_ATTN"
os.environ["FASTVIDEO_ATTENTION_BACKEND"] = ATTENTION_BACKEND
assert len(TURBODIFFUSION_I2V_TEST_PROMPTS) == len(TURBODIFFUSION_I2V_IMAGE_PATHS), \
"Expect number of prompts equal to number of images"
script_dir = os.path.dirname(os.path.abspath(__file__))
base_output_dir = os.path.join(script_dir, 'generated_videos', model_id)
output_dir = os.path.join(base_output_dir, ATTENTION_BACKEND)
output_video_name = f"{prompt[:100].strip()}.mp4"
os.makedirs(output_dir, exist_ok=True)
BASE_PARAMS = TURBODIFFUSION_I2V_MODEL_TO_PARAMS[model_id]
num_inference_steps = BASE_PARAMS["num_inference_steps"]
@pytest.mark.parametrize(
"model_id",
list(TURBODIFFUSION_I2V_MODEL_TO_PARAMS.keys()),
)
def test_turbodiffusion_i2v_inference_similarity(
prompt: str,
model_id: str,
) -> None:
image_path = TURBODIFFUSION_I2V_IMAGE_PATHS[TURBODIFFUSION_I2V_TEST_PROMPTS.index(prompt)]
init_kwargs = {
"num_gpus": BASE_PARAMS["num_gpus"],
"sp_size": BASE_PARAMS["sp_size"],
"tp_size": BASE_PARAMS["tp_size"],
"override_pipeline_cls_name": "TurboDiffusionI2VPipeline",
# Keep both transformers in VRAM - avoids CPU RAM bottleneck
"dit_cpu_offload": False,
"use_fsdp_inference": True,
"dit_layerwise_offload": False,
}
generation_kwargs = {
"num_inference_steps": num_inference_steps,
"output_path": output_dir,
"image_path": image_path,
"height": BASE_PARAMS["height"],
"width": BASE_PARAMS["width"],
"num_frames": BASE_PARAMS["num_frames"],
"guidance_scale": BASE_PARAMS["guidance_scale"],
"seed": BASE_PARAMS["seed"],
"fps": BASE_PARAMS["fps"],
}
generator = VideoGenerator.from_pretrained(
model_path=BASE_PARAMS["model_path"],
**init_kwargs
)
generator.generate_video(prompt, **generation_kwargs)
if isinstance(generator.executor, MultiprocExecutor):
generator.executor.shutdown()
assert os.path.exists(output_dir), f"Output video was not generated at {output_dir}"
reference_folder = os.path.join(
script_dir, device_reference_folder, model_id, ATTENTION_BACKEND
)
if not os.path.exists(reference_folder):
logger.error("Reference folder missing")
raise FileNotFoundError(
f"Reference video folder does not exist: {reference_folder}"
)
# Find the matching reference video based on the prompt
reference_video_name = None
for filename in os.listdir(reference_folder):
if filename.endswith('.mp4') and prompt[:100].strip() in filename:
reference_video_name = filename
break
if not reference_video_name:
logger.error(
f"Reference video not found for prompt: {prompt} with backend: {ATTENTION_BACKEND}"
)
raise FileNotFoundError(f"Reference video missing")
reference_video_path = os.path.join(reference_folder, reference_video_name)
generated_video_path = os.path.join(output_dir, output_video_name)
logger.info(
f"Computing SSIM between {reference_video_path} and {generated_video_path}"
)
ssim_values = compute_video_ssim_torchvision(
reference_video_path, generated_video_path, use_ms_ssim=True
)
mean_ssim = ssim_values[0]
logger.info(f"SSIM mean value: {mean_ssim}")
logger.info(f"Writing SSIM results to directory: {output_dir}")
success = write_ssim_results(
output_dir, ssim_values, reference_video_path,
generated_video_path, num_inference_steps, prompt
)
if not success:
logger.error("Failed to write SSIM results to file")
# TurboDiffusion I2V uses fewer steps, may have slightly lower SSIM
min_acceptable_ssim = 0.95
assert mean_ssim >= min_acceptable_ssim, (
f"SSIM value {mean_ssim} is below threshold {min_acceptable_ssim} "
f"for {model_id} with backend {ATTENTION_BACKEND}"
run_image_to_video_similarity_test(
logger=logger,
script_dir=os.path.dirname(os.path.abspath(__file__)),
device_reference_folder=device_reference_folder,
prompt=prompt,
image_path=image_path,
attention_backend_name="SLA_ATTN",
model_id=model_id,
default_params_map=TURBODIFFUSION_I2V_MODEL_TO_PARAMS,
full_quality_params_map=(FULL_QUALITY_TURBODIFFUSION_I2V_MODEL_TO_PARAMS),
min_acceptable_ssim=0.95,
init_kwargs_override={
"override_pipeline_cls_name": ("TurboDiffusionI2VPipeline"),
},
)
@@ -0,0 +1,99 @@
# SPDX-License-Identifier: Apache-2.0
import os
import pytest
from fastvideo.configs.sample.wan import WanI2V_14B_480P_SamplingParam
from fastvideo.logger import init_logger
from fastvideo.tests.ssim.inference_similarity_utils import (
resolve_inference_device_reference_folder,
run_image_to_video_similarity_test,
)
logger = init_logger(__name__)
REQUIRED_GPUS = 2
device_reference_folder = resolve_inference_device_reference_folder(logger)
WAN_I2V_PARAMS = {
"num_gpus": 2,
"model_path": "Wan-AI/Wan2.1-I2V-14B-480P-Diffusers",
"height": 480,
"width": 832,
"num_frames": 45,
"num_inference_steps": 2,
"guidance_scale": 5.0,
"embedded_cfg_scale": 6,
"flow_shift": 7.0,
"seed": 1024,
"sp_size": 2,
"tp_size": 1,
"vae_sp": True,
"fps": 24,
"neg_prompt": (
"Bright tones, overexposed, static, blurred details, subtitles, style, "
"works, paintings, images, static, overall gray, worst quality, low "
"quality, JPEG compression residue, ugly, incomplete, extra fingers, "
"poorly drawn hands, poorly drawn faces, deformed, disfigured, "
"misshapen limbs, fused fingers, still picture, messy background, "
"three legs, many people in the background, walking backwards"
),
"text-encoder-precision": ("fp32",),
}
_WAN_I2V_FULL_QUALITY_DEFAULTS = WanI2V_14B_480P_SamplingParam()
WAN_I2V_FULL_QUALITY_PARAMS = {
"num_gpus": WAN_I2V_PARAMS["num_gpus"],
"model_path": WAN_I2V_PARAMS["model_path"],
"height": _WAN_I2V_FULL_QUALITY_DEFAULTS.height,
"width": _WAN_I2V_FULL_QUALITY_DEFAULTS.width,
"num_frames": WAN_I2V_PARAMS["num_frames"], # default num_frames: 81
"num_inference_steps": _WAN_I2V_FULL_QUALITY_DEFAULTS.num_inference_steps,
"guidance_scale": _WAN_I2V_FULL_QUALITY_DEFAULTS.guidance_scale,
"embedded_cfg_scale": WAN_I2V_PARAMS["embedded_cfg_scale"],
"flow_shift": WAN_I2V_PARAMS["flow_shift"],
"seed": _WAN_I2V_FULL_QUALITY_DEFAULTS.seed,
"sp_size": WAN_I2V_PARAMS["sp_size"],
"tp_size": WAN_I2V_PARAMS["tp_size"],
"vae_sp": WAN_I2V_PARAMS["vae_sp"],
"fps": _WAN_I2V_FULL_QUALITY_DEFAULTS.fps,
"neg_prompt": _WAN_I2V_FULL_QUALITY_DEFAULTS.negative_prompt,
"text-encoder-precision": WAN_I2V_PARAMS["text-encoder-precision"],
}
WAN_I2V_MODEL_TO_PARAMS = {
"Wan2.1-I2V-14B-480P-Diffusers": WAN_I2V_PARAMS,
}
FULL_QUALITY_WAN_I2V_MODEL_TO_PARAMS = {
"Wan2.1-I2V-14B-480P-Diffusers": WAN_I2V_FULL_QUALITY_PARAMS,
}
WAN_I2V_TEST_CASES = [
(
"An astronaut hatching from an egg, on the surface of the moon, the darkness and depth of space realised in the background. High quality, ultrarealistic detail and breath-taking movie-like camera shot.",
"https://huggingface.co/datasets/huggingface/documentation-images/resolve/main/diffusers/astronaut.jpg",
),
]
@pytest.mark.parametrize(("prompt", "image_path"), WAN_I2V_TEST_CASES)
@pytest.mark.parametrize("attention_backend_name", ["FLASH_ATTN"])
@pytest.mark.parametrize("model_id", list(WAN_I2V_MODEL_TO_PARAMS.keys()))
def test_wan_i2v_inference_similarity(
prompt: str,
image_path: str,
attention_backend_name: str,
model_id: str,
) -> None:
run_image_to_video_similarity_test(
logger=logger,
script_dir=os.path.dirname(os.path.abspath(__file__)),
device_reference_folder=device_reference_folder,
prompt=prompt,
image_path=image_path,
attention_backend_name=attention_backend_name,
model_id=model_id,
default_params_map=WAN_I2V_MODEL_TO_PARAMS,
full_quality_params_map=FULL_QUALITY_WAN_I2V_MODEL_TO_PARAMS,
min_acceptable_ssim=0.97,
)
@@ -0,0 +1,94 @@
# SPDX-License-Identifier: Apache-2.0
import os
import pytest
from fastvideo.configs.sample.wan import WanT2V_1_3B_SamplingParam
from fastvideo.logger import init_logger
from fastvideo.tests.ssim.inference_similarity_utils import (
resolve_inference_device_reference_folder,
run_text_to_video_similarity_test,
)
logger = init_logger(__name__)
REQUIRED_GPUS = 2
device_reference_folder = resolve_inference_device_reference_folder(logger)
WAN_T2V_PARAMS = {
"num_gpus": 2,
"model_path": "Wan-AI/Wan2.1-T2V-1.3B-Diffusers",
"height": 480,
"width": 832,
"num_frames": 45,
"num_inference_steps": 4,
"guidance_scale": 3,
"embedded_cfg_scale": 6,
"flow_shift": 7.0,
"seed": 1024,
"sp_size": 2,
"tp_size": 1,
"vae_sp": True,
"fps": 24,
"neg_prompt": (
"Bright tones, overexposed, static, blurred details, subtitles, style, "
"works, paintings, images, static, overall gray, worst quality, low "
"quality, JPEG compression residue, ugly, incomplete, extra fingers, "
"poorly drawn hands, poorly drawn faces, deformed, disfigured, "
"misshapen limbs, fused fingers, still picture, messy background, "
"three legs, many people in the background, walking backwards"
),
"text-encoder-precision": ("fp32",),
}
_WAN_T2V_FULL_QUALITY_DEFAULTS = WanT2V_1_3B_SamplingParam()
WAN_T2V_FULL_QUALITY_PARAMS = {
"num_gpus": WAN_T2V_PARAMS["num_gpus"],
"model_path": WAN_T2V_PARAMS["model_path"],
"height": _WAN_T2V_FULL_QUALITY_DEFAULTS.height,
"width": _WAN_T2V_FULL_QUALITY_DEFAULTS.width,
"num_frames": WAN_T2V_PARAMS["num_frames"], # default num_frames: 81
"num_inference_steps": _WAN_T2V_FULL_QUALITY_DEFAULTS.num_inference_steps,
"guidance_scale": _WAN_T2V_FULL_QUALITY_DEFAULTS.guidance_scale,
"embedded_cfg_scale": WAN_T2V_PARAMS["embedded_cfg_scale"],
"flow_shift": WAN_T2V_PARAMS["flow_shift"],
"seed": _WAN_T2V_FULL_QUALITY_DEFAULTS.seed,
"sp_size": WAN_T2V_PARAMS["sp_size"],
"tp_size": WAN_T2V_PARAMS["tp_size"],
"vae_sp": WAN_T2V_PARAMS["vae_sp"],
"fps": _WAN_T2V_FULL_QUALITY_DEFAULTS.fps,
"neg_prompt": _WAN_T2V_FULL_QUALITY_DEFAULTS.negative_prompt,
"text-encoder-precision": WAN_T2V_PARAMS["text-encoder-precision"],
}
WAN_T2V_MODEL_TO_PARAMS = {
"Wan2.1-T2V-1.3B-Diffusers": WAN_T2V_PARAMS,
}
FULL_QUALITY_WAN_T2V_MODEL_TO_PARAMS = {
"Wan2.1-T2V-1.3B-Diffusers": WAN_T2V_FULL_QUALITY_PARAMS,
}
WAN_T2V_TEST_PROMPTS = [
"Will Smith casually eats noodles, his relaxed demeanor contrasting with the energetic background of a bustling street food market. The scene captures a mix of humor and authenticity. Mid-shot framing, vibrant lighting.",
]
@pytest.mark.parametrize("prompt", WAN_T2V_TEST_PROMPTS)
@pytest.mark.parametrize("attention_backend_name", ["FLASH_ATTN", "TORCH_SDPA"])
@pytest.mark.parametrize("model_id", list(WAN_T2V_MODEL_TO_PARAMS.keys()))
def test_wan_t2v_inference_similarity(
prompt: str,
attention_backend_name: str,
model_id: str,
) -> None:
run_text_to_video_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=WAN_T2V_MODEL_TO_PARAMS,
full_quality_params_map=FULL_QUALITY_WAN_T2V_MODEL_TO_PARAMS,
min_acceptable_ssim=0.93,
)
@@ -1,63 +0,0 @@
#!/bin/bash
# Script to update reference videos using videos from generated_videos directory
# Both directories should exist in the same directory as this script
set -e # Exit on any error
# Define directory paths
GENERATED_DIR="generated_videos"
REFERENCE_DIR="L40S_reference_videos"
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m' # No Color
echo -e "${YELLOW}Starting reference video update...${NC}"
# Check if generated_videos directory exists
if [ ! -d "$GENERATED_DIR" ]; then
echo -e "${RED}Error: $GENERATED_DIR directory not found!${NC}"
exit 1
fi
# Check if reference_videos directory exists
if [ ! -d "$REFERENCE_DIR" ]; then
echo -e "${RED}Error: $REFERENCE_DIR directory not found!${NC}"
exit 1
fi
# Function to copy videos recursively
copy_videos() {
local src_dir="$1"
local dst_dir="$2"
# Find all video files in the source directory
find "$src_dir" -type f \( -name "*.mp4" -o -name "*.avi" -o -name "*.mov" -o -name "*.mkv" -o -name "*.webm" -o -name "*.flv" \) | while read -r video_file; do
# Get relative path from source directory
relative_path="${video_file#$src_dir/}"
# Construct destination path
dst_file="$dst_dir/$relative_path"
# Create destination directory if it doesn't exist
dst_file_dir=$(dirname "$dst_file")
mkdir -p "$dst_file_dir"
# Copy the video file
echo -e "${GREEN}Copying: $relative_path${NC}"
cp "$video_file" "$dst_file"
done
}
# Perform the copy operation
echo -e "${YELLOW}Copying videos from $GENERATED_DIR to $REFERENCE_DIR...${NC}"
copy_videos "$GENERATED_DIR" "$REFERENCE_DIR"
echo -e "${GREEN}Reference videos updated successfully!${NC}"
# Show summary
video_count=$(find "$GENERATED_DIR" -type f \( -name "*.mp4" -o -name "*.avi" -o -name "*.mov" -o -name "*.mkv" -o -name "*.webm" -o -name "*.flv" \) | wc -l)
echo -e "${YELLOW}Total videos processed: $video_count${NC}"
@@ -7,9 +7,7 @@ import pytest
import torch
from fastvideo.configs.pipelines.base import PipelineConfig
from fastvideo.distributed.parallel_state import (
get_sp_parallel_rank,
get_sp_world_size)
from fastvideo.distributed.parallel_state import get_sp_parallel_rank, get_sp_world_size
from fastvideo.logger import init_logger
from fastvideo.models.loader.component_loader import TransformerLoader
from fastvideo.fastvideo_args import FastVideoArgs
@@ -24,9 +22,7 @@ os.environ["MASTER_ADDR"] = "localhost"
os.environ["MASTER_PORT"] = "29503"
BASE_MODEL_PATH = "hunyuanvideo-community/HunyuanVideo"
MODEL_PATH = maybe_download_model(BASE_MODEL_PATH,
local_dir=os.path.join("data", BASE_MODEL_PATH) # store in the large /workspace disk on Runpod
)
MODEL_PATH = maybe_download_model(BASE_MODEL_PATH, local_dir=os.path.join("data", BASE_MODEL_PATH))
TRANSFORMER_PATH = os.path.join(MODEL_PATH, "transformer")
CONFIG_PATH = os.path.join(TRANSFORMER_PATH, "config.json")
@@ -41,9 +37,7 @@ REFERENCE_LATENT = 93.23263549804688
@pytest.mark.usefixtures("distributed_setup")
@pytest.mark.skip(reason="Skipping test_hunyuanvideo_distributed")
def test_hunyuanvideo_distributed():
logger.info(
f"Initializing process: rank={RANK}, local_rank={LOCAL_RANK}, world_size={WORLD_SIZE}"
)
logger.info(f"Initializing process: rank={RANK}, local_rank={LOCAL_RANK}, world_size={WORLD_SIZE}")
torch.cuda.set_device(f"cuda:{LOCAL_RANK}")
@@ -51,9 +45,7 @@ def test_hunyuanvideo_distributed():
sp_rank = get_sp_parallel_rank()
sp_world_size = get_sp_world_size()
logger.info(
f"Process rank {RANK} initialized with SP rank {sp_rank} in SP world size {sp_world_size}"
)
logger.info(f"Process rank {RANK} initialized with SP rank {sp_rank} in SP world size {sp_world_size}")
config = json.load(open(CONFIG_PATH))
# remove "_class_name": "HunyuanVideoTransformer3DModel", "_diffusers_version": "0.32.0.dev0",
@@ -62,9 +54,11 @@ def test_hunyuanvideo_distributed():
config.pop("_diffusers_version")
precision_str = "bf16"
args = FastVideoArgs(model_path=TRANSFORMER_PATH,
dit_cpu_offload=True,
pipeline_config=PipelineConfig(dit_config=HunyuanVideoConfig(), dit_precision=precision_str))
args = FastVideoArgs(
model_path=TRANSFORMER_PATH,
dit_cpu_offload=True,
pipeline_config=PipelineConfig(dit_config=HunyuanVideoConfig(), dit_precision=precision_str),
)
args.device = torch.device(f"cuda:{LOCAL_RANK}")
loader = TransformerLoader()
@@ -78,23 +72,12 @@ def test_hunyuanvideo_distributed():
device = torch.device(f"cuda:{LOCAL_RANK}")
# Video latents [B, C, T, H, W]
hidden_states = torch.randn(batch_size,
16,
8,
16,
16,
device=device,
dtype=torch.bfloat16)
hidden_states = torch.randn(batch_size, 16, 8, 16, 16, device=device, dtype=torch.bfloat16)
chunk_per_rank = hidden_states.shape[2] // sp_world_size
hidden_states = hidden_states[:, :, sp_rank * chunk_per_rank:(sp_rank + 1) *
chunk_per_rank]
hidden_states = hidden_states[:, :, sp_rank * chunk_per_rank : (sp_rank + 1) * chunk_per_rank]
# Text embeddings [B, L, D] (including global token)
encoder_hidden_states = torch.randn(batch_size,
seq_len + 1,
4096,
device=device,
dtype=torch.bfloat16)
encoder_hidden_states = torch.randn(batch_size, seq_len + 1, 4096, device=device, dtype=torch.bfloat16)
# Timestep
timestep = torch.tensor([500], device=device, dtype=torch.bfloat16)
@@ -117,7 +100,5 @@ def test_hunyuanvideo_distributed():
# Check if latents are similar
diff_output_latents = abs(REFERENCE_LATENT - latent)
logger.info(
f"Reference latent: {REFERENCE_LATENT}, Current latent: {latent}"
)
logger.info(f"Reference latent: {REFERENCE_LATENT}, Current latent: {latent}")
assert diff_output_latents < 1e-4, f"Output latents differ significantly: max diff = {diff_output_latents}"
+22 -32
View File
@@ -23,9 +23,7 @@ os.environ["MASTER_ADDR"] = "localhost"
os.environ["MASTER_PORT"] = "29503"
BASE_MODEL_PATH = "Wan-AI/Wan2.1-T2V-1.3B-Diffusers"
MODEL_PATH = maybe_download_model(BASE_MODEL_PATH,
local_dir=os.path.join("data", BASE_MODEL_PATH) # store in the large /workspace disk on Runpod
)
MODEL_PATH = maybe_download_model(BASE_MODEL_PATH, local_dir=os.path.join("data", BASE_MODEL_PATH))
TRANSFORMER_PATH = os.path.join(MODEL_PATH, "transformer")
@@ -34,22 +32,25 @@ def test_wan_transformer():
device = torch.device("cuda:0" if torch.cuda.is_available() else "cpu")
precision = torch.bfloat16
precision_str = "bf16"
args = FastVideoArgs(model_path=TRANSFORMER_PATH,
dit_cpu_offload=True,
pipeline_config=PipelineConfig(dit_config=WanVideoConfig(), dit_precision=precision_str))
args = FastVideoArgs(
model_path=TRANSFORMER_PATH,
dit_cpu_offload=True,
pipeline_config=PipelineConfig(dit_config=WanVideoConfig(), dit_precision=precision_str),
)
args.device = device
loader = TransformerLoader()
model2 = loader.load(TRANSFORMER_PATH, args).to(dtype=precision)
model1 = WanTransformer3DModel.from_pretrained(
TRANSFORMER_PATH, device=device,
torch_dtype=precision).to(device, dtype=precision).requires_grad_(False)
model1 = (
WanTransformer3DModel.from_pretrained(TRANSFORMER_PATH, device=device, torch_dtype=precision)
.to(device, dtype=precision)
.requires_grad_(False)
)
total_params = sum(p.numel() for p in model1.parameters())
# Calculate weight sum for model1 (converting to float64 to avoid overflow)
weight_sum_model1 = sum(
p.to(torch.float64).sum().item() for p in model1.parameters())
weight_sum_model1 = sum(p.to(torch.float64).sum().item() for p in model1.parameters())
# Also calculate mean for more stable comparison
weight_mean_model1 = weight_sum_model1 / total_params
logger.info("Model 1 weight sum: %s", weight_sum_model1)
@@ -57,8 +58,7 @@ def test_wan_transformer():
# Calculate weight sum for model2 (converting to float64 to avoid overflow)
total_params_model2 = sum(p.numel() for p in model2.parameters())
weight_sum_model2 = sum(
p.to(torch.float64).sum().item() for p in model2.parameters())
weight_sum_model2 = sum(p.to(torch.float64).sum().item() for p in model2.parameters())
# Also calculate mean for more stable comparison
weight_mean_model2 = weight_sum_model2 / total_params_model2
logger.info("Model 2 weight sum: %s", weight_sum_model2)
@@ -78,20 +78,10 @@ def test_wan_transformer():
seq_len = 30
# Video latents [B, C, T, H, W]
hidden_states = torch.randn(batch_size,
16,
21,
160,
90,
device=device,
dtype=precision)
hidden_states = torch.randn(batch_size, 16, 21, 160, 90, device=device, dtype=precision)
# Text embeddings [B, L, D] (including global token)
encoder_hidden_states = torch.randn(batch_size,
seq_len + 1,
4096,
device=device,
dtype=precision)
encoder_hidden_states = torch.randn(batch_size, seq_len + 1, 4096, device=device, dtype=precision)
# Timestep
timestep = torch.tensor([500], device=device, dtype=precision)
@@ -100,7 +90,7 @@ def test_wan_transformer():
data_type="dummy",
)
with torch.amp.autocast('cuda', dtype=precision):
with torch.amp.autocast("cuda", dtype=precision):
output1 = model1(
hidden_states=hidden_states,
encoder_hidden_states=encoder_hidden_states,
@@ -108,13 +98,13 @@ def test_wan_transformer():
return_dict=False,
)[0]
with set_forward_context(
current_timestep=0,
attn_metadata=None,
forward_batch=forward_batch,
current_timestep=0,
attn_metadata=None,
forward_batch=forward_batch,
):
output2 = model2(hidden_states=hidden_states,
encoder_hidden_states=encoder_hidden_states,
timestep=timestep)
output2 = model2(
hidden_states=hidden_states, encoder_hidden_states=encoder_hidden_states, timestep=timestep
)
# Check if outputs have the same shape
assert output1.shape == output2.shape, f"Output shapes don't match: {output1.shape} vs {output2.shape}"
+48 -30
View File
@@ -7,11 +7,42 @@ from fastvideo.logger import init_logger
import numpy as np
import torch
from pytorch_msssim import ms_ssim, ssim
from torchvision.io import read_video
logger = init_logger(__name__)
def _read_video_frames(path: str) -> torch.Tensor:
"""Read video frames as a ``(T, C, H, W)`` uint8 tensor.
Tries ``torchvision.io.read_video`` first (available in older
torchvision) and falls back to PyAV when the function has been
removed (torchvision >= 0.26).
"""
try:
from torchvision.io import read_video
frames, _, _ = read_video(
path,
pts_unit="sec",
output_format="TCHW",
)
return frames
except (ImportError, AttributeError):
pass
import av
container = av.open(path)
frames = []
for frame in container.decode(video=0):
arr = frame.to_ndarray(format="rgb24")
frames.append(torch.from_numpy(arr).permute(2, 0, 1))
container.close()
if not frames:
raise RuntimeError(f"No video frames decoded from {path}")
return torch.stack(frames)
def compute_video_ssim_torchvision(video1_path, video2_path, use_ms_ssim=True):
"""
Compute SSIM between two videos.
@@ -27,12 +58,8 @@ def compute_video_ssim_torchvision(video1_path, video2_path, use_ms_ssim=True):
if not os.path.exists(video2_path):
raise FileNotFoundError(f"Video2 not found: {video2_path}")
frames1, _, _ = read_video(video1_path,
pts_unit='sec',
output_format="TCHW")
frames2, _, _ = read_video(video2_path,
pts_unit='sec',
output_format="TCHW")
frames1 = _read_video_frames(video1_path)
frames2 = _read_video_frames(video2_path)
# Ensure same number of frames
min_frames = min(frames1.shape[0], frames2.shape[0])
@@ -50,8 +77,8 @@ def compute_video_ssim_torchvision(video1_path, video2_path, use_ms_ssim=True):
# Process each frame individually
for i in range(min_frames):
img1 = frames1[i:i + 1]
img2 = frames2[i:i + 1]
img1 = frames1[i : i + 1]
img2 = frames2[i : i + 1]
with torch.no_grad():
value = ms_ssim(img1, img2, data_range=1.0) if use_ms_ssim else ssim(img1, img2, data_range=1.0)
@@ -71,14 +98,14 @@ def compute_video_ssim_torchvision(video1_path, video2_path, use_ms_ssim=True):
return mean_ssim, min_ssim, max_ssim
else:
print('No SSIM values calculated')
print("No SSIM values calculated")
return 0, 0, 0
def compare_folders(reference_folder, generated_folder, use_ms_ssim=True):
"""
Compare videos with the same filename between reference_folder and generated_folder
Example usage:
results = compare_folders(reference_folder, generated_folder,
args.use_ms_ssim)
@@ -97,10 +124,8 @@ def compare_folders(reference_folder, generated_folder, use_ms_ssim=True):
else:
print("\nNo valid SSIM values to average")
"""
reference_videos = [
f for f in os.listdir(reference_folder) if f.endswith('.mp4')
]
reference_videos = [f for f in os.listdir(reference_folder) if f.endswith(".mp4")]
results = {}
@@ -111,27 +136,23 @@ def compare_folders(reference_folder, generated_folder, use_ms_ssim=True):
if os.path.exists(gen_path):
print(f"\nComparing {video_name}...")
try:
ssim_value = compute_video_ssim_torchvision(
ref_path, gen_path, use_ms_ssim)
ssim_value = compute_video_ssim_torchvision(ref_path, gen_path, use_ms_ssim)
results[video_name] = ssim_value
except Exception as e:
print(f"Error comparing {video_name}: {e}")
results[video_name] = None
else:
print(
f"\nSkipping {video_name} - no matching file in generated folder"
)
print(f"\nSkipping {video_name} - no matching file in generated folder")
return results
def write_ssim_results(output_dir, ssim_values, reference_path, generated_path,
num_inference_steps, prompt):
def write_ssim_results(output_dir, ssim_values, reference_path, generated_path, num_inference_steps, prompt):
"""
Write SSIM results to a JSON file in the same directory as the generated videos.
"""
try:
logger.info(
f"Attempting to write SSIM results to directory: {output_dir}")
logger.info(f"Attempting to write SSIM results to directory: {output_dir}")
if not os.path.exists(output_dir):
os.makedirs(output_dir, exist_ok=True)
@@ -144,20 +165,17 @@ def write_ssim_results(output_dir, ssim_values, reference_path, generated_path,
"max_ssim": max_ssim,
"reference_video": reference_path,
"generated_video": generated_path,
"parameters": {
"num_inference_steps": num_inference_steps,
"prompt": prompt
}
"parameters": {"num_inference_steps": num_inference_steps, "prompt": prompt},
}
test_name = f"steps{num_inference_steps}_{prompt[:100]}"
result_file = os.path.join(output_dir, f"{test_name}_ssim.json")
logger.info(f"Writing JSON results to: {result_file}")
with open(result_file, 'w') as f:
with open(result_file, "w") as f:
json.dump(result, f, indent=2)
logger.info(f"SSIM results written to {result_file}")
return True
except Exception as e:
logger.error(f"ERROR writing SSIM results: {str(e)}")
return False
return False
+15 -21
View File
@@ -9,6 +9,7 @@ from safetensors.torch import load_file
from fastvideo.configs.pipelines import PipelineConfig
from fastvideo.logger import init_logger
# from fastvideo.models.vaes.hunyuanvae import (
# AutoencoderKLHunyuanVideo as MyHunyuanVAE)
from fastvideo.fastvideo_args import FastVideoArgs
@@ -22,9 +23,7 @@ os.environ["MASTER_ADDR"] = "localhost"
os.environ["MASTER_PORT"] = "29503"
BASE_MODEL_PATH = "hunyuanvideo-community/HunyuanVideo"
MODEL_PATH = maybe_download_model(BASE_MODEL_PATH,
local_dir=os.path.join("data", BASE_MODEL_PATH) # store in the large /workspace disk on Runpod
)
MODEL_PATH = maybe_download_model(BASE_MODEL_PATH, local_dir=os.path.join("data", BASE_MODEL_PATH))
VAE_PATH = os.path.join(MODEL_PATH, "vae")
CONFIG_PATH = os.path.join(VAE_PATH, "config.json")
@@ -42,30 +41,28 @@ def test_hunyuan_vae():
device = torch.device("cuda:0")
precision = torch.bfloat16
precision_str = "bf16"
args = FastVideoArgs(model_path=VAE_PATH, pipeline_config=PipelineConfig(vae_config=HunyuanVAEConfig(), vae_precision=precision_str))
args = FastVideoArgs(
model_path=VAE_PATH, pipeline_config=PipelineConfig(vae_config=HunyuanVAEConfig(), vae_precision=precision_str)
)
args.device = device
args.vae_cpu_offload = False
loader = VAELoader()
model = loader.load(VAE_PATH, args)
model.enable_tiling(tile_sample_min_height=32,
tile_sample_min_width=32,
tile_sample_min_num_frames=8,
tile_sample_stride_height=16,
tile_sample_stride_width=16,
tile_sample_stride_num_frames=4)
model.enable_tiling(
tile_sample_min_height=32,
tile_sample_min_width=32,
tile_sample_min_num_frames=8,
tile_sample_stride_height=16,
tile_sample_stride_width=16,
tile_sample_stride_num_frames=4,
)
batch_size = 1
# Video input [B, C, T, H, W]
input_tensor = torch.randn(batch_size,
3,
21,
64,
64,
device=device,
dtype=torch.bfloat16)
input_tensor = torch.randn(batch_size, 3, 21, 64, 64, device=device, dtype=torch.bfloat16)
# Disable gradients for inference
with torch.no_grad():
@@ -81,8 +78,5 @@ def test_hunyuan_vae():
raise ValueError(f"Unknown device: {device_name}")
diff_encoded_latents = abs(REFERENCE_LATENT - latent)
logger.info(
f"Reference latent: {REFERENCE_LATENT}, Current latent: {latent}"
)
logger.info(f"Reference latent: {REFERENCE_LATENT}, Current latent: {latent}")
assert diff_encoded_latents < 1e-4, f"Encoded latents differ significantly: max diff = {diff_encoded_latents}"
+18 -21
View File
@@ -20,9 +20,7 @@ os.environ["MASTER_ADDR"] = "localhost"
os.environ["MASTER_PORT"] = "29503"
BASE_MODEL_PATH = "Wan-AI/Wan2.1-T2V-1.3B-Diffusers"
MODEL_PATH = maybe_download_model(BASE_MODEL_PATH,
local_dir=os.path.join("data", BASE_MODEL_PATH) # store in the large /workspace disk on Runpod
)
MODEL_PATH = maybe_download_model(BASE_MODEL_PATH, local_dir=os.path.join("data", BASE_MODEL_PATH))
VAE_PATH = os.path.join(MODEL_PATH, "vae")
@@ -31,28 +29,23 @@ def test_wan_vae():
device = torch.device("cuda:0" if torch.cuda.is_available() else "cpu")
precision = torch.float32
precision_str = "fp32"
args = FastVideoArgs(model_path=VAE_PATH, pipeline_config=PipelineConfig(vae_config=WanVAEConfig(), vae_precision=precision_str))
args = FastVideoArgs(
model_path=VAE_PATH, pipeline_config=PipelineConfig(vae_config=WanVAEConfig(), vae_precision=precision_str)
)
args.device = device
args.vae_cpu_offload = False
loader = VAELoader()
model2 = loader.load(VAE_PATH, args)
assert model2.use_feature_cache # Default to use the original WanVAE algorithm
assert model2.use_feature_cache # Default to use the original WanVAE algorithm
model1 = AutoencoderKLWan.from_pretrained(
VAE_PATH, torch_dtype=precision).to(device).eval()
model1 = AutoencoderKLWan.from_pretrained(VAE_PATH, torch_dtype=precision).to(device).eval()
# Create identical inputs for both models
batch_size = 1
# Video input [B, C, T, H, W]
input_tensor = torch.randn(batch_size,
3,
81,
32,
32,
device=device,
dtype=precision)
input_tensor = torch.randn(batch_size, 3, 81, 32, 32, device=device, dtype=precision)
# latent_tensor = torch.randn(batch_size,
# 16,
# 21,
@@ -69,18 +62,22 @@ def test_wan_vae():
print("--------------------------------")
latent2 = model2.encode(input_tensor)
# Check if latents have the same shape
assert latent1.mean.shape == latent2.mean.shape, f"Latent shapes don't match: {latent1.mean.shape} vs {latent2.mean.shape}"
assert latent1.mean.shape == latent2.mean.shape, (
f"Latent shapes don't match: {latent1.mean.shape} vs {latent2.mean.shape}"
)
# Check if latents are similar
assert_close(latent1.mean, latent2.mean, atol=1e-4, rtol=1e-4)
# Test decoding
logger.info("Testing decoding...")
latent1_tensor = latent1.mode()
mean1 = (torch.tensor(model1.config.latents_mean).view(
1, model1.config.z_dim, 1, 1, 1).to(input_tensor.device,
input_tensor.dtype))
std1 = (1.0 / torch.tensor(model1.config.latents_std).view(
1, model1.config.z_dim, 1, 1, 1)).to(input_tensor.device,
input_tensor.dtype)
mean1 = (
torch.tensor(model1.config.latents_mean)
.view(1, model1.config.z_dim, 1, 1, 1)
.to(input_tensor.device, input_tensor.dtype)
)
std1 = (1.0 / torch.tensor(model1.config.latents_std).view(1, model1.config.z_dim, 1, 1, 1)).to(
input_tensor.device, input_tensor.dtype
)
latent1_tensor = latent1_tensor / std1 + mean1
output1 = model1.decode(latent1_tensor).sample
+1 -18
View File
@@ -1,7 +1,6 @@
# SPDX-License-Identifier: Apache-2.0
from abc import ABC, abstractmethod
from collections.abc import Callable
from queue import Queue
from typing import Any, TypeVar, cast
from fastvideo.fastvideo_args import FastVideoArgs
@@ -15,14 +14,8 @@ _R = TypeVar("_R")
class Executor(ABC):
def __init__(
self,
fastvideo_args: FastVideoArgs,
*,
log_queue=None,
):
def __init__(self, fastvideo_args: FastVideoArgs):
self.fastvideo_args = fastvideo_args
self._log_queue = log_queue
self._init_executor()
@@ -104,16 +97,6 @@ class Executor(ABC):
"""
raise NotImplementedError
@abstractmethod
def set_log_queue(self, log_queue: Queue | None) -> None:
"""Forward worker logs to the given queue. Call before generate_video."""
self.collective_rpc("set_log_queue", kwargs={"log_queue": log_queue})
@abstractmethod
def clear_log_queue(self) -> None:
"""Stop forwarding worker logs to the queue. Call after generate_video."""
self.collective_rpc("clear_log_queue")
@abstractmethod
def shutdown(self) -> None:
"""
+7 -108
View File
@@ -7,8 +7,6 @@ import contextlib
from dataclasses import dataclass
from enum import Enum
import faulthandler
import logging
import logging.handlers
import multiprocessing as mp
from multiprocessing.connection import Connection
from multiprocessing.queues import Queue
@@ -36,11 +34,6 @@ from fastvideo.worker.worker_base import WorkerWrapperBase
logger = init_logger(__name__)
def _make_queue_log_handler(log_queue: Queue) -> logging.Handler:
"""Create a QueueHandler that forwards fastvideo logs to a multiprocessing queue."""
return logging.handlers.QueueHandler(log_queue)
class StreamingTaskType(str, Enum):
"""
Enumeration for different streaming task types.
@@ -104,7 +97,6 @@ class MultiprocExecutor(Executor):
distributed_init_method=distributed_init_method,
streaming_input_queue=self._streaming_input_queue,
streaming_output_queue=self._streaming_output_queue,
log_queue=self._log_queue,
))
# Workers must be created before wait_for_ready to avoid
@@ -254,14 +246,6 @@ class MultiprocExecutor(Executor):
if response["status"] != "lora_adapter_merged":
raise RuntimeError(f"Worker {i} failed to merge LoRA weights")
def set_log_queue(self, log_queue: Queue | None) -> None:
"""Forward worker logs to the given queue. Call before generate_video."""
self.collective_rpc("set_log_queue", kwargs={"log_queue": log_queue})
def clear_log_queue(self) -> None:
"""Stop forwarding worker logs to the queue. Call after generate_video."""
self.collective_rpc("clear_log_queue")
def collective_rpc(self,
method: str | Callable,
timeout: float | None = None,
@@ -314,12 +298,6 @@ class MultiprocExecutor(Executor):
return # Prevent multiple shutdown calls
logger.info("Shutting down MultiprocExecutor...")
# Check if workers were initialized (they might not be if initialization failed)
if not hasattr(self, 'workers') or not self.workers:
logger.info("No workers to shut down.")
return
self.shutting_down = True
# First try gentle termination
@@ -449,14 +427,11 @@ class WorkerMultiprocProc:
pipe: Connection,
streaming_input_queue: Queue | None = None,
streaming_output_queue: Queue | None = None,
_initial_log_handler: logging.Handler | None = None,
**kwargs: Any,
):
self.rank = rank
self.pipe = pipe
self.streaming_input_queue = streaming_input_queue
self.streaming_output_queue = streaming_output_queue
self._initial_log_handler = _initial_log_handler
wrapper = WorkerWrapperBase(fastvideo_args=fastvideo_args, rpc_rank=rank)
all_kwargs: list[dict] = [{} for _ in range(fastvideo_args.num_gpus)]
@@ -483,7 +458,6 @@ class WorkerMultiprocProc:
distributed_init_method: str,
streaming_input_queue: Queue | None = None,
streaming_output_queue: Queue | None = None,
log_queue: Queue | None = None,
) -> UnreadyWorkerProcHandle:
context = get_mp_context()
executor_pipe, worker_pipe = context.Pipe(duplex=True)
@@ -498,7 +472,6 @@ class WorkerMultiprocProc:
"ready_pipe": writer,
"streaming_input_queue": streaming_input_queue,
"streaming_output_queue": streaming_output_queue,
"log_queue": log_queue,
}
# Run EngineCore busy loop in background process.
proc = context.Process(target=WorkerMultiprocProc.worker_main,
@@ -515,13 +488,6 @@ class WorkerMultiprocProc:
""" Worker initialization and execution loops.
This runs a background process """
log_queue = kwargs.pop("log_queue", None)
# Add log handler before model loading so we capture fsdp_load, cuda, etc.
if log_queue is not None:
_handler = _make_queue_log_handler(log_queue)
logging.getLogger("fastvideo").addHandler(_handler)
kwargs["_initial_log_handler"] = _handler
# Signal handler used for graceful termination.
# SystemExit exception is only raised once to allow this and worker
# processes to terminate without error
@@ -557,21 +523,9 @@ class WorkerMultiprocProc:
worker.worker_busy_loop()
except Exception as exc:
except Exception:
if ready_pipe is not None:
logger.exception("WorkerMultiprocProc failed to start.")
# Send error status to parent before closing pipe
try:
traceback_str = get_exception_traceback()
ready_pipe.send({
"status": "ERROR",
"error": str(exc),
"traceback": traceback_str,
"rank": rank,
})
except Exception:
# If sending fails, at least log it
pass
else:
logger.exception("WorkerMultiprocProc failed.")
@@ -581,8 +535,7 @@ class WorkerMultiprocProc:
shutdown_requested = True
traceback = get_exception_traceback()
logger.error("Worker %d hit an exception: %s", rank, traceback)
if parent_process:
parent_process.send_signal(signal.SIGQUIT)
parent_process.send_signal(signal.SIGQUIT)
finally:
if ready_pipe is not None:
@@ -599,8 +552,7 @@ class WorkerMultiprocProc:
"See stack trace for root cause.")
pipes = {handle.ready_pipe: handle for handle in unready_proc_handles}
ready_proc_handles: list[WorkerProcHandle | None] = [None] * len(unready_proc_handles)
worker_errors: list[str] = []
ready_proc_handles: list[WorkerProcHandle | None] = ([None] * len(unready_proc_handles))
while pipes:
ready = mp.connection.wait(pipes.keys())
for pipe in ready:
@@ -609,40 +561,19 @@ class WorkerMultiprocProc:
# Wait until the WorkerProc is ready.
unready_proc_handle = pipes.pop(pipe)
response: dict[str, Any] = pipe.recv()
if response["status"] == "ERROR":
# Worker sent error details
error_msg = response.get("error", "Unknown error")
traceback_str = response.get("traceback", "")
rank = response.get("rank", "unknown")
error_info = f"Worker {rank} error: {error_msg}"
if traceback_str:
error_info += f"\n{traceback_str}"
worker_errors.append(error_info)
# Log a concise error message (full traceback will be in the exception)
logger.error("Worker %s initialization failed: %s", rank, error_msg)
# Continue to check other workers, but we'll fail at the end
elif response["status"] != "READY":
worker_errors.append(f"Worker returned unexpected status: {response.get('status', 'unknown')}")
if response["status"] != "READY":
raise e
if response["status"] == "READY":
ready_proc_handles[unready_proc_handle.rank] = (
WorkerProcHandle.from_unready_handle(unready_proc_handle))
ready_proc_handles[unready_proc_handle.rank] = (
WorkerProcHandle.from_unready_handle(unready_proc_handle))
except EOFError:
# Pipe closed without sending status - worker crashed
worker_errors.append(f"Worker process crashed (pipe closed unexpectedly)")
e.__suppress_context__ = True
raise e from None
finally:
# Close connection.
pipe.close()
# If any workers failed, raise exception with details
if worker_errors:
error_msg = "WorkerMultiprocProc initialization failed due to exceptions in background processes:\n"
error_msg += "\n".join(f" - {err}" for err in worker_errors)
raise Exception(error_msg) from None
logger.info("%d workers ready", len(ready_proc_handles))
return cast(list[WorkerProcHandle], ready_proc_handles)
@@ -666,14 +597,6 @@ class WorkerMultiprocProc:
with contextlib.suppress(Exception):
self.pipe.send(response)
break
if method == "set_log_queue":
self._set_log_queue(kwargs.get("log_queue"))
self.pipe.send({"status": "ok"})
continue
if method == "clear_log_queue":
self._clear_log_queue()
self.pipe.send({"status": "ok"})
continue
if method == "start_streaming_queue_loop":
self.pipe.send({"status": "streaming_queue_loop_started"})
self.streaming_queue_loop()
@@ -742,30 +665,6 @@ class WorkerMultiprocProc:
logger.error("Worker %d queue loop error: %s", self.rank, e)
self.streaming_output_queue.put(StreamingResult(task_type=StreamingTaskType.STEP, error=e))
_log_queue_handler: logging.Handler | None = None
def _set_log_queue(self, log_queue: Queue | None) -> None:
"""Add a handler that forwards fastvideo logs to the given queue."""
self._clear_log_queue()
if log_queue is None:
return
# Remove initial handler if present (from worker_main) to avoid duplicates
if self._initial_log_handler is not None:
logging.getLogger("fastvideo").removeHandler(
self._initial_log_handler
)
self._initial_log_handler = None
self._log_queue_handler = _make_queue_log_handler(log_queue)
logging.getLogger("fastvideo").addHandler(self._log_queue_handler)
def _clear_log_queue(self) -> None:
"""Remove the log queue handler."""
if self._log_queue_handler is not None:
logging.getLogger("fastvideo").removeHandler(
self._log_queue_handler
)
self._log_queue_handler = None
@staticmethod
def setup_proc_title_and_log_prefix() -> None:
dp_size = get_dp_group().world_size
+2
View File
@@ -167,6 +167,8 @@ nav:
- Index: contributing/developer_env/index.md
- Docker: contributing/developer_env/docker.md
- RunPod: contributing/developer_env/runpod.md
- Pull Requests: contributing/pull_requests.md
- CI Architecture: contributing/ci_architecture.md
- Testing: contributing/testing.md
- Profiling: contributing/profiling.md
- Coding Agents: contributing/coding_agents.md
+1 -5
View File
@@ -80,10 +80,6 @@ dependencies = [
"torchcodec",
"ray>=2.49.1",
"ftfy==6.3.1",
# Job Runner
"fastapi==0.129.0",
"uvicorn==0.41.0"
]
[tool.uv]
@@ -150,7 +146,7 @@ check_untyped_defs = true
follow_imports = "silent"
[tool.codespell]
skip = "./data,./wandb,./ui/package-lock.json"
skip ="./data,./wandb"
[tool.ruff]
# Allow lines to be as long as 120.
-1
View File
@@ -1 +0,0 @@
PUBLIC_API_BASE_URL=http://localhost:8189/api
-42
View File
@@ -1,42 +0,0 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
.env.local
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts
.svelte-kit

Some files were not shown because too many files have changed in this diff Show More