Compare commits
36
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fc51550920 | ||
|
|
942e8b8404 | ||
|
|
c548e5834e | ||
|
|
368f6b8891 | ||
|
|
e55934b62e | ||
|
|
a06e63827d | ||
|
|
e536bb8544 | ||
|
|
3eabb7b40b | ||
|
|
a159b63c67 | ||
|
|
ac48bb3cd1 | ||
|
|
3987b9ddcd | ||
|
|
c7da2f5d60 | ||
|
|
39ae1decc0 | ||
|
|
1aed667377 | ||
|
|
c1612ff397 | ||
|
|
a534ba20a0 | ||
|
|
e9bbaca07d | ||
|
|
9bfa585448 | ||
|
|
b2062556a9 | ||
|
|
c9c5585758 | ||
|
|
9212f4f218 | ||
|
|
6388db815b | ||
|
|
7a4285189f | ||
|
|
a837fe841a | ||
|
|
f9e3680f11 | ||
|
|
98f761ec45 | ||
|
|
c041318f2c | ||
|
|
604e0205a4 | ||
|
|
13213395b4 | ||
|
|
46afee5998 | ||
|
|
c488fa1211 | ||
|
|
d3cff517cd | ||
|
|
2f3d407406 | ||
|
|
bcffa4026e | ||
|
|
6d6a10be7a | ||
|
|
73dd105f3d |
@@ -0,0 +1,99 @@
|
||||
---
|
||||
name: ci-runner
|
||||
description: Work on FastVideo's Slurm-only, change-aware GPU CI lanes, static Buildkite graph, trusted ci-runner policy, lane scripts, and GB200 validation.
|
||||
---
|
||||
|
||||
# Slinky Slurm CI lanes
|
||||
|
||||
FastVideo's `ci-runner` Buildkite queue is the control plane for all active
|
||||
GPU CI. A private host-owned dispatcher leases GPUs from the Slinky Slurm tray
|
||||
and runs the immutable PR SHA inside an isolated Enroot container. Buildkite
|
||||
pipeline upload and Slurm submission occur on the login plane; every test
|
||||
payload executes on Slurm compute.
|
||||
|
||||
The files under `fastvideo/tests/modal/` and `.buildkite/scripts/pr_test.sh`
|
||||
are dormant rollback code. Never add an active Buildkite or slash-command
|
||||
route to them. `pr_test.sh` must continue to reject Buildkite invocations.
|
||||
|
||||
The private operator bundle is deliberately outside this repository because
|
||||
it contains site paths and credentials. See
|
||||
`docs/contributing/ci_architecture.md`; this skill covers the repository half
|
||||
and the coordination contract with that bundle.
|
||||
|
||||
## Invariants
|
||||
|
||||
- `.buildkite/pipeline.yml` contains exactly one static step for every active
|
||||
GPU lane. Each step pins a unique key and label, a 90-minute timeout, the
|
||||
trusted `/opt/fastvideo-ci-runner/run-ci` command (`run-unit` is the one
|
||||
compatibility wrapper), step-level internal `TEST_TYPE`, and
|
||||
`queue: "ci-runner"`.
|
||||
- Active CI contains no `pr_test.sh` command, Modal invocation, default queue,
|
||||
Buildkite plugin, `soft_fail`, or job-controlled artifact glob.
|
||||
- The six Fastcheck lanes use `:microscope:` labels. Full-Suite-only lanes use
|
||||
`:test_tube:` or `:bar_chart:` so direct reruns update the right aggregate.
|
||||
- SSIM and vanilla training request all four GPUs. Keep both in the
|
||||
`fastvideo/slinky/whole-tray` Buildkite concurrency group with a limit of one
|
||||
so the second job does not consume an agent or command timeout while waiting
|
||||
for the same tray.
|
||||
- `/test full` schedules all twenty lanes. `/merge`, `ready`, and new pushes to
|
||||
ready PRs use the trusted base-branch planner in
|
||||
`.github/scripts/plan_merge_ci.py`: automatic Fastcheck remains the universal
|
||||
six-lane baseline, and the merge build adds only path-relevant integration
|
||||
lanes. Unknown source/build paths fail closed to all fourteen additive lanes.
|
||||
The trusted uploader still normalizes and validates the complete static graph
|
||||
before Buildkite evaluates its plan conditions.
|
||||
- Focused merge builds may pass allowlisted golden-gate and SSIM test basenames.
|
||||
The private host validates the lane plan and basenames before staging them,
|
||||
and the in-container scripts validate them again. Direct `/test ssim`,
|
||||
explicit `/test full`, and the weekly main-branch schedule run the complete
|
||||
SSIM matrix.
|
||||
- The trusted uploader serves exactly three entry pipelines:
|
||||
`pr-fastcheck` for automatic PR builds, `ci` for slash-command/ready-label
|
||||
API builds, and `fastvideo-performance-lane` for the weekly schedule. Keep
|
||||
incoming GitHub webhook processing disabled on `ci` so it cannot duplicate
|
||||
`pr-fastcheck` on every PR update.
|
||||
- Test payloads live in `.buildkite/scripts/unit_test.sh` or executable
|
||||
`.buildkite/scripts/lanes/<lane>.sh`. Backend policy (GPU count, extras,
|
||||
secrets, kernel build, artifacts) stays in the agent-owned lane table.
|
||||
- Tests must preserve an inherited `MASTER_PORT`. Packed containers share the
|
||||
tray network namespace, so the private runner assigns a distinct port range
|
||||
per GPU lease and the SSIM scheduler assigns task offsets within its range.
|
||||
- The ARM64 runner image includes the pinned FA4 CuTe overlay validated on
|
||||
GB200. Keep SSIM at `FASTVIDEO_FA4=1` because its references were seeded with
|
||||
FA4; keep lanes with FA2 baselines at `FASTVIDEO_FA4=0`. A runner image change
|
||||
must revalidate both the FA4 import and an actual GB200 forward kernel.
|
||||
- `fastvideo/tests/ssim/ci_runner.py` is the active four-GPU SSIM scheduler.
|
||||
New SSIM files are discovered through `REQUIRED_GPUS` and
|
||||
`*_MODEL_TO_PARAMS`; do not wire them through the dormant Modal scheduler.
|
||||
- The host policy fail-closes unknown tuples. A repository-side lane change is
|
||||
inert until the operator updates the private lane table and uploader policy
|
||||
in the same rollout.
|
||||
|
||||
## Adding or changing a lane
|
||||
|
||||
1. Read the closest `AGENTS.md` and the domain-specific testing guide.
|
||||
2. Add or update the executable lane payload under `.buildkite/scripts/`.
|
||||
Keep it deterministic and free of host-specific paths or credential fetches.
|
||||
3. Add the static pipeline step and canonical `/test <name>` mapping. Keep the
|
||||
`<name>-ci` alias only when compatibility requires it.
|
||||
4. Add its source/test path ownership to `.github/scripts/plan_merge_ci.py`.
|
||||
Prefer the narrowest correctness-preserving lane set; leave unknown paths
|
||||
fail-closed. Extend `fastvideo/tests/contract/test_ci_test_collection.py`,
|
||||
`test_merge_ci_plan.py`, and focused CPU-only scheduler/policy tests.
|
||||
5. Coordinate the private lane row: GPU count (1-4), wall time, script, scope
|
||||
pairs, step key, command, HF cache/token, tracking mode, extras, attention
|
||||
backend policy, kernel policy, and artifact relay. Active training lanes
|
||||
keep W&B offline and do not stage a W&B credential.
|
||||
6. Update the trusted pipeline-uploader schema. A mismatch must reject the
|
||||
pipeline rather than silently skip a lane.
|
||||
7. Run `pre-commit run --files <changed paths>`, the planner's representative
|
||||
diff matrix, contract tests, private driver tests, and a real GB200 canary.
|
||||
Multi-GPU, hardware-reference, training, performance, and SSIM changes need
|
||||
their own target-hardware evidence.
|
||||
|
||||
## Rollback
|
||||
|
||||
Rollback the Slurm routing/configuration change or pause the `ci-runner` queue.
|
||||
Do not silently reactivate Modal. A manual Modal experiment requires the
|
||||
explicit local opt-in documented in `ci_architecture.md`; returning it to
|
||||
production CI needs a separate reviewed decision.
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: reseed-ssim-references
|
||||
description: Re-seed HF reference videos for a single existing SSIM test on Modal L40S. Always backs up current refs locally first, regenerates on Modal, pauses for the user to eyeball before-vs-after quality, then overwrites the targeted `<model_id>` subtree on `FastVideo/ssim-reference-videos` with `--force`. Use when an intentional code change (model port fix, attention backend swap, kernel upgrade, hyperparameter change) has invalidated existing refs and they need to be regenerated. Pairs with `seed-ssim-references`, which is for first-time seeding only.
|
||||
description: Re-seed HF reference videos for a single existing SSIM test on Modal L40S. Always backs up current refs locally first, regenerates on Modal, pauses for the user to eyeball before-vs-after quality, then overwrites the targeted model subtree on `FastVideo/ssim-reference-videos` with `--force`. Use when an intentional code change (model port fix, attention backend swap, kernel upgrade, hyperparameter change) has invalidated existing refs and they need to be regenerated. Pairs with `seed-ssim-references`, which is for first-time seeding only.
|
||||
---
|
||||
|
||||
# Re-seed SSIM Reference Videos
|
||||
@@ -13,7 +13,7 @@ on HF — the old refs are overwritten — so the skill always:
|
||||
|
||||
1. Confirms intent with a one-liner the user has to type.
|
||||
2. Downloads the existing refs as a local, timestamped backup.
|
||||
3. Regenerates on Modal L40S (same code path that CI uses).
|
||||
3. Regenerates through the manual legacy Modal L40S maintenance path.
|
||||
4. Pauses for a side-by-side eyeball of backup vs new mp4s.
|
||||
5. Uploads with `--force`, scoped to the single `--model-id`.
|
||||
6. Reminds the user to keep the backup until the PR lands.
|
||||
@@ -51,8 +51,9 @@ harder to recover from than failing closed.
|
||||
|
||||
Hardcoded:
|
||||
|
||||
- Modal GPU: **L40S** (matches CI; re-seeding from another SKU produces refs
|
||||
that L40S CI cannot match).
|
||||
- Modal GPU: **L40S**. This is a manual reference-maintenance target, not the
|
||||
active Slurm CI compute path; changing the SKU also changes the historical
|
||||
`L40S_reference_videos` contract.
|
||||
- Quality tier: **`default`**. `full_quality` is a separate, deliberate
|
||||
operation.
|
||||
- HF repo: `FastVideo/ssim-reference-videos` (override via
|
||||
|
||||
@@ -35,7 +35,8 @@ The skill is run **manually**, once per new test. Before invoking it, the user
|
||||
has already sanity-tested the new test locally — it launches `VideoGenerator`
|
||||
and writes an artefact without crashing (the missing-reference assertion at
|
||||
the end is expected). The skill does not re-test locally; it goes straight
|
||||
to Modal L40S (which is what CI uses).
|
||||
to the manual legacy Modal L40S reference-maintenance target. Active CI runs
|
||||
on the Slinky Slurm cluster and only consumes the resulting references.
|
||||
|
||||
## When to use
|
||||
|
||||
@@ -61,7 +62,8 @@ Prompt the user for it if they didn't supply it.
|
||||
|
||||
Everything else is fixed:
|
||||
|
||||
- Modal runner GPU: **L40S** (hardcoded in `fastvideo/tests/modal/ssim_test.py`).
|
||||
- Modal maintenance GPU: **L40S** (hardcoded in
|
||||
`fastvideo/tests/modal/ssim_test.py`; this is not the active CI compute path).
|
||||
- Device folder: `L40S_reference_videos`.
|
||||
- Quality tier: `default` (the tier CI runs). The `full_quality` tier is not
|
||||
seeded by this skill.
|
||||
|
||||
+448
-528
@@ -1,7 +1,8 @@
|
||||
env:
|
||||
IMAGE_VERSION: "py3.12-latest"
|
||||
BUILDKITE_CLEAN_CHECKOUT: true
|
||||
# Buildkite only launches Modal; remote jobs initialize their own submodules.
|
||||
# Slurm workers clone the immutable commit and initialize submodules inside
|
||||
# their isolated container. The Buildkite login-plane checkout is a no-op.
|
||||
BUILDKITE_GIT_SUBMODULES: false
|
||||
|
||||
notify:
|
||||
@@ -10,539 +11,458 @@ notify:
|
||||
if: build.env("TEST_SCOPE") == "fastcheck" || build.env("TEST_SCOPE") == null
|
||||
- github_commit_status:
|
||||
context: "full-suite-passed"
|
||||
if: build.env("TEST_SCOPE") == "full"
|
||||
if: build.env("TEST_SCOPE") == "full" || build.env("TEST_SCOPE") == "merge"
|
||||
- github_commit_status:
|
||||
context: "direct-test-completed"
|
||||
if: build.env("TEST_SCOPE") == "direct"
|
||||
- github_commit_status:
|
||||
context: "scheduled-ssim-passed"
|
||||
if: build.env("TEST_SCOPE") == "scheduled"
|
||||
|
||||
# This is the complete active GPU CI surface. Every command is a trusted host
|
||||
# dispatcher, and every test payload executes inside the Slinky Slurm tray.
|
||||
# fastvideo/tests/modal remains available only for an explicit manual rollback;
|
||||
# no active pipeline or slash-command route invokes it.
|
||||
steps:
|
||||
# ============================================================
|
||||
# Direct test: triggered by /test <name> slash command.
|
||||
# Labels match fastcheck/full-suite counterparts so the GitHub
|
||||
# check status overwrites the original failed check.
|
||||
# Only ONE step executes per build (gated by TEST_TYPE).
|
||||
# ============================================================
|
||||
- label: ":microscope: Encoder Tests"
|
||||
key: "encoder"
|
||||
if: |
|
||||
build.env("TEST_SCOPE") == "full" ||
|
||||
(build.env("TEST_SCOPE") == "merge" &&
|
||||
build.env("MERGE_TEST_PLAN") =~ /,encoder,/) ||
|
||||
build.env("TEST_SCOPE") == "fastcheck" ||
|
||||
build.env("TEST_SCOPE") == null ||
|
||||
(build.env("TEST_SCOPE") == "direct" &&
|
||||
(build.env("TEST_TYPE") == "encoder" || build.env("TEST_TYPE") == "encoder_ci"))
|
||||
command: "/opt/fastvideo-ci-runner/run-ci"
|
||||
timeout_in_minutes: 90
|
||||
env:
|
||||
TEST_TYPE: "encoder_ci"
|
||||
retry:
|
||||
automatic:
|
||||
- exit_status: 128
|
||||
limit: 3
|
||||
- exit_status: -1
|
||||
limit: 2
|
||||
agents:
|
||||
queue: "ci-runner"
|
||||
|
||||
# --- Fastcheck-scope direct tests ---
|
||||
- label: ":microscope: Encoder Tests"
|
||||
if: build.env("TEST_SCOPE") == "direct" && build.env("TEST_TYPE") == "encoder"
|
||||
command: "timeout 90m .buildkite/scripts/pr_test.sh"
|
||||
retry:
|
||||
automatic:
|
||||
- exit_status: 128
|
||||
limit: 3
|
||||
- exit_status: -1
|
||||
limit: 2
|
||||
agents:
|
||||
queue: "default"
|
||||
- label: ":microscope: VAE Tests"
|
||||
if: build.env("TEST_SCOPE") == "direct" && build.env("TEST_TYPE") == "vae"
|
||||
command: "timeout 90m .buildkite/scripts/pr_test.sh"
|
||||
retry:
|
||||
automatic:
|
||||
- exit_status: 128
|
||||
limit: 3
|
||||
- exit_status: -1
|
||||
limit: 2
|
||||
agents:
|
||||
queue: "default"
|
||||
- label: ":microscope: Transformer Tests"
|
||||
if: build.env("TEST_SCOPE") == "direct" && build.env("TEST_TYPE") == "transformer"
|
||||
command: "timeout 90m .buildkite/scripts/pr_test.sh"
|
||||
retry:
|
||||
automatic:
|
||||
- exit_status: 128
|
||||
limit: 3
|
||||
- exit_status: -1
|
||||
limit: 2
|
||||
agents:
|
||||
queue: "default"
|
||||
- label: ":microscope: Kernel Tests"
|
||||
if: build.env("TEST_SCOPE") == "direct" && build.env("TEST_TYPE") == "kernel_tests"
|
||||
command: "timeout 90m .buildkite/scripts/pr_test.sh"
|
||||
retry:
|
||||
automatic:
|
||||
- exit_status: 128
|
||||
limit: 3
|
||||
- exit_status: -1
|
||||
limit: 2
|
||||
agents:
|
||||
queue: "default"
|
||||
- label: ":vertical_traffic_light: Golden-Gate Tests"
|
||||
if: build.env("TEST_SCOPE") == "direct" && build.env("TEST_TYPE") == "golden_gate"
|
||||
command: "timeout 90m .buildkite/scripts/pr_test.sh"
|
||||
retry:
|
||||
automatic:
|
||||
- exit_status: 128
|
||||
limit: 3
|
||||
- exit_status: -1
|
||||
limit: 2
|
||||
agents:
|
||||
queue: "default"
|
||||
- label: ":microscope: Unit Tests"
|
||||
if: build.env("TEST_SCOPE") == "direct" && build.env("TEST_TYPE") == "unit_test"
|
||||
command: "timeout 90m .buildkite/scripts/pr_test.sh"
|
||||
retry:
|
||||
automatic:
|
||||
- exit_status: 128
|
||||
limit: 3
|
||||
- exit_status: -1
|
||||
limit: 2
|
||||
agents:
|
||||
queue: "default"
|
||||
- label: ":microscope: DreamVerse App Tests"
|
||||
if: build.env("TEST_SCOPE") == "direct" && build.env("TEST_TYPE") == "dreamverse_app"
|
||||
command: "timeout 90m .buildkite/scripts/pr_test.sh"
|
||||
retry:
|
||||
automatic:
|
||||
- exit_status: 128
|
||||
limit: 3
|
||||
- exit_status: -1
|
||||
limit: 2
|
||||
agents:
|
||||
queue: "default"
|
||||
- label: ":microscope: VAE Tests"
|
||||
key: "vae"
|
||||
if: |
|
||||
build.env("TEST_SCOPE") == "full" ||
|
||||
(build.env("TEST_SCOPE") == "merge" &&
|
||||
build.env("MERGE_TEST_PLAN") =~ /,vae,/) ||
|
||||
build.env("TEST_SCOPE") == "fastcheck" ||
|
||||
build.env("TEST_SCOPE") == null ||
|
||||
(build.env("TEST_SCOPE") == "direct" &&
|
||||
(build.env("TEST_TYPE") == "vae" || build.env("TEST_TYPE") == "vae_ci"))
|
||||
command: "/opt/fastvideo-ci-runner/run-ci"
|
||||
timeout_in_minutes: 90
|
||||
env:
|
||||
TEST_TYPE: "vae_ci"
|
||||
retry:
|
||||
automatic:
|
||||
- exit_status: 128
|
||||
limit: 3
|
||||
- exit_status: -1
|
||||
limit: 2
|
||||
agents:
|
||||
queue: "ci-runner"
|
||||
|
||||
# --- Full-suite-scope direct tests ---
|
||||
- label: ":bar_chart: SSIM Tests"
|
||||
if: build.env("TEST_SCOPE") == "direct" && build.env("TEST_TYPE") == "ssim"
|
||||
command: "timeout 90m .buildkite/scripts/pr_test.sh"
|
||||
retry:
|
||||
automatic:
|
||||
- exit_status: 128
|
||||
limit: 3
|
||||
- exit_status: -1
|
||||
limit: 2
|
||||
- exit_status: 1
|
||||
limit: 2
|
||||
agents:
|
||||
queue: "default"
|
||||
- label: ":test_tube: LoRA Inference Tests"
|
||||
if: build.env("TEST_SCOPE") == "direct" && build.env("TEST_TYPE") == "inference_lora"
|
||||
command: "timeout 90m .buildkite/scripts/pr_test.sh"
|
||||
retry:
|
||||
automatic:
|
||||
- exit_status: 128
|
||||
limit: 3
|
||||
- exit_status: -1
|
||||
limit: 2
|
||||
agents:
|
||||
queue: "default"
|
||||
- label: ":test_tube: LoRA Extraction Tests"
|
||||
if: build.env("TEST_SCOPE") == "direct" && build.env("TEST_TYPE") == "lora_extraction"
|
||||
command: "timeout 90m .buildkite/scripts/pr_test.sh"
|
||||
retry:
|
||||
automatic:
|
||||
- exit_status: 128
|
||||
limit: 3
|
||||
- exit_status: -1
|
||||
limit: 2
|
||||
agents:
|
||||
queue: "default"
|
||||
- label: ":test_tube: Training Tests"
|
||||
if: build.env("TEST_SCOPE") == "direct" && build.env("TEST_TYPE") == "training"
|
||||
command: "timeout 90m .buildkite/scripts/pr_test.sh"
|
||||
retry:
|
||||
automatic:
|
||||
- exit_status: 128
|
||||
limit: 3
|
||||
- exit_status: -1
|
||||
limit: 2
|
||||
agents:
|
||||
queue: "default"
|
||||
- label: ":test_tube: Distillation DMD Tests"
|
||||
if: build.env("TEST_SCOPE") == "direct" && build.env("TEST_TYPE") == "distillation_dmd"
|
||||
command: "timeout 90m .buildkite/scripts/pr_test.sh"
|
||||
retry:
|
||||
automatic:
|
||||
- exit_status: 128
|
||||
limit: 3
|
||||
- exit_status: -1
|
||||
limit: 2
|
||||
agents:
|
||||
queue: "default"
|
||||
- label: ":test_tube: Self-Forcing Tests"
|
||||
if: build.env("TEST_SCOPE") == "direct" && build.env("TEST_TYPE") == "self_forcing"
|
||||
command: "timeout 90m .buildkite/scripts/pr_test.sh"
|
||||
retry:
|
||||
automatic:
|
||||
- exit_status: 128
|
||||
limit: 3
|
||||
- exit_status: -1
|
||||
limit: 2
|
||||
agents:
|
||||
queue: "default"
|
||||
- label: ":test_tube: LoRA Training Tests"
|
||||
if: build.env("TEST_SCOPE") == "direct" && build.env("TEST_TYPE") == "training_lora"
|
||||
command: "timeout 90m .buildkite/scripts/pr_test.sh"
|
||||
retry:
|
||||
automatic:
|
||||
- exit_status: 128
|
||||
limit: 3
|
||||
- exit_status: -1
|
||||
limit: 2
|
||||
- exit_status: 1
|
||||
limit: 2
|
||||
agents:
|
||||
queue: "default"
|
||||
- label: ":test_tube: Training Tests VSA"
|
||||
if: build.env("TEST_SCOPE") == "direct" && build.env("TEST_TYPE") == "training_vsa"
|
||||
command: "timeout 90m .buildkite/scripts/pr_test.sh"
|
||||
retry:
|
||||
automatic:
|
||||
- exit_status: 128
|
||||
limit: 3
|
||||
- exit_status: -1
|
||||
limit: 2
|
||||
- exit_status: 1
|
||||
limit: 2
|
||||
agents:
|
||||
queue: "default"
|
||||
- label: ":test_tube: Inference Tests VMoBA"
|
||||
if: build.env("TEST_SCOPE") == "direct" && build.env("TEST_TYPE") == "inference_vmoba"
|
||||
command: "timeout 90m .buildkite/scripts/pr_test.sh"
|
||||
retry:
|
||||
automatic:
|
||||
- exit_status: 128
|
||||
limit: 3
|
||||
- exit_status: -1
|
||||
limit: 2
|
||||
agents:
|
||||
queue: "default"
|
||||
- label: ":test_tube: Performance Tests"
|
||||
if: build.env("TEST_SCOPE") == "direct" && build.env("TEST_TYPE") == "performance"
|
||||
command: "timeout 90m .buildkite/scripts/pr_test.sh"
|
||||
retry:
|
||||
automatic:
|
||||
- exit_status: 128
|
||||
limit: 3
|
||||
- exit_status: -1
|
||||
limit: 2
|
||||
agents:
|
||||
queue: "default"
|
||||
- label: ":test_tube: API Server Tests"
|
||||
if: build.env("TEST_SCOPE") == "direct" && build.env("TEST_TYPE") == "api_server"
|
||||
command: "timeout 90m .buildkite/scripts/pr_test.sh"
|
||||
retry:
|
||||
automatic:
|
||||
- exit_status: 128
|
||||
limit: 3
|
||||
- exit_status: -1
|
||||
limit: 2
|
||||
agents:
|
||||
queue: "default"
|
||||
- label: ":test_tube: Train Framework Tests"
|
||||
if: build.env("TEST_SCOPE") == "direct" && build.env("TEST_TYPE") == "train_framework"
|
||||
command: "timeout 90m .buildkite/scripts/pr_test.sh"
|
||||
retry:
|
||||
automatic:
|
||||
- exit_status: 128
|
||||
limit: 3
|
||||
- exit_status: -1
|
||||
limit: 2
|
||||
agents:
|
||||
queue: "default"
|
||||
- label: ":test_tube: Eval Metrics Tests"
|
||||
if: build.env("TEST_SCOPE") == "direct" && build.env("TEST_TYPE") == "eval"
|
||||
command: "timeout 90m .buildkite/scripts/pr_test.sh"
|
||||
retry:
|
||||
automatic:
|
||||
- exit_status: 128
|
||||
limit: 3
|
||||
- exit_status: -1
|
||||
limit: 2
|
||||
agents:
|
||||
queue: "default"
|
||||
- label: ":microscope: Transformer Tests"
|
||||
key: "transformer"
|
||||
if: |
|
||||
build.env("TEST_SCOPE") == "full" ||
|
||||
(build.env("TEST_SCOPE") == "merge" &&
|
||||
build.env("MERGE_TEST_PLAN") =~ /,transformer,/) ||
|
||||
build.env("TEST_SCOPE") == "fastcheck" ||
|
||||
build.env("TEST_SCOPE") == null ||
|
||||
(build.env("TEST_SCOPE") == "direct" &&
|
||||
(build.env("TEST_TYPE") == "transformer" || build.env("TEST_TYPE") == "transformer_ci"))
|
||||
command: "/opt/fastvideo-ci-runner/run-ci"
|
||||
timeout_in_minutes: 90
|
||||
env:
|
||||
TEST_TYPE: "transformer_ci"
|
||||
retry:
|
||||
automatic:
|
||||
- exit_status: 128
|
||||
limit: 3
|
||||
- exit_status: -1
|
||||
limit: 2
|
||||
agents:
|
||||
queue: "ci-runner"
|
||||
|
||||
# ============================================================
|
||||
# Fastcheck: Runs on every PR (~10-15 min parallel)
|
||||
# Core component validation: encoders, VAEs, transformers,
|
||||
# CUDA kernels, and unit tests.
|
||||
# ============================================================
|
||||
- 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/**"
|
||||
- "fastvideo/tests/encoders/**"
|
||||
- "pyproject.toml"
|
||||
- "docker/Dockerfile"
|
||||
config:
|
||||
command: "timeout 20m .buildkite/scripts/pr_test.sh"
|
||||
label: ":microscope: Encoder Tests"
|
||||
env:
|
||||
- TEST_TYPE=encoder
|
||||
agents:
|
||||
queue: "default"
|
||||
- path:
|
||||
- "fastvideo/models/vaes/**"
|
||||
- "fastvideo/models/loader/**"
|
||||
- "fastvideo/tests/vaes/**"
|
||||
- "pyproject.toml"
|
||||
- "docker/Dockerfile"
|
||||
config:
|
||||
command: "timeout 20m .buildkite/scripts/pr_test.sh"
|
||||
label: ":microscope: VAE Tests"
|
||||
env:
|
||||
- TEST_TYPE=vae
|
||||
agents:
|
||||
queue: "default"
|
||||
- path:
|
||||
- "fastvideo/models/dits/**"
|
||||
- "fastvideo/models/loader/**"
|
||||
- "fastvideo/tests/transformers/**"
|
||||
- "fastvideo/layers/**"
|
||||
- "fastvideo/attention/**"
|
||||
- "pyproject.toml"
|
||||
- "docker/Dockerfile"
|
||||
config:
|
||||
command: "timeout 15m .buildkite/scripts/pr_test.sh"
|
||||
label: ":microscope: Transformer Tests"
|
||||
env:
|
||||
- TEST_TYPE=transformer
|
||||
agents:
|
||||
queue: "default"
|
||||
- path:
|
||||
- "fastvideo-kernel/**"
|
||||
- "pyproject.toml"
|
||||
- "docker/Dockerfile"
|
||||
config:
|
||||
command: "timeout 15m .buildkite/scripts/pr_test.sh"
|
||||
label: ":microscope: Kernel Tests"
|
||||
env:
|
||||
- TEST_TYPE=kernel_tests
|
||||
agents:
|
||||
queue: "default"
|
||||
- path:
|
||||
- "fastvideo/**"
|
||||
- ".buildkite/**"
|
||||
- ".github/**"
|
||||
- "pyproject.toml"
|
||||
- "docker/Dockerfile"
|
||||
config:
|
||||
command: "timeout 15m .buildkite/scripts/pr_test.sh"
|
||||
label: ":microscope: Unit Tests"
|
||||
env:
|
||||
- TEST_TYPE=unit_test
|
||||
agents:
|
||||
queue: "default"
|
||||
- path:
|
||||
- "apps/dreamverse/**"
|
||||
- "pyproject.toml"
|
||||
config:
|
||||
command: "timeout 30m .buildkite/scripts/pr_test.sh"
|
||||
label: ":microscope: DreamVerse App Tests"
|
||||
env:
|
||||
- TEST_TYPE=dreamverse_app
|
||||
agents:
|
||||
queue: "default"
|
||||
- label: ":microscope: Kernel Tests"
|
||||
key: "kernel-tests"
|
||||
if: |
|
||||
build.env("TEST_SCOPE") == "full" ||
|
||||
(build.env("TEST_SCOPE") == "merge" &&
|
||||
build.env("MERGE_TEST_PLAN") =~ /,kernel-tests,/) ||
|
||||
build.env("TEST_SCOPE") == "fastcheck" ||
|
||||
build.env("TEST_SCOPE") == null ||
|
||||
(build.env("TEST_SCOPE") == "direct" &&
|
||||
(build.env("TEST_TYPE") == "kernel_tests" || build.env("TEST_TYPE") == "kernel_tests_ci"))
|
||||
command: "/opt/fastvideo-ci-runner/run-ci"
|
||||
timeout_in_minutes: 90
|
||||
env:
|
||||
TEST_TYPE: "kernel_tests_ci"
|
||||
retry:
|
||||
automatic:
|
||||
- exit_status: 128
|
||||
limit: 3
|
||||
- exit_status: -1
|
||||
limit: 2
|
||||
agents:
|
||||
queue: "ci-runner"
|
||||
|
||||
# ============================================================
|
||||
# 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("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"
|
||||
- "docker/Dockerfile"
|
||||
config:
|
||||
command: "timeout 90m .buildkite/scripts/pr_test.sh"
|
||||
label: ":bar_chart: SSIM Tests"
|
||||
env:
|
||||
- TEST_TYPE=ssim
|
||||
retry:
|
||||
automatic:
|
||||
- exit_status: 1
|
||||
limit: 2
|
||||
agents:
|
||||
queue: "default"
|
||||
- path:
|
||||
- "fastvideo/tests/lora/**"
|
||||
- "fastvideo/models/loader/**"
|
||||
- "fastvideo/tests/transformers/**"
|
||||
- "fastvideo/pipelines/**"
|
||||
- "fastvideo/layers/lora/**"
|
||||
- "pyproject.toml"
|
||||
- "docker/Dockerfile"
|
||||
config:
|
||||
command: "timeout 20m .buildkite/scripts/pr_test.sh"
|
||||
label: ":test_tube: LoRA Inference Tests"
|
||||
env:
|
||||
- TEST_TYPE=inference_lora
|
||||
agents:
|
||||
queue: "default"
|
||||
- path:
|
||||
- "scripts/lora_extraction/**"
|
||||
- "fastvideo/tests/lora_extraction/**"
|
||||
- "fastvideo/models/loader/**"
|
||||
- "fastvideo/training/training_utils.py"
|
||||
- "fastvideo/layers/lora/**"
|
||||
- "pyproject.toml"
|
||||
- "docker/Dockerfile"
|
||||
config:
|
||||
command: "timeout 90m .buildkite/scripts/pr_test.sh"
|
||||
label: ":test_tube: LoRA Extraction Tests"
|
||||
env:
|
||||
- TEST_TYPE=lora_extraction
|
||||
agents:
|
||||
queue: "default"
|
||||
- path:
|
||||
- "fastvideo/**"
|
||||
- "pyproject.toml"
|
||||
- "docker/Dockerfile"
|
||||
config:
|
||||
command: "timeout 25m .buildkite/scripts/pr_test.sh"
|
||||
label: ":test_tube: Training Tests"
|
||||
env:
|
||||
- TEST_TYPE=training
|
||||
agents:
|
||||
queue: "default"
|
||||
- path:
|
||||
- "fastvideo/training/*distillation_pipeline.py"
|
||||
- "pyproject.toml"
|
||||
- "docker/Dockerfile"
|
||||
config:
|
||||
command: "timeout 25m .buildkite/scripts/pr_test.sh"
|
||||
label: ":test_tube: Distillation DMD Tests"
|
||||
env:
|
||||
- TEST_TYPE=distillation_dmd
|
||||
agents:
|
||||
queue: "default"
|
||||
- path:
|
||||
- "fastvideo/training/*self_forcing_distillation_pipeline.py"
|
||||
- "fastvideo/tests/training/self-forcing/**"
|
||||
- "pyproject.toml"
|
||||
- "docker/Dockerfile"
|
||||
config:
|
||||
command: "timeout 30m .buildkite/scripts/pr_test.sh"
|
||||
label: ":test_tube: Self-Forcing Tests"
|
||||
env:
|
||||
- TEST_TYPE=self_forcing
|
||||
agents:
|
||||
queue: "default"
|
||||
- path:
|
||||
- "fastvideo/**"
|
||||
- "pyproject.toml"
|
||||
- "docker/Dockerfile"
|
||||
config:
|
||||
command: "timeout 25m .buildkite/scripts/pr_test.sh"
|
||||
label: ":test_tube: LoRA Training Tests"
|
||||
env:
|
||||
- TEST_TYPE=training_lora
|
||||
retry:
|
||||
automatic:
|
||||
- exit_status: 1
|
||||
limit: 2
|
||||
agents:
|
||||
queue: "default"
|
||||
- path:
|
||||
- "fastvideo/**"
|
||||
- "fastvideo-kernel/**"
|
||||
- "pyproject.toml"
|
||||
- "docker/Dockerfile"
|
||||
config:
|
||||
command: "timeout 15m .buildkite/scripts/pr_test.sh"
|
||||
label: ":test_tube: Training Tests VSA"
|
||||
env:
|
||||
- TEST_TYPE=training_vsa
|
||||
retry:
|
||||
automatic:
|
||||
- exit_status: 1
|
||||
limit: 2
|
||||
agents:
|
||||
queue: "default"
|
||||
- path:
|
||||
- "fastvideo-kernel/**"
|
||||
- "fastvideo/attention/backends/vmoba.py"
|
||||
- "pyproject.toml"
|
||||
- "docker/Dockerfile"
|
||||
config:
|
||||
command: "timeout 15m .buildkite/scripts/pr_test.sh"
|
||||
label: ":test_tube: Inference Tests VMoBA"
|
||||
env:
|
||||
- TEST_TYPE=inference_vmoba
|
||||
agents:
|
||||
queue: "default"
|
||||
- path:
|
||||
- "fastvideo/models/dits/**"
|
||||
- "fastvideo/pipelines/**"
|
||||
- "fastvideo/attention/**"
|
||||
- "fastvideo/layers/**"
|
||||
- "fastvideo/worker/**"
|
||||
- "fastvideo/entrypoints/**"
|
||||
- "fastvideo/performance/**"
|
||||
- "fastvideo/tests/performance/**"
|
||||
- ".buildkite/performance-benchmarks/**"
|
||||
- "pyproject.toml"
|
||||
- "docker/Dockerfile"
|
||||
config:
|
||||
command: "timeout 30m .buildkite/scripts/pr_test.sh"
|
||||
label: ":test_tube: Performance Tests"
|
||||
env:
|
||||
- TEST_TYPE=performance
|
||||
agents:
|
||||
queue: "default"
|
||||
- path:
|
||||
- "fastvideo/entrypoints/openai/**"
|
||||
- "fastvideo/entrypoints/cli/serve.py"
|
||||
- "fastvideo/tests/entrypoints/test_openai_api_integration.py"
|
||||
- "pyproject.toml"
|
||||
- "docker/Dockerfile"
|
||||
config:
|
||||
command: "timeout 30m .buildkite/scripts/pr_test.sh"
|
||||
label: ":test_tube: API Server Tests"
|
||||
env:
|
||||
- TEST_TYPE=api_server
|
||||
agents:
|
||||
queue: "default"
|
||||
- path:
|
||||
- "fastvideo/train/**"
|
||||
- "fastvideo/tests/train/models/**"
|
||||
- "fastvideo/tests/train/fixtures/**"
|
||||
- "fastvideo/models/dits/**"
|
||||
- "fastvideo/models/loader/**"
|
||||
- "pyproject.toml"
|
||||
- "docker/Dockerfile"
|
||||
config:
|
||||
command: "timeout 30m .buildkite/scripts/pr_test.sh"
|
||||
label: ":test_tube: Train Framework Tests"
|
||||
env:
|
||||
- TEST_TYPE=train_framework
|
||||
agents:
|
||||
queue: "default"
|
||||
- path:
|
||||
- "fastvideo/eval/**"
|
||||
- "fastvideo/tests/eval/**"
|
||||
- "pyproject.toml"
|
||||
- "docker/Dockerfile"
|
||||
config:
|
||||
command: "timeout 90m .buildkite/scripts/pr_test.sh"
|
||||
label: ":test_tube: Eval Metrics Tests"
|
||||
env:
|
||||
- TEST_TYPE=eval
|
||||
agents:
|
||||
queue: "default"
|
||||
- label: ":microscope: Unit Tests"
|
||||
key: "unit"
|
||||
if: |
|
||||
build.env("TEST_SCOPE") == "full" ||
|
||||
(build.env("TEST_SCOPE") == "merge" &&
|
||||
build.env("MERGE_TEST_PLAN") =~ /,unit,/) ||
|
||||
build.env("TEST_SCOPE") == "fastcheck" ||
|
||||
build.env("TEST_SCOPE") == null ||
|
||||
(build.env("TEST_SCOPE") == "direct" &&
|
||||
(build.env("TEST_TYPE") == "unit_test" || build.env("TEST_TYPE") == "unit_test_ci"))
|
||||
command: "/opt/fastvideo-ci-runner/run-unit"
|
||||
timeout_in_minutes: 90
|
||||
env:
|
||||
TEST_TYPE: "unit_test_ci"
|
||||
retry:
|
||||
automatic:
|
||||
- exit_status: 128
|
||||
limit: 3
|
||||
- exit_status: -1
|
||||
limit: 2
|
||||
agents:
|
||||
queue: "ci-runner"
|
||||
|
||||
- label: ":microscope: DreamVerse App Tests"
|
||||
key: "dreamverse"
|
||||
if: |
|
||||
build.env("TEST_SCOPE") == "full" ||
|
||||
(build.env("TEST_SCOPE") == "merge" &&
|
||||
build.env("MERGE_TEST_PLAN") =~ /,dreamverse,/) ||
|
||||
build.env("TEST_SCOPE") == "fastcheck" ||
|
||||
build.env("TEST_SCOPE") == null ||
|
||||
(build.env("TEST_SCOPE") == "direct" &&
|
||||
(build.env("TEST_TYPE") == "dreamverse_app" || build.env("TEST_TYPE") == "dreamverse_app_ci"))
|
||||
command: "/opt/fastvideo-ci-runner/run-ci"
|
||||
timeout_in_minutes: 90
|
||||
env:
|
||||
TEST_TYPE: "dreamverse_app_ci"
|
||||
retry:
|
||||
automatic:
|
||||
- exit_status: 128
|
||||
limit: 3
|
||||
- exit_status: -1
|
||||
limit: 2
|
||||
agents:
|
||||
queue: "ci-runner"
|
||||
|
||||
- label: ":test_tube: Golden-Gate Tests"
|
||||
key: "golden-gate"
|
||||
if: |
|
||||
build.env("TEST_SCOPE") == "full" ||
|
||||
(build.env("TEST_SCOPE") == "merge" &&
|
||||
build.env("MERGE_TEST_PLAN") =~ /,golden-gate,/) ||
|
||||
(build.env("TEST_SCOPE") == "direct" &&
|
||||
(build.env("TEST_TYPE") == "golden_gate" || build.env("TEST_TYPE") == "golden_gate_ci"))
|
||||
command: "/opt/fastvideo-ci-runner/run-ci"
|
||||
timeout_in_minutes: 90
|
||||
env:
|
||||
TEST_TYPE: "golden_gate_ci"
|
||||
retry:
|
||||
automatic:
|
||||
- exit_status: 128
|
||||
limit: 3
|
||||
- exit_status: -1
|
||||
limit: 2
|
||||
agents:
|
||||
queue: "ci-runner"
|
||||
|
||||
- label: ":bar_chart: SSIM Tests"
|
||||
key: "ssim"
|
||||
if: |
|
||||
build.env("TEST_SCOPE") == "full" ||
|
||||
build.env("TEST_SCOPE") == "scheduled" ||
|
||||
(build.env("TEST_SCOPE") == "merge" &&
|
||||
build.env("MERGE_TEST_PLAN") =~ /,ssim,/) ||
|
||||
(build.env("TEST_SCOPE") == "direct" &&
|
||||
(build.env("TEST_TYPE") == "ssim" || build.env("TEST_TYPE") == "ssim_ci"))
|
||||
command: "/opt/fastvideo-ci-runner/run-ci"
|
||||
timeout_in_minutes: 90
|
||||
concurrency: 1
|
||||
concurrency_group: "fastvideo/slinky/whole-tray"
|
||||
env:
|
||||
TEST_TYPE: "ssim_ci"
|
||||
retry:
|
||||
automatic:
|
||||
- exit_status: 128
|
||||
limit: 3
|
||||
- exit_status: -1
|
||||
limit: 2
|
||||
- exit_status: 1
|
||||
limit: 2
|
||||
agents:
|
||||
queue: "ci-runner"
|
||||
|
||||
- label: ":test_tube: LoRA Inference Tests"
|
||||
key: "lora-inference"
|
||||
if: |
|
||||
build.env("TEST_SCOPE") == "full" ||
|
||||
(build.env("TEST_SCOPE") == "merge" &&
|
||||
build.env("MERGE_TEST_PLAN") =~ /,lora-inference,/) ||
|
||||
(build.env("TEST_SCOPE") == "direct" &&
|
||||
(build.env("TEST_TYPE") == "inference_lora" || build.env("TEST_TYPE") == "inference_lora_ci"))
|
||||
command: "/opt/fastvideo-ci-runner/run-ci"
|
||||
timeout_in_minutes: 90
|
||||
env:
|
||||
TEST_TYPE: "inference_lora_ci"
|
||||
retry:
|
||||
automatic:
|
||||
- exit_status: 128
|
||||
limit: 3
|
||||
- exit_status: -1
|
||||
limit: 2
|
||||
agents:
|
||||
queue: "ci-runner"
|
||||
|
||||
- label: ":test_tube: LoRA Extraction Tests"
|
||||
key: "lora-extraction"
|
||||
if: |
|
||||
build.env("TEST_SCOPE") == "full" ||
|
||||
(build.env("TEST_SCOPE") == "merge" &&
|
||||
build.env("MERGE_TEST_PLAN") =~ /,lora-extraction,/) ||
|
||||
(build.env("TEST_SCOPE") == "direct" &&
|
||||
(build.env("TEST_TYPE") == "lora_extraction" || build.env("TEST_TYPE") == "lora_extraction_ci"))
|
||||
command: "/opt/fastvideo-ci-runner/run-ci"
|
||||
timeout_in_minutes: 90
|
||||
env:
|
||||
TEST_TYPE: "lora_extraction_ci"
|
||||
retry:
|
||||
automatic:
|
||||
- exit_status: 128
|
||||
limit: 3
|
||||
- exit_status: -1
|
||||
limit: 2
|
||||
agents:
|
||||
queue: "ci-runner"
|
||||
|
||||
- label: ":test_tube: Training Tests"
|
||||
key: "training"
|
||||
if: |
|
||||
build.env("TEST_SCOPE") == "full" ||
|
||||
(build.env("TEST_SCOPE") == "merge" &&
|
||||
build.env("MERGE_TEST_PLAN") =~ /,training,/) ||
|
||||
(build.env("TEST_SCOPE") == "direct" &&
|
||||
(build.env("TEST_TYPE") == "training" || build.env("TEST_TYPE") == "training_ci"))
|
||||
command: "/opt/fastvideo-ci-runner/run-ci"
|
||||
timeout_in_minutes: 90
|
||||
concurrency: 1
|
||||
concurrency_group: "fastvideo/slinky/whole-tray"
|
||||
env:
|
||||
TEST_TYPE: "training_ci"
|
||||
retry:
|
||||
automatic:
|
||||
- exit_status: 128
|
||||
limit: 3
|
||||
- exit_status: -1
|
||||
limit: 2
|
||||
agents:
|
||||
queue: "ci-runner"
|
||||
|
||||
- label: ":test_tube: Distillation DMD Tests"
|
||||
key: "distillation"
|
||||
if: |
|
||||
build.env("TEST_SCOPE") == "full" ||
|
||||
(build.env("TEST_SCOPE") == "merge" &&
|
||||
build.env("MERGE_TEST_PLAN") =~ /,distillation,/) ||
|
||||
(build.env("TEST_SCOPE") == "direct" &&
|
||||
(build.env("TEST_TYPE") == "distillation_dmd" || build.env("TEST_TYPE") == "distillation_dmd_ci"))
|
||||
command: "/opt/fastvideo-ci-runner/run-ci"
|
||||
timeout_in_minutes: 90
|
||||
env:
|
||||
TEST_TYPE: "distillation_dmd_ci"
|
||||
retry:
|
||||
automatic:
|
||||
- exit_status: 128
|
||||
limit: 3
|
||||
- exit_status: -1
|
||||
limit: 2
|
||||
agents:
|
||||
queue: "ci-runner"
|
||||
|
||||
- label: ":test_tube: Self-Forcing Tests"
|
||||
key: "self-forcing"
|
||||
if: |
|
||||
build.env("TEST_SCOPE") == "full" ||
|
||||
(build.env("TEST_SCOPE") == "merge" &&
|
||||
build.env("MERGE_TEST_PLAN") =~ /,self-forcing,/) ||
|
||||
(build.env("TEST_SCOPE") == "direct" &&
|
||||
(build.env("TEST_TYPE") == "self_forcing" || build.env("TEST_TYPE") == "self_forcing_ci"))
|
||||
command: "/opt/fastvideo-ci-runner/run-ci"
|
||||
timeout_in_minutes: 90
|
||||
env:
|
||||
TEST_TYPE: "self_forcing_ci"
|
||||
retry:
|
||||
automatic:
|
||||
- exit_status: 128
|
||||
limit: 3
|
||||
- exit_status: -1
|
||||
limit: 2
|
||||
agents:
|
||||
queue: "ci-runner"
|
||||
|
||||
- label: ":test_tube: LoRA Training Tests"
|
||||
key: "lora-training"
|
||||
if: |
|
||||
build.env("TEST_SCOPE") == "full" ||
|
||||
(build.env("TEST_SCOPE") == "merge" &&
|
||||
build.env("MERGE_TEST_PLAN") =~ /,lora-training,/) ||
|
||||
(build.env("TEST_SCOPE") == "direct" &&
|
||||
(build.env("TEST_TYPE") == "training_lora" || build.env("TEST_TYPE") == "training_lora_ci"))
|
||||
command: "/opt/fastvideo-ci-runner/run-ci"
|
||||
timeout_in_minutes: 90
|
||||
env:
|
||||
TEST_TYPE: "training_lora_ci"
|
||||
retry:
|
||||
automatic:
|
||||
- exit_status: 128
|
||||
limit: 3
|
||||
- exit_status: -1
|
||||
limit: 2
|
||||
- exit_status: 1
|
||||
limit: 2
|
||||
agents:
|
||||
queue: "ci-runner"
|
||||
|
||||
- label: ":test_tube: Training Tests VSA"
|
||||
key: "training-vsa"
|
||||
if: |
|
||||
build.env("TEST_SCOPE") == "full" ||
|
||||
(build.env("TEST_SCOPE") == "merge" &&
|
||||
build.env("MERGE_TEST_PLAN") =~ /,training-vsa,/) ||
|
||||
(build.env("TEST_SCOPE") == "direct" &&
|
||||
(build.env("TEST_TYPE") == "training_vsa" || build.env("TEST_TYPE") == "training_vsa_ci"))
|
||||
command: "/opt/fastvideo-ci-runner/run-ci"
|
||||
timeout_in_minutes: 90
|
||||
env:
|
||||
TEST_TYPE: "training_vsa_ci"
|
||||
retry:
|
||||
automatic:
|
||||
- exit_status: 128
|
||||
limit: 3
|
||||
- exit_status: -1
|
||||
limit: 2
|
||||
- exit_status: 1
|
||||
limit: 2
|
||||
agents:
|
||||
queue: "ci-runner"
|
||||
|
||||
- label: ":test_tube: Inference Tests VMoBA"
|
||||
key: "inference-vmoba"
|
||||
if: |
|
||||
build.env("TEST_SCOPE") == "full" ||
|
||||
(build.env("TEST_SCOPE") == "merge" &&
|
||||
build.env("MERGE_TEST_PLAN") =~ /,inference-vmoba,/) ||
|
||||
(build.env("TEST_SCOPE") == "direct" &&
|
||||
(build.env("TEST_TYPE") == "inference_vmoba" || build.env("TEST_TYPE") == "inference_vmoba_ci"))
|
||||
command: "/opt/fastvideo-ci-runner/run-ci"
|
||||
timeout_in_minutes: 90
|
||||
env:
|
||||
TEST_TYPE: "inference_vmoba_ci"
|
||||
retry:
|
||||
automatic:
|
||||
- exit_status: 128
|
||||
limit: 3
|
||||
- exit_status: -1
|
||||
limit: 2
|
||||
agents:
|
||||
queue: "ci-runner"
|
||||
|
||||
- label: ":test_tube: Performance Tests"
|
||||
key: "performance"
|
||||
if: |
|
||||
build.env("TEST_SCOPE") == "full" ||
|
||||
(build.env("TEST_SCOPE") == "merge" &&
|
||||
build.env("MERGE_TEST_PLAN") =~ /,performance,/) ||
|
||||
(build.env("TEST_SCOPE") == "direct" &&
|
||||
(build.env("TEST_TYPE") == "performance" || build.env("TEST_TYPE") == "performance_ci"))
|
||||
command: "/opt/fastvideo-ci-runner/run-ci"
|
||||
timeout_in_minutes: 90
|
||||
env:
|
||||
TEST_TYPE: "performance_ci"
|
||||
retry:
|
||||
automatic:
|
||||
- exit_status: 128
|
||||
limit: 3
|
||||
- exit_status: -1
|
||||
limit: 2
|
||||
agents:
|
||||
queue: "ci-runner"
|
||||
|
||||
- label: ":test_tube: API Server Tests"
|
||||
key: "api-server"
|
||||
if: |
|
||||
build.env("TEST_SCOPE") == "full" ||
|
||||
(build.env("TEST_SCOPE") == "merge" &&
|
||||
build.env("MERGE_TEST_PLAN") =~ /,api-server,/) ||
|
||||
(build.env("TEST_SCOPE") == "direct" &&
|
||||
(build.env("TEST_TYPE") == "api_server" || build.env("TEST_TYPE") == "api_server_ci"))
|
||||
command: "/opt/fastvideo-ci-runner/run-ci"
|
||||
timeout_in_minutes: 90
|
||||
env:
|
||||
TEST_TYPE: "api_server_ci"
|
||||
retry:
|
||||
automatic:
|
||||
- exit_status: 128
|
||||
limit: 3
|
||||
- exit_status: -1
|
||||
limit: 2
|
||||
agents:
|
||||
queue: "ci-runner"
|
||||
|
||||
- label: ":test_tube: Train Framework Tests"
|
||||
key: "train-framework"
|
||||
if: |
|
||||
build.env("TEST_SCOPE") == "full" ||
|
||||
(build.env("TEST_SCOPE") == "merge" &&
|
||||
build.env("MERGE_TEST_PLAN") =~ /,train-framework,/) ||
|
||||
(build.env("TEST_SCOPE") == "direct" &&
|
||||
(build.env("TEST_TYPE") == "train_framework" || build.env("TEST_TYPE") == "train_framework_ci"))
|
||||
command: "/opt/fastvideo-ci-runner/run-ci"
|
||||
timeout_in_minutes: 90
|
||||
env:
|
||||
TEST_TYPE: "train_framework_ci"
|
||||
retry:
|
||||
automatic:
|
||||
- exit_status: 128
|
||||
limit: 3
|
||||
- exit_status: -1
|
||||
limit: 2
|
||||
agents:
|
||||
queue: "ci-runner"
|
||||
|
||||
- label: ":test_tube: Eval Metrics Tests"
|
||||
key: "eval"
|
||||
if: |
|
||||
build.env("TEST_SCOPE") == "full" ||
|
||||
(build.env("TEST_SCOPE") == "merge" &&
|
||||
build.env("MERGE_TEST_PLAN") =~ /,eval,/) ||
|
||||
(build.env("TEST_SCOPE") == "direct" &&
|
||||
(build.env("TEST_TYPE") == "eval" || build.env("TEST_TYPE") == "eval_ci"))
|
||||
command: "/opt/fastvideo-ci-runner/run-ci"
|
||||
timeout_in_minutes: 90
|
||||
env:
|
||||
TEST_TYPE: "eval_ci"
|
||||
retry:
|
||||
automatic:
|
||||
- exit_status: 128
|
||||
limit: 3
|
||||
- exit_status: -1
|
||||
limit: 2
|
||||
agents:
|
||||
queue: "ci-runner"
|
||||
|
||||
Executable
+5
@@ -0,0 +1,5 @@
|
||||
#!/usr/bin/env bash
|
||||
# Canonical Slurm CI selection for the OpenAI-compatible API lane.
|
||||
set -euo pipefail
|
||||
|
||||
exec pytest ./fastvideo/tests/entrypoints/test_openai_api_integration.py -vs
|
||||
Executable
+5
@@ -0,0 +1,5 @@
|
||||
#!/usr/bin/env bash
|
||||
# Canonical Slurm CI selection for the distillation-DMD lane.
|
||||
set -euo pipefail
|
||||
|
||||
exec pytest ./fastvideo/tests/training/distill/test_distill_dmd.py -vs
|
||||
Executable
+87
@@ -0,0 +1,87 @@
|
||||
#!/usr/bin/env bash
|
||||
# DreamVerse needs a GPU for import-time device resolution, but it does not
|
||||
# build or exercise fastvideo-kernel. A checksummed Node archive is installed
|
||||
# in the disposable Slurm container because the shared CI image is
|
||||
# Python/CUDA focused.
|
||||
set -euo pipefail
|
||||
|
||||
node_version=v22.23.2
|
||||
case $(uname -m) in
|
||||
aarch64 | arm64)
|
||||
node_arch=arm64
|
||||
node_archive_sha256=013b59cfd2819703a6f4a14ab891fc46fc2a4e3f5bcd92de3fb4929b43e35b30
|
||||
;;
|
||||
x86_64 | amd64)
|
||||
node_arch=x64
|
||||
node_archive_sha256=b294a556e639d64338823920e5866c21c02741742d2e1529ee1a225c1ec9252a
|
||||
;;
|
||||
*)
|
||||
echo "Unsupported architecture for DreamVerse Node runtime: $(uname -m)" >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
node_archive="node-${node_version}-linux-${node_arch}.tar.gz"
|
||||
node_runtime_root=$(mktemp -d -t fastvideo-node.XXXXXX)
|
||||
node_archive_path="${node_runtime_root}/${node_archive}"
|
||||
node_install_dir="${node_runtime_root}/${node_archive%.tar.gz}"
|
||||
curl --proto '=https' --tlsv1.2 --retry 5 --retry-all-errors \
|
||||
--location --fail --silent --show-error \
|
||||
"https://nodejs.org/dist/${node_version}/${node_archive}" \
|
||||
--output "$node_archive_path"
|
||||
printf '%s %s\n' "$node_archive_sha256" "$node_archive_path" | sha256sum --check --status
|
||||
tar -xzf "$node_archive_path" -C "$node_runtime_root"
|
||||
export PATH="${node_install_dir}/bin:${PATH}"
|
||||
node --version
|
||||
npm --version
|
||||
|
||||
export PYTHONPATH="$(pwd)/apps/dreamverse${PYTHONPATH:+:$PYTHONPATH}"
|
||||
pytest apps/dreamverse/dreamverse/tests -q
|
||||
|
||||
cd apps/dreamverse/web
|
||||
npm ci
|
||||
npm run typecheck
|
||||
npm test
|
||||
machine_arch=$(uname -m)
|
||||
if [[ $machine_arch =~ ^(aarch64|arm64)$ ]]; then
|
||||
npx playwright install --with-deps chromium firefox
|
||||
else
|
||||
npx playwright install --with-deps chromium webkit firefox
|
||||
fi
|
||||
|
||||
master_port=${MASTER_PORT:-7959}
|
||||
BACKEND_PORT=${BACKEND_PORT:-$((master_port + 50))}
|
||||
python -m uvicorn dreamverse.mock_server:app --host 127.0.0.1 --port "$BACKEND_PORT" &
|
||||
mock_server_pid=$!
|
||||
cleanup() {
|
||||
kill "$mock_server_pid" 2>/dev/null || true
|
||||
wait "$mock_server_pid" 2>/dev/null || true
|
||||
}
|
||||
trap cleanup EXIT INT TERM
|
||||
|
||||
for _ in {1..30}; do
|
||||
curl -fsS "http://127.0.0.1:$BACKEND_PORT/healthz" && break
|
||||
sleep 1
|
||||
done
|
||||
curl -fsS "http://127.0.0.1:$BACKEND_PORT/healthz"
|
||||
|
||||
if [[ $machine_arch =~ ^(aarch64|arm64)$ ]]; then
|
||||
# Playwright WebKit traps before opening a page on Linux ARM64, and its
|
||||
# bundled Chromium lacks the H.264/AAC codecs used by the fMP4 assertions.
|
||||
# Firefox covers every flow, including streaming. Chromium and its mobile
|
||||
# profile still cover all codec-independent UI behavior on GB200.
|
||||
BACKEND_HOST=127.0.0.1 BACKEND_PORT="$BACKEND_PORT" CI=1 \
|
||||
npm run e2e -- --project=firefox
|
||||
BACKEND_HOST=127.0.0.1 BACKEND_PORT="$BACKEND_PORT" CI=1 \
|
||||
npm run e2e -- \
|
||||
--project=chromium \
|
||||
--project=mobile-chromium \
|
||||
--grep-invert='streams, plays, and surfaces a downloadable clip|starts a new project and switches back to the prior session|saved projects persist across a page reload'
|
||||
else
|
||||
BACKEND_HOST=127.0.0.1 BACKEND_PORT="$BACKEND_PORT" CI=1 \
|
||||
npm run e2e -- \
|
||||
--project=chromium \
|
||||
--project=webkit \
|
||||
--project=firefox \
|
||||
--project=mobile-safari \
|
||||
--project=mobile-chromium
|
||||
fi
|
||||
Executable
+5
@@ -0,0 +1,5 @@
|
||||
#!/usr/bin/env bash
|
||||
# Canonical Slurm CI selection for the encoder lane.
|
||||
set -euo pipefail
|
||||
|
||||
exec pytest ./fastvideo/tests/encoders -vs
|
||||
Executable
+5
@@ -0,0 +1,5 @@
|
||||
#!/usr/bin/env bash
|
||||
# Canonical Slurm CI selection for the evaluation lane.
|
||||
set -euo pipefail
|
||||
|
||||
exec pytest ./fastvideo/tests/eval -vs
|
||||
Executable
+35
@@ -0,0 +1,35 @@
|
||||
#!/usr/bin/env bash
|
||||
# Canonical Slurm CI selection for the golden-gate lane. Environment (HF_HOME
|
||||
# and authentication) is the runner's responsibility.
|
||||
set -euo pipefail
|
||||
|
||||
golden_root=./fastvideo/tests/golden_gate
|
||||
selected=${FASTVIDEO_GOLDEN_TEST_FILES-}
|
||||
if [ -z "$selected" ]; then
|
||||
if [ "${TEST_SCOPE:-}" = merge ]; then
|
||||
echo "Missing FASTVIDEO_GOLDEN_TEST_FILES for merge scope" >&2
|
||||
exit 2
|
||||
fi
|
||||
selected=all
|
||||
fi
|
||||
if [ "$selected" = all ]; then
|
||||
exec pytest "$golden_root" -vs
|
||||
fi
|
||||
|
||||
[[ $selected =~ ^test_[a-z0-9_]+\.py(,test_[a-z0-9_]+\.py)*$ ]] || {
|
||||
echo "Invalid FASTVIDEO_GOLDEN_TEST_FILES selection" >&2
|
||||
exit 2
|
||||
}
|
||||
|
||||
IFS=, read -r -a golden_files <<< "$selected"
|
||||
golden_paths=()
|
||||
for golden_file in "${golden_files[@]}"; do
|
||||
golden_path="$golden_root/$golden_file"
|
||||
[ -f "$golden_path" ] || {
|
||||
echo "Selected golden test does not exist: $golden_file" >&2
|
||||
exit 2
|
||||
}
|
||||
golden_paths+=("$golden_path")
|
||||
done
|
||||
|
||||
exec pytest "${golden_paths[@]}" -vs
|
||||
Executable
+5
@@ -0,0 +1,5 @@
|
||||
#!/usr/bin/env bash
|
||||
# Canonical Slurm CI selection for the LoRA-inference lane.
|
||||
set -euo pipefail
|
||||
|
||||
exec pytest ./fastvideo/tests/inference/lora/test_lora_inference_similarity.py -vs
|
||||
Executable
+5
@@ -0,0 +1,5 @@
|
||||
#!/usr/bin/env bash
|
||||
# Canonical Slurm CI selection for the VMoBA-inference lane.
|
||||
set -euo pipefail
|
||||
|
||||
exec python fastvideo/tests/inference/vmoba/test_vmoba_inference.py
|
||||
Executable
+5
@@ -0,0 +1,5 @@
|
||||
#!/usr/bin/env bash
|
||||
# Canonical Slurm CI selection for the custom-kernel lane.
|
||||
set -euo pipefail
|
||||
|
||||
exec pytest fastvideo-kernel/tests/ -vs
|
||||
Executable
+5
@@ -0,0 +1,5 @@
|
||||
#!/usr/bin/env bash
|
||||
# Canonical Slurm CI selection for the LoRA-extraction lane.
|
||||
set -euo pipefail
|
||||
|
||||
exec pytest ./fastvideo/tests/lora_extraction/test_lora_extraction.py -vs
|
||||
Executable
+52
@@ -0,0 +1,52 @@
|
||||
#!/usr/bin/env bash
|
||||
# Canonical Slurm performance lane. Reports are written outside the checkout
|
||||
# so the trusted host driver can upload them after untrusted code exits.
|
||||
set -uo pipefail
|
||||
|
||||
export PERFORMANCE_TRACKING_ROOT=/tmp/perf-tracking
|
||||
export PERF_REPORTS_DIR=/workspace/artifacts/performance
|
||||
mkdir -p "$PERF_REPORTS_DIR"
|
||||
|
||||
if [[ ${BUILDKITE_PULL_REQUEST:-false} =~ ^[1-9][0-9]*$ ]]; then
|
||||
export PERF_RUN_SOURCE=pr
|
||||
export PERF_UPLOAD_POLICY=pass
|
||||
elif [ "${BUILDKITE_BRANCH:-}" = main ] \
|
||||
&& { [ "${BUILDKITE_SOURCE:-}" = schedule ] || [ "${TEST_SCOPE:-}" = full ]; }; then
|
||||
export PERF_RUN_SOURCE=scheduled_main
|
||||
export PERF_UPLOAD_POLICY=always
|
||||
elif [ "${TEST_SCOPE:-}" = direct ]; then
|
||||
export PERF_RUN_SOURCE=unknown
|
||||
export PERF_UPLOAD_POLICY=pass
|
||||
else
|
||||
export PERF_RUN_SOURCE=unknown
|
||||
export PERF_UPLOAD_POLICY=never
|
||||
fi
|
||||
|
||||
nvidia-smi \
|
||||
--query-gpu=index,timestamp,clocks.sm,clocks.max.sm,power.draw,power.limit,temperature.gpu \
|
||||
--format=csv -l 10 > "$PERF_REPORTS_DIR/gpu_telemetry.csv" 2>/dev/null &
|
||||
telemetry_pid=$!
|
||||
cleanup() {
|
||||
kill "$telemetry_pid" 2>/dev/null || true
|
||||
wait "$telemetry_pid" 2>/dev/null || true
|
||||
}
|
||||
trap cleanup EXIT INT TERM
|
||||
|
||||
pytest ./fastvideo/tests/performance -vs
|
||||
pytest_rc=$?
|
||||
compare_rc=0
|
||||
if [ "$pytest_rc" -eq 0 ] || [ "$PERF_UPLOAD_POLICY" = always ]; then
|
||||
PERF_PYTEST_RC=$pytest_rc python ./fastvideo/tests/performance/compare_baseline.py
|
||||
compare_rc=$?
|
||||
fi
|
||||
python ./fastvideo/tests/performance/dashboard.py || true
|
||||
cp -f fastvideo/tests/performance/results/*.json "$PERF_REPORTS_DIR/" 2>/dev/null || true
|
||||
|
||||
echo "--- GPU telemetry (clocks.sm vs clocks.max.sm reveals capped hosts) ---"
|
||||
cat "$PERF_REPORTS_DIR/gpu_telemetry.csv" || true
|
||||
|
||||
final_rc=$pytest_rc
|
||||
if [ "$final_rc" -eq 0 ]; then
|
||||
final_rc=$compare_rc
|
||||
fi
|
||||
exit "$final_rc"
|
||||
Executable
+6
@@ -0,0 +1,6 @@
|
||||
#!/usr/bin/env bash
|
||||
# Canonical Slurm CI selection for the self-forcing lane.
|
||||
set -euo pipefail
|
||||
|
||||
export WANDB_MODE=offline
|
||||
exec pytest ./fastvideo/tests/training/self-forcing/test_self_forcing.py -vs
|
||||
Executable
+40
@@ -0,0 +1,40 @@
|
||||
#!/usr/bin/env bash
|
||||
# Canonical four-GPU SSIM lane for the Slinky Slurm worker.
|
||||
set -euo pipefail
|
||||
|
||||
args=()
|
||||
if [ "${FASTVIDEO_SSIM_BOOTSTRAP_MODE:-0}" = 1 ]; then
|
||||
args+=(--bootstrap-mode)
|
||||
fi
|
||||
selected=${FASTVIDEO_SSIM_TEST_FILES-}
|
||||
if [ -z "$selected" ]; then
|
||||
if [ "${TEST_SCOPE:-}" = merge ]; then
|
||||
echo "Missing FASTVIDEO_SSIM_TEST_FILES for merge scope" >&2
|
||||
exit 2
|
||||
fi
|
||||
selected=all
|
||||
fi
|
||||
if [ "$selected" != all ]; then
|
||||
[[ $selected =~ ^test_[a-z0-9_]+\.py(,test_[a-z0-9_]+\.py)*$ ]] || {
|
||||
echo "Invalid FASTVIDEO_SSIM_TEST_FILES selection" >&2
|
||||
exit 2
|
||||
}
|
||||
IFS=, read -r -a ssim_files <<< "$selected"
|
||||
for ssim_file in "${ssim_files[@]}"; do
|
||||
args+=(--test-file "$ssim_file")
|
||||
done
|
||||
fi
|
||||
|
||||
# MoGe's utils3d dependency builds glcontext from source on ARM64. The current
|
||||
# runner image predates the baked-in X11 headers below, so keep this guarded
|
||||
# bootstrap until every deployed image digest contains libx11-dev.
|
||||
if [ ! -f /usr/include/X11/Xlib.h ]; then
|
||||
apt-get -o Acquire::Retries=5 update
|
||||
apt-get -o Acquire::Retries=5 install -y --no-install-recommends libx11-dev
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
fi
|
||||
|
||||
uv pip install git+https://github.com/microsoft/MoGe.git
|
||||
uv pip install k_diffusion einops_exts alias_free_torch torchsde
|
||||
|
||||
exec python fastvideo/tests/ssim/ci_runner.py "${args[@]}"
|
||||
Executable
+5
@@ -0,0 +1,5 @@
|
||||
#!/usr/bin/env bash
|
||||
# Canonical Slurm CI selection for the modular training-framework lane.
|
||||
set -euo pipefail
|
||||
|
||||
exec pytest ./fastvideo/tests/train/models ./fastvideo/tests/train/methods -vs
|
||||
Executable
+6
@@ -0,0 +1,6 @@
|
||||
#!/usr/bin/env bash
|
||||
# Canonical Slurm CI selection for the legacy vanilla-training lane.
|
||||
set -euo pipefail
|
||||
|
||||
export WANDB_MODE=offline
|
||||
exec pytest ./fastvideo/tests/training/Vanilla -srP
|
||||
Executable
+6
@@ -0,0 +1,6 @@
|
||||
#!/usr/bin/env bash
|
||||
# Canonical Slurm CI selection for the legacy LoRA-training lane.
|
||||
set -euo pipefail
|
||||
|
||||
export WANDB_MODE=offline
|
||||
exec pytest ./fastvideo/tests/training/lora/test_lora_training.py -srP
|
||||
Executable
+6
@@ -0,0 +1,6 @@
|
||||
#!/usr/bin/env bash
|
||||
# Canonical Slurm CI selection for the legacy VSA-training lane.
|
||||
set -euo pipefail
|
||||
|
||||
export WANDB_MODE=offline
|
||||
exec pytest ./fastvideo/tests/training/VSA -srP
|
||||
Executable
+5
@@ -0,0 +1,5 @@
|
||||
#!/usr/bin/env bash
|
||||
# Canonical Slurm CI selection for the transformer lane.
|
||||
set -euo pipefail
|
||||
|
||||
exec pytest ./fastvideo/tests/transformers -vs
|
||||
Executable
+5
@@ -0,0 +1,5 @@
|
||||
#!/usr/bin/env bash
|
||||
# Canonical Slurm CI selection for the VAE lane.
|
||||
set -euo pipefail
|
||||
|
||||
exec pytest ./fastvideo/tests/vaes -vs
|
||||
@@ -1,6 +1,19 @@
|
||||
#!/bin/bash
|
||||
set -uo pipefail
|
||||
|
||||
# DORMANT ROLLBACK ONLY. Active CI is Slurm-only and pipeline.yml never calls
|
||||
# this launcher. Refuse every Buildkite invocation even if a stale step or
|
||||
# operator typo reaches this file; local rollback experiments require an
|
||||
# explicit opt-in.
|
||||
if [ -n "${BUILDKITE:-}" ]; then
|
||||
echo "Legacy Modal CI is disabled; use the Slinky Slurm runner." >&2
|
||||
exit 2
|
||||
fi
|
||||
if [ "${FASTVIDEO_ENABLE_LEGACY_MODAL_CI:-0}" != 1 ]; then
|
||||
echo "Legacy Modal CI is dormant. Set FASTVIDEO_ENABLE_LEGACY_MODAL_CI=1 only for a manual rollback test." >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
log() {
|
||||
echo "[$(date '+%Y-%m-%d %H:%M:%S')] $1"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
exec pytest \
|
||||
./fastvideo/tests/api/ \
|
||||
./fastvideo/tests/contract/ \
|
||||
./fastvideo/tests/dataset/ \
|
||||
./fastvideo/tests/workflow/ \
|
||||
./fastvideo/tests/entrypoints/ \
|
||||
./fastvideo/tests/loader/ \
|
||||
./fastvideo/tests/pipelines/ \
|
||||
./fastvideo/tests/platforms/ \
|
||||
./fastvideo/tests/train/ \
|
||||
./fastvideo/tests/stages/ \
|
||||
./fastvideo/tests/ops/ \
|
||||
./fastvideo/tests/worker/ \
|
||||
./fastvideo/tests/training/test_trackers.py \
|
||||
./fastvideo/tests/attention/test_sdpa_metadata_mask_contract.py \
|
||||
./fastvideo/tests/modal/test_kernel_build_cache.py \
|
||||
./fastvideo/tests/modal/test_pr_test.py \
|
||||
./fastvideo/tests/modal/test_ssim_test.py \
|
||||
--ignore=./fastvideo/tests/entrypoints/test_openai_api_integration.py \
|
||||
--ignore=./fastvideo/tests/train/models \
|
||||
--ignore=./fastvideo/tests/train/methods \
|
||||
-vs
|
||||
@@ -8,10 +8,10 @@ PR TITLE: Must start with a type tag, e.g.:
|
||||
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
|
||||
3. A path-aware merge gate runs only relevant integration tests → auto-merge on success
|
||||
|
||||
ON-DEMAND TESTING (write access required):
|
||||
/test full — Full Test Suite /test ssim — SSIM regression
|
||||
/test full — Explicit all-lane run /test ssim — Full 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
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
#!/usr/bin/env bash
|
||||
# Gate the expensive Buildkite full suite on the cheap GitHub checks.
|
||||
# Gate the path-aware Buildkite merge plan on the cheap GitHub checks.
|
||||
#
|
||||
# Polls the workflow runs for the PR head commit and only exits 0 once the
|
||||
# watched cheap workflows (pre-commit, docs build) have succeeded, so the
|
||||
# 'ready' label cannot burn ~20 GPU lanes on a head that a cheap check has
|
||||
# already doomed.
|
||||
# 'ready' label cannot burn path-selected GPU lanes on a head that a cheap
|
||||
# check has already doomed.
|
||||
#
|
||||
# Semantics:
|
||||
# - watched run completed with a bad conclusion -> exit 1 (fail CLOSED:
|
||||
# no full suite; the next push re-arms via the 'synchronize' trigger)
|
||||
# no merge gate; the next push re-arms via the 'synchronize' trigger)
|
||||
# - watched run cancelled -> still pending: the docs
|
||||
# workflow's repo-global 'pages' concurrency group cancels runs superseded
|
||||
# by unrelated pushes, so 'cancelled' is not a verdict on this PR
|
||||
@@ -29,7 +29,7 @@ set -euo pipefail
|
||||
: "${PR_NUMBER:?PR_NUMBER (pull request number) is required}"
|
||||
: "${GITHUB_REPOSITORY:?GITHUB_REPOSITORY is required}"
|
||||
|
||||
# Workflow-level `name:` values that must be green before the full suite
|
||||
# Workflow-level `name:` values that must be green before the merge gate
|
||||
# may start. "Deploy Documentation" is path-filtered on PRs, so its run may
|
||||
# legitimately never exist; pre-commit always runs, so it must appear.
|
||||
WATCHED_NAMES='["pre-commit", "Deploy Documentation"]'
|
||||
@@ -56,7 +56,7 @@ recheck_ready_label() {
|
||||
if pr_json=$(gh_api "repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}" 2>/dev/null); then
|
||||
if ! jq -e '[.labels[]?.name] | index("ready")' <<<"$pr_json" >/dev/null 2>&1; then
|
||||
echo "::error::PR #${PR_NUMBER} no longer has the 'ready' label —" \
|
||||
"NOT triggering the Buildkite full suite. Re-add the label to re-arm."
|
||||
"NOT triggering the Buildkite merge gate. Re-add the label to re-arm."
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
@@ -84,7 +84,7 @@ while true; do
|
||||
| map(.name) | join(", ")' <<<"$state")
|
||||
if [ -n "$failed" ]; then
|
||||
echo "::error::Cheap check(s) failed on ${PR_SHA}: ${failed}." \
|
||||
"NOT triggering the Buildkite full suite. Push a fix (the 'ready'" \
|
||||
"NOT triggering the Buildkite merge gate. Push a fix (the 'ready'" \
|
||||
"label re-arms on every push), or re-run the failed check and then" \
|
||||
"re-run this workflow."
|
||||
exit 1
|
||||
@@ -97,7 +97,7 @@ while true; do
|
||||
if [ "$pending" -eq 0 ]; then
|
||||
if [ -z "$missing" ]; then
|
||||
recheck_ready_label
|
||||
echo "All watched cheap checks are green — full suite may proceed."
|
||||
echo "All watched cheap checks are green — merge gate may proceed."
|
||||
exit 0
|
||||
fi
|
||||
case "$missing" in
|
||||
@@ -119,14 +119,14 @@ while true; do
|
||||
echo "::warning::GitHub API error querying workflow runs for ${PR_SHA} (attempt ${api_fails}/3)."
|
||||
if [ "$api_fails" -ge 3 ]; then
|
||||
recheck_ready_label
|
||||
echo "::warning::FAILING OPEN: cannot query GitHub check status — triggering the full suite WITHOUT the cheap-check gate."
|
||||
echo "::warning::FAILING OPEN: cannot query GitHub check status — triggering the merge gate WITHOUT the cheap-check gate."
|
||||
exit 0
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ "$elapsed" -ge "$MAX_WAIT_SECS" ]; then
|
||||
recheck_ready_label
|
||||
echo "::warning::FAILING OPEN: watched checks still pending after $(( MAX_WAIT_SECS / 60 )) min${missing:+ (never appeared: ${missing})} — triggering the full suite anyway."
|
||||
echo "::warning::FAILING OPEN: watched checks still pending after $(( MAX_WAIT_SECS / 60 )) min${missing:+ (never appeared: ${missing})} — triggering the merge gate anyway."
|
||||
exit 0
|
||||
fi
|
||||
sleep "$POLL_SECS"
|
||||
|
||||
@@ -0,0 +1,570 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Select the additive GPU integration lanes needed by a PR diff.
|
||||
|
||||
Fastcheck is the universal six-lane baseline and is intentionally not repeated
|
||||
here. This planner selects only the more expensive merge-gate lanes. Unknown
|
||||
source/build paths fail closed to the complete integration set, while explicit
|
||||
documentation and repository-metadata paths require no additional GPU work.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import fnmatch
|
||||
import re
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import TextIO
|
||||
|
||||
MERGE_LANES = (
|
||||
"golden-gate",
|
||||
"ssim",
|
||||
"lora-inference",
|
||||
"lora-extraction",
|
||||
"training",
|
||||
"distillation",
|
||||
"self-forcing",
|
||||
"lora-training",
|
||||
"training-vsa",
|
||||
"inference-vmoba",
|
||||
"performance",
|
||||
"api-server",
|
||||
"train-framework",
|
||||
"eval",
|
||||
)
|
||||
|
||||
LANE_SCRIPT_TO_KEY = {
|
||||
"api_server.sh": "api-server",
|
||||
"distillation_dmd.sh": "distillation",
|
||||
"eval.sh": "eval",
|
||||
"golden_gate.sh": "golden-gate",
|
||||
"inference_lora.sh": "lora-inference",
|
||||
"inference_vmoba.sh": "inference-vmoba",
|
||||
"lora_extraction.sh": "lora-extraction",
|
||||
"performance.sh": "performance",
|
||||
"self_forcing.sh": "self-forcing",
|
||||
"ssim.sh": "ssim",
|
||||
"train_framework.sh": "train-framework",
|
||||
"training.sh": "training",
|
||||
"training_lora.sh": "lora-training",
|
||||
"training_vsa.sh": "training-vsa",
|
||||
}
|
||||
|
||||
FASTCHECK_LANE_SCRIPTS = {
|
||||
"dreamverse.sh",
|
||||
"encoder.sh",
|
||||
"kernel_tests.sh",
|
||||
"transformer.sh",
|
||||
"vae.sh",
|
||||
}
|
||||
|
||||
LEGACY_TRAINING_LANES = (
|
||||
"training",
|
||||
"distillation",
|
||||
"self-forcing",
|
||||
"lora-training",
|
||||
"training-vsa",
|
||||
)
|
||||
|
||||
ALL_TRAINING_LANES = (*LEGACY_TRAINING_LANES, "train-framework")
|
||||
|
||||
SSIM_SMOKE_TESTS = (
|
||||
"test_flux_t2i_similarity.py",
|
||||
"test_wan_t2v_similarity.py",
|
||||
)
|
||||
|
||||
SAFE_PATTERNS = (
|
||||
"*.md",
|
||||
"*.rst",
|
||||
".agents/**",
|
||||
".claude/**",
|
||||
".codex/**",
|
||||
".github/ISSUE_TEMPLATE/**",
|
||||
".github/PULL_REQUEST_TEMPLATE.md",
|
||||
".github/dependabot.yml",
|
||||
".github/mergify.yml",
|
||||
".github/scripts/**",
|
||||
".github/workflows/**",
|
||||
".buildkite/scripts/pre_commit.sh",
|
||||
".git-blame-ignore-revs",
|
||||
".gitattributes",
|
||||
".gitignore",
|
||||
".pre-commit-config.yaml",
|
||||
"AGENTS.md",
|
||||
"CITATION.cff",
|
||||
"CODE_OF_CONDUCT.md",
|
||||
"CONTRIBUTING.md",
|
||||
"LICENSE",
|
||||
"NOTICE",
|
||||
"__init__.py",
|
||||
"collect_env.py",
|
||||
"SECURITY.md",
|
||||
"assets/**",
|
||||
"comfyui/**",
|
||||
"docs/**",
|
||||
"examples/**",
|
||||
"mkdocs.yml",
|
||||
"requirements-mkdocs.in",
|
||||
"requirements-mkdocs.txt",
|
||||
"scripts/**",
|
||||
"tests/__init__.py",
|
||||
"tests/local_tests/**",
|
||||
)
|
||||
|
||||
ALL_IMPACT_PATTERNS = (
|
||||
".buildkite/pipeline.yml",
|
||||
"docker/**",
|
||||
"pyproject.toml",
|
||||
"requirements*.txt",
|
||||
"setup.cfg",
|
||||
"setup.py",
|
||||
"uv.lock",
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class FamilyCoverage:
|
||||
pattern: re.Pattern[str]
|
||||
golden_tests: tuple[str, ...]
|
||||
ssim_tests: tuple[str, ...]
|
||||
|
||||
|
||||
FAMILY_COVERAGE = (
|
||||
FamilyCoverage(
|
||||
re.compile(r"(^|[/_.-])dreamx(_world)?([/_.-]|$)"),
|
||||
("test_dreamx.py", ),
|
||||
("test_dreamx_world_similarity.py", ),
|
||||
),
|
||||
FamilyCoverage(
|
||||
re.compile(r"(^|[/_.-])flux[_-]?2([/_.-]|$)"),
|
||||
("test_flux2_klein.py", ),
|
||||
("test_flux2_similarity.py", ),
|
||||
),
|
||||
FamilyCoverage(
|
||||
re.compile(r"(^|[/_.-])flux(?![_-]?2)([/_.-]|$)"),
|
||||
("test_flux.py", ),
|
||||
("test_flux_t2i_similarity.py", ),
|
||||
),
|
||||
FamilyCoverage(
|
||||
re.compile(r"(^|[/_.-])(hunyuan)?gamecraft([/_.-]|$)"),
|
||||
("test_gamecraft.py", ),
|
||||
("test_gamecraft_similarity.py", ),
|
||||
),
|
||||
FamilyCoverage(
|
||||
re.compile(r"(^|[/_.-])gen3c([/_.-]|$)"),
|
||||
("test_gen3c.py", ),
|
||||
("test_gen3c_similarity.py", ),
|
||||
),
|
||||
FamilyCoverage(
|
||||
re.compile(r"(^|[/_.-])glm[_-]?image([/_.-]|$)"),
|
||||
("test_glm_image.py", ),
|
||||
("test_glm_image_similarity.py", ),
|
||||
),
|
||||
FamilyCoverage(
|
||||
re.compile(r"(^|[/_.-])kandinsky[_-]?5([/_.-]|$)"),
|
||||
("test_kandinsky5.py", ),
|
||||
("test_kandinsky5_similarity.py", ),
|
||||
),
|
||||
FamilyCoverage(
|
||||
re.compile(r"(^|[/_.-])lingbot([a-z0-9_-]*)([/_.-]|$)"),
|
||||
("test_lingbot.py", ),
|
||||
("test_lingbot_similarity.py", ),
|
||||
),
|
||||
FamilyCoverage(
|
||||
re.compile(r"(^|[/_.-])longcat([/_.-]|$)"),
|
||||
("test_longcat.py", ),
|
||||
("test_longcat_similarity.py", ),
|
||||
),
|
||||
FamilyCoverage(
|
||||
re.compile(r"(^|[/_.-])ltx[_-]?2([/_.-]|$)"),
|
||||
("test_ltx2.py", ),
|
||||
("test_ltx2_similarity.py", ),
|
||||
),
|
||||
FamilyCoverage(
|
||||
re.compile(r"(^|[/_.-])matrixgame[_-]?2([/_.-]|$)"),
|
||||
("test_matrixgame.py", ),
|
||||
("test_matrixgame2_similarity.py", ),
|
||||
),
|
||||
FamilyCoverage(
|
||||
re.compile(r"(^|[/_.-])matrixgame[_-]?3([/_.-]|$)"),
|
||||
("test_matrixgame.py", ),
|
||||
("test_matrixgame3_similarity.py", ),
|
||||
),
|
||||
FamilyCoverage(
|
||||
re.compile(r"(^|[/_.-])minimax[_-]?h3([/_.-]|$)"),
|
||||
("test_minimax_h3_t2v.py", ),
|
||||
("test_minimax_h3_similarity.py", ),
|
||||
),
|
||||
FamilyCoverage(
|
||||
re.compile(r"(^|[/_.-])sd[_-]?3([._-]?5)?([/_.-]|$)"),
|
||||
("test_sd35.py", ),
|
||||
("test_sd35_similarity.py", ),
|
||||
),
|
||||
FamilyCoverage(
|
||||
re.compile(r"(^|[/_.-])stable[_-]?audio([/_.-]|$)"),
|
||||
("test_stable_audio.py", ),
|
||||
("test_stable_audio_similarity.py", ),
|
||||
),
|
||||
FamilyCoverage(
|
||||
re.compile(r"(^|[/_.-])turbo(diffusion)?([/_.-]|$)"),
|
||||
(),
|
||||
("test_turbodiffusion_similarity.py", ),
|
||||
),
|
||||
FamilyCoverage(
|
||||
re.compile(r"(^|[/_.-])wan(video)?([/_.-]|$)"),
|
||||
("test_wan_t2v.py", ),
|
||||
(
|
||||
"test_causal_similarity.py",
|
||||
"test_wan_i2v_similarity.py",
|
||||
"test_wan_t2v_similarity.py",
|
||||
),
|
||||
),
|
||||
FamilyCoverage(
|
||||
re.compile(r"(^|[/_.-])z[_-]?image([/_.-]|$)"),
|
||||
("test_zimage.py", ),
|
||||
("test_zimage_similarity.py", ),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class MergePlan:
|
||||
lanes: set[str] = field(default_factory=set)
|
||||
golden_tests: set[str] = field(default_factory=set)
|
||||
ssim_tests: set[str] = field(default_factory=set)
|
||||
golden_all: bool = False
|
||||
ssim_all: bool = False
|
||||
reasons: list[str] = field(default_factory=list)
|
||||
|
||||
def add_lanes(self, *lanes: str, reason: str) -> None:
|
||||
unknown = set(lanes) - set(MERGE_LANES)
|
||||
if unknown:
|
||||
raise ValueError(f"Unknown merge lanes: {sorted(unknown)}")
|
||||
self.lanes.update(lanes)
|
||||
self.reasons.append(reason)
|
||||
|
||||
def add_golden(self, tests: tuple[str, ...], reason: str) -> None:
|
||||
self.add_lanes("golden-gate", reason=reason)
|
||||
self.golden_tests.update(tests)
|
||||
|
||||
def add_ssim(self, tests: tuple[str, ...], reason: str) -> None:
|
||||
self.add_lanes("ssim", reason=reason)
|
||||
self.ssim_tests.update(tests)
|
||||
|
||||
def require_all(self, reason: str) -> None:
|
||||
self.lanes.update(MERGE_LANES)
|
||||
self.golden_all = True
|
||||
self.ssim_all = True
|
||||
self.reasons.append(reason)
|
||||
|
||||
def ordered_lanes(self) -> tuple[str, ...]:
|
||||
return tuple(lane for lane in MERGE_LANES if lane in self.lanes)
|
||||
|
||||
def encoded_lanes(self) -> str:
|
||||
lanes = self.ordered_lanes()
|
||||
return "," + ",".join(lanes or ("none", )) + ","
|
||||
|
||||
def encoded_golden_tests(self) -> str:
|
||||
if "golden-gate" not in self.lanes:
|
||||
return "none"
|
||||
if self.golden_all or not self.golden_tests:
|
||||
return "all"
|
||||
return ",".join(sorted(self.golden_tests))
|
||||
|
||||
def encoded_ssim_tests(self) -> str:
|
||||
if "ssim" not in self.lanes:
|
||||
return "none"
|
||||
if self.ssim_all or not self.ssim_tests:
|
||||
return "all"
|
||||
return ",".join(sorted(self.ssim_tests))
|
||||
|
||||
|
||||
def _matches_any(path: str, patterns: tuple[str, ...]) -> bool:
|
||||
return any(fnmatch.fnmatchcase(path, pattern) for pattern in patterns)
|
||||
|
||||
|
||||
def _family_coverage(path: str) -> tuple[set[str], set[str]]:
|
||||
normalized = path.lower()
|
||||
golden: set[str] = set()
|
||||
ssim: set[str] = set()
|
||||
for family in FAMILY_COVERAGE:
|
||||
if family.pattern.search(normalized):
|
||||
golden.update(family.golden_tests)
|
||||
ssim.update(family.ssim_tests)
|
||||
return golden, ssim
|
||||
|
||||
|
||||
def _select_output_coverage(plan: MergePlan, path: str) -> None:
|
||||
golden, ssim = _family_coverage(path)
|
||||
if golden:
|
||||
plan.add_golden(tuple(sorted(golden)), reason=f"model-family golden coverage: {path}")
|
||||
else:
|
||||
plan.golden_all = True
|
||||
plan.add_lanes("golden-gate", reason=f"shared output golden coverage: {path}")
|
||||
if ssim:
|
||||
plan.add_ssim(tuple(sorted(ssim)), reason=f"model-family SSIM coverage: {path}")
|
||||
else:
|
||||
plan.add_ssim(SSIM_SMOKE_TESTS, reason=f"shared output SSIM smoke coverage: {path}")
|
||||
|
||||
|
||||
def classify_paths(paths: list[str]) -> MergePlan:
|
||||
plan = MergePlan()
|
||||
normalized_paths: list[str] = []
|
||||
for raw_path in paths:
|
||||
path = raw_path.strip()
|
||||
while path.startswith("./"):
|
||||
path = path[2:]
|
||||
if path:
|
||||
normalized_paths.append(path)
|
||||
normalized_paths = sorted(set(normalized_paths))
|
||||
if not normalized_paths:
|
||||
plan.require_all("changed-file list was empty; failing closed")
|
||||
return plan
|
||||
|
||||
for path in normalized_paths:
|
||||
if path == "__FASTVIDEO_CI_PLAN_ALL__":
|
||||
plan.require_all("changed-file API failed; failing closed")
|
||||
continue
|
||||
|
||||
if path in {"requirements-mkdocs.in", "requirements-mkdocs.txt"}:
|
||||
plan.reasons.append(f"documentation dependencies need no GPU integration: {path}")
|
||||
continue
|
||||
|
||||
if _matches_any(path, ALL_IMPACT_PATTERNS):
|
||||
plan.require_all(f"cross-cutting build/runtime surface: {path}")
|
||||
continue
|
||||
|
||||
lane_script_prefix = ".buildkite/scripts/lanes/"
|
||||
if path.startswith(lane_script_prefix):
|
||||
script_name = Path(path).name
|
||||
lane = LANE_SCRIPT_TO_KEY.get(script_name)
|
||||
if lane is None:
|
||||
if script_name in FASTCHECK_LANE_SCRIPTS:
|
||||
plan.reasons.append(f"covered by automatic Fastcheck lane: {path}")
|
||||
else:
|
||||
plan.require_all(f"unknown lane script: {path}")
|
||||
elif lane == "golden-gate":
|
||||
plan.golden_all = True
|
||||
plan.add_lanes(lane, reason=f"golden lane implementation: {path}")
|
||||
elif lane == "ssim":
|
||||
plan.ssim_all = True
|
||||
plan.add_lanes(lane, reason=f"SSIM lane implementation: {path}")
|
||||
else:
|
||||
plan.add_lanes(lane, reason=f"lane implementation: {path}")
|
||||
continue
|
||||
|
||||
if path.startswith("fastvideo/tests/golden_gate/"):
|
||||
name = Path(path).name
|
||||
if name.startswith("test_") and name.endswith(".py"):
|
||||
plan.add_golden((name, ), reason=f"changed golden test: {path}")
|
||||
elif name in {"AGENTS.md", "README.md"}:
|
||||
plan.reasons.append(f"golden documentation only: {path}")
|
||||
else:
|
||||
plan.golden_all = True
|
||||
plan.add_lanes("golden-gate", reason=f"shared golden harness/reference: {path}")
|
||||
continue
|
||||
|
||||
if path.startswith("fastvideo/tests/ssim/"):
|
||||
name = Path(path).name
|
||||
if name.startswith("test_") and name.endswith(".py"):
|
||||
plan.add_ssim((name, ), reason=f"changed SSIM test: {path}")
|
||||
elif path.endswith((".py", ".json", ".pt", ".png", ".mp4")):
|
||||
plan.ssim_all = True
|
||||
plan.add_lanes("ssim", reason=f"shared SSIM harness/reference: {path}")
|
||||
continue
|
||||
|
||||
if path.startswith("fastvideo/tests/performance/") or path.startswith(".buildkite/performance-benchmarks/"):
|
||||
plan.add_lanes("performance", reason=f"performance coverage: {path}")
|
||||
continue
|
||||
if path.startswith(("fastvideo/performance/", "fastvideo/performance_dashboard/",
|
||||
"apps/performance_dashboard/")):
|
||||
plan.add_lanes("performance", reason=f"performance implementation: {path}")
|
||||
continue
|
||||
if path.startswith("fastvideo/benchmarks/"):
|
||||
if "/mlx_" in path or Path(path).name.startswith("mlx_"):
|
||||
plan.reasons.append(f"covered by the path-filtered macOS MLX workflow: {path}")
|
||||
else:
|
||||
plan.add_lanes("performance", reason=f"benchmark implementation: {path}")
|
||||
continue
|
||||
if path.startswith("fastvideo/tests/eval/") or path.startswith("fastvideo/eval/"):
|
||||
plan.add_lanes("eval", reason=f"evaluation coverage: {path}")
|
||||
continue
|
||||
if path.startswith("fastvideo/third_party/eval/"):
|
||||
plan.add_lanes("eval", reason=f"vendored evaluation implementation: {path}")
|
||||
continue
|
||||
if path.startswith("fastvideo/tests/lora_extraction/") or path.startswith("scripts/lora_extraction/"):
|
||||
plan.add_lanes("lora-extraction", reason=f"LoRA extraction coverage: {path}")
|
||||
continue
|
||||
if path.startswith("fastvideo/tests/inference/lora/"):
|
||||
plan.add_lanes("lora-inference", reason=f"LoRA inference coverage: {path}")
|
||||
continue
|
||||
if path.startswith("fastvideo/tests/inference/vmoba/"):
|
||||
plan.add_lanes("inference-vmoba", reason=f"VMoBA inference coverage: {path}")
|
||||
continue
|
||||
if path.startswith(("fastvideo/dataset/", "fastvideo/workflow/", "fastvideo/pipelines/preprocess/",
|
||||
"fastvideo/pipelines/training/")):
|
||||
plan.add_lanes(*ALL_TRAINING_LANES, reason=f"shared data/training input surface: {path}")
|
||||
continue
|
||||
if path.startswith("fastvideo/tests/train/") or path.startswith("fastvideo/train/"):
|
||||
plan.add_lanes("train-framework", reason=f"modular training coverage: {path}")
|
||||
continue
|
||||
|
||||
if path.startswith("fastvideo/tests/training/"):
|
||||
lowered = path.lower()
|
||||
if "/vanilla/" in lowered:
|
||||
plan.add_lanes("training", reason=f"vanilla training coverage: {path}")
|
||||
elif "/distill/" in lowered:
|
||||
plan.add_lanes("distillation", reason=f"distillation coverage: {path}")
|
||||
elif "/self-forcing/" in lowered:
|
||||
plan.add_lanes("self-forcing", reason=f"self-forcing coverage: {path}")
|
||||
elif "/lora/" in lowered:
|
||||
plan.add_lanes("lora-training", reason=f"LoRA training coverage: {path}")
|
||||
elif "/vsa/" in lowered:
|
||||
plan.add_lanes("training-vsa", reason=f"VSA training coverage: {path}")
|
||||
else:
|
||||
plan.add_lanes(*LEGACY_TRAINING_LANES, reason=f"shared legacy training coverage: {path}")
|
||||
continue
|
||||
|
||||
if path.startswith("fastvideo/training/"):
|
||||
lowered = path.lower()
|
||||
if "self_forcing" in lowered:
|
||||
plan.add_lanes("self-forcing", reason=f"self-forcing implementation: {path}")
|
||||
elif "distill" in lowered:
|
||||
plan.add_lanes("distillation", reason=f"distillation implementation: {path}")
|
||||
elif "lora" in lowered:
|
||||
plan.add_lanes("lora-training", reason=f"LoRA training implementation: {path}")
|
||||
else:
|
||||
plan.add_lanes(*LEGACY_TRAINING_LANES, reason=f"shared legacy training implementation: {path}")
|
||||
continue
|
||||
|
||||
lowered = path.lower()
|
||||
if "vmoba" in lowered and path.startswith(("fastvideo/", ".buildkite/")):
|
||||
plan.add_lanes("inference-vmoba", reason=f"VMoBA implementation: {path}")
|
||||
plan.add_golden(("test_wan_t2v.py", ), reason=f"VMoBA end-to-end coverage: {path}")
|
||||
continue
|
||||
if "lora" in lowered and path.startswith("fastvideo/"):
|
||||
plan.add_lanes(
|
||||
"lora-inference",
|
||||
"lora-extraction",
|
||||
"lora-training",
|
||||
reason=f"shared LoRA implementation: {path}",
|
||||
)
|
||||
_select_output_coverage(plan, path)
|
||||
continue
|
||||
|
||||
if path.startswith("fastvideo/entrypoints/") or path.startswith("fastvideo/api/"):
|
||||
plan.add_lanes("api-server", reason=f"API/entrypoint integration: {path}")
|
||||
if "openai" not in lowered and "/cli/" not in lowered:
|
||||
_select_output_coverage(plan, path)
|
||||
continue
|
||||
if path.startswith("fastvideo/worker/"):
|
||||
plan.add_lanes("api-server", reason=f"worker/API integration: {path}")
|
||||
_select_output_coverage(plan, path)
|
||||
continue
|
||||
if path.startswith("fastvideo/distributed/"):
|
||||
plan.add_lanes(
|
||||
"training",
|
||||
"train-framework",
|
||||
reason=f"distributed runtime integration: {path}",
|
||||
)
|
||||
_select_output_coverage(plan, path)
|
||||
continue
|
||||
if path.startswith(("fastvideo/hooks/", "fastvideo/platforms/", "fastvideo/third_party/")):
|
||||
_select_output_coverage(plan, path)
|
||||
continue
|
||||
if path.startswith(("fastvideo/models/", "fastvideo/pipelines/", "fastvideo/configs/",
|
||||
"fastvideo/layers/", "fastvideo/attention/")):
|
||||
_select_output_coverage(plan, path)
|
||||
continue
|
||||
if path in {
|
||||
"fastvideo/fastvideo_args.py",
|
||||
"fastvideo/forward_context.py",
|
||||
"fastvideo/image_processor.py",
|
||||
"fastvideo/registry.py",
|
||||
"fastvideo/utils.py",
|
||||
}:
|
||||
_select_output_coverage(plan, path)
|
||||
continue
|
||||
if path.startswith("fastvideo/mlx_runtime/"):
|
||||
plan.reasons.append(f"covered by the path-filtered macOS MLX workflow: {path}")
|
||||
continue
|
||||
if path.startswith("fastvideo/logging_utils/") or path in {
|
||||
"fastvideo/__init__.py",
|
||||
"fastvideo/envs.py",
|
||||
"fastvideo/logger.py",
|
||||
"fastvideo/profiler.py",
|
||||
"fastvideo/version.py",
|
||||
}:
|
||||
plan.reasons.append(f"covered by automatic Fastcheck: {path}")
|
||||
continue
|
||||
if path.startswith(("fastvideo-kernel/", "csrc/")):
|
||||
plan.add_golden(("test_wan_t2v.py", ), reason=f"kernel integration smoke: {path}")
|
||||
plan.add_ssim(("test_wan_t2v_similarity.py", ), reason=f"kernel numerical smoke: {path}")
|
||||
continue
|
||||
|
||||
if path.startswith("apps/dreamverse/"):
|
||||
# DreamVerse is already one of the six automatic Fastcheck lanes.
|
||||
plan.reasons.append(f"covered by automatic DreamVerse Fastcheck: {path}")
|
||||
continue
|
||||
if path.startswith("fastvideo/tests/"):
|
||||
# The automatic unit/component Fastcheck lanes own the remaining
|
||||
# package tests. Domain-specific expensive test roots were handled
|
||||
# above.
|
||||
plan.reasons.append(f"covered by automatic Fastcheck: {path}")
|
||||
continue
|
||||
if path in {".buildkite/scripts/unit_test.sh", ".buildkite/scripts/pr_test.sh"}:
|
||||
plan.reasons.append(f"covered by automatic unit Fastcheck: {path}")
|
||||
continue
|
||||
if _matches_any(path, SAFE_PATTERNS):
|
||||
plan.reasons.append(f"no additional GPU integration needed: {path}")
|
||||
continue
|
||||
|
||||
plan.require_all(f"unclassified path; failing closed: {path}")
|
||||
|
||||
return plan
|
||||
|
||||
|
||||
def _write_github_output(output: TextIO, plan: MergePlan) -> None:
|
||||
output.write(f"merge_test_plan={plan.encoded_lanes()}\n")
|
||||
output.write(f"merge_golden_tests={plan.encoded_golden_tests()}\n")
|
||||
output.write(f"merge_ssim_tests={plan.encoded_ssim_tests()}\n")
|
||||
output.write(f"merge_plan_label={','.join(plan.ordered_lanes()) or 'none'}\n")
|
||||
|
||||
|
||||
def _write_summary(output: TextIO, plan: MergePlan) -> None:
|
||||
output.write("## Change-aware merge test plan\n\n")
|
||||
output.write("| Selection | Value |\n|---|---|\n")
|
||||
output.write(f"| Additional Slurm lanes | `{','.join(plan.ordered_lanes()) or 'none'}` |\n")
|
||||
output.write(f"| Golden tests | `{plan.encoded_golden_tests()}` |\n")
|
||||
output.write(f"| SSIM tests | `{plan.encoded_ssim_tests()}` |\n\n")
|
||||
output.write("Fastcheck remains the universal six-lane baseline.\n")
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--paths-file", type=Path, required=True)
|
||||
parser.add_argument("--github-output", type=Path)
|
||||
parser.add_argument("--summary-file", type=Path)
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def main() -> int:
|
||||
args = parse_args()
|
||||
paths = args.paths_file.read_text(encoding="utf-8").splitlines()
|
||||
plan = classify_paths(paths)
|
||||
print(f"MERGE_TEST_PLAN={plan.encoded_lanes()}")
|
||||
print(f"MERGE_GOLDEN_TESTS={plan.encoded_golden_tests()}")
|
||||
print(f"MERGE_SSIM_TESTS={plan.encoded_ssim_tests()}")
|
||||
for reason in plan.reasons:
|
||||
print(f"- {reason}")
|
||||
if args.github_output:
|
||||
with args.github_output.open("a", encoding="utf-8") as output:
|
||||
_write_github_output(output, plan)
|
||||
if args.summary_file:
|
||||
with args.summary_file.open("a", encoding="utf-8") as output:
|
||||
_write_summary(output, plan)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -53,7 +53,7 @@ PC_PENDING='{"name": "pre-commit", "id": 1, "status": "in_progress", "conclusion
|
||||
DOCS_OK='{"name": "Deploy Documentation", "id": 2, "status": "completed", "conclusion": "success"}'
|
||||
DOCS_BAD='{"name": "Deploy Documentation", "id": 2, "status": "completed", "conclusion": "failure"}'
|
||||
DOCS_CANCELLED='{"name": "Deploy Documentation", "id": 2, "status": "completed", "conclusion": "cancelled"}'
|
||||
OTHER='{"name": "Trigger Full Suite", "id": 3, "status": "in_progress", "conclusion": null}'
|
||||
OTHER='{"name": "Trigger Merge Gate", "id": 3, "status": "in_progress", "conclusion": null}'
|
||||
NULL_NAME='{"name": null, "id": 4, "status": "completed", "conclusion": "failure"}'
|
||||
PC_OK_RERUN='{"name": "pre-commit", "id": 5, "status": "completed", "conclusion": "success"}'
|
||||
|
||||
|
||||
@@ -190,6 +190,7 @@ jobs:
|
||||
if: ${{ !inputs.push_by_digest }}
|
||||
run: |
|
||||
echo "✅ Python ${{ inputs.python_version }} image successfully built and pushed to ${{ steps.image.outputs.name }}:${{ inputs.tag_suffix }}-sha-${GITHUB_SHA::7}"
|
||||
echo "Digest: ${{ steps.build-push.outputs.digest }}"
|
||||
echo "To run tests with this image, manually trigger the 'Run Tests' workflow."
|
||||
|
||||
- name: Digest success message
|
||||
|
||||
@@ -26,29 +26,48 @@ jobs:
|
||||
per_page: 100,
|
||||
});
|
||||
|
||||
const bkStatuses = data.statuses.filter(
|
||||
s => s.context.startsWith('buildkite/ci/')
|
||||
);
|
||||
|
||||
const FASTCHECK_PREFIX = 'buildkite/ci/microscope-';
|
||||
// Buildkite derives the GitHub context prefix from the label emoji.
|
||||
// Keep hard Full Suite lanes in test-tube/bar-chart namespaces and
|
||||
// Fastcheck lanes in microscope so targeted reruns cannot clear the
|
||||
// wrong aggregate status. Automatic PR jobs use pr-fastcheck while
|
||||
// slash-command and Full Suite jobs use ci; normalize the suffix
|
||||
// and keep the newest status for each logical lane.
|
||||
const FASTCHECK_PREFIXES = [
|
||||
'buildkite/pr-fastcheck/microscope-',
|
||||
'buildkite/ci/microscope-',
|
||||
];
|
||||
const FULL_SUITE_PREFIXES = [
|
||||
'buildkite/ci/test-tube-',
|
||||
'buildkite/ci/bar-chart-',
|
||||
];
|
||||
|
||||
const fastcheck = bkStatuses.filter(
|
||||
s => s.context.startsWith(FASTCHECK_PREFIX)
|
||||
);
|
||||
const fullSuite = bkStatuses.filter(
|
||||
s => FULL_SUITE_PREFIXES.some(p => s.context.startsWith(p))
|
||||
);
|
||||
function newestByLane(prefixes) {
|
||||
const statuses = new Map();
|
||||
for (const status of data.statuses) {
|
||||
const prefix = prefixes.find(p => status.context.startsWith(p));
|
||||
if (!prefix) continue;
|
||||
const lane = status.context.slice(prefix.length);
|
||||
const previous = statuses.get(lane);
|
||||
if (!previous || Date.parse(status.updated_at) > Date.parse(previous.updated_at)) {
|
||||
statuses.set(lane, status);
|
||||
}
|
||||
}
|
||||
return statuses;
|
||||
}
|
||||
|
||||
if (
|
||||
fastcheck.length > 0
|
||||
&& fastcheck.every(s => s.state === 'success')
|
||||
) {
|
||||
const fastcheck = newestByLane(FASTCHECK_PREFIXES);
|
||||
const fullSuiteOnly = newestByLane(FULL_SUITE_PREFIXES);
|
||||
const fastcheckPassed =
|
||||
fastcheck.size === 6
|
||||
&& [...fastcheck.values()].every(s => s.state === 'success');
|
||||
const fullSuitePassed =
|
||||
fastcheckPassed
|
||||
&& fullSuiteOnly.size === 14
|
||||
&& [...fullSuiteOnly.values()].every(s => s.state === 'success');
|
||||
|
||||
if (fastcheckPassed) {
|
||||
core.info(
|
||||
`All ${fastcheck.length} fastcheck tests passed — updating fastcheck-passed`
|
||||
`All ${fastcheck.size} fastcheck tests passed — updating fastcheck-passed`
|
||||
);
|
||||
await github.rest.repos.createCommitStatus({
|
||||
owner: context.repo.owner,
|
||||
@@ -56,17 +75,13 @@ jobs:
|
||||
sha,
|
||||
state: 'success',
|
||||
context: 'fastcheck-passed',
|
||||
description:
|
||||
`All ${fastcheck.length} fastcheck tests passed`,
|
||||
description: `All ${fastcheck.size} fastcheck tests passed`,
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
fullSuite.length > 0
|
||||
&& fullSuite.every(s => s.state === 'success')
|
||||
) {
|
||||
if (fullSuitePassed) {
|
||||
core.info(
|
||||
`All ${fullSuite.length} full suite tests passed — updating full-suite-passed`
|
||||
'All 20 full suite tests passed — updating full-suite-passed'
|
||||
);
|
||||
await github.rest.repos.createCommitStatus({
|
||||
owner: context.repo.owner,
|
||||
@@ -74,7 +89,6 @@ jobs:
|
||||
sha,
|
||||
state: 'success',
|
||||
context: 'full-suite-passed',
|
||||
description:
|
||||
`All ${fullSuite.length} full suite tests passed`,
|
||||
description: 'All 20 full suite tests passed',
|
||||
});
|
||||
}
|
||||
|
||||
@@ -78,6 +78,7 @@ jobs:
|
||||
fastvideo/tests/mlx/test_mlx_dit_parity.py \
|
||||
fastvideo/tests/mlx/test_mlx_compile_parity.py \
|
||||
fastvideo/tests/mlx/test_mlx_checkpoint.py \
|
||||
fastvideo/tests/mlx/test_mlx_checkpoint_compat.py \
|
||||
fastvideo/tests/mlx/test_mlx_fastwan_benchmark.py \
|
||||
fastvideo/tests/mlx/test_taehv_decode.py \
|
||||
fastvideo/tests/mlx/test_frame_upsample.py \
|
||||
@@ -134,6 +135,7 @@ jobs:
|
||||
fastvideo/tests/mlx/test_mlx_dit_parity.py \
|
||||
fastvideo/tests/mlx/test_mlx_compile_parity.py \
|
||||
fastvideo/tests/mlx/test_mlx_checkpoint.py \
|
||||
fastvideo/tests/mlx/test_mlx_checkpoint_compat.py \
|
||||
fastvideo/tests/mlx/test_mlx_fastwan_benchmark.py \
|
||||
fastvideo/tests/mlx/test_taehv_decode.py \
|
||||
fastvideo/tests/mlx/test_frame_upsample.py \
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
name: Scheduled Full SSIM
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: "0 5 * * 0"
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
trigger:
|
||||
if: github.repository == 'hao-ai-lab/FastVideo'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Trigger weekly full SSIM on Slinky Slurm
|
||||
env:
|
||||
BUILDKITE_API_TOKEN: ${{ secrets.BUILDKITE_API_TOKEN }}
|
||||
SOURCE_SHA: ${{ github.sha }}
|
||||
SOURCE_BRANCH: ${{ github.event.repository.default_branch }}
|
||||
BK_ORG: ${{ vars.BUILDKITE_ORG_SLUG }}
|
||||
BK_PIPELINE: ${{ vars.BUILDKITE_PIPELINE_SLUG }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
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 "$SOURCE_SHA" \
|
||||
--arg branch "$SOURCE_BRANCH" \
|
||||
'{
|
||||
commit: $commit,
|
||||
branch: $branch,
|
||||
message: "Weekly full SSIM on Slinky Slurm",
|
||||
ignore_pipeline_branch_filters: true,
|
||||
env: {
|
||||
TEST_SCOPE: "scheduled",
|
||||
FULL_SUITE: "false",
|
||||
TEST_TYPE: "ssim",
|
||||
PR_NUMBER: "false",
|
||||
PR_TITLE: "Scheduled full SSIM"
|
||||
}
|
||||
}')"
|
||||
@@ -33,7 +33,6 @@ jobs:
|
||||
core.setOutput('has_write', String(hasWrite));
|
||||
|
||||
- name: Add ready label and react
|
||||
id: label
|
||||
if: steps.perm.outputs.has_write == 'true'
|
||||
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
|
||||
with:
|
||||
@@ -48,47 +47,6 @@ jobs:
|
||||
comment_id: context.payload.comment.id,
|
||||
content: 'rocket',
|
||||
});
|
||||
const { data: pr } = await github.rest.pulls.get({ owner, repo, pull_number: prNumber });
|
||||
core.setOutput('pr_sha', pr.head.sha);
|
||||
core.setOutput('pr_branch', pr.head.ref);
|
||||
core.setOutput('pr_number', String(prNumber));
|
||||
core.setOutput('pr_title', pr.title);
|
||||
|
||||
- name: Trigger Full Suite
|
||||
if: steps.perm.outputs.has_write == 'true'
|
||||
env:
|
||||
BUILDKITE_API_TOKEN: ${{ secrets.BUILDKITE_API_TOKEN }}
|
||||
PR_SHA: ${{ steps.label.outputs.pr_sha }}
|
||||
PR_BRANCH: ${{ steps.label.outputs.pr_branch }}
|
||||
PR_NUMBER: ${{ steps.label.outputs.pr_number }}
|
||||
PR_TITLE: ${{ steps.label.outputs.pr_title }}
|
||||
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} (via /merge)" \
|
||||
--arg pr_title "$PR_TITLE" \
|
||||
--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),
|
||||
PR_TITLE: $pr_title
|
||||
}
|
||||
}')"
|
||||
|
||||
parse-command:
|
||||
if: >-
|
||||
@@ -129,7 +87,7 @@ jobs:
|
||||
set -euo pipefail
|
||||
TEST_NAME=$(echo "$COMMENT" | grep -oP '(?<=/test\s)\S+' | head -1 || true)
|
||||
|
||||
VALID="encoder vae transformer kernel unit dreamverse ssim golden-gate training lora-inference lora-training lora-extraction distillation self-forcing vsa vmoba performance api train-framework eval full fastcheck pre-commit"
|
||||
VALID="encoder vae transformer kernel unit dreamverse ssim golden-gate training lora-inference lora-training lora-extraction distillation self-forcing vsa vmoba performance api train-framework eval unit-ci kernel-ci dreamverse-ci ssim-ci golden-gate-ci encoder-ci vae-ci transformer-ci lora-inference-ci lora-training-ci lora-extraction-ci training-ci distillation-ci self-forcing-ci vsa-ci vmoba-ci performance-ci api-ci train-framework-ci eval-ci full fastcheck pre-commit"
|
||||
if [ -z "$TEST_NAME" ] || ! echo "$VALID" | grep -qw "$TEST_NAME"; then
|
||||
echo "Unknown test: '$TEST_NAME'. Valid: $VALID"
|
||||
exit 1
|
||||
@@ -137,7 +95,17 @@ jobs:
|
||||
|
||||
declare -A MAP=(
|
||||
[encoder]=encoder [vae]=vae [transformer]=transformer
|
||||
[kernel]=kernel_tests [unit]=unit_test [dreamverse]=dreamverse_app
|
||||
[kernel]=kernel_tests [unit]=unit_test [unit-ci]=unit_test_ci
|
||||
[kernel-ci]=kernel_tests_ci [dreamverse-ci]=dreamverse_app_ci
|
||||
[ssim-ci]=ssim_ci [vmoba-ci]=inference_vmoba_ci
|
||||
[golden-gate-ci]=golden_gate_ci [training-ci]=training_ci
|
||||
[encoder-ci]=encoder_ci [vae-ci]=vae_ci [transformer-ci]=transformer_ci
|
||||
[lora-inference-ci]=inference_lora_ci [lora-training-ci]=training_lora_ci
|
||||
[lora-extraction-ci]=lora_extraction_ci [distillation-ci]=distillation_dmd_ci
|
||||
[self-forcing-ci]=self_forcing_ci [vsa-ci]=training_vsa_ci
|
||||
[performance-ci]=performance_ci [api-ci]=api_server_ci
|
||||
[train-framework-ci]=train_framework_ci [eval-ci]=eval_ci
|
||||
[dreamverse]=dreamverse_app
|
||||
[ssim]=ssim [golden-gate]=golden_gate [training]=training
|
||||
[lora-inference]=inference_lora [lora-training]=training_lora
|
||||
[lora-extraction]=lora_extraction
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
name: Trigger Full Suite
|
||||
name: Trigger Merge Gate
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
@@ -10,7 +10,7 @@ permissions:
|
||||
actions: read
|
||||
|
||||
concurrency:
|
||||
group: full-suite-${{ github.event.pull_request.number }}
|
||||
group: merge-gate-${{ github.event.pull_request.number }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
@@ -34,29 +34,72 @@ jobs:
|
||||
});
|
||||
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.');
|
||||
core.setOutput('changed_files', String(pr.changed_files));
|
||||
if (!hasReady) core.info('No ready label — skipping merge-gate 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 }}
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
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(try (.env.TEST_SCOPE == "full") catch false) | .number')
|
||||
# Match both branch and PR number: forks can reuse the same branch name.
|
||||
builds=$(curl -sS --get -H "Authorization: Bearer $BUILDKITE_API_TOKEN" \
|
||||
--data-urlencode "branch=$PR_BRANCH" \
|
||||
--data-urlencode "state=running,scheduled" \
|
||||
"https://api.buildkite.com/v2/organizations/${{ vars.BUILDKITE_ORG_SLUG }}/pipelines/${{ vars.BUILDKITE_PIPELINE_SLUG }}/builds" \
|
||||
| jq -r --arg pr_number "$PR_NUMBER" \
|
||||
'.[] | select((.env.TEST_SCOPE? == "merge") and (.env.PR_NUMBER? == $pr_number)) | .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
|
||||
|
||||
# Checks out the BASE branch (default for pull_request_target), so PR
|
||||
# authors cannot tamper with the gate script.
|
||||
- name: Checkout gate script
|
||||
# Check out the immutable BASE SHA: pull_request_target must never run a
|
||||
# planner or gate script from the untrusted PR head.
|
||||
- name: Checkout trusted merge planner
|
||||
if: steps.check.outputs.has_ready == 'true'
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.base.sha }}
|
||||
persist-credentials: false
|
||||
|
||||
- name: Collect changed paths
|
||||
if: steps.check.outputs.has_ready == 'true'
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
EXPECTED_CHANGED_FILES: ${{ steps.check.outputs.changed_files }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
changed_json="$RUNNER_TEMP/merge-changed-files.json"
|
||||
changed_paths="$RUNNER_TEMP/merge-changed-paths.txt"
|
||||
if gh api --paginate --slurp \
|
||||
"repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}/files?per_page=100" \
|
||||
> "$changed_json"; then
|
||||
observed=$(jq '[.[][] | .filename] | unique | length' "$changed_json")
|
||||
if [ "$observed" = "$EXPECTED_CHANGED_FILES" ]; then
|
||||
jq -r '.[][] | .filename, (.previous_filename // empty)' "$changed_json" \
|
||||
| sort -u > "$changed_paths"
|
||||
else
|
||||
echo "::warning::Changed-file API returned $observed of $EXPECTED_CHANGED_FILES paths; selecting all merge lanes."
|
||||
echo '__FASTVIDEO_CI_PLAN_ALL__' > "$changed_paths"
|
||||
fi
|
||||
else
|
||||
echo "::warning::Changed-file API failed; selecting all merge lanes."
|
||||
echo '__FASTVIDEO_CI_PLAN_ALL__' > "$changed_paths"
|
||||
fi
|
||||
|
||||
- name: Select minimal merge tests
|
||||
id: plan
|
||||
if: steps.check.outputs.has_ready == 'true'
|
||||
run: |
|
||||
python3 .github/scripts/plan_merge_ci.py \
|
||||
--paths-file "$RUNNER_TEMP/merge-changed-paths.txt" \
|
||||
--github-output "$GITHUB_OUTPUT" \
|
||||
--summary-file "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
- name: Wait for pre-commit and docs build
|
||||
if: steps.check.outputs.has_ready == 'true'
|
||||
@@ -66,7 +109,7 @@ jobs:
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
run: bash .github/scripts/gate_full_suite.sh
|
||||
|
||||
- name: Trigger Buildkite Full Suite
|
||||
- name: Trigger Buildkite merge gate
|
||||
if: steps.check.outputs.has_ready == 'true'
|
||||
env:
|
||||
BUILDKITE_API_TOKEN: ${{ secrets.BUILDKITE_API_TOKEN }}
|
||||
@@ -76,6 +119,10 @@ jobs:
|
||||
PR_TITLE: ${{ github.event.pull_request.title }}
|
||||
BK_ORG: ${{ vars.BUILDKITE_ORG_SLUG }}
|
||||
BK_PIPELINE: ${{ vars.BUILDKITE_PIPELINE_SLUG }}
|
||||
MERGE_TEST_PLAN: ${{ steps.plan.outputs.merge_test_plan }}
|
||||
MERGE_GOLDEN_TESTS: ${{ steps.plan.outputs.merge_golden_tests }}
|
||||
MERGE_SSIM_TESTS: ${{ steps.plan.outputs.merge_ssim_tests }}
|
||||
MERGE_PLAN_LABEL: ${{ steps.plan.outputs.merge_plan_label }}
|
||||
run: |
|
||||
curl -sS --fail-with-body -X POST \
|
||||
"https://api.buildkite.com/v2/organizations/${BK_ORG}/pipelines/${BK_PIPELINE}/builds" \
|
||||
@@ -84,8 +131,11 @@ jobs:
|
||||
--data-raw "$(jq -n \
|
||||
--arg commit "$PR_SHA" \
|
||||
--arg branch "$PR_BRANCH" \
|
||||
--arg message "Full Suite for PR #${PR_NUMBER}" \
|
||||
--arg message "Merge gate [${MERGE_PLAN_LABEL}] for PR #${PR_NUMBER}" \
|
||||
--arg pr_title "$PR_TITLE" \
|
||||
--arg merge_test_plan "$MERGE_TEST_PLAN" \
|
||||
--arg merge_golden_tests "$MERGE_GOLDEN_TESTS" \
|
||||
--arg merge_ssim_tests "$MERGE_SSIM_TESTS" \
|
||||
--argjson pr_id "$PR_NUMBER" \
|
||||
'{
|
||||
commit: $commit,
|
||||
@@ -95,8 +145,11 @@ jobs:
|
||||
pull_request_id: $pr_id,
|
||||
pull_request_base_branch: "main",
|
||||
env: {
|
||||
TEST_SCOPE: "full",
|
||||
TEST_SCOPE: "merge",
|
||||
FULL_SUITE: "true",
|
||||
MERGE_TEST_PLAN: $merge_test_plan,
|
||||
MERGE_GOLDEN_TESTS: $merge_golden_tests,
|
||||
MERGE_SSIM_TESTS: $merge_ssim_tests,
|
||||
PR_NUMBER: ($pr_id | tostring),
|
||||
PR_TITLE: $pr_title
|
||||
}
|
||||
|
||||
@@ -38,17 +38,17 @@ jobs:
|
||||
|
||||
**How our CI works:**
|
||||
|
||||
PRs run a two-tier CI system:
|
||||
PRs run a three-tier CI system:
|
||||
1. **Pre-commit** — formatting (yapf), linting (ruff), type checking (mypy). Runs immediately on every PR.
|
||||
2. **Fastcheck** — core GPU tests (encoders, VAEs, transformers, kernels, unit tests). Runs automatically via Buildkite on relevant file changes (~10-15 min).
|
||||
3. **Full Suite** — integration tests, training pipelines, SSIM regression. Runs only when a reviewer adds the `ready` label.
|
||||
2. **Fastcheck** — six core GPU lanes run automatically via Buildkite (~10-15 min).
|
||||
3. **Merge gate** — a reviewer adds `ready`; changed paths select only the relevant integration, training, golden, or SSIM coverage.
|
||||
|
||||
**Before your PR is reviewed:**
|
||||
- [ ] `pre-commit run --all-files` passes locally
|
||||
- [ ] You've added or updated tests for your changes
|
||||
- [ ] The PR description explains what and why
|
||||
|
||||
If pre-commit fails, a bot comment will explain how to fix it. Fastcheck and Full Suite results appear in the Checks section below.
|
||||
If pre-commit fails, a bot comment will explain how to fix it. Fastcheck and merge-gate results appear in the Checks section below.
|
||||
|
||||
**Useful links:**
|
||||
- [Contributing Guide](https://hao-ai-lab.github.io/FastVideo/contributing/overview/)
|
||||
|
||||
@@ -13,6 +13,11 @@ on:
|
||||
required: false
|
||||
default: false
|
||||
type: boolean
|
||||
build_ci_runner_image:
|
||||
description: 'Build the ARM64 CUDA 13 CI runner image (sm_100)'
|
||||
required: false
|
||||
default: false
|
||||
type: boolean
|
||||
# Auto-rebuild the CUDA images when a repository-controlled image input
|
||||
# changes on main. This includes the trusted SM89 kernel artifact's source,
|
||||
# metadata/key helper, ABI dependency metadata, and build orchestration.
|
||||
@@ -198,6 +203,28 @@ jobs:
|
||||
docker buildx imagetools create "${TAG_ARGS[@]}" "${IMAGE_REFS[@]}"
|
||||
docker buildx imagetools inspect "${TAGS[0]}"
|
||||
|
||||
# The CI runner is ARM64 like DGX Spark, but targets sm_100 rather than sm_121.
|
||||
# Publish a single-architecture variant so the self-hosted CI runner can reuse
|
||||
# the exact prebuilt kernel instead of compiling it in every job.
|
||||
build-ci-runner-image:
|
||||
if: ${{ (github.event_name == 'push' && github.repository == 'hao-ai-lab/FastVideo') || github.event.inputs.build_ci_runner_image == 'true' }}
|
||||
uses: ./.github/workflows/_template-build-image.yml
|
||||
with:
|
||||
python_version: '3.12'
|
||||
dockerfile_path: docker/Dockerfile
|
||||
tag_suffix: py3.12-cuda13.0.0-sm100
|
||||
runner: ubuntu-24.04-arm
|
||||
architecture: arm64
|
||||
build_args: |
|
||||
PYTHON_VERSION=3.12
|
||||
CUDA_VERSION=13.0.0
|
||||
UV_TORCH_BACKEND=cu130
|
||||
TORCH_CUDA_ARCH_LIST=10.0
|
||||
CMAKE_BUILD_PARALLEL_LEVEL=1
|
||||
FLASH_ATTN_WHEEL_TAG=cu130torch2.12
|
||||
FLASH_ATTN_WHEEL_RELEASE_ARM64=https://github.com/mjun0812/flash-attention-prebuild-wheels/releases/download/v0.9.22
|
||||
secrets: inherit
|
||||
|
||||
# Dreamverse matrix: {backend, UI} x {12.6.3, 13.0.0}, Python 3.12. Torch backend
|
||||
# matches the base CUDA (cu126 / cu130). Keep these images amd64-only until the
|
||||
# required FA4 dependency stack is available and validated on arm64.
|
||||
|
||||
@@ -62,8 +62,9 @@ jobs:
|
||||
cuda-version: '13.0.0'
|
||||
torch-cuda-short: 'cu130'
|
||||
platform:
|
||||
# x86_64 builds the full cu126 + cu130 set (cu130 ships the consumer
|
||||
# Blackwell sm_120a FP4 kernels).
|
||||
# x86_64 builds the full cu126 + cu130 set. cu130 ships the
|
||||
# data-center Blackwell sm_100a VSA and consumer sm_120a FP4
|
||||
# kernels.
|
||||
- os: ubuntu-22.04
|
||||
arch: x86_64
|
||||
wheel-plat: manylinux_2_35_x86_64
|
||||
@@ -124,7 +125,7 @@ jobs:
|
||||
- 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 apt install -y git 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
|
||||
@@ -168,7 +169,8 @@ jobs:
|
||||
# covers sm_120a; turbodiffusion covers sm_100a+sm_120a. The sm_100 FP4
|
||||
# forward is the FA4 CuTe DSL path in the fastvideo package (PR #1221),
|
||||
# JIT-compiled at runtime — not built into this wheel.
|
||||
# * x86_64 cu130 = Hopper TK + consumer Blackwell sm_120a FP4.
|
||||
# * x86_64 cu130 = Hopper TK + data-center Blackwell sm_100a VSA
|
||||
# + consumer Blackwell sm_120a FP4.
|
||||
# * x86_64 cu126 = Hopper TK only (older drivers; CUDA < 12.8 has no FP4).
|
||||
# The per-arch split in CMakeLists pins the FP4 targets to sm_120a and builds
|
||||
# the main extension for the full arch list. CMAKE_BUILD_PARALLEL_LEVEL caps
|
||||
@@ -178,7 +180,7 @@ jobs:
|
||||
export CMAKE_ARGS="${CMAKE_ARGS:-} -DFASTVIDEO_KERNEL_BUILD_TK=OFF -DFASTVIDEO_KERNEL_BUILD_ATTN_QAT_INFER=ON"
|
||||
export CMAKE_BUILD_PARALLEL_LEVEL=1
|
||||
elif [ "${{ matrix.torch-cuda.torch-cuda-short }}" = "cu130" ]; then
|
||||
export TORCH_CUDA_ARCH_LIST="9.0a;12.0a"
|
||||
export TORCH_CUDA_ARCH_LIST="9.0a;10.0a;12.0a"
|
||||
export CMAKE_ARGS="${CMAKE_ARGS:-} -DFASTVIDEO_KERNEL_BUILD_TK=ON -DFASTVIDEO_KERNEL_BUILD_ATTN_QAT_INFER=ON -DCMAKE_CUDA_ARCHITECTURES=90a"
|
||||
# A single FP4 TU (attn_qat_infer) can use ~8-12 GB on its own, so serialize.
|
||||
export CMAKE_BUILD_PARALLEL_LEVEL=1
|
||||
@@ -194,7 +196,11 @@ jobs:
|
||||
python -m build --wheel --outdir dist
|
||||
|
||||
# Fix the wheel to be manylinux compliant
|
||||
uv pip install --system auditwheel
|
||||
# Ubuntu 22.04 ships patchelf 0.14.3, while current auditwheel
|
||||
# requires at least 0.14.5. Use the stable PyPI binary on both
|
||||
# x86_64 and aarch64 release runners.
|
||||
uv pip install --system auditwheel patchelf==0.17.2.4
|
||||
patchelf --version
|
||||
# Point auditwheel at torch libs, but do not vendor them into the wheel.
|
||||
TORCH_LIB_DIR=$(python - <<'PY'
|
||||
import os
|
||||
@@ -211,7 +217,8 @@ jobs:
|
||||
--exclude libtorch.so \
|
||||
--exclude libc10.so \
|
||||
--exclude libc10_cuda.so \
|
||||
--exclude libtorch_python.so
|
||||
--exclude libtorch_python.so \
|
||||
--exclude libnccl.so.2
|
||||
# Move fixed wheels back to dist for upload consistency
|
||||
rm dist/*.whl
|
||||
mv fixed_dist/*.whl dist/
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
**FastVideo is a unified post-training and real-time inference framework for accelerated video generation.**
|
||||
|
||||
## NEWS
|
||||
- `2026/08/23`: [FastH3 Preview v0.2](https://huggingface.co/FastVideo/FastVideo-Minimax-FastH3-Preview-v0.2) is a 4-step DMD2-distilled MiniMax-H3 checkpoint that generates synchronized video and audio. Run the verified [basic FastH3 example](examples/inference/basic/basic_fasth3.py), see the [inference guide](examples/inference/basic/README.md#fasth3-preview), or have a coding agent install FastVideo with the [agent setup prompt](#install-with-an-ai-coding-agent).
|
||||
- `2026/08/19`: FastVideo now supports MLX on Apple Silicon with [FastMetal-QAD](https://huggingface.co/collections/FastVideo/fastmetal), a family of 1.3B, 5B, and 14B models optimized for Mac—follow the [Apple Silicon guide](https://hao-ai-lab.github.io/FastVideo/getting_started/installation/mps/) and read the [Blog](https://haoailab.com/blogs/fastmetal/).
|
||||
- `2026/06/23`: Release FastWan-QAD: 5s of Video generated in 1.8s E2E. See the [FastWan-QAD models](https://huggingface.co/FastVideo/FastWan-QAD-FP8-1.3B), [Attn-QAT training guide](https://haoailab.com/FastVideo/training/attn_qat/), and [blog](https://haoailab.com/blogs/fastwan-qad/).
|
||||
- `2026/03/17`: Release demo: Into the Dreamverse: Vibe Directing in FastVideo, check out the [Blog](https://haoailab.com/blogs/dreamverse/).
|
||||
@@ -63,9 +64,10 @@ UV_TORCH_BACKEND=cu126 uv pip install fastvideo
|
||||
Use `UV_TORCH_BACKEND=cu130` on CUDA 13. Apple silicon users should follow the
|
||||
[MPS installation guide](https://hao-ai-lab.github.io/FastVideo/getting_started/installation/mps/).
|
||||
|
||||
> **On an Apple Silicon Mac?** FastVideo runs FastWan text-to-video natively
|
||||
> through an MLX runtime — a 5-second 480p clip generated locally, no cloud,
|
||||
> no discrete GPU. Install with `uv pip install -e '.[mlx]'` and follow the
|
||||
> **On an Apple Silicon Mac?** FastVideo runs FastMetal-QAD through an MLX
|
||||
> runtime. Install with `uv pip install -e '.[mlx]'`, download
|
||||
> [`FastVideo/FastMetal-1.3B-QAD`](https://huggingface.co/FastVideo/FastMetal-1.3B-QAD),
|
||||
> and follow the
|
||||
> [Apple Silicon guide](https://hao-ai-lab.github.io/FastVideo/getting_started/installation/mps/).
|
||||
|
||||
Please see our [docs](https://hao-ai-lab.github.io/FastVideo/getting_started/installation/) for more detailed installation instructions.
|
||||
|
||||
+26
-12
@@ -89,11 +89,29 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
zsh \
|
||||
vim \
|
||||
curl \
|
||||
ffmpeg \
|
||||
libgl1 \
|
||||
libglib2.0-0 \
|
||||
libx11-dev \
|
||||
gcc-11 \
|
||||
g++-11 \
|
||||
clang-11 \
|
||||
cmake \
|
||||
pkg-config \
|
||||
build-essential \
|
||||
libssl-dev \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Rust toolchain: some dependencies only ship sdists on aarch64 and need cargo
|
||||
# to build. The dormant legacy Modal image layers the identical apt set +
|
||||
# rustup on top of this image (fastvideo/tests/modal/pr_test.py); baking both
|
||||
# here keeps its manual rollback path reproducible without changing the Slurm
|
||||
# runner's package surface.
|
||||
RUN set -o pipefail && \
|
||||
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain stable --profile minimal && \
|
||||
/root/.cargo/bin/cargo --version && /root/.cargo/bin/rustc --version
|
||||
ENV PATH=/root/.cargo/bin:${PATH}
|
||||
|
||||
# Set up C++20 compilers for ThunderKittens
|
||||
RUN update-alternatives --install /usr/bin/gcc gcc /usr/bin/gcc-11 100 --slave /usr/bin/g++ g++ /usr/bin/g++-11
|
||||
|
||||
@@ -138,6 +156,7 @@ RUN --mount=type=cache,target=/opt/uv/cache \
|
||||
source /opt/venv/bin/activate && \
|
||||
uv pip install --upgrade pip && \
|
||||
uv pip install --excludes docker/uv-excludes ".[dev]" && \
|
||||
python -c "import cv2; print('OpenCV', cv2.__version__)" && \
|
||||
PYTAG=cp$(echo "${PYTHON_VERSION}" | tr -d .) && \
|
||||
case "${TARGETARCH:-amd64}" in \
|
||||
amd64) \
|
||||
@@ -169,26 +188,21 @@ RUN --mount=type=cache,target=/opt/uv/cache \
|
||||
# flash_attn/__init__.py), so FA2/varlen/bert_padding stay from the install above;
|
||||
# rmtree clears the wheel's stale cute files first to avoid an install conflict.
|
||||
# Then verify both survive so a broken overlay fails the build instead of shipping
|
||||
# an FA2-less image. x86 only: the FA4 stack (quack-kernels etc.) is unvalidated on
|
||||
# arm64 / GB10 (sm_121), so there we skip the overlay; FA4 is opt-in
|
||||
# (FASTVIDEO_FA4=1) and errors if set without the overlay, so leave it unset on
|
||||
# arm64 and the image runs FA3/FA2 as usual.
|
||||
# an FA2-less image. The pinned stack is validated on ARM64 GB200 (sm_100) as well
|
||||
# as x86; FA4 remains opt-in through FASTVIDEO_FA4=1 so lanes with FA2 baselines
|
||||
# keep their existing numerics.
|
||||
RUN --mount=type=cache,target=/opt/uv/cache \
|
||||
source $HOME/.local/bin/env && \
|
||||
source /opt/venv/bin/activate && \
|
||||
if [ "${TARGETARCH}" = "arm64" ]; then \
|
||||
echo "Skipping FA4 cute overlay on arm64 (FA4 stack unvalidated there; do not set FASTVIDEO_FA4)"; \
|
||||
else \
|
||||
python -c "import glob, shutil; [shutil.rmtree(d, ignore_errors=True) for d in glob.glob('/opt/venv/lib/python*/site-packages/flash_attn/cute')]" && \
|
||||
uv pip install "flash-attn-4 @ git+https://github.com/Dao-AILab/flash-attention.git@${FA4_CUTE_REF}#subdirectory=flash_attn/cute" && \
|
||||
python -c "import flash_attn; assert hasattr(flash_attn, 'flash_attn_func'), 'FA2 was clobbered by the cute overlay'; import flash_attn.cute; print('FA2 + FA4 cute OK')"; \
|
||||
fi
|
||||
python -c "import glob, shutil; [shutil.rmtree(d, ignore_errors=True) for d in glob.glob('/opt/venv/lib/python*/site-packages/flash_attn/cute')]" && \
|
||||
uv pip install "flash-attn-4 @ git+https://github.com/Dao-AILab/flash-attention.git@${FA4_CUTE_REF}#subdirectory=flash_attn/cute" && \
|
||||
python -c "import flash_attn; assert hasattr(flash_attn, 'flash_attn_func'), 'FA2 was clobbered by the cute overlay'; import flash_attn.cute; print('FA2 + FA4 cute OK')"
|
||||
|
||||
COPY . .
|
||||
|
||||
# Build immutable FastVideo kernel wheels for the published image. The requested
|
||||
# architecture remains installed for normal image users; amd64 images also carry
|
||||
# an SM89 artifact so the predominant L40S Modal lanes can reuse it exactly.
|
||||
# an SM89 artifact for L40S users and the dormant legacy rollback path.
|
||||
ARG FASTVIDEO_KERNEL_PREBUILT_DIR=/opt/fastvideo-kernel-prebuilt
|
||||
RUN --mount=type=cache,target=/opt/uv/cache \
|
||||
source $HOME/.local/bin/env && \
|
||||
|
||||
@@ -6,7 +6,8 @@ lives in [Testing](testing.md).
|
||||
|
||||
## Overview
|
||||
|
||||
FastVideo splits validation across GitHub Actions, Buildkite, and Modal:
|
||||
FastVideo splits validation across GitHub Actions, Buildkite, Slinky Slurm,
|
||||
and Mergify:
|
||||
|
||||
```text
|
||||
PR opened or updated
|
||||
@@ -16,17 +17,24 @@ PR opened or updated
|
||||
| style, lint, type, spelling, Markdown, workflow syntax, filenames
|
||||
|
|
||||
|-- Tier 2: Fastcheck
|
||||
| Buildkite orchestrates Modal GPU jobs
|
||||
| path-filtered component and unit checks
|
||||
| Buildkite schedules six lanes on the Slinky Slurm cluster
|
||||
| encoder, VAE, transformer, kernel, unit, DreamVerse
|
||||
|
|
||||
|-- /merge, /test full, or ready label
|
||||
|-- /merge or ready label
|
||||
|
|
||||
`-- Tier 3: Full Suite
|
||||
Buildkite orchestrates Modal GPU jobs
|
||||
path-filtered integration, SSIM, training, eval, and performance checks
|
||||
`-- Tier 3: Change-aware merge gate
|
||||
trusted base-branch planner classifies the complete PR diff
|
||||
Buildkite adds only relevant integration, quality, training,
|
||||
API, or performance lanes on Slinky Slurm
|
||||
|
|
||||
pass -> Mergify squash-merges when all merge conditions pass
|
||||
fail -> fix, push, and re-run
|
||||
|
||||
|-- /test full
|
||||
`-- Explicit all-20-lane diagnostic run
|
||||
|
||||
`-- weekly schedule on main
|
||||
`-- Complete four-GPU SSIM matrix
|
||||
```
|
||||
|
||||
CI is not one monolithic job:
|
||||
@@ -34,10 +42,31 @@ CI is not one monolithic job:
|
||||
- GitHub Actions owns pre-commit, slash-command handling, aggregate status
|
||||
updates, docs deployment, image builds, package publishing, and community
|
||||
automations.
|
||||
- Buildkite owns the GPU test pipeline and path filtering.
|
||||
- Modal owns the actual GPU execution environment for test jobs.
|
||||
- Buildkite owns the GPU test graph, statuses, and trusted dispatch control
|
||||
plane. Its agent runs on the Slurm login plane; it does not execute test
|
||||
payloads.
|
||||
- Slinky Slurm is the only active CI compute backend. A host-owned dispatcher
|
||||
leases GPUs from a persistent four-GPU allocation and runs each lane in an
|
||||
isolated Enroot container at the immutable PR SHA.
|
||||
- Mergify owns merge protection, labeling, and the final squash merge.
|
||||
|
||||
The old files under `fastvideo/tests/modal/` are retained as dormant manual
|
||||
rollback code. `.buildkite/scripts/pr_test.sh` rejects Buildkite invocations,
|
||||
and no pipeline or slash-command route calls Modal.
|
||||
|
||||
Three Buildkite entry pipelines share the validated graph:
|
||||
|
||||
| Pipeline | Trigger | Scope |
|
||||
|---|---|---|
|
||||
| `pr-fastcheck` | Automatic pull-request webhook | Six Fastcheck lanes |
|
||||
| `ci` | `/merge`, `ready`, schedules, and `/test` API builds | Change-aware merge gates, scheduled SSIM, explicit Full Suite, Fastcheck reruns, or one direct lane |
|
||||
| `fastvideo-performance-lane` | Weekly scheduler | Direct performance lane |
|
||||
|
||||
Each entry pipeline starts with the same trusted `pipeline-upload` job on the
|
||||
`ci-runner` queue. The `ci` pipeline's incoming GitHub webhook is disabled;
|
||||
otherwise it would duplicate the automatic `pr-fastcheck` build. API and
|
||||
scheduled builds continue to work with webhook processing disabled.
|
||||
|
||||
## CI Tiers
|
||||
|
||||
### Tier 1: Pre-commit
|
||||
@@ -70,34 +99,37 @@ debugging a hook implementation.
|
||||
| Attribute | Value |
|
||||
|---|---|
|
||||
| Triggered by | Buildkite PR builds with `TEST_SCOPE=fastcheck` or unset |
|
||||
| Runner | Buildkite agent that launches Modal GPU jobs |
|
||||
| Compute | Slinky Slurm (`ci-runner` queue) |
|
||||
| Definition | `.buildkite/pipeline.yml` |
|
||||
| Entrypoint | `.buildkite/scripts/pr_test.sh` -> `fastvideo/tests/modal/pr_test.py` |
|
||||
| Entrypoint | Trusted host driver -> `.buildkite/scripts/unit_test.sh` or `.buildkite/scripts/lanes/*.sh` |
|
||||
|
||||
Fastcheck uses Buildkite's `monorepo-diff` plugin. Jobs whose watched paths did
|
||||
not change are skipped and do not block the aggregate `fastcheck-passed`
|
||||
status.
|
||||
Fastcheck always schedules these six lanes: encoder, VAE, transformer, custom
|
||||
kernels, unit tests, and DreamVerse. Static steps replace the former
|
||||
host-side path-filter plugin: the login plane never checks out or executes PR
|
||||
code.
|
||||
|
||||
| Buildkite label | `TEST_TYPE` | Main watched paths |
|
||||
|---|---|---|
|
||||
| Encoder Tests | `encoder` | `fastvideo/models/encoders/**`, `fastvideo/models/loader/**`, `fastvideo/tests/encoders/**`, `pyproject.toml`, `docker/Dockerfile` |
|
||||
| VAE Tests | `vae` | `fastvideo/models/vaes/**`, `fastvideo/models/loader/**`, `fastvideo/tests/vaes/**`, `pyproject.toml`, `docker/Dockerfile` |
|
||||
| Transformer Tests | `transformer` | `fastvideo/models/dits/**`, `fastvideo/models/loader/**`, `fastvideo/tests/transformers/**`, `fastvideo/layers/**`, `fastvideo/attention/**`, `pyproject.toml`, `docker/Dockerfile` |
|
||||
| Kernel Tests | `kernel_tests` | `fastvideo-kernel/**`, `pyproject.toml`, `docker/Dockerfile` |
|
||||
| Unit Tests | `unit_test` | `fastvideo/**`, `.buildkite/**`, `.github/**`, `pyproject.toml`, `docker/Dockerfile` |
|
||||
| DreamVerse App Tests | `dreamverse_app` | `apps/dreamverse/**`, `pyproject.toml` |
|
||||
|
||||
### Tier 3: Full Suite
|
||||
### Tier 3: Change-Aware Merge Gate
|
||||
|
||||
| Attribute | Value |
|
||||
|---|---|
|
||||
| Triggered by | `/merge`, adding `ready`, `/test full`, or a new push to a PR that already has `ready` |
|
||||
| Runner | Buildkite agent that launches Modal GPU jobs |
|
||||
| Triggered by | `/merge`, adding `ready`, or a new push to a PR that already has `ready` |
|
||||
| Compute | Slinky Slurm only (`ci-runner` queue) |
|
||||
| Definition | `.buildkite/pipeline.yml` |
|
||||
| Entrypoint | `.buildkite/scripts/pr_test.sh` -> `fastvideo/tests/modal/pr_test.py` |
|
||||
| Entrypoint | `/opt/fastvideo-ci-runner/run-ci` (`run-unit` is a compatibility wrapper) |
|
||||
|
||||
Full Suite is also path-filtered. It validates broader behavior before Mergify
|
||||
can merge a PR.
|
||||
Fastcheck is the universal six-lane baseline. The merge gate does not repeat
|
||||
those jobs: it classifies every changed path and adds only the relevant lanes
|
||||
from the fourteen-lane integration set below. Selected jobs are hard gates;
|
||||
there are no soft-fail hardware lanes. A documentation-only PR can therefore
|
||||
finish its merge build after the trusted uploader, while model-family changes
|
||||
typically add focused golden-gate and SSIM files and a training-only change
|
||||
adds only its owning training lane.
|
||||
|
||||
`.github/scripts/plan_merge_ci.py` is the canonical path policy. It runs from
|
||||
the immutable base SHA under `pull_request_target`; PR code is never executed
|
||||
on the GitHub runner. The changed-file list includes both sides of renames. An
|
||||
API failure, truncated response, empty list, unknown build input, or unknown
|
||||
source path fails closed to all fourteen integration lanes.
|
||||
|
||||
A `ready`-labeled PR does not hit Buildkite immediately:
|
||||
`ci-trigger-full-suite.yml` first runs `.github/scripts/gate_full_suite.sh`,
|
||||
@@ -106,25 +138,100 @@ head. A red cheap check blocks the suite (fail closed; the next push re-arms
|
||||
it), while a GitHub outage or a >25 min wait lets it run anyway (fail open).
|
||||
`/test full` bypasses the gate.
|
||||
|
||||
| Buildkite label | `TEST_TYPE` | Main watched paths |
|
||||
|---|---|---|
|
||||
| SSIM Tests | `ssim` | `fastvideo/**/*.py`, `pyproject.toml`, `docker/Dockerfile` |
|
||||
| LoRA Inference Tests | `inference_lora` | LoRA tests, loader, transformer tests, pipelines, LoRA layers |
|
||||
| LoRA Extraction Tests | `lora_extraction` | LoRA extraction scripts/tests, loader, training utilities, LoRA layers |
|
||||
| Training Tests | `training` | `fastvideo/**`, `pyproject.toml`, `docker/Dockerfile` |
|
||||
| Distillation DMD Tests | `distillation_dmd` | `fastvideo/training/*distillation_pipeline.py` |
|
||||
| Self-Forcing Tests | `self_forcing` | self-forcing distillation pipeline and tests |
|
||||
| LoRA Training Tests | `training_lora` | `fastvideo/**`, `pyproject.toml`, `docker/Dockerfile` |
|
||||
| Training Tests VSA | `training_vsa` | `fastvideo/**`, `fastvideo-kernel/**`, `pyproject.toml`, `docker/Dockerfile` |
|
||||
| Inference Tests VMoBA | `inference_vmoba` | `fastvideo-kernel/**`, `fastvideo/attention/backends/vmoba.py` |
|
||||
| Performance Tests | `performance` | DiTs, pipelines, attention, layers, worker, entrypoints, performance tests/configs |
|
||||
| API Server Tests | `api_server` | OpenAI entrypoints, serve CLI, OpenAI API integration test |
|
||||
| Train Framework Tests | `train_framework` | `fastvideo/train/**`, train model/method tests, model loader, DiTs |
|
||||
| Eval Metrics Tests | `eval` | `fastvideo/eval/**`, `fastvideo/tests/eval/**`, `pyproject.toml`, `docker/Dockerfile` |
|
||||
The complete static graph remains available through `/test full`; path
|
||||
selection never deletes or dynamically invents a Buildkite step.
|
||||
|
||||
| Lane | Public `TEST_TYPE` | GPUs | Typical merge trigger |
|
||||
|---|---|---:|---|
|
||||
| Encoder | `encoder` | 1 | Universal Fastcheck |
|
||||
| VAE | `vae` | 1 | Universal Fastcheck |
|
||||
| Transformer | `transformer` | 1 | Universal Fastcheck |
|
||||
| Kernel | `kernel_tests` | 1 | Universal Fastcheck |
|
||||
| Unit | `unit_test` | 1 | Universal Fastcheck |
|
||||
| DreamVerse | `dreamverse_app` | 1 | Universal Fastcheck |
|
||||
| Golden gate | `golden_gate` | 1 | Model, pipeline, attention, layer, or output changes |
|
||||
| SSIM | `ssim` | 4 | Matching model/SSIM paths; focused files when possible |
|
||||
| LoRA inference | `inference_lora` | 1 | LoRA inference/shared LoRA paths |
|
||||
| LoRA extraction | `lora_extraction` | 1 | LoRA extraction/shared LoRA paths |
|
||||
| Vanilla training | `training` | 4 | Legacy vanilla/shared training paths |
|
||||
| DMD distillation | `distillation_dmd` | 2 | DMD/shared training paths |
|
||||
| Self-forcing | `self_forcing` | 2 | Self-forcing/shared training paths |
|
||||
| LoRA training | `training_lora` | 2 | LoRA/shared training paths |
|
||||
| VSA training | `training_vsa` | 2 | VSA/shared training paths |
|
||||
| VMoBA inference | `inference_vmoba` | 1 | VMoBA backend/config paths |
|
||||
| Performance | `performance` | 2 | Performance tests or benchmark policy |
|
||||
| API server | `api_server` | 1 | API, worker, or server entrypoint paths |
|
||||
| Modular train framework | `train_framework` | 1 | `fastvideo/train/` and its tests |
|
||||
| Eval metrics | `eval` | 1 | `fastvideo/eval/` and its tests |
|
||||
|
||||
Golden-gate and SSIM selections are basenames, not arbitrary pytest arguments.
|
||||
The private host checks the comma-separated allowlist before staging, and the
|
||||
container checks it again before invoking pytest. Shared quality-harness
|
||||
changes still run the complete owning lane. The full SSIM matrix also runs on
|
||||
`main` every Sunday at 05:00 UTC through `ci-scheduled-ssim.yml`; `/test ssim`
|
||||
and `/test full` remain available for deliberate complete runs.
|
||||
|
||||
The four Buildkite workers may accept multiple jobs concurrently. The
|
||||
agent-owned lease broker packs their requested GPU counts onto one persistent
|
||||
four-GPU Slurm allocation and waits when capacity is full. A four-GPU lane
|
||||
such as SSIM or vanilla training owns the whole tray; two two-GPU lanes or up
|
||||
to four one-GPU lanes can overlap without sharing devices. SSIM and vanilla
|
||||
training also share the `fastvideo/slinky/whole-tray` Buildkite concurrency
|
||||
group. That keeps the second whole-tray lane in Buildkite instead of consuming
|
||||
an agent and its command timeout while the first lane waits for four free GPUs.
|
||||
Because packed Enroot containers share the node network namespace, each GPU
|
||||
lease receives its own 100-port rendezvous range. Tests preserve the
|
||||
runner-assigned `MASTER_PORT`, and parallel SSIM tasks use distinct offsets
|
||||
inside that range.
|
||||
|
||||
See [Performance Benchmarks](performance_benchmarks.md) for the performance
|
||||
lane's thresholds, rolling baseline, artifacts, and reseeding process.
|
||||
|
||||
## `/merge` Request Flow
|
||||
|
||||
The Buildkite agent and Slurm have deliberately separate responsibilities:
|
||||
|
||||
```text
|
||||
/merge PR comment
|
||||
-> GitHub verifies write permission and refreshes the `ready` label
|
||||
-> base-branch `ci-trigger-full-suite` workflow fetches the PR file list,
|
||||
computes MERGE_TEST_PLAN plus focused golden/SSIM basenames, and gates on
|
||||
cheap checks
|
||||
-> sends the PR SHA to pipeline `ci` with TEST_SCOPE=merge and FULL_SUITE=true
|
||||
-> trusted `pipeline-upload` job on queue `ci-runner`
|
||||
fetch exact-SHA .buildkite/pipeline.yml
|
||||
normalize + validate the complete static 20-lane policy and conditions
|
||||
upload static Buildkite steps
|
||||
-> each accepted step reaches the host policy hook
|
||||
validate org/repo/SHA/ref/step key/command/scope/timeout
|
||||
skip checkout on the login plane
|
||||
stage a mode-0600 request on Lustre
|
||||
lease 1-4 GPUs and attach an `srun` step to the Slinky tray
|
||||
-> Enroot worker container
|
||||
clone and verify the exact PR SHA
|
||||
install the lane's project extras and cached kernel
|
||||
run the repository-owned lane script
|
||||
write numeric exit status and approved artifacts
|
||||
-> trusted host returns that status to Buildkite
|
||||
-> Buildkite publishes `full-suite-passed` only when every selected lane passes
|
||||
```
|
||||
|
||||
The pipeline uploader and dispatcher run on the Slurm login plane, but those
|
||||
are control-plane operations only. Python tests, model loading, CUDA kernels,
|
||||
Node/Playwright checks, inference, training, SSIM generation, and performance
|
||||
benchmarks all execute in Slurm allocations.
|
||||
|
||||
PR-controlled values never become host commands. The host policy accepts only
|
||||
the pinned pipeline uploader or a known lane tuple. It rejects plugins,
|
||||
artifact globs, shell injection variables, non-immutable commits, and unknown
|
||||
commands before checkout. Hugging Face credentials are added only for lanes
|
||||
that declare them, passed through a mode-0600 request file, and removed before
|
||||
the PR payload starts. Active training lanes keep W&B offline and do not stage
|
||||
a W&B credential. The ARM64 image includes the pinned FA4 CuTe overlay validated
|
||||
on GB200. SSIM opts into FA4 to preserve its reference-video numerics; lanes
|
||||
with FA2 baselines keep `FASTVIDEO_FA4=0`. Performance artifacts are relayed
|
||||
afterward by the trusted host from an allowlisted directory and extension set.
|
||||
|
||||
## Slash Commands
|
||||
|
||||
Slash commands are handled by `.github/workflows/ci-slash-commands.yml`.
|
||||
@@ -132,7 +239,7 @@ Repository write permission is required.
|
||||
|
||||
| Command | Effect |
|
||||
|---|---|
|
||||
| `/merge` | Adds `ready` and triggers Full Suite for the PR head branch. |
|
||||
| `/merge` | Adds `ready` and triggers the path-aware merge gate for the PR head. |
|
||||
| `/test full` | Runs the whole Full Suite with `TEST_SCOPE=full`. |
|
||||
| `/test fastcheck` | Runs the whole Fastcheck suite with `TEST_SCOPE=fastcheck`. |
|
||||
| `/test pre-commit` | Re-runs the pre-commit workflow on the PR merge ref. |
|
||||
@@ -149,6 +256,7 @@ Valid direct test names:
|
||||
| `/test unit` | `unit_test` |
|
||||
| `/test dreamverse` | `dreamverse_app` |
|
||||
| `/test ssim` | `ssim` |
|
||||
| `/test golden-gate` | `golden_gate` |
|
||||
| `/test training` | `training` |
|
||||
| `/test lora-inference` | `inference_lora` |
|
||||
| `/test lora-training` | `training_lora` |
|
||||
@@ -162,12 +270,18 @@ Valid direct test names:
|
||||
| `/test train-framework` | `train_framework` |
|
||||
| `/test eval` | `eval` |
|
||||
|
||||
The temporary `<name>-ci` spellings remain accepted as compatibility aliases;
|
||||
they select the same Slurm lane and do not identify a second backend.
|
||||
|
||||
When a direct test completes successfully, Buildkite posts
|
||||
`direct-test-completed`. `.github/workflows/ci-aggregate-status.yml` then reads
|
||||
the latest Buildkite statuses for the commit and updates `fastcheck-passed` or
|
||||
`full-suite-passed` if all jobs in that group are green.
|
||||
|
||||
Skipped path-filtered jobs have no status entry and do not block the aggregate.
|
||||
Buildkite label emojis define the status namespace used by that aggregation:
|
||||
`:microscope:` is reserved for the six Fastcheck lanes, while Full-Suite-only
|
||||
lanes use `:test_tube:` or `:bar_chart:`. Each active lane has exactly one
|
||||
label and therefore one status context.
|
||||
|
||||
## Merge Protection
|
||||
|
||||
@@ -177,7 +291,7 @@ Mergify enforces these conditions before it squash-merges to `main`:
|
||||
|---|---|
|
||||
| `check-success~=pre-commit` | Tier 1 passed. |
|
||||
| `check-success=fastcheck-passed` | All triggered Fastcheck jobs passed. |
|
||||
| `check-success=full-suite-passed` | All triggered Full Suite jobs passed. |
|
||||
| `check-success=full-suite-passed` | The selected merge gate or explicit Full Suite passed. |
|
||||
| `#approved-reviews-by>=1` | At least one approving review. |
|
||||
| Valid title regex | PR title starts with an accepted `[type]` tag. |
|
||||
| `label=ready` | The PR has entered the merge flow. |
|
||||
@@ -226,29 +340,40 @@ Process labels:
|
||||
|
||||
| Label | Who sets it | Meaning |
|
||||
|---|---|---|
|
||||
| `ready` | `/merge` or maintainer action | Triggers/keeps Full Suite active and enables auto-merge. |
|
||||
| `ready` | `/merge` or maintainer action | Triggers/keeps the change-aware merge gate active and enables auto-merge. |
|
||||
| `needs-rebase` | Mergify | PR has merge conflicts. |
|
||||
| `do-not-merge` | Maintainer | Blocks merge regardless of CI status. |
|
||||
|
||||
## Modal Test Entrypoints
|
||||
## Slurm Lane Entrypoints
|
||||
|
||||
All Buildkite test jobs go through `.buildkite/scripts/pr_test.sh`, which:
|
||||
Every active test selection lives in `.buildkite/scripts/unit_test.sh` or a
|
||||
focused `.buildkite/scripts/lanes/<lane>.sh`. The private, agent-owned lane
|
||||
table binds each internal `*_ci` type to that script, its GPU count, wall-clock
|
||||
limit, dependency extras, kernel-build policy, secrets, and artifacts. The
|
||||
internal suffix is an implementation detail; there is only one active backend.
|
||||
|
||||
1. Reads Buildkite secrets for Modal, Hugging Face, and W&B when needed.
|
||||
2. Selects a Modal function based on `TEST_TYPE`.
|
||||
3. Passes Buildkite metadata into the Modal container.
|
||||
4. Runs the selected test command from `fastvideo/tests/modal/pr_test.py` or
|
||||
`fastvideo/tests/modal/ssim_test.py`.
|
||||
5. Uploads performance artifacts for `TEST_TYPE=performance`.
|
||||
SSIM uses `fastvideo/tests/ssim/ci_runner.py` inside a single four-GPU lease.
|
||||
It discovers `REQUIRED_GPUS` and `*_MODEL_TO_PARAMS` with AST parsing, then
|
||||
packs independent pytest subprocesses across the visible GPUs with fail-fast
|
||||
termination. Performance writes reports to a host-mounted artifact directory;
|
||||
the host uploads only `.md`, `.html`, `.json`, and `.csv` files after the
|
||||
container exits.
|
||||
|
||||
The Modal launchers remain in the repository for manual rollback archaeology,
|
||||
but they are not CI entrypoints. `pr_test.sh` rejects Buildkite calls and needs
|
||||
`FASTVIDEO_ENABLE_LEGACY_MODAL_CI=1` even for a local manual invocation.
|
||||
|
||||
If you add a new CI test category:
|
||||
|
||||
1. Add the Modal function in `fastvideo/tests/modal/pr_test.py` or a focused
|
||||
companion module.
|
||||
2. Add the `TEST_TYPE` case in `.buildkite/scripts/pr_test.sh`.
|
||||
3. Add the Buildkite direct-test step and any Fastcheck/Full Suite path filters
|
||||
in `.buildkite/pipeline.yml`.
|
||||
4. Add or update the `/test` mapping in `.github/workflows/ci-slash-commands.yml`.
|
||||
1. Add an executable `.buildkite/scripts/lanes/<lane>.sh` containing the test
|
||||
payload only.
|
||||
2. Add the static Buildkite step in `.buildkite/pipeline.yml`, its source/test
|
||||
ownership in `.github/scripts/plan_merge_ci.py`, and the `/test` mapping in
|
||||
`.github/workflows/ci-slash-commands.yml`.
|
||||
3. Extend `fastvideo/tests/contract/test_ci_test_collection.py`,
|
||||
`test_merge_ci_plan.py`, and the trusted private runner's lane table plus
|
||||
uploader policy in the same rollout.
|
||||
4. Validate on the target GB200 hardware before making the lane a merge gate.
|
||||
5. Document the lane here and link any domain-specific authoring guide.
|
||||
|
||||
## CD And Release Workflows
|
||||
@@ -282,13 +407,18 @@ the explicit `py3.12-cuda13.0.0-latest` tag. This publication policy does not
|
||||
change the unparameterized `docker/Dockerfile` build defaults, which remain CUDA
|
||||
13 and `cu130`.
|
||||
|
||||
Published amd64 development images keep their configured Hopper kernel wheel
|
||||
installed and also carry an immutable SM89 wheel under
|
||||
`/opt/fastvideo-kernel-prebuilt`. Modal PR and SSIM jobs select the exact
|
||||
source, ABI, and GPU-architecture match from that directory, so L40S jobs reuse
|
||||
the trusted image artifact while kernel-changing PRs still build locally. Once
|
||||
a kernel or artifact-key change reaches `main`, the image workflow republishes
|
||||
the matching trusted artifact before later jobs consume the updated image tag.
|
||||
Published development images carry architecture-specific kernel wheels under
|
||||
`/opt/fastvideo-kernel-prebuilt`. The Slurm worker selects the exact source,
|
||||
ABI, and GPU-architecture match, so normal lanes reuse the trusted artifact
|
||||
while kernel-changing PRs still build locally. Once a kernel or artifact-key
|
||||
change reaches `main`, the image workflow republishes the matching artifact
|
||||
before later jobs consume the updated image pin.
|
||||
|
||||
The same workflow publishes a single-architecture ARM64, CUDA 13, SM100 image
|
||||
for the self-hosted CI runner under the
|
||||
`py3.12-cuda13.0.0-sm100-{latest,sha-*}` tags. It carries the matching prebuilt
|
||||
kernel wheel so runner jobs can validate and install the exact source and ABI
|
||||
match instead of recompiling it in every lane.
|
||||
|
||||
The optional Dreamverse matrix builds backend and UI images for CUDA 12.6 and
|
||||
CUDA 13 on `amd64`. Dreamverse remains `amd64`-only because its FA4 dependency
|
||||
@@ -320,15 +450,17 @@ The reusable implementation lives in
|
||||
| `.github/mergify.yml` | Merge protection, PR title validation, PR labels, conflict labels, auto-merge |
|
||||
| `.github/workflows/ci-precommit.yml` | Tier 1 pre-commit |
|
||||
| `.github/workflows/ci-slash-commands.yml` | `/merge` and `/test` handling |
|
||||
| `.github/workflows/ci-trigger-full-suite.yml` | Full Suite trigger for `ready` PRs and new pushes to ready PRs |
|
||||
| `.github/workflows/ci-aggregate-status.yml` | Aggregate Fastcheck/Full Suite commit statuses |
|
||||
| `.buildkite/pipeline.yml` | Buildkite test graph and path filters |
|
||||
| `.buildkite/scripts/pr_test.sh` | Buildkite-to-Modal test dispatcher |
|
||||
| `fastvideo/tests/modal/pr_test.py` | Modal functions for most GPU CI lanes |
|
||||
| `fastvideo/tests/modal/ssim_test.py` | Modal functions and partitioning for SSIM |
|
||||
| `.github/workflows/ci-trigger-full-suite.yml` | Change-aware merge-gate trigger for `ready` PRs and new pushes |
|
||||
| `.github/workflows/ci-scheduled-ssim.yml` | Weekly complete SSIM trigger on `main` |
|
||||
| `.github/scripts/plan_merge_ci.py` | Trusted changed-path to integration-lane planner |
|
||||
| `.github/workflows/ci-aggregate-status.yml` | Aggregate Fastcheck and explicit Full Suite direct-rerun statuses |
|
||||
| `.buildkite/pipeline.yml` | Static 20-lane Slurm Buildkite graph |
|
||||
| `.buildkite/scripts/unit_test.sh`, `.buildkite/scripts/lanes/*.sh` | Active Slurm lane payloads |
|
||||
| `fastvideo/tests/ssim/ci_runner.py` | Four-GPU Slurm SSIM scheduler |
|
||||
| `.buildkite/scripts/pr_test.sh`, `fastvideo/tests/modal/*.py` | Dormant manual Modal rollback path (disabled in Buildkite) |
|
||||
| `.buildkite/performance-benchmarks/tests/*.json` | Performance benchmark configs and thresholds |
|
||||
| `.github/workflows/infra-docs.yml` | Docs build and GitHub Pages deploy |
|
||||
| `.github/workflows/infra-build-image.yml` | Automatic CUDA matrix and manual Docker image builds |
|
||||
| `.github/workflows/infra-build-image.yml` | CUDA matrix, CI runner image, and manual Docker image builds |
|
||||
| `.github/workflows/publish-fastvideo.yml` | FastVideo PyPI publishing |
|
||||
| `.github/workflows/publish-kernel.yml` | FastVideo kernel PyPI publishing |
|
||||
| `.github/workflows/publish-comfyui.yml` | ComfyUI registry publishing |
|
||||
|
||||
@@ -23,8 +23,8 @@ It serves three audiences:
|
||||
pytest fastvideo/tests/performance/ -vs
|
||||
|
||||
# Optional: compare against the rolling HF baseline.
|
||||
# PERF_REPORTS_DIR defaults to /root/data/perf_reports for Modal/CI, so
|
||||
# override it when running outside the container.
|
||||
# PERF_REPORTS_DIR defaults to /root/data/perf_reports in a CI container, so
|
||||
# override it for a local run.
|
||||
PERF_REPORTS_DIR=/tmp/fastvideo_perf_reports \
|
||||
python fastvideo/tests/performance/compare_baseline.py
|
||||
|
||||
@@ -459,16 +459,18 @@ FlashInfer, Cutlass DSL, SageAttention, Triton, and xFormers when installed.
|
||||
| `FASTVIDEO_FA4` | `0` | `test_inference_performance.py` | FlashAttention-4 toggle included in `software_profile_id`. |
|
||||
| `FASTVIDEO_PERFORMANCE_PROFILE_VERSION` | unset | `test_inference_performance.py` | Optional explicit software cohort/profile version included in `software_profile_id`. |
|
||||
| `IMAGE_VERSION` | unset | `test_inference_performance.py` | CI container image/profile version included in `software_profile_id` when available. |
|
||||
| `FASTVIDEO_CONTAINER_IMAGE_REF` | unset | `pr_test.py`, `launch_l40s_job.py`, `test_inference_performance.py` | Resolved CI container image ref or digest recorded in `environment_metadata` for audit without changing `software_profile_id`. |
|
||||
| `FASTVIDEO_CONTAINER_IMAGE_REF` | unset | Slurm runner, `test_inference_performance.py` | Pinned CI container image digest recorded in `environment_metadata` and `software_profile_id`. |
|
||||
| `FASTVIDEO_STAGE_LOGGING` | set by the pytest test | `test_inference_performance.py` | Enables pipeline stage timing capture for component metrics during benchmark runs. |
|
||||
|
||||
## CI integration
|
||||
|
||||
The performance step can run on demand with `/test performance` and as part of
|
||||
the Full Suite (see [CI/CD Architecture](ci_architecture.md)). The Modal entry
|
||||
point is `fastvideo/tests/modal/pr_test.py:run_performance_tests` and the
|
||||
Buildkite artifact upload is in
|
||||
`.buildkite/scripts/pr_test.sh:upload_performance_artifacts`.
|
||||
The performance step can run on demand with `/test performance`, through a
|
||||
merge gate when performance tests or benchmark policy changed, and as part of
|
||||
an explicit `/test full` run (see [CI/CD Architecture](ci_architecture.md)).
|
||||
The weekly `fastvideo-performance-lane` schedule runs the same Slurm payload.
|
||||
The active entry point is `.buildkite/scripts/lanes/performance.sh`; the
|
||||
trusted host dispatcher relays its allowlisted reports to Buildkite after the
|
||||
isolated container exits.
|
||||
|
||||
Each performance build runs pytest first. PR and direct runs only continue to
|
||||
`compare_baseline.py` when that fixed-threshold phase passes; if pytest fails,
|
||||
|
||||
@@ -42,19 +42,20 @@ The important process labels are:
|
||||
|
||||
| Label | Meaning |
|
||||
|---|---|
|
||||
| `ready` | The PR is ready for Full Suite and auto-merge consideration. |
|
||||
| `ready` | The PR is ready for the change-aware merge gate and auto-merge consideration. |
|
||||
| `needs-rebase` | The PR has merge conflicts with `main`. |
|
||||
| `do-not-merge` | A maintainer has blocked merge. |
|
||||
|
||||
## CI Summary
|
||||
|
||||
FastVideo has three validation tiers:
|
||||
FastVideo has three routine validation tiers plus an explicit full diagnostic:
|
||||
|
||||
| Tier | Runs when | What it does |
|
||||
|---|---|---|
|
||||
| Pre-commit | Pull requests and `/test pre-commit` | Formatting, linting, typing, spelling, Markdown, workflow syntax, filename checks |
|
||||
| Fastcheck | PR Buildkite builds | Path-filtered component and unit checks on Modal GPU runners |
|
||||
| Full Suite | `/merge`, `ready`, `/test full`, or new pushes to ready PRs | Path-filtered integration, SSIM, training, eval, API, and performance checks |
|
||||
| Fastcheck | PR Buildkite builds | Six component, kernel, unit, and app lanes on Slinky Slurm |
|
||||
| Merge gate | `/merge`, `ready`, or new pushes to ready PRs | Only path-relevant integration lanes on Slinky Slurm; Fastcheck remains the baseline |
|
||||
| Full Suite | `/test full` | Explicit all-twenty-lane diagnostic run on Slinky Slurm |
|
||||
|
||||
See [CI/CD Architecture](ci_architecture.md#ci-tiers) for exact jobs, path
|
||||
filters, and workflow files.
|
||||
@@ -66,10 +67,11 @@ filters, and workflow files.
|
||||
3. Fix pre-commit failures locally with `pre-commit run --all-files`.
|
||||
4. Wait for at least one approving review.
|
||||
5. When the PR is approved and ready, comment `/merge`.
|
||||
6. `/merge` adds `ready` and triggers the Full Suite for the PR branch.
|
||||
6. `/merge` adds `ready`, waits for cheap checks, and triggers the minimal
|
||||
path-relevant integration lanes for the PR branch.
|
||||
7. If all required checks pass, Mergify squash-merges the PR to `main`.
|
||||
8. If Full Suite fails, fix the regression, push again, and re-run `/merge` or
|
||||
the failed test.
|
||||
8. If the merge gate fails, fix the regression, push again, and re-run
|
||||
`/merge`. Use a targeted `/test` command for diagnosis.
|
||||
|
||||
Only contributors with repository write permission can use slash commands. If
|
||||
you are an external contributor, ask a maintainer to run `/merge` or add
|
||||
@@ -128,7 +130,7 @@ git push --force-with-lease
|
||||
|
||||
Mergify removes `needs-rebase` after conflicts are resolved.
|
||||
|
||||
### Full Suite Fails
|
||||
### Merge Gate Or Full Suite Fails
|
||||
|
||||
The failing Buildkite step is the source of truth. Common causes are:
|
||||
|
||||
|
||||
@@ -138,47 +138,31 @@ pytest fastvideo/tests/ssim/ -vs
|
||||
|
||||
Use a machine whose GPU and backend match the reference folder you are testing.
|
||||
|
||||
## Modal Runs For SSIM
|
||||
## Slurm CI Runs For SSIM
|
||||
|
||||
For CI-like SSIM execution, use `fastvideo/tests/modal/ssim_test.py`:
|
||||
Comment `/test ssim` on a pull request to run the canonical four-GPU SSIM
|
||||
lane on the Slinky Slurm cluster. `fastvideo/tests/ssim/ci_runner.py`
|
||||
discovers the suite without importing test modules, packs independent pytest
|
||||
processes across the four assigned GPUs, and stops the lane on the first
|
||||
failure.
|
||||
|
||||
The change-aware `/merge` planner may run only the SSIM test basenames owned
|
||||
by the changed model family. Shared SSIM harness changes still select the
|
||||
complete lane. Independently, `main` runs the full SSIM matrix every Sunday at
|
||||
05:00 UTC so infrequently touched model families retain periodic coverage.
|
||||
|
||||
For a focused developer run, invoke pytest directly and optionally select one
|
||||
model from a parameterized test through `FASTVIDEO_SSIM_MODEL_ID`:
|
||||
|
||||
```bash
|
||||
python -m modal run fastvideo/tests/modal/ssim_test.py::run_ssim_tests
|
||||
pytest fastvideo/tests/ssim/test_wan_t2v_similarity.py -vs
|
||||
|
||||
FASTVIDEO_SSIM_MODEL_ID=Wan2.1-T2V-1.3B-Diffusers \
|
||||
pytest fastvideo/tests/ssim/test_wan_t2v_similarity.py -vs
|
||||
```
|
||||
|
||||
Target specific files or model ids:
|
||||
|
||||
```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_API_KEY`, `HUGGINGFACE_HUB_TOKEN`, or `HF_TOKEN` is not set, 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 downloads into
|
||||
`./generated_videos_modal/<quality-tier>`. Convert those outputs into local
|
||||
references with `copy-local`:
|
||||
|
||||
```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
|
||||
```
|
||||
The files under `fastvideo/tests/modal/` are retained only as a disabled
|
||||
manual rollback implementation. No active CI trigger invokes them.
|
||||
|
||||
### SSIM Bootstrap Mode
|
||||
|
||||
@@ -206,28 +190,30 @@ python fastvideo/tests/ssim/reference_videos_cli.py promote-draft \
|
||||
|
||||
## CI Integration
|
||||
|
||||
FastVideo CI tests are orchestrated by Buildkite and run on Modal GPU
|
||||
instances. The main files are:
|
||||
FastVideo GPU CI is orchestrated by Buildkite and runs only on isolated Slinky
|
||||
Slurm workers. The main files are:
|
||||
|
||||
| File | Purpose |
|
||||
|---|---|
|
||||
| `.buildkite/pipeline.yml` | Buildkite test graph and path filters. |
|
||||
| `.buildkite/scripts/pr_test.sh` | Dispatches `TEST_TYPE` to a Modal function. |
|
||||
| `fastvideo/tests/modal/pr_test.py` | Modal functions for most test lanes. |
|
||||
| `fastvideo/tests/modal/ssim_test.py` | Modal functions and partitioning for SSIM. |
|
||||
| `.buildkite/pipeline.yml` | Static, validated 20-lane Slurm test graph. |
|
||||
| `.github/scripts/plan_merge_ci.py` | Trusted path-to-lane and focused quality-test policy for `/merge`. |
|
||||
| `.buildkite/scripts/unit_test.sh`, `.buildkite/scripts/lanes/*.sh` | Repository-owned test payloads executed inside Slurm containers. |
|
||||
| `fastvideo/tests/ssim/ci_runner.py` | Four-GPU SSIM task discovery and scheduling. |
|
||||
| `.buildkite/scripts/pr_test.sh`, `fastvideo/tests/modal/*.py` | Dormant manual rollback path; rejected in Buildkite. |
|
||||
|
||||
For exact tier membership, path filters, slash commands, and aggregate statuses,
|
||||
For exact tier membership, slash commands, runner isolation, and aggregate statuses,
|
||||
see [CI/CD Architecture](ci_architecture.md).
|
||||
|
||||
### Adding A New CI Test Category
|
||||
|
||||
If a new test does not fit an existing lane:
|
||||
|
||||
1. Add a Modal function in `fastvideo/tests/modal/pr_test.py` or a focused
|
||||
companion module.
|
||||
2. Add a matching `TEST_TYPE` case in `.buildkite/scripts/pr_test.sh`.
|
||||
3. Add Buildkite direct-test and path-filter entries in `.buildkite/pipeline.yml`.
|
||||
4. Add the `/test` mapping in `.github/workflows/ci-slash-commands.yml`.
|
||||
1. Put the test payload in an executable `.buildkite/scripts/lanes/<lane>.sh`.
|
||||
2. Add its static step to `.buildkite/pipeline.yml`, its changed-path ownership
|
||||
to `.github/scripts/plan_merge_ci.py`, and extend the CI contract tests.
|
||||
3. Add the `/test` mapping in `.github/workflows/ci-slash-commands.yml`.
|
||||
4. Coordinate the matching GPU, timeout, dependency, secret, and artifact
|
||||
policy in the private Slurm runner allowlist.
|
||||
5. Document the new category in [CI/CD Architecture](ci_architecture.md) and add
|
||||
authoring notes here if contributors need them.
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ compute ~3.7×, so the wall-clock drops far more than 2×. RIFE (which estimates
|
||||
its own optical flow — no motion vectors needed) fills the dropped frames back
|
||||
in for ~1.4 s, and a light unsharp pass counters its softening.
|
||||
|
||||
Measured on the 1.3B INT8 QAD model (fox, 480×832×81, M4): generate 41 + RIFE→81
|
||||
Measured on the 1.3B INT8 QAD model (480×832×81, M4): generate 41 + RIFE→81
|
||||
runs in ~35 s of denoise vs ~90 s full, at reconstruction MS-SSIM **0.97**.
|
||||
Reproduce with `python -m fastvideo.benchmarks.eval_metalfx_rife --mode int8`.
|
||||
|
||||
@@ -26,10 +26,11 @@ uv pip install -e ".[mlx]" # RIFE ships vendored; this only needs MLX
|
||||
|
||||
```bash
|
||||
python examples/inference/basic/mlx_wan_prompt_to_video.py \
|
||||
--mlx-checkpoint <FastWan2.1-T2V-1.3B-INT8-QAD> \
|
||||
--prompt "A red fox trotting through a snowy pine forest at golden hour, cinematic" \
|
||||
--model-root ./FastMetal-1.3B-QAD \
|
||||
--mlx-checkpoint ./FastMetal-1.3B-QAD \
|
||||
--prompt "A bird's-eye view of a misty forest valley at dawn." \
|
||||
--num-frames 81 --fast \
|
||||
--output-path video_samples/fox_fast.mp4
|
||||
--output-path video_samples/forest_fast.mp4
|
||||
```
|
||||
|
||||
`--num-frames` stays the *target* length; fast mode generates the smallest
|
||||
@@ -57,9 +58,9 @@ of denoise. It composes with `--fast`; both together run the same clip in
|
||||
|
||||
```bash
|
||||
python examples/inference/basic/mlx_wan_prompt_to_video.py \
|
||||
--prompt "A red fox trotting through a snowy pine forest at golden hour, cinematic" \
|
||||
--prompt "A bird's-eye view of a misty forest valley at dawn." \
|
||||
--height 480 --width 832 --num-frames 81 --fast-spatial \
|
||||
--output-path video_samples/fox_fast_spatial.mp4
|
||||
--output-path video_samples/forest_fast_spatial.mp4
|
||||
```
|
||||
|
||||
| Flag | Default | Meaning |
|
||||
|
||||
@@ -21,6 +21,8 @@ surfaces:
|
||||
hsdp_shard_dim: generator.engine.parallelism.hsdp_shard_dim
|
||||
dist_timeout: generator.engine.parallelism.dist_timeout
|
||||
lora_path: generator.pipeline.components.lora_path
|
||||
lora_nickname: generator.pipeline.components.lora_nickname
|
||||
lora_strength: generator.pipeline.components.lora_strength
|
||||
dit_cpu_offload: generator.engine.offload.dit
|
||||
use_fsdp_inference: generator.engine.use_fsdp_inference
|
||||
dit_layerwise_offload: generator.engine.offload.dit_layerwise
|
||||
@@ -72,11 +74,11 @@ surfaces:
|
||||
compatibility_only:
|
||||
mode: "Legacy multi-mode FastVideoArgs switch; typed inference config should not expose execution mode."
|
||||
inference_mode: "Legacy boolean mirror of mode; kept only through adapters while FastVideoArgs remains."
|
||||
lora_nickname: "Legacy adapter-selection surface pending LoRA API cleanup."
|
||||
lora_target_modules: "Legacy LoRA configuration surface pending dedicated component API."
|
||||
output_type: "Legacy output formatting surface pending GenerationResult cleanup."
|
||||
VSA_sparsity: "Model-specific inference optimization not yet represented in the typed public schema."
|
||||
VSA_tile_size: "VSA-H3 tile geometry request; model-specific optimization not yet represented in the typed public schema."
|
||||
inference_torch_compile: "Regional inference compile opt-in currently carried through PipelineSelection.experimental rather than CompileConfig."
|
||||
vae_parallel_decode: "MiniMax-H3 sequence-parallel VAE decode opt-in; model-specific optimization not yet represented in the typed public schema."
|
||||
vae_parallel_encode: "MiniMax-H3 sequence-parallel reference VAE encode opt-in; model-specific optimization not yet represented in the typed public schema."
|
||||
vae_parallel_decode_strategy: "Chunk-transport collective for vae_parallel_decode; model-specific optimization not yet represented in the typed public schema."
|
||||
@@ -123,12 +125,14 @@ surfaces:
|
||||
vae_precision: "Precision override pending dedicated typed component precision design."
|
||||
vae_decode_precision: "Decode-only precision override pending dedicated typed component precision design."
|
||||
image_encoder_precision: "Precision override pending dedicated typed component precision design."
|
||||
image_encoder_precisions: "Precision overrides pending dedicated typed component precision design."
|
||||
text_encoder_precisions: "Precision override pending dedicated typed component precision design."
|
||||
internal_only:
|
||||
dit_config: "Legacy internal component config object."
|
||||
upsampler_config: "Legacy internal component config object."
|
||||
vae_config: "Legacy internal component config object."
|
||||
image_encoder_config: "Legacy internal component config object."
|
||||
image_encoder_configs: "Legacy internal component config objects."
|
||||
text_encoder_configs: "Legacy internal component config object."
|
||||
preprocess_text_funcs: "Internal text preprocessing hooks."
|
||||
postprocess_text_funcs: "Internal text postprocessing hooks."
|
||||
@@ -365,6 +369,30 @@ surfaces:
|
||||
sources: [fastvideo.configs.pipelines.matrixgame2.MatrixGame2I2V480PConfig]
|
||||
num_frames_per_block:
|
||||
sources: [fastvideo.configs.pipelines.matrixgame2.MatrixGame2I2V480PConfig]
|
||||
duration_s:
|
||||
sources: [fastvideo.configs.pipelines.mmaudio.MMAudioV2AConfig]
|
||||
spectrogram_frame_rate:
|
||||
sources: [fastvideo.configs.pipelines.mmaudio.MMAudioV2AConfig]
|
||||
latent_downsample_rate:
|
||||
sources: [fastvideo.configs.pipelines.mmaudio.MMAudioV2AConfig]
|
||||
clip_frame_rate:
|
||||
sources: [fastvideo.configs.pipelines.mmaudio.MMAudioV2AConfig]
|
||||
sync_frame_rate:
|
||||
sources: [fastvideo.configs.pipelines.mmaudio.MMAudioV2AConfig]
|
||||
sync_segment_size:
|
||||
sources: [fastvideo.configs.pipelines.mmaudio.MMAudioV2AConfig]
|
||||
sync_segment_stride:
|
||||
sources: [fastvideo.configs.pipelines.mmaudio.MMAudioV2AConfig]
|
||||
sync_downsample_rate:
|
||||
sources: [fastvideo.configs.pipelines.mmaudio.MMAudioV2AConfig]
|
||||
clip_image_size:
|
||||
sources: [fastvideo.configs.pipelines.mmaudio.MMAudioV2AConfig]
|
||||
sync_image_size:
|
||||
sources: [fastvideo.configs.pipelines.mmaudio.MMAudioV2AConfig]
|
||||
clip_batch_size_multiplier:
|
||||
sources: [fastvideo.configs.pipelines.mmaudio.MMAudioV2AConfig]
|
||||
sync_batch_size_multiplier:
|
||||
sources: [fastvideo.configs.pipelines.mmaudio.MMAudioV2AConfig]
|
||||
audio_channels:
|
||||
sources:
|
||||
- fastvideo.configs.pipelines.stable_audio.StableAudioT2AConfig
|
||||
@@ -379,6 +407,7 @@ surfaces:
|
||||
- fastvideo.configs.pipelines.stable_audio.StableAudioOpenSmallConfig
|
||||
max_audio_duration_s:
|
||||
sources:
|
||||
- fastvideo.configs.pipelines.mmaudio.MMAudioV2AConfig
|
||||
- fastvideo.configs.pipelines.stable_audio.StableAudioT2AConfig
|
||||
- fastvideo.configs.pipelines.stable_audio.StableAudioOpenSmallConfig
|
||||
sample_size:
|
||||
@@ -387,6 +416,7 @@ surfaces:
|
||||
- fastvideo.configs.pipelines.stable_audio.StableAudioOpenSmallConfig
|
||||
sampling_rate:
|
||||
sources:
|
||||
- fastvideo.configs.pipelines.mmaudio.MMAudioV2AConfig
|
||||
- fastvideo.configs.pipelines.stable_audio.StableAudioT2AConfig
|
||||
- fastvideo.configs.pipelines.stable_audio.StableAudioOpenSmallConfig
|
||||
audio_txt_guidance_scale:
|
||||
@@ -561,15 +591,31 @@ surfaces:
|
||||
openai_video_request:
|
||||
kept:
|
||||
model: "HTTP adapter model-routing field."
|
||||
user: "OpenAI-compatible caller tracking field."
|
||||
task: "SGLang-compatible MiniMax-H3 task selector validated against the startup pipeline."
|
||||
quality: "vLLM-Omni-compatible quality-intent field; model-specific."
|
||||
lora: "vLLM-Omni-compatible selector for the adapter fixed at server startup."
|
||||
moved:
|
||||
prompt: request.prompt
|
||||
input_reference: request.inputs.image_path
|
||||
reference_url: request.inputs.image_path
|
||||
image_reference: request.inputs.image_path,last_image,references
|
||||
video_reference: request.inputs.video_path,references
|
||||
audio_reference: request.inputs.references
|
||||
video_path: request.inputs.video_path
|
||||
video_url: request.inputs.video_path
|
||||
video_params: request.sampling.width,height,num_frames,fps
|
||||
size:
|
||||
target: request.sampling.width,height
|
||||
note: "Adapter parses OpenAI size strings as WIDTHxHEIGHT and forwards width then height."
|
||||
width: request.sampling.width
|
||||
height: request.sampling.height
|
||||
fps: request.sampling.fps
|
||||
num_frames: request.sampling.num_frames
|
||||
aspect_ratio: request.sampling.width,height
|
||||
short_edge: request.sampling.width,height
|
||||
num_outputs_per_prompt: request.sampling.num_videos_per_prompt
|
||||
n: request.sampling.num_videos_per_prompt
|
||||
seed: request.sampling.seed
|
||||
num_inference_steps: request.sampling.num_inference_steps
|
||||
guidance_scale: request.sampling.guidance_scale
|
||||
@@ -577,11 +623,21 @@ surfaces:
|
||||
true_cfg_scale: request.sampling.true_cfg_scale
|
||||
negative_prompt: request.negative_prompt
|
||||
enable_teacache: request.runtime.enable_teacache
|
||||
output_path: request.output.output_path
|
||||
max_sequence_length: request.sampling.max_sequence_length
|
||||
boundary_ratio: request.sampling.boundary_ratio
|
||||
extra_params: request.extensions
|
||||
compatibility_only:
|
||||
seconds:
|
||||
target: request.sampling.num_frames
|
||||
note: "HTTP adapter duration convenience field. If num_frames is omitted, the adapter computes num_frames = fps * seconds."
|
||||
start_time_seconds: "vLLM-Omni reference-video offset; rejected by pipelines that cannot represent it."
|
||||
flow_shift: "vLLM-Omni request field; accepted only when the selected model exposes a matching request parameter."
|
||||
generate_sound: "vLLM-Omni audio-output intent; accepted only by models with a matching request parameter."
|
||||
sound_duration: "vLLM-Omni audio-duration intent; accepted only by models with a matching request parameter."
|
||||
enable_frame_interpolation: "vLLM-Omni post-processing field; unavailable until FastVideo exposes a frame-interpolation stage."
|
||||
frame_interpolation_exp: "vLLM-Omni post-processing field; unavailable until FastVideo exposes a frame-interpolation stage."
|
||||
frame_interpolation_scale: "vLLM-Omni post-processing field; unavailable until FastVideo exposes a frame-interpolation stage."
|
||||
frame_interpolation_model_path: "vLLM-Omni post-processing field; unavailable until FastVideo exposes a frame-interpolation stage."
|
||||
|
||||
cli:
|
||||
notes:
|
||||
|
||||
@@ -1,116 +1,170 @@
|
||||
# OpenAI-compatible HTTP Contract
|
||||
# OpenAI-compatible HTTP contract
|
||||
|
||||
The stateless FastVideo HTTP server lives at
|
||||
[`fastvideo/entrypoints/openai/`](https://github.com/hao-ai-lab/FastVideo/tree/main/fastvideo/entrypoints/openai).
|
||||
Launch: `fastvideo serve --config serve.yaml`.
|
||||
FastVideo exposes one model-agnostic REST engine for image and video models.
|
||||
Launch it from a typed serve config:
|
||||
|
||||
```bash
|
||||
fastvideo serve --config examples/serving/openai_fasth3.yaml
|
||||
```
|
||||
|
||||
All generation routes share one serialized engine. FastVideo pipelines mutate
|
||||
per-request sampling state, and some adapters merge weights at load time, so a
|
||||
single loaded pipeline is never entered concurrently by image and video
|
||||
requests. HTTP handling and job polling remain asynchronous.
|
||||
|
||||
## Endpoints
|
||||
|
||||
| Method | Path | Description |
|
||||
| --- | --- | --- |
|
||||
| `POST` | `/v1/videos/generations` | Synchronous video generation |
|
||||
| `GET` | `/v1/videos` | List prior jobs held in the in-memory store |
|
||||
| `GET` | `/v1/videos/{id}` | Job status / result |
|
||||
| `GET` | `/v1/videos/{id}/content` | Download the MP4 once ready |
|
||||
| `POST` | `/v1/images/generations` | Synchronous image generation |
|
||||
| `GET` | `/v1/models` | Enumerate registered models |
|
||||
| `GET` | `/v1/models` | List the served model and optional startup adapter |
|
||||
| `GET` | `/v1/models/{model}` | Retrieve one served model card |
|
||||
| `POST` | `/v1/videos` | Submit an asynchronous video job |
|
||||
| `POST` | `/v1/videos/sync` | Generate and return an MP4 response directly |
|
||||
| `GET` | `/v1/videos` | List in-memory jobs with `after`, `limit`, and `order` |
|
||||
| `GET` | `/v1/videos/{id}` | Retrieve job status and metadata |
|
||||
| `GET` | `/v1/videos/{id}/content` | Download a completed MP4 |
|
||||
| `DELETE` | `/v1/videos/{id}` | Delete a job and its completed artifact |
|
||||
| `POST` | `/v1/images` | Generate an image |
|
||||
| `POST` | `/v1/images/edits` | Generate an image from image references |
|
||||
| `GET` | `/v1/images/{id}/content` | Download a generated image |
|
||||
| `GET` | `/health` | Liveness probe |
|
||||
|
||||
## `VideoGenerationsRequest` shape
|
||||
`POST /v1/videos/generations` remains an alias for older FastVideo clients.
|
||||
|
||||
Mirrors the OpenAI `POST /v1/videos/generations` shape:
|
||||
## Video requests
|
||||
|
||||
The canonical shape follows vLLM-Omni and accepts SGLang's common flat
|
||||
extensions. Fields that FastVideo cannot represent for the loaded model fail
|
||||
at admission with HTTP 400 instead of creating a job that later fails.
|
||||
|
||||
```json
|
||||
{
|
||||
"prompt": "a fox running through snow",
|
||||
"size": "1024x1536",
|
||||
"seconds": 5,
|
||||
"fps": 24,
|
||||
"num_frames": 121,
|
||||
"model": "fasth3",
|
||||
"prompt": "A fox runs through fresh snow.",
|
||||
"seconds": "5",
|
||||
"size": "1344x768",
|
||||
"video_params": {
|
||||
"fps": 24,
|
||||
"num_frames": 124
|
||||
},
|
||||
"seed": 42,
|
||||
"num_inference_steps": 8,
|
||||
"num_inference_steps": 5,
|
||||
"guidance_scale": 1.0,
|
||||
"negative_prompt": "blurry, low quality",
|
||||
"input_reference": "/path/to/init.png"
|
||||
}
|
||||
```
|
||||
|
||||
SGLang-compatible extensions carried today:
|
||||
`num_inference_steps`, `guidance_scale`, `guidance_scale_2`,
|
||||
`true_cfg_scale`, `negative_prompt`, `enable_teacache`, `output_path`.
|
||||
|
||||
## Merge precedence
|
||||
|
||||
The server builds a `GenerationRequest` each call using three layers,
|
||||
highest first:
|
||||
|
||||
1. **Request body (client-explicit)** — only fields carried in
|
||||
`request.model_fields_set` (Pydantic v2). Unset fields do not count,
|
||||
even if the Pydantic model has a schema default for them.
|
||||
2. **`ServeConfig.default_request` (operator-explicit)** — projected via
|
||||
[`explicit_request_updates()`](https://github.com/hao-ai-lab/FastVideo/blob/main/fastvideo/api/compat.py);
|
||||
only fields the operator actually wrote into the YAML count as
|
||||
defaults. Every other field inherits the schema default rather than
|
||||
being pinned.
|
||||
3. **Hardcoded fallback** — e.g. `fps = 24`.
|
||||
|
||||
The gate matters: both surfaces carry schema defaults. Without
|
||||
`model_fields_set` / explicit-path tracking, schema defaults would
|
||||
masquerade as intent and silently shadow the other side.
|
||||
|
||||
See [`video_api.py::_build_generation_kwargs`](https://github.com/hao-ai-lab/FastVideo/blob/main/fastvideo/entrypoints/openai/video_api.py)
|
||||
for the canonical implementation; the per-request assembly lives there,
|
||||
not in pipeline code.
|
||||
|
||||
## Continuation state
|
||||
|
||||
The stateless surface accepts an opaque `ContinuationState` round-trip.
|
||||
Clients that want continuation pass the prior `state` blob back on the
|
||||
next request, and receive a new one on the response when
|
||||
`request.output.return_state = true`.
|
||||
|
||||
Shape:
|
||||
|
||||
```json
|
||||
{
|
||||
"state": {
|
||||
"kind": "ltx2.v1",
|
||||
"payload": { "schema_version": 1, "segment_index": 3, ... }
|
||||
"image_reference": [
|
||||
{"image_url": "https://example.com/first-frame.png"}
|
||||
],
|
||||
"extra_params": {
|
||||
"vsa_mode": "exempt"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Payload is always JSON-serializable. Large tensors may live in an
|
||||
opaque blob-store reference the client simply round-trips; see
|
||||
[`LTX2ContinuationState`](https://github.com/hao-ai-lab/FastVideo/blob/main/fastvideo/pipelines/basic/ltx2/continuation.py).
|
||||
Resolution precedence matches vLLM-Omni:
|
||||
|
||||
Continuation is not yet wired all the way through to
|
||||
`generator.generate_video(...)` — PR 7.6 (GPU pool upstream) is the
|
||||
pipeline-level consumer. PR 7 locked the envelope so this surface is
|
||||
stable ahead of that plumbing.
|
||||
1. `size`
|
||||
2. top-level `width` and `height`
|
||||
3. `video_params.width` and `video_params.height`
|
||||
|
||||
## Error codes
|
||||
Top-level `fps` and `num_frames` similarly take precedence over the nested
|
||||
block. If `num_frames` is absent, `seconds * fps` is used. FastVideo also keeps
|
||||
the legacy `input_reference`, `reference_url`, `video_path`, and `video_url`
|
||||
spellings.
|
||||
|
||||
| HTTP | Condition |
|
||||
| --- | --- |
|
||||
| `400 Bad Request` | Parse/validation failure (unknown field, type mismatch, incompatible preset/state) |
|
||||
| `404 Not Found` | `GET /v1/videos/{id}` for an unknown job |
|
||||
| `409 Conflict` | Job id already exists |
|
||||
| `500 Internal Server Error` | Pipeline raised; body mirrors upstream OpenAI error envelope |
|
||||
| `503 Service Unavailable` | No generator loaded, or shutdown in progress |
|
||||
Reference objects support URL or local-path strings through `image_url`,
|
||||
`video_url`, and `audio_url`. `file_id` references are schema-compatible but
|
||||
return HTTP 400 because FastVideo does not provide an OpenAI Files store.
|
||||
Image URLs, data URLs, local paths, and multipart `input_reference` uploads are
|
||||
materialized and decoded under the configured output directory during
|
||||
admission. Invalid media returns HTTP 400 before a job is created.
|
||||
|
||||
Errors include a JSON body with
|
||||
`{"error": {"type": "...", "message": "..."}}` matching the OpenAI
|
||||
Python SDK's expectation.
|
||||
## Jobs and synchronous responses
|
||||
|
||||
## What does not cross this boundary
|
||||
An asynchronous submission returns a `video` object in `queued` state. Its
|
||||
status advances through `in_progress` to `completed` or `failed`. Completed
|
||||
jobs expose `file_name`, the FastVideo compatibility extension `file_path`,
|
||||
timings, and peak-memory metadata when the pipeline reports them.
|
||||
|
||||
* Flat legacy kwargs (`ltx2_refine_enabled`, `torch_compile_kwargs`,
|
||||
etc.) — these are init-time, configured via `ServeConfig.generator`,
|
||||
never per-request.
|
||||
* Private Dreamverse-only fields — those live in a private adapter on
|
||||
the Dreamverse side; the public FastVideo surface never promises
|
||||
backward compatibility for them.
|
||||
* Raw tensor payloads (`ltx2_audio_clean_latent` et al.) — these are
|
||||
derived by the pipeline from `ContinuationState`, never shipped as
|
||||
request fields.
|
||||
`POST /v1/videos/sync` returns `video/mp4` bytes. It includes
|
||||
`X-Request-Id`, `X-Model`, `X-Inference-Time-S`, `X-Stage-Durations`, and
|
||||
`X-Peak-Memory-MB` headers. Its temporary MP4 is removed after the response is
|
||||
streamed. Asynchronous artifacts remain available until their job is deleted.
|
||||
|
||||
Output paths are controlled by the server. Clients cannot choose filesystem
|
||||
destinations; every video is written beneath `server.output_dir` with a unique
|
||||
request id.
|
||||
|
||||
FastVideo's synchronous CUDA execution cannot be interrupted after launch.
|
||||
Deleting an in-progress resource removes it from the API immediately; the
|
||||
engine remains serialized until the call exits and then removes any artifact.
|
||||
|
||||
## Model and LoRA selection
|
||||
|
||||
`server.served_model_name` controls the public model id. If omitted, the
|
||||
checkpoint path is used. Requests that name another model fail with HTTP 400.
|
||||
|
||||
LoRAs are configured under
|
||||
`generator.pipeline.components.{lora_path,lora_nickname,lora_strength}`. The
|
||||
startup adapter is the only model advertised by a LoRA server, and requests can
|
||||
select it by its model nickname or with a selector:
|
||||
|
||||
```json
|
||||
{
|
||||
"prompt": "A fox runs through fresh snow.",
|
||||
"model": "fasth3-dense-datafree",
|
||||
"lora": {
|
||||
"name": "fasth3-dense-datafree",
|
||||
"path": "/models/adapter_model.safetensors",
|
||||
"scale": 1.0
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The selector must match the adapter already loaded at startup. FastH3 adapter
|
||||
files can contain dense replacement tensors and VSA gates in addition to
|
||||
low-rank factors, so swapping them inside concurrent requests would corrupt
|
||||
shared pipeline state. A mismatch is rejected with HTTP 400.
|
||||
|
||||
## MiniMax-H3 and FastH3
|
||||
|
||||
FastH3 uses the same general routes and adapter. `task` is accepted for
|
||||
SGLang-compatible H3 clients:
|
||||
|
||||
- `t2va` uses text only.
|
||||
- `fl2va` takes one or two image references.
|
||||
- `ref2va` takes ordered image, video, and audio references and requires a
|
||||
server started with `MiniMaxH3Ref2VAModularPipeline`.
|
||||
|
||||
The released FastH3 pipeline generates one packed video/audio result per
|
||||
request, uses 24 fps, requires guidance scale 1, and accepts frame counts on
|
||||
its causal-VAE grid. The serving examples pin its five-point distilled sigma
|
||||
schedule (four DiT forwards).
|
||||
|
||||
## Defaults and errors
|
||||
|
||||
Incoming explicit fields override operator-explicit `default_request` fields,
|
||||
which override model preset defaults. Pydantic defaults do not masquerade as
|
||||
client intent; the transport uses `model_fields_set`, while typed config parsing
|
||||
tracks the exact paths written by the operator.
|
||||
|
||||
Errors use the OpenAI envelope:
|
||||
|
||||
```json
|
||||
{
|
||||
"error": {
|
||||
"message": "...",
|
||||
"type": "invalid_request_error",
|
||||
"param": null,
|
||||
"code": 400
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Parse, model-selection, startup-LoRA, and unsupported-parameter failures are
|
||||
HTTP 400; missing resources are HTTP 404; generation failures are stored on
|
||||
asynchronous jobs and returned as HTTP 500 when that job is retrieved.
|
||||
Unknown top-level fields are rejected. `extra_params` accepts only the explicit
|
||||
request-batch passthrough fields supported by the typed request adapter.
|
||||
|
||||
`GET /health` also verifies that the generation engine is open and all local
|
||||
multiprocess workers are alive. It returns HTTP 503 when the worker pool is no
|
||||
longer usable.
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
# MPS (Apple Silicon)
|
||||
|
||||
Instructions to install FastVideo for Apple Silicon.
|
||||
Install FastVideo on Apple Silicon and run FastMetal-QAD or FastH3 Preview.
|
||||
|
||||
Apple Silicon uses the MLX runtime. FastMetal-QAD ships ready-to-run MLX
|
||||
checkpoints; FastH3 Preview currently requires a local MLX DiT conversion.
|
||||
See the [FastMetal-QAD blog](https://haoailab.com/blogs/fastmetal/) and the
|
||||
[FastMetal collection](https://huggingface.co/collections/FastVideo/fastmetal).
|
||||
|
||||
## Requirements
|
||||
|
||||
@@ -49,7 +54,7 @@ brew install ffmpeg
|
||||
|
||||
### Installation
|
||||
|
||||
FastWan's native Apple Silicon runtime requires the `mlx` extra.
|
||||
FastMetal's native Apple Silicon runtime requires the `mlx` extra.
|
||||
|
||||
#### With uv (recommended)
|
||||
|
||||
@@ -87,6 +92,98 @@ Alternative with Conda environment:
|
||||
uv pip install -e ".[mlx]"
|
||||
```
|
||||
|
||||
## Run FastMetal-QAD
|
||||
|
||||
Each release is self-contained. Download one checkpoint and point both
|
||||
`--model-root` and `--mlx-checkpoint` at it (the example also auto-detects
|
||||
`mlx_dit.json` under `--model-root`).
|
||||
|
||||
| Checkpoint | Script | Mac tier |
|
||||
| --- | --- | --- |
|
||||
| [`FastVideo/FastMetal-1.3B-QAD`](https://huggingface.co/FastVideo/FastMetal-1.3B-QAD) | `mlx_wan_prompt_to_video.py` | 16 GB+ |
|
||||
| [`FastVideo/FastMetal-5B-QAD`](https://huggingface.co/FastVideo/FastMetal-5B-QAD) | `mlx_wan22_generate.py` | 16 GB+ |
|
||||
| [`FastVideo/FastMetal-14B-QAD`](https://huggingface.co/FastVideo/FastMetal-14B-QAD) | `mlx_wan_prompt_to_video.py` | 36 GB+ |
|
||||
|
||||
```bash
|
||||
hf download FastVideo/FastMetal-1.3B-QAD --local-dir ./FastMetal-1.3B-QAD
|
||||
|
||||
python examples/inference/basic/mlx_wan_prompt_to_video.py \
|
||||
--model-root ./FastMetal-1.3B-QAD \
|
||||
--mlx-checkpoint ./FastMetal-1.3B-QAD \
|
||||
--height 480 --width 832 --num-frames 81 \
|
||||
--prompt "A bird's-eye view of a misty forest valley at dawn."
|
||||
```
|
||||
|
||||
14B uses the same script. Point both flags at `./FastMetal-14B-QAD`. That repo also ships an EMA variant: keep `--model-root` at the repo root and set `--mlx-checkpoint ./FastMetal-14B-QAD/ema`.
|
||||
|
||||
Wan2.2 5B uses a different latent layout, so it has its own entrypoint:
|
||||
|
||||
```bash
|
||||
hf download FastVideo/FastMetal-5B-QAD --local-dir ./FastMetal-5B-QAD
|
||||
|
||||
python examples/inference/basic/mlx_wan22_generate.py \
|
||||
--mlx-checkpoint ./FastMetal-5B-QAD \
|
||||
--text-encoder-root ./FastMetal-5B-QAD \
|
||||
--vae-root ./FastMetal-5B-QAD/vae \
|
||||
--height 704 --width 1280 --num-frames 81 \
|
||||
--prompt "A cinematic portrait with soft neon lighting and smooth camera motion."
|
||||
```
|
||||
|
||||
CUDA FastWan-QAD (`FastVideo/FastWan-QAD-1.3B`, `FastVideo/FastWan-QAD-FP8-1.3B`) is a separate NVIDIA release. The MLX examples look for FastMetal packed weights (`mlx_dit.json`).
|
||||
|
||||
`basic_mps.py` is a generic PyTorch MPS demo. For local video on Mac, use the FastMetal commands above.
|
||||
|
||||
## Run FastH3 Preview
|
||||
|
||||
FastH3 Preview uses the existing MLX runtime for text-to-video-with-audio
|
||||
(T2VA). The runtime streams the Qwen3-VL text conditioner, loads one
|
||||
heavyweight component at a time, denoises synchronized video and audio
|
||||
latents with a converted INT8, INT6, or INT4 DiT, and decodes both modalities
|
||||
with native MLX VAEs.
|
||||
|
||||
Download the FastH3 snapshot, then convert one or more DiT formats:
|
||||
|
||||
```bash
|
||||
hf download FastVideo/FastVideo-Minimax-FastH3-Preview-v0.2 \
|
||||
--local-dir ./FastH3-Preview-v0.2
|
||||
|
||||
python scripts/checkpoint_conversion/convert_minimax_h3_mlx.py \
|
||||
--model-root ./FastH3-Preview-v0.2/transformer \
|
||||
--out ./FastH3-MLX \
|
||||
--formats "int6"
|
||||
```
|
||||
|
||||
Run the baseline path:
|
||||
|
||||
```bash
|
||||
python examples/inference/basic/mlx_fasth3.py \
|
||||
--model-root ./FastH3-Preview-v0.2 \
|
||||
--mlx-checkpoint ./FastH3-MLX/int6 \
|
||||
--prompt "(S1) A presenter says <d>[English] Fast H3 is amazing.</d>" \
|
||||
--height 480 --width 832 --num-frames 124 --seed 2026 \
|
||||
--output-path ./outputs/fasth3_int6.mp4
|
||||
```
|
||||
|
||||
Add `--fast` for temporal fast mode. It denoises a shorter video sequence,
|
||||
uses MLX RIFE to restore the requested frame count, and keeps the audio
|
||||
sequence at full duration:
|
||||
|
||||
```bash
|
||||
python examples/inference/basic/mlx_fasth3.py \
|
||||
--model-root ./FastH3-Preview-v0.2 \
|
||||
--mlx-checkpoint ./FastH3-MLX/int6 \
|
||||
--prompt "(S1) A presenter says <d>[English] Fast H3 is even faster.</d>" \
|
||||
--height 720 --width 1280 --num-frames 124 --seed 2027 \
|
||||
--fast \
|
||||
--output-path ./outputs/fasth3_int6_fast_720p.mp4
|
||||
```
|
||||
|
||||
!!! note "Current MLX scope"
|
||||
This source runtime supports T2VA and temporal `--fast`. FL2VA, Ref2VA,
|
||||
spatial fast mode, two-pass refinement, VSA, and `VideoGenerator`
|
||||
registry dispatch are not wired yet. The checkpoint uses the MiniMax H3
|
||||
Community License; review the model card before use or redistribution.
|
||||
|
||||
## Development Environment Setup
|
||||
|
||||
If you're planning to contribute to FastVideo please see the following page:
|
||||
@@ -94,9 +191,12 @@ If you're planning to contribute to FastVideo please see the following page:
|
||||
|
||||
## Hardware Requirements
|
||||
|
||||
### For Basic Inference
|
||||
|
||||
- Mac M1, M2, M3, or M4 (at least 32 GB RAM is preferable for high quality video generation)
|
||||
- **1.3B / 5B:** 16 GB unified memory and up (M1 and later)
|
||||
- **14B:** 36 GB unified memory and up
|
||||
- **FastH3 Preview:** validated on an M4 Max with 36 GB unified memory; use one
|
||||
converted DiT format at a time and leave substantial free disk space for the
|
||||
source snapshot plus the converted checkpoint
|
||||
- Fanless 13-inch MacBook Air can run 1.3B and 5B at the same resolutions
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
|
||||
@@ -156,8 +156,11 @@ is power-cycled. To avoid it:
|
||||
|
||||
- **Builds** (flash-attn, kernel): `nice -n 19`, `MAX_JOBS=2`, `nohup`. Never a
|
||||
bare foreground high-parallelism build.
|
||||
- Leave `*_cpu_offload` at the example defaults — "CPU" offload is the *same*
|
||||
unified RAM on the GB10, so the win is tiling + sane resolution, not offloading.
|
||||
- FastVideo automatically disables DiT layerwise/CPU offload and encoder/VAE CPU
|
||||
offload after each worker binds its GB10 device. Do not force those modes back
|
||||
on: "CPU" offload uses the same unified RAM. Multi-GPU FSDP sharding remains
|
||||
available because it partitions weights without parking them in a separate
|
||||
host pool.
|
||||
|
||||
## Gotchas specific to the GB10
|
||||
|
||||
|
||||
@@ -14,6 +14,13 @@ vae_cpu_offload: bool = True
|
||||
pin_cpu_memory: bool = True
|
||||
```
|
||||
|
||||
On unified-memory accelerators such as NVIDIA GB10 and Apple silicon, FastVideo
|
||||
detects the selected device inside each worker and disables all five host-offload
|
||||
modes before loading modules. Host and accelerator allocations share one physical
|
||||
pool there, so offload adds transfers and duplicate residency instead of freeing
|
||||
memory. CUDA FSDP sharding remains enabled when requested; MPS continues to
|
||||
disable FSDP. `pin_cpu_memory` is not an offload mode and is left unchanged.
|
||||
|
||||
## Behavior Explanation
|
||||
|
||||
!!! note
|
||||
|
||||
@@ -88,6 +88,7 @@ runtime on some GPU/shape combinations. To use FA4, install the pinned
|
||||
`flash-attn-4` build (see the `flash-attn-4` source in `pyproject.toml`) and set:
|
||||
|
||||
```bash
|
||||
UV_TORCH_BACKEND=cu130 uv pip install -e ".[fasth3]"
|
||||
export FASTVIDEO_FA4=1
|
||||
```
|
||||
|
||||
@@ -97,6 +98,21 @@ sm90+) and GQA attention (FA4's `pack_gqa` fails to JIT-compile below sm90).
|
||||
On sm90+ both run on FA4. If FA4 is unusable while `FASTVIDEO_FA4=1` is set,
|
||||
FastVideo fails loudly instead of silently falling back.
|
||||
|
||||
MiniMax-H3 can additionally use FA4's packed-varlen entry point for its long,
|
||||
single-sequence dense DiT self-attention:
|
||||
|
||||
```bash
|
||||
export FASTVIDEO_FA4=1
|
||||
export FASTVIDEO_MINIMAX_H3_FA4_PACKED_VARLEN=1
|
||||
```
|
||||
|
||||
This route is inference-only and remains disabled by default. Runtime guards
|
||||
keep masked attention, batch sizes above one, unequal query/key lengths,
|
||||
grad-enabled calls, and NVFP4 on their established paths. It does not apply to
|
||||
the Preview checkpoint's sparse VSA blocks. Packed-varlen changes floating-point
|
||||
reduction order relative to fixed-length FA4, so treat it as a speed/quality
|
||||
evaluation option rather than an exact-parity mode.
|
||||
|
||||
### FP4 Flash Attention 4 (Blackwell only)
|
||||
|
||||
**`FLASH_ATTN`** with **`--nvfp4_fa4`**
|
||||
@@ -337,7 +353,42 @@ Only DiT submodules that declare `_compile_conditions` are compiled
|
||||
(most shipped models). The text encoder and VAE are not compiled by this
|
||||
flag.
|
||||
|
||||
### What to expect
|
||||
### Regional fullgraph compile (experimental)
|
||||
|
||||
`inference_torch_compile` is a stricter, kwargs-free variant that ports the
|
||||
training-side regional compile of
|
||||
[#1718](https://github.com/hao-ai-lab/FastVideo/pull/1718) to inference: the
|
||||
loader wraps each `_compile_conditions` block in
|
||||
`torch.compile(fullgraph=True)` with inductor
|
||||
`options={"emulate_precision_casts": True}` right after the transformer
|
||||
loads. The ordinary compile path keeps its historical compiler-disabled
|
||||
attention boundary by default; regional compile opts in only the compatible
|
||||
attention instances owned by this transformer. MiniMax-H3 VSA is supported
|
||||
only by the inference-only sm_100a tile-64 route
|
||||
(`FASTVIDEO_VSA_SM100A=1` and `VSA_tile_size=64`); the loader probes that
|
||||
route before capture and keeps the transformer eager when the kernel or
|
||||
device is unsupported. Legacy VSA, MiniMax-H3 tile-256 VSA, and the explicit
|
||||
`FASTVIDEO_DISABLE_ATTENTION_COMPILE=1` escape hatch keep the transformer
|
||||
eager with one warning instead of failing mid-denoise.
|
||||
|
||||
```python
|
||||
generator = VideoGenerator.from_pretrained(
|
||||
"MiniMaxAI/MiniMax-H3",
|
||||
inference_torch_compile=True, # or FASTVIDEO_INFERENCE_TORCH_COMPILE=1
|
||||
)
|
||||
```
|
||||
|
||||
Do not combine it with `torch_compile_kwargs['mode']` (the loader injects
|
||||
inductor options, and torch.compile forbids mode+options); it is
|
||||
independent of `enable_torch_compile`, and when both are set the regional
|
||||
compile wins for the DiT.
|
||||
|
||||
### What to expect from generic compile
|
||||
|
||||
The Wan result below measures the existing generic
|
||||
`enable_torch_compile=True` path. It is useful evidence that compile can help,
|
||||
but it is **not** a benchmark or numerical gate for the stricter regional
|
||||
fullgraph path above.
|
||||
|
||||
| Config | Effect |
|
||||
|---|---|
|
||||
@@ -353,6 +404,24 @@ generations with the same input shapes. Always exclude the first
|
||||
(warmup) generation when measuring steady-state latency — measuring the
|
||||
warmup is the most common way to wrongly conclude "compile is slower".
|
||||
|
||||
**Regional MiniMax-H3 accuracy caveat (job 2660).** On one GB200 at
|
||||
768×1344×124, the native 50-point schedule ran exactly 49 transformer
|
||||
forwards. After one warmup, three fixed-prompt/fixed-seed repeats averaged
|
||||
**185.08s → 157.01s end to end** and **174.90s → 147.12s denoising**. Each
|
||||
leg was independently pixel-deterministic, but compiled output did **not**
|
||||
match eager: mean absolute pixel error **20.247/255**, PSNR **16.67 dB**,
|
||||
mean SSIM **0.7108**, and mean MS-SSIM **0.6370** across 124 frames. Treat
|
||||
regional MiniMax-H3 compile as an opt-in performance experiment, not an
|
||||
eager-parity-safe mode.
|
||||
|
||||
The same caveat applies to sparse MiniMax-H3 regional compile. Its mask
|
||||
compaction, sm_100a launch, trained compression gates, and inference-only H3
|
||||
fusions are fullgraph-compatible, but compilation can still change model
|
||||
numerics. The FastH3 `all` profile enables regional compile by default because
|
||||
it is the fastest measured route at 124, 243, and 345 frames. Use
|
||||
`--no-inference-torch-compile` when comparing against the eager sparse-DiT
|
||||
route.
|
||||
|
||||
**Numerics.** Inductor's lowering is designed to preserve eager
|
||||
semantics within floating-point tolerance, but per-model equivalence is
|
||||
not asserted by any standing SSIM regression here — the SSIM tests in
|
||||
@@ -371,9 +440,14 @@ MS-SSIM gate on *your* config, especially when combining
|
||||
hao-ai-lab/FastVideo#1365 — keep that fix to get a clean compiled
|
||||
region under the default offload path.
|
||||
- **`mode="reduce-overhead"` / CUDA graphs**: not yet supported
|
||||
end-to-end. The attention dispatch is an untraceable custom op and
|
||||
still breaks the graph, which CUDA-graph trees cannot span. Use the
|
||||
default inductor mode (shown above) until that is resolved.
|
||||
by regional compile because that path injects inductor `options`, and
|
||||
PyTorch rejects `mode` together with `options`. The generic compile path
|
||||
can accept `mode`, but CUDA-graph compatibility remains backend- and
|
||||
shape-dependent. FA2/FA3 inference and FA4 expose traceable custom-op
|
||||
boundaries. MiniMax-H3's sm_100a tile-64 inference route is the only VSA
|
||||
path in the regional support envelope; other VSA paths and the FA3
|
||||
grad-enabled path remain outside it. Use the default inductor mode shown
|
||||
above unless your exact configuration has its own gate.
|
||||
|
||||
Extra `torch.compile` options are passed through `torch_compile_kwargs`
|
||||
(a dict), accepted by `VideoGenerator.from_pretrained(...)` and by the
|
||||
|
||||
@@ -182,12 +182,17 @@ optimizations: absence means **untested**, not incompatible.
|
||||
|
||||
| Release path | Model | Mode | Validated hardware | Status |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| MLX FastWan T2V | FastWan-QAD-INT8-1.3B `[release model ID pending]` | 480x832, 81 frames, 3-step DMD, INT8 DiT + TAEHV decode | Apple M4 Max, 36 GB unified-memory class, MLX 0.31.2 | Release candidate; requires release-owner visual sign-off |
|
||||
| MLX FastMetal T2V 1.3B | [`FastVideo/FastMetal-1.3B-QAD`](https://huggingface.co/FastVideo/FastMetal-1.3B-QAD) | 480x832, 81 frames, 3-step DMD, INT8 DiT + TAEHV decode | Apple M4 Max, 16 GB+ unified memory | Released |
|
||||
| MLX FastMetal TI2V 5B | [`FastVideo/FastMetal-5B-QAD`](https://huggingface.co/FastVideo/FastMetal-5B-QAD) | 480p / 720p, 81 frames, 3-step DMD, INT8 DiT + TAEHV decode | Apple M4 Max, 16 GB+ unified memory | Released |
|
||||
| MLX FastMetal T2V 14B | [`FastVideo/FastMetal-14B-QAD`](https://huggingface.co/FastVideo/FastMetal-14B-QAD) | 480p / 720p, 81 frames, 3-step DMD, INT8 DiT + TAEHV decode | Apple M4 Max, 36 GB+ unified memory | Released |
|
||||
| MLX FastH3 Preview T2VA | [`FastVideo/FastVideo-Minimax-FastH3-Preview-v0.2`](https://huggingface.co/FastVideo/FastVideo-Minimax-FastH3-Preview-v0.2) + locally converted DiT | 480p / 720p, 124 frames, 4-step DMD2, INT8/INT6/INT4 DiT, native video + audio VAE; optional temporal RIFE fast mode | Apple M4 Max, 36 GB unified memory | Source runtime; T2VA only |
|
||||
|
||||
This is a text-to-video-only source-install release. It is validated on the
|
||||
hardware listed above; MLX allocator caps are not evidence of support for a
|
||||
physical 16 GB Mac. See [Apple Silicon FastWan](../getting_started/installation/mps.md)
|
||||
for the supported command and release gates.
|
||||
Apple Silicon uses the native MLX runtime. FastMetal-QAD is the packaged Wan
|
||||
release, while FastH3 Preview currently uses a source checkout and local DiT
|
||||
conversion. CUDA FastWan-QAD (`FastVideo/FastWan-QAD-1.3B`,
|
||||
`FastVideo/FastWan-QAD-FP8-1.3B`) is the NVIDIA release. See the
|
||||
[Apple Silicon guide](../getting_started/installation/mps.md) and the
|
||||
[FastMetal-QAD blog](https://haoailab.com/blogs/fastmetal/).
|
||||
|
||||
**Note**: Wan2.2 TI2V 5B has some quality issues when performing I2V generation. We are working on fixing this issue.
|
||||
|
||||
@@ -216,9 +221,10 @@ Per the installation guides:
|
||||
[GPU install guide](../getting_started/installation/gpu.md).
|
||||
- **NVIDIA DGX Spark (GB10, aarch64)** — CUDA 13, from-source kernel build; see
|
||||
the [DGX Spark install guide](../getting_started/installation/spark.md).
|
||||
- **Apple silicon (MPS)** — macOS 14 or newer; see the
|
||||
[MPS install guide](../getting_started/installation/mps.md) and
|
||||
[`basic_mps.py`](https://github.com/hao-ai-lab/FastVideo/blob/main/examples/inference/basic/basic_mps.py).
|
||||
- **Apple silicon** — macOS 14 or newer; FastMetal-QAD via the MLX runtime. See the
|
||||
[Apple Silicon guide](../getting_started/installation/mps.md). The older
|
||||
[`basic_mps.py`](https://github.com/hao-ai-lab/FastVideo/blob/main/examples/inference/basic/basic_mps.py)
|
||||
demo is PyTorch MPS only.
|
||||
|
||||
Optimization-specific hardware constraints (e.g. STA requiring Hopper) are
|
||||
listed under [Special requirements](#special-requirements).
|
||||
|
||||
@@ -18,11 +18,44 @@ git clone https://github.com/hao-ai-lab/FastVideo.git && cd FastVideo
|
||||
python examples/inference/basic/basic.py
|
||||
```
|
||||
|
||||
For an example on Apple silicon:
|
||||
### Apple Silicon (FastMetal-QAD)
|
||||
|
||||
Use the MLX runtime with FastMetal-QAD. See the
|
||||
[Apple Silicon guide](https://hao-ai-lab.github.io/FastVideo/getting_started/installation/mps/).
|
||||
|
||||
```bash
|
||||
hf download FastVideo/FastMetal-1.3B-QAD --local-dir ./FastMetal-1.3B-QAD
|
||||
|
||||
python examples/inference/basic/mlx_wan_prompt_to_video.py \
|
||||
--model-root ./FastMetal-1.3B-QAD \
|
||||
--mlx-checkpoint ./FastMetal-1.3B-QAD \
|
||||
--prompt "A bird's-eye view of a misty forest valley at dawn."
|
||||
```
|
||||
python examples/inference/basic/basic_mps.py
|
||||
|
||||
5B uses
|
||||
[`mlx_wan22_generate.py`](https://github.com/hao-ai-lab/FastVideo/blob/main/examples/inference/basic/mlx_wan22_generate.py)
|
||||
with
|
||||
`FastVideo/FastMetal-5B-QAD`.
|
||||
|
||||
`examples/inference/basic/basic_mps.py` is the older PyTorch MPS demo.
|
||||
|
||||
FastH3 Preview T2VA also runs through the native MLX runtime. Convert the DiT
|
||||
to INT8, INT6, or INT4 first, then run:
|
||||
|
||||
```bash
|
||||
python examples/inference/basic/mlx_fasth3.py \
|
||||
--model-root ./FastH3-Preview-v0.2 \
|
||||
--mlx-checkpoint ./FastH3-MLX/int6 \
|
||||
--prompt "(S1) A presenter says <d>[English] Fast H3 is amazing.</d>" \
|
||||
--height 480 --width 832 --num-frames 124 \
|
||||
--output-path ./outputs/fasth3_int6.mp4
|
||||
```
|
||||
|
||||
Pass `--fast` for temporal RIFE fast mode. This MLX entrypoint currently
|
||||
supports T2VA only; FL2VA, Ref2VA, spatial fast mode, and two-pass refinement
|
||||
remain follow-up work. The complete setup and conversion commands are in the
|
||||
[Apple Silicon guide](https://hao-ai-lab.github.io/FastVideo/getting_started/installation/mps/).
|
||||
|
||||
For an example running DMD+VSA inference:
|
||||
```
|
||||
python examples/inference/basic/basic_dmd.py
|
||||
@@ -33,13 +66,95 @@ For the typed config/request path added during the inference API refactor:
|
||||
python examples/inference/basic/basic_dmd_new_api.py
|
||||
```
|
||||
|
||||
For the few-step (4-step, DMD2-distilled) MiniMax-H3 preview, generating synchronized video and audio, optionally with block-sparse VSA attention:
|
||||
```
|
||||
python examples/inference/basic/basic_fasth3.py --prompt "your prompt" [--vsa-sparsity 0.9]
|
||||
```
|
||||
The default checkpoint `FastVideo/FastVideo-Minimax-FastH3-Preview-v0.1` is private on the Hub while its license review completes; until it flips public, pass `--model-path` with a local snapshot of the release.
|
||||
### FastH3 Preview
|
||||
|
||||
On Blackwell (sm_100) GPUs with a `fastvideo-kernel` build that carries the sm_100a block-sparse extension, `--vsa-kernel sm100a` routes the tile-64 attention forwards through the CUDA kernel instead of Triton (it sets `FASTVIDEO_VSA_SM100A=1` before the pipeline boots); if the extension or the arch is missing, the run warns once and falls back to Triton.
|
||||
The verified [basic FastH3 example](https://github.com/hao-ai-lab/FastVideo/blob/main/examples/inference/basic/basic_fasth3.py)
|
||||
runs the few-step (4-forward, DMD2-distilled) MiniMax-H3 preview, generating
|
||||
synchronized video and audio with its trained block-sparse VSA attention:
|
||||
|
||||
```bash
|
||||
UV_TORCH_BACKEND=cu130 uv pip install -e ".[fasth3]"
|
||||
```
|
||||
|
||||
This installs the pinned FA4 CuTe package and FastVideo kernel release used by
|
||||
the measured GB200 profile. Then run:
|
||||
|
||||
```
|
||||
python examples/inference/basic/basic_fasth3.py --prompt "your prompt"
|
||||
```
|
||||
The default checkpoint, [FastH3 Preview v0.2](https://huggingface.co/FastVideo/FastVideo-Minimax-FastH3-Preview-v0.2), is public on the Hub under the MiniMax H3 Community License. Review its model card and license before use or redistribution.
|
||||
|
||||
The default `all` profile is the fastest measured four-GPU Preview recipe on GB200. It selects VSA sparsity 0.9 with 64-token tiles and the sm_100a sparse kernel, enables FA4 for eligible non-VSA paths, regionally compiles and replicates the sparse DiT, compiles and temporally parallelizes the video VAE with the `gather` strategy, and pins CPU-offloaded component memory. It also pins the benchmark protocol: five sigma-grid points (exactly four DiT forwards), one excluded seed-999 warmup, then three timed seed-1000 requests with distinct output paths.
|
||||
|
||||
The equivalent explicit command is:
|
||||
|
||||
```bash
|
||||
python examples/inference/basic/basic_fasth3.py \
|
||||
--prompt "your prompt" \
|
||||
--profile all \
|
||||
--num-gpus 4 \
|
||||
--steps 5 \
|
||||
--vsa-sparsity 0.9 \
|
||||
--vsa-tile-size 64 \
|
||||
--vsa-kernel sm100a \
|
||||
--compile-vae \
|
||||
--parallel-vae \
|
||||
--replicated-dit \
|
||||
--pin-cpu-memory \
|
||||
--fa4 \
|
||||
--no-torch-compile \
|
||||
--inference-torch-compile \
|
||||
--ulysses-a2a off \
|
||||
--warmup \
|
||||
--repeats 3 \
|
||||
--seed 1000 \
|
||||
--warmup-seed 999
|
||||
```
|
||||
|
||||
`all` enables the inference-only H3 fusions and regional compile. Both can change floating-point operation order, so this is a report-only performance profile rather than an exact-parity route. Use `--profile strict` to disable the H3 fusions while preserving regional compile, or `--profile strict --no-inference-torch-compile` for the eager strict route. Individual `--no-*` switches are available for portability and attribution; in particular, use `--vsa-kernel triton --no-fa4` if the Blackwell kernels are unavailable. The script preserves the warmup and each measured video under distinct paths, then prints per-request wall time plus a warmup-excluded median.
|
||||
|
||||
One script covers each validated duration; regional compile is the fastest
|
||||
measured DiT route for all three:
|
||||
|
||||
```bash
|
||||
# 5 s
|
||||
python examples/inference/basic/basic_fasth3.py \
|
||||
--prompt "your prompt" --output outputs/fasth3_5s
|
||||
# 10 s
|
||||
python examples/inference/basic/basic_fasth3.py \
|
||||
--prompt "your prompt" --num-frames 243 --output outputs/fasth3_10s
|
||||
# 15 s
|
||||
python examples/inference/basic/basic_fasth3.py \
|
||||
--prompt "your prompt" --num-frames 345 --output outputs/fasth3_15s
|
||||
```
|
||||
|
||||
Pass `--no-inference-torch-compile` to recover the eager sparse-DiT route.
|
||||
|
||||
### FastH3 Preview LoRAs
|
||||
|
||||
The LoRA release runs on top of `MiniMaxAI/MiniMax-H3` with the same default
|
||||
compile, fusion, FA4, VSA, and parallel-VAE profile as the full FastH3 example:
|
||||
|
||||
```bash
|
||||
bash examples/inference/basic/run_fasth3_lora_preview_vsa_datafree.sh \
|
||||
--prompt "your prompt"
|
||||
```
|
||||
|
||||
The four release launchers are:
|
||||
|
||||
- `run_fasth3_lora_preview_vsa_datafree.sh`
|
||||
- `run_fasth3_lora_preview_vsa_synthetic_step1300.sh`
|
||||
- `run_fasth3_lora_preview_vsa_synthetic_step1900.sh`
|
||||
- `run_fasth3_lora_preview_dense_datafree.sh`
|
||||
|
||||
Each downloads its exact private adapter file from
|
||||
`FastVideo/FastVideo-FastH3-4-step-Preview-v1-LoRA`; authenticate with `hf auth
|
||||
login` first. Pass `--lora-strength 0.5` to interpolate every adapter payload at
|
||||
half strength. Strength `1` applies the published rank-64 adapter at its trained
|
||||
scale and approximates the full student; `0` removes its weight deltas. VSA
|
||||
launchers still use sparse attention at strength `0` and require FastVideo's
|
||||
tile-64 VSA kernel; the dense launcher selects FA4. Each launcher writes to its
|
||||
own variant directory by default so comparison outputs do not collide.
|
||||
|
||||
## Basic Walkthrough
|
||||
|
||||
|
||||
@@ -1,27 +1,32 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""Few-step video+audio generation with the DMD2-distilled MiniMax H3 preview.
|
||||
|
||||
FastVideo/FastVideo-Minimax-FastH3-Preview-v0.1 is a 4-step distillation of
|
||||
MiniMaxAI/MiniMax-H3 (data-free DMD2): it walks a 4-step grid on the release
|
||||
sampler's shift-12 schedule instead of the base model's 50 steps, generating
|
||||
synchronized video and audio in one pipeline call.
|
||||
The default ``all`` profile reproduces the fastest measured FastH3 Preview
|
||||
recipe on four GB200 GPUs. It runs the checkpoint's native five-point sigma
|
||||
grid (exactly four DiT forwards), trained VSA policy, Blackwell sparse kernel,
|
||||
regional fullgraph DiT compile, compiled/parallel video VAE, and inference-only
|
||||
H3 fusions. One compile warmup is excluded before three measured requests.
|
||||
|
||||
The student was trained with block-sparse video attention (VSA, 64-token
|
||||
tiles) and its checkpoint carries the trained sparse-gate parameters
|
||||
(``attn.to_gate_compress``), so this script always runs the VSA-H3 attention
|
||||
backend. At the default ``--vsa-sparsity 0.0`` the attention math is exactly
|
||||
dense (every tile is selected); raise the sparsity for additional speedup.
|
||||
Both regional compile and the default fusions can change floating-point
|
||||
operation order, so ``all`` is a report-only performance profile.
|
||||
``--profile strict`` disables the H3 fusions but preserves regional compile;
|
||||
combine it with ``--no-inference-torch-compile`` for the eager strict route.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import importlib.util
|
||||
import os
|
||||
import statistics
|
||||
import time
|
||||
from collections.abc import Sequence
|
||||
from pathlib import Path
|
||||
|
||||
from fastvideo import VideoGenerator
|
||||
from fastvideo.api import (
|
||||
CompileConfig,
|
||||
ComponentConfig,
|
||||
EngineConfig,
|
||||
GenerationRequest,
|
||||
GeneratorConfig,
|
||||
@@ -32,149 +37,331 @@ from fastvideo.api import (
|
||||
SamplingConfig,
|
||||
)
|
||||
|
||||
DEFAULT_MODEL = "FastVideo/FastVideo-Minimax-FastH3-Preview-v0.2"
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--model-path", default="FastVideo/FastVideo-Minimax-FastH3-Preview-v0.1")
|
||||
# The HF repo is private while the MiniMax H3 Community License review
|
||||
# completes; until it flips public, pass --model-path with a local
|
||||
# snapshot of the release instead (e.g. the team export at
|
||||
# /mnt/lustre/vlm-wlsaidhi/fastvideo/exports/FastVideo-Minimax-FastH3-Preview-v0.1).
|
||||
|
||||
def build_parser(description: str | None = None) -> argparse.ArgumentParser:
|
||||
"""Build the shared FastH3 preview CLI used by full and LoRA checkpoints."""
|
||||
parser = argparse.ArgumentParser(description=description or __doc__)
|
||||
parser.add_argument("--model-path", default=DEFAULT_MODEL)
|
||||
# The HF repo may require authentication while the MiniMax H3 Community
|
||||
# License review completes. A local snapshot can be passed here instead.
|
||||
parser.add_argument("--prompt", required=True)
|
||||
parser.add_argument("--output", default="outputs/fasth3")
|
||||
parser.add_argument("--profile",
|
||||
choices=("all", "strict"),
|
||||
default="all",
|
||||
help="all enables the fastest measured, non-parity H3 fusions; strict disables only them")
|
||||
parser.add_argument("--height", type=int, default=768)
|
||||
parser.add_argument("--width", type=int, default=1344)
|
||||
parser.add_argument("--num-frames", type=int, default=124)
|
||||
# num_inference_steps counts sigma-GRID POINTS, matching the base model's
|
||||
# convention ("50 steps" = a 50-point grid = 49 transformer forwards). The
|
||||
# student's distilled 4-step grid is 4 FORWARDS, i.e. a 5-point grid
|
||||
# (t = 1000, 750, 500, 250 -> 0 on the shift-12 schedule) — so the correct
|
||||
# default here is 5. Other grids are off-distribution.
|
||||
# num_inference_steps counts sigma-GRID POINTS. The distilled schedule is
|
||||
# t=1000,750,500,250 -> 0: five points and exactly four DiT forwards.
|
||||
parser.add_argument("--steps",
|
||||
type=int,
|
||||
default=5,
|
||||
help="num_inference_steps = sigma-grid points; N points run N-1 denoising "
|
||||
"forwards. 5 (default) is the distilled 4-forward grid")
|
||||
parser.add_argument("--seed", type=int, default=0)
|
||||
help="sigma-grid points; N points run N-1 DiT forwards (the trained default is 5)")
|
||||
parser.add_argument("--seed", type=int, default=1000, help="seed reused for every measured request")
|
||||
parser.add_argument("--warmup-seed", type=int, default=999)
|
||||
parser.add_argument("--repeats", type=int, default=3, help="number of measured requests after warmup")
|
||||
parser.add_argument("--warmup",
|
||||
action=argparse.BooleanOptionalAction,
|
||||
default=True,
|
||||
help="run one excluded request before timing")
|
||||
parser.add_argument("--num-gpus", type=int, default=4)
|
||||
parser.add_argument("--vsa-sparsity",
|
||||
type=float,
|
||||
default=0.0,
|
||||
help="Run-level VSA sparsity in [0, 1). 0.0 (default) selects every tile, which is "
|
||||
"exactly dense attention; the student was trained at 0.9")
|
||||
# 64 is the trained contract: the student was TRAINED with 64-token
|
||||
# (4,4,4) tiles, and its to_gate_compress gates were learned against
|
||||
# pooling at that granularity — keep 64 unless you are ablating.
|
||||
default=0.9,
|
||||
help="run-level VSA sparsity in [0, 1); 0.9 is the checkpoint's trained policy")
|
||||
parser.add_argument("--vsa-tile-size",
|
||||
type=int,
|
||||
choices=(64, 256),
|
||||
default=64,
|
||||
help="VSA-H3 tile size in tokens; 64 (default) is what the student was trained "
|
||||
"with and runs the native Triton block-sparse path, 256 is the FA4-CuTe-capable "
|
||||
"geometry for ablations")
|
||||
help="VSA-H3 tile size; 64 is the checkpoint's trained and measured geometry")
|
||||
parser.add_argument("--vsa-kernel",
|
||||
choices=("triton", "sm100a"),
|
||||
default="triton",
|
||||
help="Block-sparse kernel for the tile-64 attention forward: triton (default, "
|
||||
"portable fwd+bwd) or sm100a — the opt-in Blackwell CUDA forward "
|
||||
"(fastvideo_kernel.block_sparse_attn_sm100a). sm100a needs an sm_100 GPU and a "
|
||||
"fastvideo-kernel build that carries the extension; if a precondition fails at "
|
||||
"run time the attention layer logs one warning and falls back to Triton. Only "
|
||||
"meaningful with --vsa-tile-size 64")
|
||||
parser.add_argument("--torch-compile", action="store_true", help="torch.compile the DiT transformer path")
|
||||
default="sm100a",
|
||||
help="tile-64 sparse kernel; sm100a is the measured GB200 route and requires a compatible "
|
||||
"fastvideo-kernel build")
|
||||
parser.add_argument("--fa4",
|
||||
action=argparse.BooleanOptionalAction,
|
||||
default=True,
|
||||
help="use FA4 for eligible non-VSA attention paths")
|
||||
parser.add_argument("--h3-fusions",
|
||||
action=argparse.BooleanOptionalAction,
|
||||
default=None,
|
||||
help="override the profile's H3 fusion policy (changes model numerics when enabled)")
|
||||
parser.add_argument("--compile-vae",
|
||||
action=argparse.BooleanOptionalAction,
|
||||
default=True,
|
||||
help="compile the video VAE decoder independently of the DiT")
|
||||
parser.add_argument("--parallel-vae",
|
||||
action=argparse.BooleanOptionalAction,
|
||||
default=True,
|
||||
help="round-robin VAE temporal chunks across sequence-parallel ranks")
|
||||
parser.add_argument("--replicated-dit",
|
||||
action=argparse.BooleanOptionalAction,
|
||||
default=True,
|
||||
help="replicate DiT weights instead of FSDP-sharding them")
|
||||
parser.add_argument("--pin-cpu-memory",
|
||||
action=argparse.BooleanOptionalAction,
|
||||
default=True,
|
||||
help="pin CPU-offloaded text-encoder and VAE weights")
|
||||
parser.add_argument("--torch-compile",
|
||||
action=argparse.BooleanOptionalAction,
|
||||
default=False,
|
||||
help="compile the whole DiT path (off in the fastest FastH3 profile)")
|
||||
parser.add_argument("--inference-torch-compile",
|
||||
action=argparse.BooleanOptionalAction,
|
||||
default=True,
|
||||
help="regionally compile DiT blocks (enabled in the fastest FastH3 profile)")
|
||||
parser.add_argument("--ulysses-a2a",
|
||||
choices=("off", "auto"),
|
||||
default="off",
|
||||
help="sequence-parallel all-to-all route; off reproduces the fastest FastH3 profile, while "
|
||||
"auto opts into the fused NVLink kernel when the installed kernel package supports it")
|
||||
parser.add_argument("--compile-mode",
|
||||
default=None,
|
||||
help='torch.compile mode, e.g. "reduce-overhead" for CUDA graphs')
|
||||
parser.add_argument("--repeats",
|
||||
type=int,
|
||||
default=1,
|
||||
help="generate N times; with --torch-compile the first run pays "
|
||||
"compilation, so steady-state is the last repeat")
|
||||
return parser.parse_args()
|
||||
help='whole-DiT torch.compile mode, e.g. "reduce-overhead"; requires '
|
||||
"--no-inference-torch-compile")
|
||||
return parser
|
||||
|
||||
|
||||
def main() -> None:
|
||||
args = parse_args()
|
||||
def validate_args(parser: argparse.ArgumentParser, args: argparse.Namespace) -> argparse.Namespace:
|
||||
if args.repeats < 1:
|
||||
parser.error("--repeats must be at least 1")
|
||||
if args.num_gpus < 1:
|
||||
parser.error("--num-gpus must be at least 1")
|
||||
if not 0.0 <= args.vsa_sparsity < 1.0:
|
||||
parser.error("--vsa-sparsity must be in [0, 1)")
|
||||
if args.compile_mode is not None and args.inference_torch_compile:
|
||||
parser.error("--compile-mode cannot be combined with regional compile; pass --no-inference-torch-compile")
|
||||
return args
|
||||
|
||||
|
||||
def parse_args(argv: Sequence[str] | None = None) -> argparse.Namespace:
|
||||
parser = build_parser()
|
||||
return validate_args(parser, parser.parse_args(argv))
|
||||
|
||||
|
||||
def _uses_vsa(args: argparse.Namespace) -> bool:
|
||||
"""Full FastH3 checkpoints use VSA; LoRA previews may select dense attention."""
|
||||
return bool(getattr(args, "vsa", True))
|
||||
|
||||
|
||||
def _h3_fusions_enabled(args: argparse.Namespace) -> bool:
|
||||
if args.h3_fusions is not None:
|
||||
return bool(args.h3_fusions)
|
||||
return args.profile == "all"
|
||||
|
||||
|
||||
def profile_environment(args: argparse.Namespace) -> dict[str, str | None]:
|
||||
"""Return the complete boot-time environment for this profile.
|
||||
|
||||
``None`` means the variable must be removed. Values are explicit even for
|
||||
disabled features so a shell's inherited experiment settings cannot
|
||||
silently change the advertised profile.
|
||||
"""
|
||||
use_vsa = _uses_vsa(args)
|
||||
return {
|
||||
"FASTVIDEO_ATTENTION_BACKEND": "VIDEO_SPARSE_ATTN_H3" if use_vsa else "FLASH_ATTN",
|
||||
"FASTVIDEO_VSA_SM100A": "1" if use_vsa and args.vsa_kernel == "sm100a" else "0",
|
||||
"FASTVIDEO_VSA_CUTEDSL": "0",
|
||||
# A non-empty output path enables the diagnostic probe.
|
||||
"FASTVIDEO_H3_VSA_PROBE": None,
|
||||
"FASTVIDEO_DISABLE_ATTENTION_COMPILE": "0",
|
||||
"FASTVIDEO_FA4": "1" if args.fa4 else "0",
|
||||
"FASTVIDEO_NVFP4_FA4": "0",
|
||||
"FASTVIDEO_MINIMAX_H3_FA4_PACKED_VARLEN": "0",
|
||||
"FASTVIDEO_MINIMAX_H3_FUSIONS": "all" if _h3_fusions_enabled(args) else "0",
|
||||
"FASTVIDEO_INFERENCE_TORCH_COMPILE": "1" if args.inference_torch_compile else "0",
|
||||
"FASTVIDEO_VAE_PARALLEL_DECODE": "1" if args.parallel_vae else "0",
|
||||
"FASTVIDEO_VAE_PARALLEL_ENCODE": "0",
|
||||
"FASTVIDEO_VAE_PARALLEL_DECODE_STRATEGY": "gather",
|
||||
"FASTVIDEO_ULYSSES_A2A": args.ulysses_a2a,
|
||||
"FASTVIDEO_STAGE_LOGGING": "1",
|
||||
}
|
||||
|
||||
|
||||
def configure_environment(args: argparse.Namespace) -> dict[str, str | None]:
|
||||
environment = profile_environment(args)
|
||||
for name, value in environment.items():
|
||||
if value is None:
|
||||
os.environ.pop(name, None)
|
||||
else:
|
||||
os.environ[name] = value
|
||||
return environment
|
||||
|
||||
|
||||
def _fa4_is_installed() -> bool:
|
||||
try:
|
||||
return importlib.util.find_spec("flash_attn.cute") is not None
|
||||
except (ImportError, ModuleNotFoundError):
|
||||
return False
|
||||
|
||||
|
||||
def _sm100a_kernel_is_installed() -> bool:
|
||||
try:
|
||||
from fastvideo_kernel import block_sparse_attn_sm100a
|
||||
except ImportError:
|
||||
return False
|
||||
return bool(getattr(block_sparse_attn_sm100a, "_HAS_VSA_SM100A", False))
|
||||
|
||||
|
||||
def validate_profile_dependencies(args: argparse.Namespace) -> None:
|
||||
"""Fail before model loading when the selected measured route is absent."""
|
||||
if args.fa4 and not _fa4_is_installed():
|
||||
raise RuntimeError(
|
||||
"FastH3's FA4 profile requires the pinned flash-attn-4 package. Install it with "
|
||||
"`UV_TORCH_BACKEND=cu130 uv pip install -e \".[fasth3]\"`, or pass --no-fa4.")
|
||||
if _uses_vsa(args) and args.vsa_kernel == "sm100a" and not _sm100a_kernel_is_installed():
|
||||
raise RuntimeError(
|
||||
"FastH3's sm100a profile requires fastvideo-kernel 0.3.4 built with the Blackwell VSA extension. "
|
||||
"Install this checkout with `UV_TORCH_BACKEND=cu130 uv pip install -e \".[fasth3]\"` (or run "
|
||||
"`cd fastvideo-kernel && ./build.sh`), or pass --vsa-kernel triton.")
|
||||
|
||||
|
||||
def build_generator_config(args: argparse.Namespace) -> GeneratorConfig:
|
||||
use_vsa = _uses_vsa(args)
|
||||
experimental: dict[str, object] = {
|
||||
"attention_backend": "VIDEO_SPARSE_ATTN_H3" if use_vsa else "FLASH_ATTN",
|
||||
"inference_torch_compile": args.inference_torch_compile,
|
||||
"vae_parallel_decode": args.parallel_vae,
|
||||
"vae_parallel_decode_strategy": "gather",
|
||||
}
|
||||
if use_vsa:
|
||||
experimental.update({
|
||||
"VSA_sparsity": args.vsa_sparsity,
|
||||
"VSA_tile_size": args.vsa_tile_size,
|
||||
})
|
||||
return GeneratorConfig(
|
||||
model_path=args.model_path,
|
||||
pipeline=PipelineSelection(
|
||||
components=ComponentConfig(
|
||||
lora_path=getattr(args, "lora_path", None),
|
||||
lora_strength=float(getattr(args, "lora_strength", 1.0)),
|
||||
),
|
||||
experimental=experimental,
|
||||
),
|
||||
engine=EngineConfig(
|
||||
num_gpus=args.num_gpus,
|
||||
use_fsdp_inference=args.num_gpus > 1 and not args.replicated_dit,
|
||||
parallelism=ParallelismConfig(tp_size=1, sp_size=args.num_gpus),
|
||||
offload=OffloadConfig(
|
||||
dit=False,
|
||||
dit_layerwise=False,
|
||||
text_encoder=True,
|
||||
vae=True,
|
||||
pin_cpu_memory=args.pin_cpu_memory,
|
||||
),
|
||||
compile=CompileConfig(
|
||||
enabled=args.torch_compile,
|
||||
mode=args.compile_mode,
|
||||
vae_enabled=args.compile_vae,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def build_request(args: argparse.Namespace, output_path: Path, seed: int) -> GenerationRequest:
|
||||
return GenerationRequest(
|
||||
prompt=args.prompt,
|
||||
negative_prompt="",
|
||||
sampling=SamplingConfig(
|
||||
height=args.height,
|
||||
width=args.width,
|
||||
num_frames=args.num_frames,
|
||||
fps=24,
|
||||
num_inference_steps=args.steps,
|
||||
# MiniMax-H3 is guidance-distilled; FastH3 inherits that contract.
|
||||
guidance_scale=1.0,
|
||||
batch_cfg=False,
|
||||
seed=seed,
|
||||
),
|
||||
output=OutputConfig(
|
||||
output_path=str(output_path),
|
||||
save_video=True,
|
||||
return_frames=False,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _actual_output_path(result: object, requested: Path) -> Path:
|
||||
video_path = getattr(result, "video_path", None)
|
||||
return Path(video_path) if video_path else requested
|
||||
|
||||
|
||||
def _denoise_seconds(result: object) -> float | None:
|
||||
stages = getattr(getattr(result, "logging_info", None), "stages", None)
|
||||
if not stages:
|
||||
return None
|
||||
for stage_name, metrics in stages.items():
|
||||
if "denois" not in stage_name.lower():
|
||||
continue
|
||||
execution_time = metrics.get("execution_time")
|
||||
return float(execution_time) if execution_time is not None else None
|
||||
return None
|
||||
|
||||
|
||||
def run(args: argparse.Namespace) -> list[float]:
|
||||
output_dir = Path(args.output)
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
environment = configure_environment(args)
|
||||
validate_profile_dependencies(args)
|
||||
|
||||
if args.vsa_kernel == "sm100a":
|
||||
# The attention backend reads FASTVIDEO_VSA_SM100A per forward; set it
|
||||
# before the pipeline boots so spawned GPU workers inherit it. The
|
||||
# kernel is forward-only and inference runs under no-grad, so every
|
||||
# denoising forward qualifies for the CUDA route.
|
||||
os.environ["FASTVIDEO_VSA_SM100A"] = "1"
|
||||
print(f"Profile: {args.profile} ({'non-parity fusions' if _h3_fusions_enabled(args) else 'fusions off'})")
|
||||
print(f"Output directory: {output_dir.resolve()}")
|
||||
print("Denoising contract: 5 sigma points = 4 DiT forwards" if args.steps == 5 else
|
||||
f"Denoising contract override: {args.steps} sigma points = {args.steps - 1} DiT forwards")
|
||||
print("Profile environment: " + " ".join(f"{key}={value if value is not None else '<unset>'}"
|
||||
for key, value in environment.items()))
|
||||
|
||||
# Boot-time run configuration, folded into FastVideoArgs (the same route
|
||||
# examples/inference/basic/basic_minimax_h3_t2v.py uses for sparsity):
|
||||
# - attention_backend: the checkpoint carries trained to_gate_compress
|
||||
# gates, which only exist under the VSA-H3 backend — a dense-backend
|
||||
# load would reject them as unexpected weights. Layers that do not
|
||||
# support VSA-H3 (e.g. the token refiner) fall back to flash attention.
|
||||
# - VSA_tile_size: forwarded even at sparsity 0.0 because the gate-compress
|
||||
# branch pools per tile, and the gates were trained at 64 tokens/tile.
|
||||
experimental: dict[str, object] = {
|
||||
"attention_backend": "VIDEO_SPARSE_ATTN_H3",
|
||||
"VSA_tile_size": args.vsa_tile_size,
|
||||
}
|
||||
if args.vsa_sparsity > 0.0:
|
||||
experimental["VSA_sparsity"] = args.vsa_sparsity
|
||||
|
||||
generator = VideoGenerator.from_config(
|
||||
GeneratorConfig(
|
||||
model_path=args.model_path,
|
||||
pipeline=PipelineSelection(experimental=experimental),
|
||||
engine=EngineConfig(
|
||||
num_gpus=args.num_gpus,
|
||||
use_fsdp_inference=args.num_gpus > 1,
|
||||
parallelism=ParallelismConfig(tp_size=1, sp_size=args.num_gpus),
|
||||
offload=OffloadConfig(
|
||||
dit=False,
|
||||
dit_layerwise=False,
|
||||
text_encoder=True,
|
||||
vae=True,
|
||||
pin_cpu_memory=False,
|
||||
),
|
||||
compile=CompileConfig(
|
||||
enabled=args.torch_compile,
|
||||
mode=args.compile_mode,
|
||||
),
|
||||
),
|
||||
))
|
||||
generator = VideoGenerator.from_config(build_generator_config(args))
|
||||
measured_wall_times: list[float] = []
|
||||
measured_denoise_times: list[float] = []
|
||||
try:
|
||||
request = GenerationRequest(
|
||||
prompt=args.prompt,
|
||||
negative_prompt="",
|
||||
sampling=SamplingConfig(
|
||||
height=args.height,
|
||||
width=args.width,
|
||||
num_frames=args.num_frames,
|
||||
fps=24,
|
||||
num_inference_steps=args.steps,
|
||||
# the base model is guidance-distilled; the student inherits it
|
||||
guidance_scale=1.0,
|
||||
batch_cfg=False,
|
||||
seed=args.seed,
|
||||
),
|
||||
output=OutputConfig(
|
||||
output_path=str(output_dir / "fasth3.mp4"),
|
||||
save_video=True,
|
||||
return_frames=False,
|
||||
),
|
||||
)
|
||||
result = generator.generate(request)
|
||||
print(f"Output written to: {result.video_path}")
|
||||
if result.generation_time is not None:
|
||||
# machine-readable: benchmark harnesses parse this line to separate
|
||||
# generation from model-load time (last occurrence = steady state)
|
||||
print(f"Generation time: {result.generation_time:.2f}s")
|
||||
for _ in range(args.repeats - 1):
|
||||
result = generator.generate(request)
|
||||
if result.generation_time is not None:
|
||||
print(f"Generation time: {result.generation_time:.2f}s")
|
||||
if args.warmup:
|
||||
warmup_path = output_dir / "_fasth3_warmup.mp4"
|
||||
print(f"[warmup] generating (excluded from timing summary): {warmup_path}")
|
||||
started = time.perf_counter()
|
||||
warmup_result = generator.generate(build_request(args, warmup_path, args.warmup_seed))
|
||||
warmup_wall = time.perf_counter() - started
|
||||
actual_warmup_path = _actual_output_path(warmup_result, warmup_path)
|
||||
print(f"[warmup] wall={warmup_wall:.3f}s (excluded)")
|
||||
print(f"Warmup output written to: {actual_warmup_path}")
|
||||
|
||||
for index in range(1, args.repeats + 1):
|
||||
requested_path = output_dir / f"fasth3_{args.profile}_run_{index:02d}.mp4"
|
||||
print(f"[measured {index}/{args.repeats}] generating: {requested_path}")
|
||||
started = time.perf_counter()
|
||||
result = generator.generate(build_request(args, requested_path, args.seed))
|
||||
wall = time.perf_counter() - started
|
||||
measured_wall_times.append(wall)
|
||||
actual_path = _actual_output_path(result, requested_path)
|
||||
print(f"Output written to: {actual_path}")
|
||||
print(f"E2E wall time: {wall:.3f}s")
|
||||
generation_time = getattr(result, "generation_time", None)
|
||||
if generation_time is not None:
|
||||
print(f"Generation time: {float(generation_time):.3f}s")
|
||||
denoise_time = _denoise_seconds(result)
|
||||
if denoise_time is not None:
|
||||
measured_denoise_times.append(denoise_time)
|
||||
print(f"Denoising time: {denoise_time:.3f}s")
|
||||
|
||||
median = statistics.median(measured_wall_times)
|
||||
print(f"Measured E2E wall times (n={len(measured_wall_times)}, warmup excluded): "
|
||||
f"{[round(value, 3) for value in measured_wall_times]}")
|
||||
print(f"Median E2E wall time: {median:.3f}s")
|
||||
if measured_denoise_times:
|
||||
print(f"Median denoising time: {statistics.median(measured_denoise_times):.3f}s")
|
||||
return measured_wall_times
|
||||
finally:
|
||||
generator.shutdown()
|
||||
|
||||
|
||||
def main() -> None:
|
||||
run(parse_args())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""Run a FastH3 four-step Preview LoRA with the measured FastVideo defaults.
|
||||
|
||||
This is the LoRA counterpart of ``basic_fasth3.py``. Both routes share the
|
||||
same performance profile: four DiT forwards, regional fullgraph DiT compile,
|
||||
H3 fusions, compiled and sequence-parallel video VAE decode, replicated DiT,
|
||||
pinned CPU offload, FA4, and the sm100a tile-64 kernel for VSA adapters.
|
||||
|
||||
The FastH3 adapters include low-rank factors plus exact dense deltas. Some also
|
||||
provide the VSA compression gate that is absent from the base checkpoint. Pass
|
||||
the adapter at construction so all three payload types receive the same
|
||||
``--lora-strength``. The attention backend is inferred from that payload unless
|
||||
``--vsa`` or ``--no-vsa`` is specified explicitly.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import math
|
||||
from collections.abc import Sequence
|
||||
|
||||
try:
|
||||
from . import basic_fasth3
|
||||
except ImportError:
|
||||
# Direct script execution puts this directory, rather than ``examples``, on
|
||||
# sys.path. Keep both ``python file.py`` and module/importlib use working.
|
||||
import basic_fasth3 # type: ignore[no-redef]
|
||||
|
||||
BASE_MODEL = "MiniMaxAI/MiniMax-H3"
|
||||
|
||||
|
||||
def parse_args(argv: Sequence[str] | None = None) -> argparse.Namespace:
|
||||
parser = basic_fasth3.build_parser(description=__doc__)
|
||||
parser.set_defaults(model_path=BASE_MODEL, output="outputs/fasth3_lora_preview")
|
||||
parser.add_argument(
|
||||
"--lora-path",
|
||||
required=True,
|
||||
help="FastH3 adapter safetensors file or local adapter directory",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--lora-strength",
|
||||
type=float,
|
||||
default=1.0,
|
||||
help="adapter strength; 0 zeros its weights but keeps its backend, and 1 applies its published scale",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--vsa",
|
||||
action=argparse.BooleanOptionalAction,
|
||||
default=None,
|
||||
help="select VSA explicitly; by default it is inferred from the adapter's compression-gate payload",
|
||||
)
|
||||
args = basic_fasth3.validate_args(parser, parser.parse_args(argv))
|
||||
if not math.isfinite(args.lora_strength):
|
||||
parser.error("--lora-strength must be finite")
|
||||
return _resolve_attention_backend(parser, args)
|
||||
|
||||
|
||||
def _resolve_attention_backend(parser: argparse.ArgumentParser, args: argparse.Namespace) -> argparse.Namespace:
|
||||
# Header-only inspection keeps the payload on disk. A replacement compression
|
||||
# gate is an unambiguous VSA requirement; adapters without one default to dense.
|
||||
from fastvideo.models.loader.lora_patch import DenseLoRAPatch
|
||||
|
||||
patch = DenseLoRAPatch.from_adapter(args.lora_path, strength=args.lora_strength)
|
||||
needs_vsa = bool(patch and any("gate_compress" in name for name in patch.replacement_parameters))
|
||||
if args.vsa is None:
|
||||
args.vsa = needs_vsa
|
||||
elif needs_vsa and not args.vsa:
|
||||
parser.error(f"{args.lora_path} provides to_gate_compress and must run with VSA; drop --no-vsa")
|
||||
return args
|
||||
|
||||
|
||||
def main() -> None:
|
||||
args = parse_args()
|
||||
print(f"FastH3 adapter: {args.lora_path}")
|
||||
print(f"LoRA strength: {args.lora_strength:g}")
|
||||
print(f"Attention: {'VSA-H3' if args.vsa else 'dense FA4'}")
|
||||
basic_fasth3.run(args)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -15,6 +15,7 @@ from fastvideo.api import (
|
||||
OffloadConfig,
|
||||
OutputConfig,
|
||||
ParallelismConfig,
|
||||
PipelineSelection,
|
||||
SamplingConfig,
|
||||
)
|
||||
|
||||
@@ -41,6 +42,12 @@ def parse_args() -> argparse.Namespace:
|
||||
parser.add_argument("--compile-mode",
|
||||
default=None,
|
||||
help='torch.compile mode, e.g. "reduce-overhead" for CUDA graphs')
|
||||
parser.add_argument("--inference-torch-compile",
|
||||
action="store_true",
|
||||
help="regional fullgraph torch.compile of each DiT block after load (the #1718 "
|
||||
"training-port semantics: no kwargs; fullgraph + emulate_precision_casts injected). "
|
||||
"First generation pays the inductor JIT (~1-2 min); use --repeats >= 2 and time "
|
||||
"the last repeat. FASTVIDEO_INFERENCE_TORCH_COMPILE=1 is equivalent")
|
||||
parser.add_argument("--repeats",
|
||||
type=int,
|
||||
default=1,
|
||||
@@ -54,9 +61,16 @@ def main() -> None:
|
||||
output_dir = Path(args.output)
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Boot-time run configuration folded into FastVideoArgs (the same
|
||||
# experimental-dict route basic_fasth3.py uses for the VSA knobs).
|
||||
experimental: dict[str, object] = {}
|
||||
if args.inference_torch_compile:
|
||||
experimental["inference_torch_compile"] = True
|
||||
|
||||
generator = VideoGenerator.from_config(
|
||||
GeneratorConfig(
|
||||
model_path=args.model_path,
|
||||
pipeline=PipelineSelection(experimental=experimental),
|
||||
engine=EngineConfig(
|
||||
num_gpus=args.num_gpus,
|
||||
use_fsdp_inference=args.num_gpus > 1,
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""End-to-end MiniMax-H3 (FastH3) generation with the Apple Silicon MLX runtime.
|
||||
|
||||
Accepts a text prompt and produces an MP4 with H.264 video at 24 fps and
|
||||
stereo AAC audio at 32 kHz. One heavyweight model phase is resident at a time.
|
||||
|
||||
python examples/inference/basic/mlx_fasth3.py \
|
||||
--model-root ~/models/FastH3-Preview-v0.2 \
|
||||
--mlx-checkpoint ~/models/FastH3-MLX/int8 \
|
||||
--prompt '(S1) A red panda says <d>[English] Fast H3 is amazing.</d>' \
|
||||
--height 480 --width 832 --num-frames 124 --seed 2026 \
|
||||
--output-path ~/fasth3_outputs/int8.mp4
|
||||
|
||||
Conditioning uses the streamed Qwen3-VL text encoder on first use and caches
|
||||
the resulting embeddings under --prompt-cache-dir for instant reuse.
|
||||
|
||||
``--fast`` is temporal fast mode. It keeps full-duration audio while
|
||||
denoising fewer video frames, then uses MLX RIFE 4.25 to reconstruct the
|
||||
requested frame count. A 1280x720 request runs on H3's 1280x736 grid and is
|
||||
center-cropped after decode.
|
||||
|
||||
This entrypoint currently supports text-to-video-with-audio only. It does not
|
||||
yet wire FL2VA, Ref2VA, spatial fast mode, or two-pass refinement.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(description=__doc__,
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||
parser.add_argument("--model-root", type=Path, default=Path.home() / "models/FastH3-Preview-v0.2",
|
||||
help="H3 snapshot root (vae/, audio_vae/, text_encoder/, tokenizer/)")
|
||||
parser.add_argument("--mlx-checkpoint", type=Path, required=True,
|
||||
help="pre-quantized MLX DiT directory (int8/int6/int4 mlx_h3_dit format)")
|
||||
parser.add_argument(
|
||||
"--prompt",
|
||||
required=True,
|
||||
help="H3 text prompt; use (S1) and <d>[Language] words</d> for explicit dialogue",
|
||||
)
|
||||
parser.add_argument("--output-path", type=Path, required=True)
|
||||
parser.add_argument("--height", type=int, default=480)
|
||||
parser.add_argument("--width", type=int, default=832)
|
||||
parser.add_argument("--num-frames", type=int, default=124)
|
||||
parser.add_argument("--seed", type=int, default=0)
|
||||
parser.add_argument("--steps", type=int, default=4, help="denoise steps (trained ladder = 4)")
|
||||
parser.add_argument(
|
||||
"--fast",
|
||||
action=argparse.BooleanOptionalAction,
|
||||
default=False,
|
||||
help="denoise fewer video frames, then use MLX RIFE to restore the target frame count; audio stays full length",
|
||||
)
|
||||
parser.add_argument("--fast-factor", type=int, default=2,
|
||||
help="temporal reduction target for --fast (default: 2)")
|
||||
parser.add_argument("--fast-sharpen", type=float, default=0.6,
|
||||
help="unsharp strength after RIFE interpolation (0 disables)")
|
||||
parser.add_argument("--rife-weights-dir", type=Path, default=None,
|
||||
help="optional local mlx-community/RIFE-4.25 snapshot")
|
||||
parser.add_argument("--vae-dtype", choices=("fp32", "fp16", "bf16"), default="fp32")
|
||||
parser.add_argument("--prompt-cache-dir", type=Path, default=None,
|
||||
help="directory for reusable prompt embedding caches")
|
||||
parser.add_argument(
|
||||
"--tiled-video-decode",
|
||||
action=argparse.BooleanOptionalAction,
|
||||
default=True,
|
||||
help="decode with the reference 256px overlapping VAE tiles (disable only for diagnostics)",
|
||||
)
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def main() -> None:
|
||||
args = parse_args()
|
||||
from fastvideo.mlx_runtime.minimax_h3_pipeline import MiniMaxH3MLXPipeline
|
||||
|
||||
pipeline = MiniMaxH3MLXPipeline(
|
||||
model_root=args.model_root,
|
||||
mlx_dit_checkpoint=args.mlx_checkpoint,
|
||||
vae_dtype=args.vae_dtype,
|
||||
prompt_cache_dir=args.prompt_cache_dir,
|
||||
)
|
||||
result = pipeline.generate(
|
||||
args.prompt,
|
||||
output_path=args.output_path,
|
||||
height=args.height,
|
||||
width=args.width,
|
||||
num_frames=args.num_frames,
|
||||
seed=args.seed,
|
||||
num_steps=args.steps,
|
||||
tiled_video_decode=args.tiled_video_decode,
|
||||
fast=args.fast,
|
||||
fast_factor=args.fast_factor,
|
||||
fast_sharpen=args.fast_sharpen,
|
||||
rife_weights_dir=args.rife_weights_dir,
|
||||
)
|
||||
print(json.dumps({
|
||||
"video_path": result.video_path,
|
||||
"timings_s": {k: round(v, 2) for k, v in result.timings.items()},
|
||||
"peak_memory_gib": {k: round(v, 2) for k, v in result.peak_memory_gib.items()},
|
||||
"audio_samples": int(result.waveform.shape[-1]),
|
||||
}, indent=2))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,14 +1,18 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""End-to-end Wan2.2-TI2V-5B generation on Apple Silicon (MLX DiT + MLX TAEHV).
|
||||
"""End-to-end FastMetal-5B-QAD generation on Apple Silicon (MLX DiT + MLX TAEHV).
|
||||
|
||||
This is the Wan2.2 TI2V entrypoint. Use FastVideo/FastMetal-5B-QAD:
|
||||
|
||||
hf download FastVideo/FastMetal-5B-QAD --local-dir ./FastMetal-5B-QAD
|
||||
python examples/inference/basic/mlx_wan22_generate.py \\
|
||||
--mlx-checkpoint ./FastMetal-5B-QAD \\
|
||||
--text-encoder-root ./FastMetal-5B-QAD \\
|
||||
--vae-root ./FastMetal-5B-QAD/vae
|
||||
|
||||
Pipeline: torch/MPS UMT5 encode (shared with 1.3B) → MLXWan22DiT 3-step DMD
|
||||
(warped schedule, flow_shift=5) → MLX TAEHV decode (taew2_2.pth). Fully MLX
|
||||
on the heavy DiT + decode path.
|
||||
|
||||
PYTHONPATH=$PWD python examples/inference/basic/mlx_wan22_generate.py \
|
||||
--prompt "A red fox trotting through a snowy pine forest at golden hour" \
|
||||
--output-path video_samples/demo_5b/fox_5b_mlx.mp4
|
||||
|
||||
Decoder backends: ``taehv`` (default, MLX, ~seconds), ``taehv-torch`` (parity),
|
||||
``wan-vae`` (full AutoencoderKLWan on MPS, slow).
|
||||
"""
|
||||
@@ -31,6 +35,11 @@ from fastvideo.mlx_runtime.prompt_cache import (
|
||||
save_prompt_cache,
|
||||
text_encoder_fingerprint,
|
||||
)
|
||||
from fastvideo.mlx_runtime.checkpoint_compat import (
|
||||
UnsupportedMLXCheckpointError,
|
||||
raise_if_unsupported_mlx_checkpoint,
|
||||
resolve_mlx_checkpoint,
|
||||
)
|
||||
from fastvideo.mlx_runtime.rife_interp import aligned_keyframe_count
|
||||
|
||||
FASTWAN21_MODEL_ID = "FastVideo/FastWan2.1-T2V-1.3B-Diffusers"
|
||||
@@ -165,7 +174,9 @@ def main() -> None:
|
||||
"--mlx-checkpoint",
|
||||
type=Path,
|
||||
default=None,
|
||||
help="Pre-quantized MLX DiT checkpoint directory. Rewrapped with Wan2.2 per-token conditioning.",
|
||||
help="Packed FastMetal-5B-QAD MLX DiT directory (mlx_dit.json + mlx_dit.safetensors). "
|
||||
"If omitted, a FastMetal directory passed as --text-encoder-root is used when it "
|
||||
"already contains those files.",
|
||||
)
|
||||
parser.add_argument("--vae-root", type=Path, default=None)
|
||||
parser.add_argument("--height", type=int, default=DEFAULT_HEIGHT)
|
||||
@@ -241,6 +252,18 @@ def main() -> None:
|
||||
# latent never leaves the grid it was denoised on and the mode is usable.
|
||||
if args.refine and args.fast_spatial:
|
||||
print("[wan22] --refine takes precedence over --fast-spatial")
|
||||
|
||||
args.mlx_checkpoint = resolve_mlx_checkpoint(args.mlx_checkpoint, args.text_encoder_root)
|
||||
if args.mlx_checkpoint is not None:
|
||||
if args.text_encoder_root is None and (args.mlx_checkpoint / "text_encoder").is_dir():
|
||||
args.text_encoder_root = args.mlx_checkpoint
|
||||
if args.vae_root is None and (args.mlx_checkpoint / "vae").is_dir():
|
||||
args.vae_root = args.mlx_checkpoint / "vae"
|
||||
try:
|
||||
raise_if_unsupported_mlx_checkpoint(args.mlx_checkpoint, args.dit_checkpoint)
|
||||
except UnsupportedMLXCheckpointError as exc:
|
||||
raise SystemExit(str(exc)) from exc
|
||||
|
||||
args.text_encoder_root, args.dit_checkpoint, args.dit_config, args.vae_root = _resolve_model_paths(
|
||||
text_encoder_root=args.text_encoder_root,
|
||||
dit_checkpoint=args.dit_checkpoint,
|
||||
|
||||
@@ -1,11 +1,25 @@
|
||||
"""Generate a FastWan text-to-video clip with the Apple Silicon MLX runtime.
|
||||
"""Generate a FastMetal text-to-video clip with the Apple Silicon MLX runtime.
|
||||
|
||||
This is the supported source-tree entrypoint for the FastWan-QAD-INT8-1.3B
|
||||
Apple release:
|
||||
This is the supported source-tree entrypoint for FastMetal-QAD (Wan2.1 1.3B
|
||||
and 14B). Use ``mlx_wan22_generate.py`` for FastMetal-5B-QAD.
|
||||
|
||||
Download FastMetal-QAD and point ``--model-root`` / ``--mlx-checkpoint`` at it:
|
||||
|
||||
hf download FastVideo/FastMetal-1.3B-QAD --local-dir ./FastMetal-1.3B-QAD
|
||||
python examples/inference/basic/mlx_wan_prompt_to_video.py \\
|
||||
--model-root ./FastMetal-1.3B-QAD --mlx-checkpoint ./FastMetal-1.3B-QAD
|
||||
|
||||
CUDA FastWan-QAD (``FastVideo/FastWan-QAD-1.3B``, ``FastVideo/FastWan-QAD-FP8-1.3B``)
|
||||
is a separate NVIDIA release.
|
||||
|
||||
FastMetal-QAD Hugging Face repos ship ``mlx_dit.json`` + ``mlx_dit.safetensors``,
|
||||
not a Diffusers ``transformer/`` tree. Do not copy ``transformer/config.json``
|
||||
from Wan2.1 or other checkpoints; point ``--mlx-checkpoint`` at the FastMetal
|
||||
directory and the example reads the DiT config from ``mlx_dit.json``.
|
||||
|
||||
- Hugging Face/torch encodes the prompt with UMT5 (bf16 by default: fp32
|
||||
exponent range without fp16 overflow risk, at fp16 memory cost).
|
||||
- MLX runs the FastWan DiT denoising loop (INT8 by default, compiled with
|
||||
- MLX runs the FastMetal DiT denoising loop (INT8 by default, compiled with
|
||||
``mx.compile`` unless ``--no-mlx-compile``).
|
||||
- TAEHV (default, fast/low-memory) or the full Wan VAE (``--decode-backend
|
||||
wan-vae``, higher fidelity, bf16) decodes the final latents.
|
||||
@@ -56,13 +70,18 @@ from fastvideo.mlx_runtime.prompt_cache import (
|
||||
save_prompt_cache,
|
||||
text_encoder_fingerprint,
|
||||
)
|
||||
from fastvideo.mlx_runtime.checkpoint_compat import (
|
||||
UnsupportedMLXCheckpointError,
|
||||
raise_if_unsupported_mlx_checkpoint,
|
||||
resolve_mlx_checkpoint,
|
||||
)
|
||||
from fastvideo.mlx_runtime.rife_interp import aligned_keyframe_count
|
||||
|
||||
if TYPE_CHECKING: # pragma: no cover - typing only
|
||||
from fastvideo.mlx_runtime.fast_spatial import FastSpatialPlan
|
||||
|
||||
|
||||
DEFAULT_MODEL_ID = "FastVideo/FastWan2.1-T2V-1.3B-Diffusers"
|
||||
DEFAULT_MODEL_ID = "FastVideo/FastMetal-1.3B-QAD"
|
||||
|
||||
# Legacy pinned-snapshot location, kept for callers that import it (the MLX
|
||||
# benchmark harness). New code should prefer resolve_model_root(None), which
|
||||
@@ -99,6 +118,10 @@ def resolve_model_root(
|
||||
"tokenizer/*",
|
||||
"text_encoder/*",
|
||||
"vae/*",
|
||||
"mlx_dit.json",
|
||||
"mlx_dit.safetensors",
|
||||
"ema/mlx_dit.json",
|
||||
"ema/mlx_dit.safetensors",
|
||||
"transformer/*" if include_transformer else "transformer/config.json",
|
||||
]
|
||||
return Path(snapshot_download(
|
||||
@@ -138,6 +161,7 @@ def encode_prompt(
|
||||
text_encoder = UMT5EncoderModel.from_pretrained(
|
||||
model_root / "text_encoder",
|
||||
torch_dtype=dtype,
|
||||
low_cpu_mem_usage=True,
|
||||
local_files_only=True,
|
||||
).to(device)
|
||||
text_encoder.eval()
|
||||
@@ -379,6 +403,7 @@ def decode_latents_to_video(
|
||||
vae = AutoencoderKLWan.from_pretrained(
|
||||
model_root / "vae",
|
||||
torch_dtype=dtype,
|
||||
low_cpu_mem_usage=True,
|
||||
local_files_only=True,
|
||||
).to(device)
|
||||
vae.eval()
|
||||
@@ -471,11 +496,16 @@ def _rife_interpolate_video(*, video_path: Path, target_frames: int, factor: int
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description="Prompt-to-video FastWan generation using MLX for the DiT")
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Prompt-to-video FastMetal-QAD generation using the Apple Silicon MLX runtime")
|
||||
parser.add_argument("--model-root", type=Path, default=None,
|
||||
help=f"Model directory. Defaults to the local HF cache for {DEFAULT_MODEL_ID} "
|
||||
help="FastMetal-QAD directory (tokenizer, UMT5, VAE, packed MLX DiT). "
|
||||
f"Defaults to the local HF cache for {DEFAULT_MODEL_ID} "
|
||||
"(downloading it if missing).")
|
||||
parser.add_argument("--prompt", default="A paper boat sails through a shallow stream in a mossy forest.")
|
||||
parser.add_argument(
|
||||
"--prompt",
|
||||
default="A bird's-eye view of a misty forest valley at dawn.",
|
||||
)
|
||||
parser.add_argument("--output-path", type=Path, default=Path("video_samples/mlx_fastwan_prompt_to_video.mp4"))
|
||||
parser.add_argument("--height", type=int, default=480)
|
||||
parser.add_argument("--width", type=int, default=832)
|
||||
@@ -639,9 +669,8 @@ def main() -> None:
|
||||
"keyed by (model, prompt, length, dtype), so repeat runs skip "
|
||||
"the text encoder entirely. Default: on.")
|
||||
parser.add_argument("--mlx-checkpoint", type=Path, default=None,
|
||||
help="Load the DiT from a pre-quantized MLX checkpoint directory "
|
||||
"(created with --save-mlx-checkpoint) instead of casting/quantizing "
|
||||
"the Diffusers weights on every run.")
|
||||
help="Packed FastMetal MLX DiT directory (mlx_dit.json + mlx_dit.safetensors). "
|
||||
"Defaults to --model-root when that directory already contains those files.")
|
||||
parser.add_argument("--save-mlx-checkpoint", type=Path, default=None,
|
||||
help="After loading the DiT, save it (cast + quantized) as an MLX "
|
||||
"checkpoint directory for fast reloads via --mlx-checkpoint.")
|
||||
@@ -690,6 +719,12 @@ def main() -> None:
|
||||
np.save(args.encode_prompt_only, prompt_embeds.cpu().numpy())
|
||||
return
|
||||
|
||||
mlx_checkpoint = resolve_mlx_checkpoint(args.mlx_checkpoint, model_root)
|
||||
try:
|
||||
raise_if_unsupported_mlx_checkpoint(mlx_checkpoint or model_root)
|
||||
except UnsupportedMLXCheckpointError as exc:
|
||||
raise SystemExit(str(exc)) from exc
|
||||
|
||||
import mlx.core as mx
|
||||
import torch
|
||||
from diffusers import UniPCMultistepScheduler
|
||||
@@ -719,17 +754,21 @@ def main() -> None:
|
||||
|
||||
config_path = model_root / "transformer/config.json"
|
||||
checkpoint_path = model_root / "transformer/diffusion_pytorch_model.safetensors"
|
||||
config = json.loads(config_path.read_text())
|
||||
# A pre-quantized MLX DiT can be paired with a lightweight asset root for
|
||||
# UMT5/TAEHV. In that case the model root is *not* the architecture
|
||||
# authority: use the checkpoint's embedded transformer config for the
|
||||
# sampler guard and latent geometry.
|
||||
dit_config = config
|
||||
if args.mlx_checkpoint is not None:
|
||||
mlx_config_path = Path(args.mlx_checkpoint) / "mlx_dit.json"
|
||||
if mlx_config_path.is_file():
|
||||
mlx_checkpoint_config = json.loads(mlx_config_path.read_text())
|
||||
dit_config = mlx_checkpoint_config.get("config", mlx_checkpoint_config)
|
||||
# Packed FastMetal checkpoints are the architecture authority. Do not
|
||||
# require transformer/config.json when mlx_dit.json is already present.
|
||||
if mlx_checkpoint is not None:
|
||||
mlx_checkpoint_config = json.loads((mlx_checkpoint / "mlx_dit.json").read_text())
|
||||
dit_config = mlx_checkpoint_config.get("config", mlx_checkpoint_config)
|
||||
config = dit_config
|
||||
else:
|
||||
if not config_path.is_file():
|
||||
raise SystemExit(
|
||||
f"No packed MLX DiT (mlx_dit.json) and no Diffusers transformer config at {config_path}. "
|
||||
"FastMetal-QAD checkpoints intentionally omit transformer/; download "
|
||||
"FastVideo/FastMetal-1.3B-QAD and pass --model-root / --mlx-checkpoint at that directory."
|
||||
)
|
||||
config = json.loads(config_path.read_text())
|
||||
dit_config = config
|
||||
if int(dit_config.get("in_channels", 0)) == 48 and int(dit_config.get("out_channels", 0)) == 48:
|
||||
raise SystemExit(
|
||||
"Wan2.2-TI2V-5B uses 48-channel, per-token timestep conditioning. "
|
||||
@@ -835,10 +874,10 @@ def main() -> None:
|
||||
load_start = time.perf_counter()
|
||||
mx.clear_cache()
|
||||
mx.reset_peak_memory()
|
||||
if args.mlx_checkpoint is not None:
|
||||
if mlx_checkpoint is not None:
|
||||
from fastvideo.mlx_runtime.checkpoint import load_mlx_dit_checkpoint
|
||||
|
||||
dit = load_mlx_dit_checkpoint(args.mlx_checkpoint, compile=args.mlx_compile)
|
||||
dit = load_mlx_dit_checkpoint(mlx_checkpoint, compile=args.mlx_compile)
|
||||
config = dit.config
|
||||
else:
|
||||
dit = mlx_dit_from_diffusers_safetensors(
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
repo="FastVideo/FastVideo-FastH3-4-step-Preview-v1-LoRA"
|
||||
adapter="dense-datafree/adapter_model.safetensors"
|
||||
adapter_path="$(hf download "$repo" "$adapter")"
|
||||
|
||||
python examples/inference/basic/basic_fasth3_lora_preview.py \
|
||||
--lora-path "$adapter_path" \
|
||||
--lora-strength "${FASTH3_LORA_STRENGTH:-1.0}" \
|
||||
--output "${FASTH3_LORA_OUTPUT:-outputs/fasth3_lora_preview/dense-datafree}" \
|
||||
"$@" \
|
||||
--no-vsa
|
||||
@@ -0,0 +1,13 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
repo="FastVideo/FastVideo-FastH3-4-step-Preview-v1-LoRA"
|
||||
adapter="vsa-datafree/adapter_model.safetensors"
|
||||
adapter_path="$(hf download "$repo" "$adapter")"
|
||||
|
||||
python examples/inference/basic/basic_fasth3_lora_preview.py \
|
||||
--lora-path "$adapter_path" \
|
||||
--lora-strength "${FASTH3_LORA_STRENGTH:-1.0}" \
|
||||
--output "${FASTH3_LORA_OUTPUT:-outputs/fasth3_lora_preview/vsa-datafree}" \
|
||||
"$@" \
|
||||
--vsa
|
||||
@@ -0,0 +1,13 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
repo="FastVideo/FastVideo-FastH3-4-step-Preview-v1-LoRA"
|
||||
adapter="vsa-synthetic-step1300/adapter_model.safetensors"
|
||||
adapter_path="$(hf download "$repo" "$adapter")"
|
||||
|
||||
python examples/inference/basic/basic_fasth3_lora_preview.py \
|
||||
--lora-path "$adapter_path" \
|
||||
--lora-strength "${FASTH3_LORA_STRENGTH:-1.0}" \
|
||||
--output "${FASTH3_LORA_OUTPUT:-outputs/fasth3_lora_preview/vsa-synthetic-step1300}" \
|
||||
"$@" \
|
||||
--vsa
|
||||
@@ -0,0 +1,13 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
repo="FastVideo/FastVideo-FastH3-4-step-Preview-v1-LoRA"
|
||||
adapter="vsa-synthetic-step1900/adapter_model.safetensors"
|
||||
adapter_path="$(hf download "$repo" "$adapter")"
|
||||
|
||||
python examples/inference/basic/basic_fasth3_lora_preview.py \
|
||||
--lora-path "$adapter_path" \
|
||||
--lora-strength "${FASTH3_LORA_STRENGTH:-1.0}" \
|
||||
--output "${FASTH3_LORA_OUTPUT:-outputs/fasth3_lora_preview/vsa-synthetic-step1900}" \
|
||||
"$@" \
|
||||
--vsa
|
||||
@@ -0,0 +1,184 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""Review whether a FastH3 adapter reproduces the checkpoint it was extracted from.
|
||||
|
||||
The question this page answers is not "is the video good" but "does base + adapter land
|
||||
where the real checkpoint lands". So each row is one checkpoint, and the two players in
|
||||
it are the checkpoint itself and base MiniMax-H3 with that checkpoint's adapter merged
|
||||
in. They share a seed and a prompt, so anything you can see between them is the
|
||||
adapter's approximation error and nothing else.
|
||||
|
||||
The base model at four steps sits at the top as the floor. It is not distilled, so it
|
||||
should look clearly worse than everything below it -- if an adapter row looks like the
|
||||
floor instead of like its checkpoint, the adapter did not land.
|
||||
|
||||
python examples/inference/gradio/lora_review.py --runs /path/to/lora_review
|
||||
|
||||
Expects one directory per arm, each holding ``<index>_<case_id>.mp4``:
|
||||
|
||||
<runs>/base/ <runs>/v1-true/ <runs>/v1-lora-r64/ ...
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import subprocess
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
|
||||
import gradio as gr
|
||||
|
||||
FLOOR_ARM = "base"
|
||||
|
||||
|
||||
def discover_pairs(arms: list[str]) -> tuple[list[tuple[str, str, str]], list[str]]:
|
||||
"""Split the arms present into checkpoint/adapter pairs and everything else.
|
||||
|
||||
Pairs are found by name -- ``<x>-true`` next to ``<x>-lora-<rank>`` -- rather than
|
||||
listed, so adding an arm to the render directory is enough to get it on the page.
|
||||
Arms that pair with nothing (a third-party adapter with no checkpoint to compare
|
||||
against) still get shown, on their own row, instead of being silently dropped.
|
||||
"""
|
||||
pairs, used = [], set()
|
||||
for arm in sorted(arms):
|
||||
if not arm.endswith("-true"):
|
||||
continue
|
||||
stem = arm[:-len("-true")]
|
||||
partner = next((a for a in arms if a.startswith(f"{stem}-lora")), None)
|
||||
if partner is None:
|
||||
continue
|
||||
pairs.append((stem, arm, partner))
|
||||
used.update({arm, partner})
|
||||
standalone = [a for a in sorted(arms) if a not in used and a != FLOOR_ARM]
|
||||
return pairs, standalone
|
||||
|
||||
|
||||
def probe(path: Path) -> str:
|
||||
"""`WxH · Nf · Ds · MiB`, so a truncated or mis-sized render is visible as text."""
|
||||
try:
|
||||
out = subprocess.run(
|
||||
["ffprobe", "-v", "error", "-select_streams", "v:0", "-show_entries",
|
||||
"stream=width,height,nb_read_packets,duration", "-count_packets", "-of", "json",
|
||||
str(path)],
|
||||
capture_output=True, text=True, check=True).stdout
|
||||
stream = json.loads(out)["streams"][0]
|
||||
frames = stream.get("nb_read_packets", "?")
|
||||
duration = float(stream.get("duration", 0) or 0)
|
||||
return (f"{stream['width']}x{stream['height']} · {frames}f · {duration:.1f}s · "
|
||||
f"{path.stat().st_size / 2**20:.1f} MiB")
|
||||
except (subprocess.CalledProcessError, KeyError, IndexError, json.JSONDecodeError):
|
||||
return f"{path.stat().st_size / 2**20:.1f} MiB"
|
||||
|
||||
|
||||
class Runs:
|
||||
"""Which prompts rendered, and where each arm's clip for them lives."""
|
||||
|
||||
def __init__(self, runs_dir: Path, prompts_file: Path | None) -> None:
|
||||
self.root = runs_dir
|
||||
self.arms = sorted(d.name for d in runs_dir.iterdir() if d.is_dir())
|
||||
self.prompts: dict[str, str] = {}
|
||||
if prompts_file and prompts_file.exists():
|
||||
with prompts_file.open() as handle:
|
||||
for index, line in enumerate(handle):
|
||||
line = line.strip()
|
||||
if line:
|
||||
self.prompts[f"{index:03d}"] = json.loads(line).get("prompt", "")
|
||||
|
||||
self.clips: dict[str, dict[str, Path]] = {}
|
||||
for arm in self.arms:
|
||||
for mp4 in sorted((runs_dir / arm).glob("*.mp4")):
|
||||
self.clips.setdefault(mp4.stem.split("_")[0], {})[arm] = mp4
|
||||
if not self.clips:
|
||||
raise SystemExit(f"no clips under {runs_dir}")
|
||||
|
||||
def label(self, index: str) -> str:
|
||||
head = " ".join(self.prompts.get(index, "").split())[:90]
|
||||
return f"[{index}] {head}..." if head else f"[{index}]"
|
||||
|
||||
def by_label(self, label: str) -> str:
|
||||
return next(i for i in self.clips if self.label(i) == label)
|
||||
|
||||
|
||||
def build(runs: Runs, height: int) -> gr.Blocks:
|
||||
|
||||
@lru_cache(maxsize=512)
|
||||
def cached_probe(path: str) -> str:
|
||||
return probe(Path(path))
|
||||
|
||||
def player_update(index: str, arm: str, prefix: str):
|
||||
mp4 = runs.clips.get(index, {}).get(arm)
|
||||
if mp4 is None:
|
||||
return gr.update(value=None, label=f"{prefix} — not rendered")
|
||||
return gr.update(value=str(mp4), label=f"{prefix} — {cached_probe(str(mp4))}")
|
||||
|
||||
with gr.Blocks(title="FastH3 adapter review") as demo:
|
||||
gr.Markdown(
|
||||
"# FastH3 adapter review\n"
|
||||
"Each row is one checkpoint: **left** is the real checkpoint, **right** is base "
|
||||
"MiniMax-H3 with that checkpoint's rank-64 adapter merged in. Same prompt, same "
|
||||
"seed, same sampler. Differences between the two are the adapter's approximation "
|
||||
"error.\n\n"
|
||||
"The top player is undistilled base MiniMax-H3 at four steps — the floor. Every "
|
||||
"row below it should look clearly better than that; an adapter that landed looks "
|
||||
"like its own left-hand player, not like the floor.")
|
||||
|
||||
prompt_dd = gr.Dropdown(choices=[runs.label(i) for i in runs.clips],
|
||||
value=runs.label(next(iter(runs.clips))),
|
||||
label="Prompt")
|
||||
prompt_box = gr.Textbox(label="Prompt", lines=4, max_lines=6, interactive=False, show_copy_button=True)
|
||||
|
||||
with gr.Row():
|
||||
floor = gr.Video(label="base MiniMax-H3, 4 steps (floor)", height=height, loop=True,
|
||||
autoplay=False, interactive=False)
|
||||
|
||||
pairs, standalone = discover_pairs(runs.arms)
|
||||
players: list[tuple[gr.Video, str, str]] = []
|
||||
for row_label, true_arm, lora_arm in pairs:
|
||||
gr.Markdown(f"### {row_label}")
|
||||
with gr.Row():
|
||||
left = gr.Video(label=f"{row_label} — checkpoint", height=height, loop=True,
|
||||
autoplay=False, interactive=False)
|
||||
right = gr.Video(label=f"{row_label} — base + adapter", height=height, loop=True,
|
||||
autoplay=False, interactive=False)
|
||||
players.append((left, true_arm, "checkpoint"))
|
||||
players.append((right, lora_arm, "base + adapter"))
|
||||
|
||||
if standalone:
|
||||
gr.Markdown("### Other adapters (no matching checkpoint to compare against)")
|
||||
with gr.Row():
|
||||
for arm in standalone:
|
||||
players.append((gr.Video(label=arm, height=height, loop=True, autoplay=False,
|
||||
interactive=False), arm, arm))
|
||||
|
||||
def show(label: str):
|
||||
index = runs.by_label(label)
|
||||
return [
|
||||
player_update(index, FLOOR_ARM, "base, 4 steps"),
|
||||
gr.update(value=runs.prompts.get(index, "")),
|
||||
*[player_update(index, arm, prefix) for _, arm, prefix in players],
|
||||
]
|
||||
|
||||
outputs = [floor, prompt_box, *[p for p, _, _ in players]]
|
||||
gr.on(triggers=[prompt_dd.change, demo.load], fn=show, inputs=prompt_dd, outputs=outputs)
|
||||
|
||||
return demo
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||
parser.add_argument("--runs", default="/mnt/lustre/vlm-s4duan/arena_arms/lora_review")
|
||||
parser.add_argument("--prompts-file", default="/mnt/lustre/vlm-s4duan/FastVideo/prompts.jsonl")
|
||||
parser.add_argument("--host", default="0.0.0.0")
|
||||
parser.add_argument("--port", type=int, default=7865)
|
||||
parser.add_argument("--share", action="store_true")
|
||||
parser.add_argument("--video-height", type=int, default=420)
|
||||
args = parser.parse_args()
|
||||
|
||||
runs = Runs(Path(args.runs).resolve(), Path(args.prompts_file))
|
||||
print(f"arms: {runs.arms}")
|
||||
print(f"prompts with output: {sorted(runs.clips)}")
|
||||
build(runs, args.video_height).launch(server_name=args.host, server_port=args.port,
|
||||
share=args.share, allowed_paths=[str(runs.root)])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,234 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""Run a FastH3 distillation adapter on top of the base MiniMax-H3 checkpoint.
|
||||
|
||||
The FastH3 checkpoints are full fine-tunes of MiniMax-H3 distilled to four steps under
|
||||
video sparse attention. Published as adapters they are three things at once, and all
|
||||
three have to land for the result to match the checkpoint:
|
||||
|
||||
* low-rank factors for the attention, feed-forward, and AdaLN projections
|
||||
* exact ``.diff`` deltas for the norms and biases an SVD cannot usefully factor
|
||||
* ``.set_weight`` values for ``attn.to_gate_compress``, the VSA compression gate that
|
||||
does not exist in the base model at all
|
||||
|
||||
An adapter carrying that last one needs ``--vsa``: under any other attention backend the
|
||||
gate module is never constructed, so part of the distillation has nowhere to go. The
|
||||
requirement is read off the adapter rather than assumed, because community adapters
|
||||
built against the ComfyUI layout carry no gate and run fine either way -- run one of
|
||||
those with ``--no-vsa`` (see ``scripts/checkpoint_conversion/convert_minimax_h3_comfy_lora.py``
|
||||
for getting them into a layout this loads).
|
||||
|
||||
Because a parameter the base lacks has to be supplied while weights are still unsharded,
|
||||
the adapter is passed at construction rather than swapped in afterwards.
|
||||
|
||||
python examples/inference/lora/minimax_h3_lora_inference.py \\
|
||||
--lora-path /models/fasth3-loras-publish/FastH3-4-step-v1.1/rank-64 \\
|
||||
--prompts-file prompts.jsonl --output outputs/v1.1-rank64
|
||||
|
||||
Pass no ``--lora-path`` to render the unmodified base model as a control.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
from collections.abc import Sequence
|
||||
from pathlib import Path
|
||||
|
||||
# MiniMax-H3 generates 5-15 s at 24 fps, on a latent grid that only admits frame counts
|
||||
# of the form 17n + 5. 124 is the 5-second point the FastH3 profile is measured at.
|
||||
FRAMES_PER_CHUNK = 17
|
||||
LATENTS_PER_CHUNK = 5
|
||||
FPS = 24
|
||||
MIN_DURATION, MAX_DURATION = 5.0, 15.0
|
||||
|
||||
|
||||
def align_num_frames(num_frames: int) -> int:
|
||||
"""Round up to the next 17n + 5 the latent grid accepts."""
|
||||
if num_frames <= LATENTS_PER_CHUNK:
|
||||
return LATENTS_PER_CHUNK
|
||||
chunks = -(-(num_frames - LATENTS_PER_CHUNK) // FRAMES_PER_CHUNK)
|
||||
return LATENTS_PER_CHUNK + chunks * FRAMES_PER_CHUNK
|
||||
|
||||
|
||||
def parse_args(argv: Sequence[str] | None = None) -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||
parser.add_argument("--model-path", default="MiniMaxAI/MiniMax-H3", help="the BASE checkpoint the adapter targets")
|
||||
parser.add_argument("--lora-path", default=None, help="adapter file or directory; omit to render the base model")
|
||||
parser.add_argument("--lora-nickname", default="fasth3")
|
||||
parser.add_argument("--lora-strength", type=float, default=1.0)
|
||||
parser.add_argument("--prompt", default=None)
|
||||
parser.add_argument("--prompts-file", default=None, help="JSONL with a 'prompt' field per line")
|
||||
parser.add_argument("--limit", type=int, default=None, help="use only the first N prompts")
|
||||
parser.add_argument("--num-shards", type=int, default=1, help="split the prompt list across processes")
|
||||
parser.add_argument("--shard", type=int, default=0)
|
||||
parser.add_argument("--output", default="outputs/minimax_h3_lora")
|
||||
parser.add_argument("--skip-existing", action="store_true", help="leave already-rendered clips alone")
|
||||
parser.add_argument("--height", type=int, default=768)
|
||||
parser.add_argument("--width", type=int, default=1344)
|
||||
parser.add_argument("--num-frames", type=int, default=124)
|
||||
# Counts sigma-GRID POINTS: N points run N-1 DiT forwards. The distilled ladder is
|
||||
# t=1000,750,500,250 -> 0, which is five points and exactly four forwards.
|
||||
parser.add_argument("--steps", type=int, default=5)
|
||||
parser.add_argument("--seed", type=int, default=1000)
|
||||
parser.add_argument("--num-gpus", type=int, default=4)
|
||||
parser.add_argument("--vsa", action=argparse.BooleanOptionalAction, default=None,
|
||||
help="video sparse attention; inferred from the adapter when omitted")
|
||||
parser.add_argument("--vsa-sparsity", type=float, default=0.9)
|
||||
parser.add_argument("--vsa-tile-size", type=int, choices=(64, 256), default=64)
|
||||
parser.add_argument("--vsa-kernel", choices=("triton", "sm100a"), default="sm100a")
|
||||
parser.add_argument("--fa4", action=argparse.BooleanOptionalAction, default=True)
|
||||
args = parser.parse_args(argv)
|
||||
if not args.prompt and not args.prompts_file:
|
||||
parser.error("pass --prompt or --prompts-file")
|
||||
# Whether VSA is required is a property of the adapter, not of having one at all --
|
||||
# community adapters built against the ComfyUI layout carry no gate. Checked in
|
||||
# main(), once the path has been resolved.
|
||||
aligned = align_num_frames(args.num_frames)
|
||||
if not MIN_DURATION <= aligned / FPS <= MAX_DURATION:
|
||||
parser.error(f"MiniMax-H3 generates {MIN_DURATION}-{MAX_DURATION}s at {FPS} fps; "
|
||||
f"aligned num_frames={aligned} is {aligned / FPS:.1f}s")
|
||||
args.num_frames = aligned
|
||||
if not math.isfinite(args.lora_strength):
|
||||
parser.error("--lora-strength must be finite")
|
||||
return args
|
||||
|
||||
|
||||
def configure_environment(args: argparse.Namespace) -> None:
|
||||
"""Set the boot-time backend selection explicitly, including what is off.
|
||||
|
||||
An inherited FASTVIDEO_* from an earlier experiment would otherwise silently change
|
||||
which attention path the run actually took, which is the one thing this comparison
|
||||
cannot afford to be vague about.
|
||||
"""
|
||||
env: dict[str, str | None] = {
|
||||
"FASTVIDEO_ATTENTION_BACKEND": "VIDEO_SPARSE_ATTN_H3" if args.vsa else "FLASH_ATTN",
|
||||
"FASTVIDEO_VSA_SM100A": "1" if (args.vsa and args.vsa_kernel == "sm100a") else "0",
|
||||
"FASTVIDEO_VSA_CUTEDSL": "0",
|
||||
"FASTVIDEO_H3_VSA_PROBE": None,
|
||||
"FASTVIDEO_DISABLE_ATTENTION_COMPILE": "0",
|
||||
"FASTVIDEO_FA4": "1" if args.fa4 else "0",
|
||||
"FASTVIDEO_NVFP4_FA4": "0",
|
||||
"FASTVIDEO_MINIMAX_H3_FA4_PACKED_VARLEN": "0",
|
||||
"FASTVIDEO_MINIMAX_H3_FUSIONS": "all",
|
||||
"FASTVIDEO_INFERENCE_TORCH_COMPILE": "1",
|
||||
"FASTVIDEO_VAE_PARALLEL_DECODE": "1",
|
||||
"FASTVIDEO_VAE_PARALLEL_ENCODE": "0",
|
||||
"FASTVIDEO_VAE_PARALLEL_DECODE_STRATEGY": "gather",
|
||||
"FASTVIDEO_ULYSSES_A2A": "off",
|
||||
"FASTVIDEO_STAGE_LOGGING": "1",
|
||||
}
|
||||
for name, value in env.items():
|
||||
if value is None:
|
||||
os.environ.pop(name, None)
|
||||
else:
|
||||
os.environ[name] = value
|
||||
|
||||
|
||||
def load_prompts(args: argparse.Namespace) -> list[dict]:
|
||||
if args.prompt:
|
||||
records = [{"id": "000", "prompt": args.prompt}]
|
||||
else:
|
||||
records = []
|
||||
with open(args.prompts_file) as handle:
|
||||
for index, line in enumerate(handle):
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
item = json.loads(line)
|
||||
records.append({
|
||||
"id": str(item.get("id", item.get("sample_id", f"{index:03d}"))),
|
||||
"prompt": item["prompt"],
|
||||
})
|
||||
if args.limit is not None:
|
||||
records = records[:args.limit]
|
||||
return [r for i, r in enumerate(records) if i % args.num_shards == args.shard]
|
||||
|
||||
|
||||
def main(argv: Sequence[str] | None = None) -> None:
|
||||
args = parse_args(argv)
|
||||
# Backend selection is finalized from the adapter before model construction.
|
||||
from fastvideo.models.loader.lora_patch import DenseLoRAPatch
|
||||
from fastvideo import VideoGenerator
|
||||
from fastvideo.api import (CompileConfig, ComponentConfig, EngineConfig, GenerationRequest, GeneratorConfig,
|
||||
OffloadConfig, OutputConfig, ParallelismConfig, PipelineSelection, SamplingConfig)
|
||||
|
||||
# An adapter carrying to_gate_compress needs the VSA backend, because that is the
|
||||
# only configuration in which the module exists. One that does not carry it runs
|
||||
# fine either way, so the requirement is read off the file rather than assumed from
|
||||
# the presence of an adapter at all.
|
||||
patch = (DenseLoRAPatch.from_adapter(args.lora_path, strength=args.lora_strength)
|
||||
if args.lora_path else None)
|
||||
needs_vsa = bool(patch and any("gate_compress" in name for name in patch.replacement_parameters))
|
||||
if args.vsa is None:
|
||||
args.vsa = needs_vsa
|
||||
if needs_vsa and not args.vsa:
|
||||
raise SystemExit(f"{args.lora_path} carries to_gate_compress, which exists only under the VSA "
|
||||
"attention backend. Drop --no-vsa.")
|
||||
if args.vsa and args.lora_path and not needs_vsa:
|
||||
print(f"note: {args.lora_path} carries no VSA gate; running under VSA leaves the "
|
||||
"compression branch at its zero-initialized value.")
|
||||
configure_environment(args)
|
||||
|
||||
experimental: dict[str, object] = {
|
||||
"inference_torch_compile": True,
|
||||
"vae_parallel_decode": True,
|
||||
"vae_parallel_decode_strategy": "gather",
|
||||
}
|
||||
if args.vsa:
|
||||
experimental.update({
|
||||
"attention_backend": "VIDEO_SPARSE_ATTN_H3",
|
||||
"VSA_sparsity": args.vsa_sparsity,
|
||||
"VSA_tile_size": args.vsa_tile_size,
|
||||
})
|
||||
|
||||
config = GeneratorConfig(
|
||||
model_path=args.model_path,
|
||||
pipeline=PipelineSelection(
|
||||
components=ComponentConfig(lora_path=args.lora_path, lora_strength=args.lora_strength),
|
||||
experimental=experimental,
|
||||
),
|
||||
engine=EngineConfig(
|
||||
num_gpus=args.num_gpus,
|
||||
use_fsdp_inference=False,
|
||||
parallelism=ParallelismConfig(tp_size=1, sp_size=args.num_gpus),
|
||||
offload=OffloadConfig(dit=False, dit_layerwise=False, text_encoder=True, vae=True, pin_cpu_memory=True),
|
||||
compile=CompileConfig(enabled=False, vae_enabled=True),
|
||||
),
|
||||
)
|
||||
|
||||
records = load_prompts(args)
|
||||
out_dir = Path(args.output)
|
||||
out_dir.mkdir(parents=True, exist_ok=True)
|
||||
print(f"adapter: {args.lora_path or '(none, base model)'}")
|
||||
print(f"prompts: {len(records)} (shard {args.shard}/{args.num_shards})")
|
||||
|
||||
generator = VideoGenerator.from_config(config)
|
||||
for index, record in enumerate(records):
|
||||
stem = f"{index:03d}_{record['id']}"
|
||||
if args.skip_existing and (out_dir / f"{stem}.mp4").exists():
|
||||
print(f"[{index}] skip {stem}")
|
||||
continue
|
||||
generator.generate(
|
||||
GenerationRequest(
|
||||
prompt=record["prompt"],
|
||||
negative_prompt="",
|
||||
sampling=SamplingConfig(
|
||||
height=args.height,
|
||||
width=args.width,
|
||||
num_frames=args.num_frames,
|
||||
fps=FPS,
|
||||
num_inference_steps=args.steps,
|
||||
# MiniMax-H3 is guidance-distilled; FastH3 inherits that contract.
|
||||
guidance_scale=1.0,
|
||||
batch_cfg=False,
|
||||
seed=args.seed,
|
||||
),
|
||||
output=OutputConfig(output_path=str(out_dir / f"{stem}.mp4"), save_video=True, return_frames=False),
|
||||
))
|
||||
print(f"[{index}] wrote {stem}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,43 @@
|
||||
# OpenAI-compatible serving examples
|
||||
|
||||
The REST serving engine is model-agnostic. Any model supported by
|
||||
`VideoGenerator` can use the same `/v1/models`, `/v1/videos`, and `/v1/images`
|
||||
surface; the two configs here are FastH3 validation profiles.
|
||||
|
||||
Launch the full FastH3 checkpoint:
|
||||
|
||||
```bash
|
||||
fastvideo serve --config examples/serving/openai_fasth3.yaml
|
||||
```
|
||||
|
||||
Launch the dense FastH3 LoRA on the base MiniMax-H3 checkpoint:
|
||||
|
||||
```bash
|
||||
adapter_path="$(hf download \
|
||||
FastVideo/FastVideo-FastH3-4-step-Preview-v1-LoRA \
|
||||
dense-datafree/adapter_model.safetensors)"
|
||||
|
||||
fastvideo serve --config examples/serving/openai_fasth3_lora.yaml \
|
||||
--generator.pipeline.components.lora_path "$adapter_path"
|
||||
```
|
||||
|
||||
FastH3 adapters are hybrid startup patches: alongside low-rank factors they
|
||||
may contain dense deltas and a VSA compression-gate replacement. They must be
|
||||
selected when the server starts. A request may carry the vLLM-Omni `lora`
|
||||
selector, but its name, path, and scale must match that startup adapter. A VSA
|
||||
adapter also needs `attention_backend: VIDEO_SPARSE_ATTN_H3`, `VSA_sparsity`,
|
||||
and `VSA_tile_size` like the full-checkpoint config.
|
||||
|
||||
Submit and poll an asynchronous job:
|
||||
|
||||
```bash
|
||||
job_id="$(curl -sS http://localhost:8000/v1/videos \
|
||||
-H 'content-type: application/json' \
|
||||
-d '{"model":"fasth3","prompt":"A fox runs through fresh snow."}' \
|
||||
| jq -r .id)"
|
||||
|
||||
curl -sS "http://localhost:8000/v1/videos/$job_id"
|
||||
curl -o result.mp4 "http://localhost:8000/v1/videos/$job_id/content"
|
||||
```
|
||||
|
||||
For a blocking call, `POST /v1/videos/sync` returns the MP4 body directly.
|
||||
@@ -0,0 +1,49 @@
|
||||
# OpenAI-compatible FastH3 full-checkpoint server.
|
||||
generator:
|
||||
model_path: FastVideo/FastVideo-Minimax-FastH3-Preview-v0.2
|
||||
engine:
|
||||
num_gpus: 4
|
||||
use_fsdp_inference: false
|
||||
parallelism:
|
||||
tp_size: 1
|
||||
sp_size: 4
|
||||
offload:
|
||||
dit: false
|
||||
dit_layerwise: false
|
||||
text_encoder: true
|
||||
image_encoder: true
|
||||
vae: true
|
||||
pin_cpu_memory: true
|
||||
compile:
|
||||
enabled: false
|
||||
vae_enabled: false
|
||||
pipeline:
|
||||
workload_type: t2v
|
||||
experimental:
|
||||
attention_backend: VIDEO_SPARSE_ATTN_H3
|
||||
VSA_sparsity: 0.9
|
||||
VSA_tile_size: 64
|
||||
inference_torch_compile: false
|
||||
vae_parallel_decode: true
|
||||
vae_parallel_decode_strategy: gather
|
||||
|
||||
server:
|
||||
host: 0.0.0.0
|
||||
port: 8000
|
||||
output_dir: outputs/openai_fasth3
|
||||
served_model_name: fasth3
|
||||
|
||||
default_request:
|
||||
negative_prompt: ""
|
||||
sampling:
|
||||
height: 768
|
||||
width: 1344
|
||||
num_frames: 124
|
||||
fps: 24
|
||||
num_inference_steps: 5
|
||||
guidance_scale: 1.0
|
||||
batch_cfg: false
|
||||
seed: 1000
|
||||
output:
|
||||
return_frames: false
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
# OpenAI-compatible FastH3 startup-LoRA server. Override lora_path at launch
|
||||
# with the exact file returned by `hf download`; see README.md.
|
||||
generator:
|
||||
model_path: MiniMaxAI/MiniMax-H3
|
||||
engine:
|
||||
num_gpus: 4
|
||||
use_fsdp_inference: false
|
||||
parallelism:
|
||||
tp_size: 1
|
||||
sp_size: 4
|
||||
offload:
|
||||
dit: false
|
||||
dit_layerwise: false
|
||||
text_encoder: true
|
||||
image_encoder: true
|
||||
vae: true
|
||||
pin_cpu_memory: true
|
||||
compile:
|
||||
enabled: false
|
||||
vae_enabled: false
|
||||
pipeline:
|
||||
workload_type: t2v
|
||||
components:
|
||||
lora_path: /path/to/adapter_model.safetensors
|
||||
lora_nickname: fasth3-dense-datafree
|
||||
lora_strength: 1.0
|
||||
experimental:
|
||||
attention_backend: FLASH_ATTN
|
||||
inference_torch_compile: false
|
||||
vae_parallel_decode: true
|
||||
vae_parallel_decode_strategy: gather
|
||||
|
||||
server:
|
||||
host: 0.0.0.0
|
||||
port: 8000
|
||||
output_dir: outputs/openai_fasth3_lora
|
||||
served_model_name: fasth3-dense-datafree
|
||||
|
||||
default_request:
|
||||
negative_prompt: ""
|
||||
sampling:
|
||||
height: 768
|
||||
width: 1344
|
||||
num_frames: 124
|
||||
fps: 24
|
||||
num_inference_steps: 5
|
||||
guidance_scale: 1.0
|
||||
batch_cfg: false
|
||||
seed: 1000
|
||||
output:
|
||||
return_frames: false
|
||||
@@ -114,7 +114,63 @@ list(APPEND TORCH_INCLUDE_DIRS ${TORCH_INCLUDE_PATHS})
|
||||
# Find Torch package (still useful for libraries)
|
||||
find_package(Torch REQUIRED)
|
||||
|
||||
# Include directories
|
||||
# The Ulysses kernel needs NCCL's 2.29 device API. Keep it optional so ROCm,
|
||||
# older NCCL installs, and minimal CUDA builders still produce a usable wheel.
|
||||
set(FASTVIDEO_KERNEL_BUILD_ULYSSES_A2A "AUTO" CACHE STRING
|
||||
"Build the NCCL-device-API Ulysses all-to-all: AUTO/ON/OFF")
|
||||
set_property(CACHE FASTVIDEO_KERNEL_BUILD_ULYSSES_A2A PROPERTY STRINGS AUTO ON OFF)
|
||||
set(ENABLE_ULYSSES_A2A OFF)
|
||||
|
||||
if(NOT GPU_BACKEND STREQUAL "ROCM" AND NOT FASTVIDEO_KERNEL_BUILD_ULYSSES_A2A STREQUAL "OFF")
|
||||
# nvidia.nccl is a namespace package, so find_spec rather than __file__.
|
||||
execute_process(COMMAND "${Python_EXECUTABLE}" -c
|
||||
"import importlib.util as u;s=u.find_spec('nvidia.nccl');print(list(s.submodule_search_locations)[0] if s else '')"
|
||||
OUTPUT_VARIABLE NCCL_PIP_ROOT OUTPUT_STRIP_TRAILING_WHITESPACE ERROR_QUIET)
|
||||
find_path(NCCL_INCLUDE_DIR nccl_device/coop.h
|
||||
HINTS $ENV{NCCL_HOME}/include ${NCCL_PIP_ROOT}/include)
|
||||
find_library(NCCL_LIBRARY NAMES nccl
|
||||
HINTS $ENV{NCCL_HOME}/lib $ENV{NCCL_HOME}/lib64 ${NCCL_PIP_ROOT}/lib)
|
||||
# PyPI's nvidia-nccl-cu* wheels ship the SONAME but not the development
|
||||
# symlink (libnccl.so.2, no libnccl.so). Accept that exact versioned name;
|
||||
# target_link_libraries can link an absolute SONAME path directly.
|
||||
if(NOT NCCL_LIBRARY)
|
||||
find_file(NCCL_VERSIONED_LIBRARY NAMES libnccl.so.2
|
||||
HINTS $ENV{NCCL_HOME}/lib $ENV{NCCL_HOME}/lib64 ${NCCL_PIP_ROOT}/lib)
|
||||
if(NCCL_VERSIONED_LIBRARY)
|
||||
set(NCCL_LIBRARY "${NCCL_VERSIONED_LIBRARY}")
|
||||
endif()
|
||||
endif()
|
||||
|
||||
if(NCCL_INCLUDE_DIR AND NCCL_LIBRARY)
|
||||
include(CheckCXXSourceCompiles)
|
||||
set(_FASTVIDEO_REQUIRED_INCLUDES "${CMAKE_REQUIRED_INCLUDES}")
|
||||
# nccl.h includes cuda_runtime.h, so a host-compiler feature probe
|
||||
# needs the toolkit includes explicitly even though CUDA is enabled.
|
||||
set(CMAKE_REQUIRED_INCLUDES "${NCCL_INCLUDE_DIR};${CUDAToolkit_INCLUDE_DIRS}")
|
||||
unset(NCCL_HAS_REQUIRED_DEVICE_API CACHE)
|
||||
check_cxx_source_compiles("\
|
||||
#define NCCL_HOSTLIB_ONLY
|
||||
#include <cstddef>
|
||||
#include <nccl_device.h>
|
||||
int main() {
|
||||
ncclDevCommRequirements reqs = NCCL_DEV_COMM_REQUIREMENTS_INITIALIZER;
|
||||
ncclCommProperties props = NCCL_COMM_PROPERTIES_INITIALIZER;
|
||||
return (reqs.size == 0 || props.size == 0);
|
||||
}"
|
||||
NCCL_HAS_REQUIRED_DEVICE_API)
|
||||
set(CMAKE_REQUIRED_INCLUDES "${_FASTVIDEO_REQUIRED_INCLUDES}")
|
||||
endif()
|
||||
|
||||
if(NCCL_HAS_REQUIRED_DEVICE_API)
|
||||
set(ENABLE_ULYSSES_A2A ON)
|
||||
elseif(FASTVIDEO_KERNEL_BUILD_ULYSSES_A2A STREQUAL "ON")
|
||||
message(FATAL_ERROR
|
||||
"FASTVIDEO_KERNEL_BUILD_ULYSSES_A2A=ON requires NCCL device headers/library "
|
||||
"with NCCL_DEV_COMM_REQUIREMENTS_INITIALIZER (NCCL 2.29+). "
|
||||
"Resolved include='${NCCL_INCLUDE_DIR}', library='${NCCL_LIBRARY}'.")
|
||||
endif()
|
||||
endif()
|
||||
|
||||
include_directories(
|
||||
${CMAKE_SOURCE_DIR}/include
|
||||
${CMAKE_SOURCE_DIR}/include/cutlass/include
|
||||
@@ -124,6 +180,9 @@ include_directories(
|
||||
${CMAKE_SOURCE_DIR}/csrc/turbodiffusion
|
||||
${TORCH_INCLUDE_DIRS}
|
||||
)
|
||||
if(ENABLE_ULYSSES_A2A)
|
||||
include_directories(${NCCL_INCLUDE_DIR})
|
||||
endif()
|
||||
|
||||
# ---------------------------
|
||||
# ThunderKittens (TK) toggles
|
||||
@@ -157,6 +216,7 @@ endif()
|
||||
message(STATUS "TORCH_CUDA_ARCH_LIST (cmake/env): ${TORCH_CUDA_ARCH_LIST}")
|
||||
message(STATUS "FASTVIDEO_KERNEL_BUILD_TK: ${FASTVIDEO_KERNEL_BUILD_TK}")
|
||||
message(STATUS "FASTVIDEO_KERNEL_BUILD_ATTN_QAT_INFER: ${FASTVIDEO_KERNEL_BUILD_ATTN_QAT_INFER}")
|
||||
message(STATUS "FASTVIDEO_KERNEL_BUILD_ULYSSES_A2A: ${FASTVIDEO_KERNEL_BUILD_ULYSSES_A2A}")
|
||||
|
||||
set(ENABLE_TK_KERNELS OFF)
|
||||
if(FASTVIDEO_KERNEL_BUILD_TK STREQUAL "ON")
|
||||
@@ -307,6 +367,9 @@ if(BUILD_CXX_KERNELS)
|
||||
csrc/turbodiffusion/norm/layernorm.cu
|
||||
csrc/turbodiffusion/quant/quant.cu
|
||||
)
|
||||
if(ENABLE_ULYSSES_A2A)
|
||||
list(APPEND EXTENSION_SOURCES csrc/comm/ulysses_all_to_all.cu)
|
||||
endif()
|
||||
|
||||
# Conditionally add TK kernels
|
||||
if(ENABLE_TK_KERNELS)
|
||||
@@ -371,6 +434,9 @@ if(BUILD_CXX_KERNELS)
|
||||
if(ENABLE_TK_KERNELS)
|
||||
list(APPEND COMPILE_DEFS TK_COMPILE_ST_ATTN TK_COMPILE_BLOCK_SPARSE)
|
||||
endif()
|
||||
if(ENABLE_ULYSSES_A2A)
|
||||
list(APPEND COMPILE_DEFS FASTVIDEO_KERNEL_COMPILE_ULYSSES_A2A)
|
||||
endif()
|
||||
|
||||
|
||||
target_compile_definitions(fastvideo_kernel_ops PRIVATE ${COMPILE_DEFS})
|
||||
@@ -382,6 +448,9 @@ if(BUILD_CXX_KERNELS)
|
||||
# Link against Torch libraries to avoid undefined symbols at import time
|
||||
# (e.g., torch::autograd vtables) when loading the extension module.
|
||||
target_link_libraries(fastvideo_kernel_ops PRIVATE ${TORCH_LIBRARIES})
|
||||
if(ENABLE_ULYSSES_A2A)
|
||||
target_link_libraries(fastvideo_kernel_ops PRIVATE "${NCCL_LIBRARY}")
|
||||
endif()
|
||||
|
||||
# Also link against libtorch_python to satisfy Python-binding symbols
|
||||
# (e.g., torch::PyWarningHandler) required by torch/extension.h.
|
||||
@@ -485,6 +554,7 @@ message(STATUS "host / backend: ${CMAKE_SYSTEM_PROCESSOR} / ${GPU_BACKEND}
|
||||
message(STATUS "TORCH_CUDA_ARCH_LIST: ${TORCH_CUDA_ARCH_LIST}")
|
||||
message(STATUS "fastvideo_kernel_ops: ON (turbodiffusion int8-gemm/quant/rmsnorm/layernorm, all listed archs)")
|
||||
message(STATUS " + TK sta/block_sparse (sm_90a only): ${ENABLE_TK_KERNELS}")
|
||||
message(STATUS " + Ulysses NCCL-device all-to-all: ${ENABLE_ULYSSES_A2A}")
|
||||
message(STATUS "fp4attn/fp4quant (sm_120a only, CUDA >= 12.8): ${ENABLE_ATTN_QAT_INFER}")
|
||||
message(STATUS "Triton fallbacks ship in python/fastvideo_kernel/triton_kernels regardless.")
|
||||
message(STATUS "============================================================")
|
||||
|
||||
@@ -10,6 +10,8 @@ Compiled CUDA extensions (CMake, see the build summary printed at the end of eve
|
||||
|---|---|---|---|---|
|
||||
| `fastvideo_kernel._C.fastvideo_kernel_ops` | TurboDiffusion INT8 GEMM, quant, RMSNorm, LayerNorm | `csrc/turbodiffusion/` | every arch in `TORCH_CUDA_ARCH_LIST` | always built |
|
||||
| same extension, optional part | ThunderKittens sliding-tile attention (`sta_fwd`) and VSA block-sparse (`block_sparse_fwd/bwd`) | `csrc/attention/*_h100.cu` | Hopper `sm_90a` only | `FASTVIDEO_KERNEL_BUILD_TK` (AUTO = ON iff `9.0a` is in the arch list; always OFF on aarch64 hosts — TK headers don't compile there) |
|
||||
| same extension, optional part | MiniMax-H3 block-sparse VSA forward (64- and 128-token blocks) | `csrc/attention/block_sparse*_sm100a.cu` | Blackwell `sm_100a` only | ON iff `10.0a` is in `TORCH_CUDA_ARCH_LIST` |
|
||||
| same extension, optional part | fused NVLink Ulysses all-to-all | `csrc/comm/ulysses_all_to_all.cu` | CUDA | `FASTVIDEO_KERNEL_BUILD_ULYSSES_A2A` (AUTO = ON with NCCL 2.29+ device headers and library; always OFF on ROCm) |
|
||||
| `fp4attn_cuda`, `fp4quant_cuda` | FP4 attention + quantization ("attn_qat_infer", modified SageAttention3) | `attn_qat_infer/` | consumer Blackwell `sm_120a` only, CUDA ≥ 12.8 | `FASTVIDEO_KERNEL_BUILD_ATTN_QAT_INFER` (AUTO = ON iff `12.0a` is in the arch list) |
|
||||
|
||||
Runtime-JIT kernels (no build step, ship in every wheel/image):
|
||||
@@ -22,20 +24,23 @@ Runtime-JIT kernels (no build step, ship in every wheel/image):
|
||||
|
||||
## What gets built where, and when
|
||||
|
||||
| Surface | Trigger | Leg | `TORCH_CUDA_ARCH_LIST` | TK | FP4 |
|
||||
|---|---|---|---|---|---|
|
||||
| PyPI wheels (`.github/workflows/publish-kernel.yml`) | version bump in `fastvideo-kernel/pyproject.toml` on main, or manual dispatch | x86_64 cu126 | `9.0a` | ON | — (CUDA < 12.8) |
|
||||
| | | x86_64 cu130 | `9.0a;12.0a` | ON | ON |
|
||||
| | | aarch64 cu130 | `10.0a;12.0a` | — | ON |
|
||||
| Docker images `ghcr.io/hao-ai-lab/fastvideo/fastvideo-dev` (`.github/workflows/infra-build-image.yml`) | `docker/Dockerfile` changes on main, or manual dispatch | amd64 cuda12.6.3 + cuda13.0.0 | `9.0a` | ON | — |
|
||||
| | | arm64 cuda12.6.3 (GH200) | `9.0a` | — (aarch64) | — |
|
||||
| | | arm64 cuda13.0.0 (GB10 / DGX Spark) | `12.1` | — | — |
|
||||
| Local `./build.sh` | manual | probes the visible GPU via torch | detected | ON iff sm_90 (non-aarch64 host) | ON iff sm_120 |
|
||||
| Surface | Trigger | Leg | `TORCH_CUDA_ARCH_LIST` | TK | Ulysses | FP4 |
|
||||
|---|---|---|---|---|---|---|
|
||||
| PyPI wheels (`.github/workflows/publish-kernel.yml`) | version bump in `fastvideo-kernel/pyproject.toml` on main, or manual dispatch | x86_64 cu126 | `9.0a` | ON | AUTO | — (CUDA < 12.8) |
|
||||
| | | x86_64 cu130 | `9.0a;10.0a;12.0a` | ON | AUTO | ON |
|
||||
| | | aarch64 cu130 | `10.0a;12.0a` | — | AUTO | ON |
|
||||
| Docker images `ghcr.io/hao-ai-lab/fastvideo/fastvideo-dev` (`.github/workflows/infra-build-image.yml`) | `docker/Dockerfile` changes on main, or manual dispatch | amd64 cuda12.6.3 + cuda13.0.0 | `9.0a` | ON | AUTO | — |
|
||||
| | | arm64 cuda12.6.3 (GH200) | `9.0a` | — (aarch64) | AUTO | — |
|
||||
| | | arm64 cuda13.0.0 (GB10 / DGX Spark) | `12.1` | — | AUTO | — |
|
||||
| Local `./build.sh` | manual | probes the visible GPU via torch | detected | ON iff sm_90 (non-aarch64 host) | AUTO | ON iff sm_120 |
|
||||
|
||||
Notes:
|
||||
|
||||
- No Docker image ships the FP4 kernels; only the x86_64/aarch64 cu130 wheels do.
|
||||
- Both cu130 PyPI wheels ship the MiniMax-H3 sm_100a VSA forward.
|
||||
- On arm64 images (GH200 included) STA/VSA run on the Triton fallbacks, since TK never builds on aarch64.
|
||||
- Ulysses AUTO builds only when CMake finds a NCCL library and device-API headers with the 2.29 initializers. Use
|
||||
`-DFASTVIDEO_KERNEL_BUILD_ULYSSES_A2A=ON` to require it or `OFF` to test the portable build.
|
||||
- Kernel tests run on Buildkite GPU CI for PRs touching `fastvideo-kernel/**` (see `.buildkite/pipeline.yml`).
|
||||
|
||||
## Installation
|
||||
|
||||
@@ -0,0 +1,279 @@
|
||||
/*
|
||||
* Copyright (c) 2025 by FlashInfer team.
|
||||
*
|
||||
* Adapted from flashinfer-ai/flashinfer @ 8a94642d83cba0939035868fb6c309b4474a13d6
|
||||
* (PR #3820), csrc/ulysses_all_to_all.cu.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
// Torch host bindings for the fused Ulysses all-to-all. Kernel in
|
||||
// include/comm/ulysses_all_to_all.cuh.
|
||||
//
|
||||
// The per-group context is an ncclDevComm plus a registered symmetric window,
|
||||
// both created here from the caller's ncclComm_t.
|
||||
|
||||
#include <c10/cuda/CUDAGuard.h>
|
||||
#include <c10/cuda/CUDAStream.h>
|
||||
#include <torch/extension.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <cstdint>
|
||||
#include <memory>
|
||||
#include <vector>
|
||||
|
||||
#include <nccl.h>
|
||||
#include <nccl_device.h>
|
||||
|
||||
#include "comm/ulysses_all_to_all.cuh"
|
||||
|
||||
namespace fi = fastvideo::comm::ulysses;
|
||||
|
||||
namespace {
|
||||
|
||||
// A symmetric window every rank can store into, plus the ncclDevComm the
|
||||
// kernel opens barrier sessions on.
|
||||
struct UlyssesContext {
|
||||
ncclComm_t comm = nullptr;
|
||||
ncclWindow_t win = nullptr;
|
||||
void* buf = nullptr;
|
||||
size_t nbytes = 0;
|
||||
ncclDevComm devComm{};
|
||||
bool dev_comm_created = false;
|
||||
int device = -1;
|
||||
int rank = 0;
|
||||
int world = 0;
|
||||
};
|
||||
|
||||
#define NCCL_TRY(expr, what) \
|
||||
do { \
|
||||
ncclResult_t _r = (expr); \
|
||||
TORCH_CHECK(_r == ncclSuccess, what " failed: rc=", static_cast<int>(_r)); \
|
||||
} while (0)
|
||||
|
||||
} // namespace
|
||||
|
||||
// Allocate the local half of a context. This is intentionally separate from
|
||||
// registration so Python can vote after local allocation: if one rank is OOM,
|
||||
// no peer enters a collective window registration alone.
|
||||
int64_t allocate_ulysses_a2a(int64_t nbytes, int64_t rank, int64_t world_size,
|
||||
int64_t device_index) {
|
||||
TORCH_CHECK(world_size == 2 || world_size == 4 || world_size == 6 || world_size == 8,
|
||||
"ulysses a2a only supports world size in (2, 4, 6, 8), got ", world_size);
|
||||
TORCH_CHECK(rank >= 0 && rank < world_size, "invalid rank");
|
||||
TORCH_CHECK(nbytes > 0, "nbytes must be positive");
|
||||
TORCH_CHECK(device_index >= 0, "device index must be non-negative");
|
||||
|
||||
const at::cuda::CUDAGuard device_guard(
|
||||
c10::Device(c10::DeviceType::CUDA, static_cast<c10::DeviceIndex>(device_index)));
|
||||
auto ctx = std::make_unique<UlyssesContext>();
|
||||
ctx->nbytes = static_cast<size_t>(nbytes);
|
||||
ctx->device = static_cast<int>(device_index);
|
||||
ctx->rank = static_cast<int>(rank);
|
||||
ctx->world = static_cast<int>(world_size);
|
||||
|
||||
// The window must come from NCCL's allocator (4096B aligned per
|
||||
// NCCL_WIN_REQUIRED_ALIGNMENT), which is why this is not a torch tensor.
|
||||
NCCL_TRY(ncclMemAlloc(&ctx->buf, ctx->nbytes), "ncclMemAlloc");
|
||||
|
||||
return reinterpret_cast<int64_t>(ctx.release());
|
||||
}
|
||||
|
||||
// Register the user window. Collective: every rank must call together.
|
||||
void register_ulysses_a2a_window(int64_t handle, int64_t comm_ptr) {
|
||||
auto* ctx = reinterpret_cast<UlyssesContext*>(handle);
|
||||
TORCH_CHECK(ctx != nullptr, "handle must come from allocate_ulysses_a2a");
|
||||
TORCH_CHECK(ctx->buf != nullptr && ctx->win == nullptr && !ctx->dev_comm_created,
|
||||
"ulysses a2a context is not in the allocated state");
|
||||
|
||||
const at::cuda::CUDAGuard device_guard(
|
||||
c10::Device(c10::DeviceType::CUDA, static_cast<c10::DeviceIndex>(ctx->device)));
|
||||
ctx->comm = reinterpret_cast<ncclComm_t>(comm_ptr);
|
||||
NCCL_TRY(ncclCommWindowRegister(ctx->comm, ctx->buf, ctx->nbytes, &ctx->win,
|
||||
NCCL_WIN_COLL_SYMMETRIC),
|
||||
"ncclCommWindowRegister");
|
||||
}
|
||||
|
||||
// Create the device communicator only after Python has voted that every rank
|
||||
// registered its window. This operation is collective as well.
|
||||
void create_ulysses_a2a_dev_comm(int64_t handle) {
|
||||
auto* ctx = reinterpret_cast<UlyssesContext*>(handle);
|
||||
TORCH_CHECK(ctx != nullptr, "handle must come from allocate_ulysses_a2a");
|
||||
TORCH_CHECK(ctx->comm != nullptr && ctx->win != nullptr && !ctx->dev_comm_created,
|
||||
"ulysses a2a context is not in the window-registered state");
|
||||
|
||||
const at::cuda::CUDAGuard device_guard(
|
||||
c10::Device(c10::DeviceType::CUDA, static_cast<c10::DeviceIndex>(ctx->device)));
|
||||
|
||||
ncclDevCommRequirements reqs = NCCL_DEV_COMM_REQUIREMENTS_INITIALIZER;
|
||||
reqs.lsaBarrierCount = fi::kMaxBlocks;
|
||||
NCCL_TRY(ncclDevCommCreate(ctx->comm, &reqs, &ctx->devComm), "ncclDevCommCreate");
|
||||
ctx->dev_comm_created = true;
|
||||
}
|
||||
|
||||
static ncclResult_t first_error(ncclResult_t current, ncclResult_t next) {
|
||||
return current == ncclSuccess ? next : current;
|
||||
}
|
||||
|
||||
void dispose_ulysses_a2a(int64_t handle) {
|
||||
auto* ctx = reinterpret_cast<UlyssesContext*>(handle);
|
||||
if (ctx == nullptr) return;
|
||||
|
||||
const at::cuda::CUDAGuard device_guard(
|
||||
c10::Device(c10::DeviceType::CUDA, static_cast<c10::DeviceIndex>(ctx->device)));
|
||||
ncclResult_t result = ncclSuccess;
|
||||
if (ctx->comm != nullptr && ctx->dev_comm_created) {
|
||||
result = first_error(result, ncclDevCommDestroy(ctx->comm, &ctx->devComm));
|
||||
ctx->dev_comm_created = false;
|
||||
}
|
||||
if (ctx->comm != nullptr && ctx->win != nullptr) {
|
||||
result = first_error(result, ncclCommWindowDeregister(ctx->comm, ctx->win));
|
||||
ctx->win = nullptr;
|
||||
}
|
||||
if (ctx->buf != nullptr) {
|
||||
result = first_error(result, ncclMemFree(ctx->buf));
|
||||
ctx->buf = nullptr;
|
||||
}
|
||||
delete ctx;
|
||||
TORCH_CHECK(result == ncclSuccess, "ulysses a2a cleanup failed: rc=", static_cast<int>(result));
|
||||
}
|
||||
|
||||
// Whether the whole group is load-store accessible. NCCL determined this at
|
||||
// ncclCommInitRank.
|
||||
bool ulysses_lsa_covers_group(int64_t comm_ptr, int64_t world_size) {
|
||||
auto comm = reinterpret_cast<ncclComm_t>(comm_ptr);
|
||||
ncclCommProperties properties = NCCL_COMM_PROPERTIES_INITIALIZER;
|
||||
NCCL_TRY(ncclCommQueryProperties(comm, &properties), "ncclCommQueryProperties");
|
||||
ncclTeam_t lsa = ncclTeamLsa(comm);
|
||||
return properties.deviceApiSupport && lsa.nRanks == static_cast<int>(world_size);
|
||||
}
|
||||
|
||||
// Fused-transpose Ulysses all-to-all.
|
||||
// mode == 0: inp [B, S_local, H, D] -> out [B, S_global, H_local, D]
|
||||
// mode == 1: inp [B, S_global, H_local, D] -> out [B, S_local, H, D]
|
||||
// where H is the *global* head count and H_local = H / world_size.
|
||||
void ulysses_a2a(int64_t handle, torch::Tensor inp, torch::Tensor out, int64_t B, int64_t S_local,
|
||||
int64_t H, int64_t D, int64_t mode) {
|
||||
auto* ctx = reinterpret_cast<UlyssesContext*>(handle);
|
||||
TORCH_CHECK(ctx != nullptr, "handle must come from allocate_ulysses_a2a");
|
||||
|
||||
const at::cuda::CUDAGuard device_guard(inp.device());
|
||||
auto stream = at::cuda::getCurrentCUDAStream();
|
||||
|
||||
TORCH_CHECK(inp.is_cuda() && out.is_cuda(), "inp and out must be CUDA tensors");
|
||||
TORCH_CHECK(inp.is_contiguous() && out.is_contiguous(), "inp and out must be contiguous");
|
||||
TORCH_CHECK(inp.device() == out.device(), "inp and out must be on the same device");
|
||||
TORCH_CHECK(inp.scalar_type() == out.scalar_type(), "inp and out must share a dtype");
|
||||
TORCH_CHECK(inp.numel() == out.numel(), "inp and out must have equal element counts");
|
||||
TORCH_CHECK(inp.get_device() == ctx->device, "input is on CUDA device ", inp.get_device(),
|
||||
" but the Ulysses context belongs to device ", ctx->device);
|
||||
TORCH_CHECK(mode == 0 || mode == 1, "mode must be 0 or 1");
|
||||
TORCH_CHECK(inp.dim() == 4 && out.dim() == 4, "inp and out must be 4-D");
|
||||
|
||||
const int W = ctx->world;
|
||||
TORCH_CHECK(H % W == 0, "global head count must be divisible by world size");
|
||||
const int H_local = static_cast<int>(H / W);
|
||||
|
||||
const torch::Tensor& local_op = (mode == 0) ? inp : out; // [B, S_local, H, D]
|
||||
const torch::Tensor& global_op = (mode == 0) ? out : inp; // [B, S_global, H_local, D]
|
||||
TORCH_CHECK(local_op.size(0) == B && local_op.size(1) == S_local && local_op.size(2) == H &&
|
||||
local_op.size(3) == D,
|
||||
"the [B, S_local, H, D] operand of mode ", mode, " has shape (", local_op.size(0),
|
||||
", ", local_op.size(1), ", ", local_op.size(2), ", ", local_op.size(3),
|
||||
"), expected (", B, ", ", S_local, ", ", H, ", ", D, ")");
|
||||
TORCH_CHECK(global_op.size(0) == B && global_op.size(1) == W * S_local &&
|
||||
global_op.size(2) == H_local && global_op.size(3) == D,
|
||||
"the [B, S_global, H_local, D] operand of mode ", mode, " has shape (",
|
||||
global_op.size(0), ", ", global_op.size(1), ", ", global_op.size(2), ", ",
|
||||
global_op.size(3), "), expected (", B, ", ", W * S_local, ", ", H_local, ", ", D,
|
||||
")");
|
||||
|
||||
const size_t out_bytes = out.numel() * out.element_size();
|
||||
TORCH_CHECK(out_bytes <= ctx->nbytes, "operand of ", out_bytes,
|
||||
" bytes exceeds the window capacity ", ctx->nbytes);
|
||||
|
||||
const int64_t num_rows = B * static_cast<int64_t>(W) * S_local;
|
||||
const int blocks =
|
||||
static_cast<int>(std::max<int64_t>(1, std::min<int64_t>(fi::kMaxBlocks, num_rows)));
|
||||
const int threads = fi::kUlyssesThreads;
|
||||
|
||||
#define LAUNCH_ULYSSES_A2A(T, NG, MODE) \
|
||||
fi::ulysses_a2a_kernel<T, NG, MODE><<<blocks, threads, 0, stream>>>( \
|
||||
reinterpret_cast<const T*>(inp.data_ptr()), ctx->devComm, ctx->win, /*off=*/0, \
|
||||
ctx->rank, static_cast<int>(B), static_cast<int>(S_local), H_local, static_cast<int>(D))
|
||||
|
||||
#define DISPATCH_NGPUS(T, MODE) \
|
||||
switch (W) { \
|
||||
case 2: \
|
||||
LAUNCH_ULYSSES_A2A(T, 2, MODE); \
|
||||
break; \
|
||||
case 4: \
|
||||
LAUNCH_ULYSSES_A2A(T, 4, MODE); \
|
||||
break; \
|
||||
case 6: \
|
||||
LAUNCH_ULYSSES_A2A(T, 6, MODE); \
|
||||
break; \
|
||||
case 8: \
|
||||
LAUNCH_ULYSSES_A2A(T, 8, MODE); \
|
||||
break; \
|
||||
default: \
|
||||
TORCH_CHECK(false, "ulysses_a2a only supports world size in (2,4,6,8)"); \
|
||||
}
|
||||
|
||||
#define DISPATCH_DTYPE(MODE) \
|
||||
switch (out.scalar_type()) { \
|
||||
case at::ScalarType::Float: { \
|
||||
DISPATCH_NGPUS(float, MODE); \
|
||||
break; \
|
||||
} \
|
||||
case at::ScalarType::Half: { \
|
||||
DISPATCH_NGPUS(half, MODE); \
|
||||
break; \
|
||||
} \
|
||||
case at::ScalarType::BFloat16: { \
|
||||
DISPATCH_NGPUS(nv_bfloat16, MODE); \
|
||||
break; \
|
||||
} \
|
||||
default: \
|
||||
TORCH_CHECK(false, "ulysses_a2a only supports float32, float16 and bfloat16, got ", \
|
||||
out.scalar_type()); \
|
||||
}
|
||||
|
||||
if (mode == 0) {
|
||||
DISPATCH_DTYPE(0);
|
||||
} else {
|
||||
DISPATCH_DTYPE(1);
|
||||
}
|
||||
|
||||
#undef DISPATCH_DTYPE
|
||||
#undef DISPATCH_NGPUS
|
||||
#undef LAUNCH_ULYSSES_A2A
|
||||
|
||||
TORCH_CHECK(cudaGetLastError() == cudaSuccess, "ulysses_a2a kernel launch failed");
|
||||
// Copy this rank's completed result out of the window.
|
||||
auto status = cudaMemcpyAsync(out.data_ptr(), ctx->buf, out_bytes, cudaMemcpyDeviceToDevice,
|
||||
stream);
|
||||
TORCH_CHECK(status == cudaSuccess, "ulysses_a2a copy-out failed: ", cudaGetErrorString(status));
|
||||
}
|
||||
|
||||
void register_ulysses_a2a(pybind11::module_& m) {
|
||||
m.def("allocate_ulysses_a2a", &allocate_ulysses_a2a, "allocate a local ulysses a2a window");
|
||||
m.def("register_ulysses_a2a_window", ®ister_ulysses_a2a_window,
|
||||
"register the ulysses a2a window collectively");
|
||||
m.def("create_ulysses_a2a_dev_comm", &create_ulysses_a2a_dev_comm,
|
||||
"create the ulysses a2a device communicator collectively");
|
||||
m.def("dispose_ulysses_a2a", &dispose_ulysses_a2a, "release a ulysses a2a context");
|
||||
m.def("ulysses_lsa_covers_group", &ulysses_lsa_covers_group,
|
||||
"whether the whole group is load-store accessible");
|
||||
m.def("ulysses_a2a", &ulysses_a2a, "fused-transpose Ulysses all-to-all over NVLink");
|
||||
}
|
||||
@@ -22,6 +22,11 @@ extern std::vector<torch::Tensor> block_sparse_attention_backward(
|
||||
);
|
||||
#endif
|
||||
|
||||
#ifdef FASTVIDEO_KERNEL_COMPILE_ULYSSES_A2A
|
||||
// Ulysses sequence-parallel all-to-all (csrc/comm/)
|
||||
void register_ulysses_a2a(pybind11::module_ &);
|
||||
#endif
|
||||
|
||||
// TurboDiffusion kernels
|
||||
void register_quant(pybind11::module_ &);
|
||||
void register_rms_norm(pybind11::module_ &);
|
||||
@@ -61,6 +66,11 @@ PYBIND11_MODULE(TORCH_EXTENSION_NAME, m) {
|
||||
m.def("block_sparse_bwd", torch::wrap_pybind_function(block_sparse_attention_backward), "block sparse attention backward (Hopper)");
|
||||
#endif
|
||||
|
||||
#ifdef FASTVIDEO_KERNEL_COMPILE_ULYSSES_A2A
|
||||
// Ulysses sequence-parallel all-to-all
|
||||
register_ulysses_a2a(m);
|
||||
#endif
|
||||
|
||||
// TurboDiffusion
|
||||
register_quant(m);
|
||||
register_rms_norm(m);
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
/*
|
||||
* Copyright (c) 2025 by FlashInfer team.
|
||||
*
|
||||
* Adapted from flashinfer-ai/flashinfer @ 8a94642d83cba0939035868fb6c309b4474a13d6
|
||||
* (PR #3820), which in turn adapted ThunderKittens' NVLink all-to-all:
|
||||
* https://github.com/HazyResearch/ThunderKittens/blob/main/kernels/parallel/all_to_all/all_to_all.cu
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
// Fused-transpose Ulysses all-to-all over NVLink. Peer addresses come from
|
||||
// ncclGetLsaPointer and synchronization from ncclLsaBarrierSession; the index
|
||||
// math and slab decomposition below are upstream's.
|
||||
//
|
||||
// head_dim == 2 layout, uniform sequence splits. With
|
||||
// W = ulysses world size
|
||||
// H_local = H / W
|
||||
// S_global = S_local * W
|
||||
//
|
||||
// mode == 0 (input a2a): [B, S_local, H, D] -> [B, S_global, H_local, D]
|
||||
// y_r[b, j*S_local + s, hl, d] = x_j[b, s, r*H_local + hl, d]
|
||||
// mode == 1 (output a2a): [B, S_global, H_local, D] -> [B, S_local, H, D]
|
||||
// out_j[b, s, r*H_local + hl, d] = u_r[b, j*S_local + s, hl, d]
|
||||
//
|
||||
// In both modes the unit of transfer is a contiguous (H_local * D) block, so
|
||||
// every cross-GPU store is fully coalesced.
|
||||
|
||||
#ifndef FASTVIDEO_COMM_ULYSSES_ALL_TO_ALL_CUH_
|
||||
#define FASTVIDEO_COMM_ULYSSES_ALL_TO_ALL_CUH_
|
||||
|
||||
#include <cstdint>
|
||||
|
||||
#include <nccl.h>
|
||||
#include <nccl_device.h>
|
||||
|
||||
namespace fastvideo {
|
||||
namespace comm {
|
||||
namespace ulysses {
|
||||
|
||||
constexpr int kUlyssesThreads = 512;
|
||||
// Deliberately modest: this is link-bandwidth bound, so a small grid leaves the
|
||||
// rest of the GPU free without costing throughput.
|
||||
constexpr int kMaxBlocks = 36;
|
||||
|
||||
// Shared movement body for the fused-transpose all-to-all (no barriers).
|
||||
//
|
||||
// Rows are ordered ((b * W + peer) * S_local + s), so consecutive rows share a
|
||||
// (batch, peer) and are contiguous on the gather side of the transpose. Each
|
||||
// block takes a contiguous slab of rows and flattens its threads over the 16B
|
||||
// units in it, so consecutive lanes address one peer buffer back to back and the
|
||||
// remote writes coalesce into large bursts rather than (H_local * D)-sized
|
||||
// scattered ones.
|
||||
template <typename T, int NGPUS, int MODE>
|
||||
__device__ __forceinline__ void ulysses_a2a_move(const T* __restrict__ local_in,
|
||||
void* const* peer_ptrs, int rank, int B,
|
||||
int S_local, int H_local, int D) {
|
||||
static_assert(MODE == 0 || MODE == 1, "MODE must be 0 or 1");
|
||||
const int W = NGPUS;
|
||||
const int64_t H = static_cast<int64_t>(H_local) * W;
|
||||
const int64_t S_global = static_cast<int64_t>(S_local) * W;
|
||||
const int64_t block_len = static_cast<int64_t>(H_local) * D; // elements/row
|
||||
const int64_t num_rows = static_cast<int64_t>(B) * W * S_local;
|
||||
|
||||
// 16B-vectorized fast path when every row is 16B aligned (the common case:
|
||||
// contiguous bf16/fp16/fp32 tensors with block_len * sizeof(T) % 16 == 0).
|
||||
using Vec = int4;
|
||||
constexpr int kVecBytes = sizeof(Vec);
|
||||
const int64_t row_bytes = block_len * static_cast<int64_t>(sizeof(T));
|
||||
const bool vec_ok =
|
||||
(row_bytes % kVecBytes) == 0 && (reinterpret_cast<uintptr_t>(local_in) % kVecBytes) == 0;
|
||||
|
||||
// Contiguous slab of rows for this block.
|
||||
const int64_t rows_per_block = (num_rows + gridDim.x - 1) / gridDim.x;
|
||||
const int64_t row_lo = static_cast<int64_t>(blockIdx.x) * rows_per_block;
|
||||
int64_t row_hi = row_lo + rows_per_block;
|
||||
if (row_hi > num_rows) row_hi = num_rows;
|
||||
if (row_lo >= row_hi) return;
|
||||
|
||||
const int tid = threadIdx.x;
|
||||
const int nthr = blockDim.x;
|
||||
|
||||
// Decode (b, peer, s) and compute src/dst element offsets for a given row.
|
||||
auto offsets = [&](int64_t row, int64_t& src_off, int64_t& dst_off) {
|
||||
const int64_t s = row % S_local;
|
||||
const int64_t tmp = row / S_local;
|
||||
const int64_t peer = tmp % W;
|
||||
const int64_t b = tmp / W;
|
||||
if constexpr (MODE == 0) {
|
||||
src_off = ((b * S_local + s) * H + peer * H_local) * D;
|
||||
dst_off = (b * S_global + static_cast<int64_t>(rank) * S_local + s) * block_len;
|
||||
} else {
|
||||
src_off = (b * S_global + peer * S_local + s) * block_len;
|
||||
dst_off = ((b * S_local + s) * H + static_cast<int64_t>(rank) * H_local) * D;
|
||||
}
|
||||
return peer;
|
||||
};
|
||||
|
||||
if (vec_ok) {
|
||||
const int64_t units_per_row = row_bytes / kVecBytes;
|
||||
const int64_t total_units = (row_hi - row_lo) * units_per_row;
|
||||
for (int64_t u = tid; u < total_units; u += nthr) {
|
||||
const int64_t local_row = u / units_per_row;
|
||||
const int64_t unit = u - local_row * units_per_row;
|
||||
const int64_t row = row_lo + local_row;
|
||||
int64_t src_off, dst_off;
|
||||
const int64_t peer = offsets(row, src_off, dst_off);
|
||||
const Vec* s4 = reinterpret_cast<const Vec*>(local_in + src_off);
|
||||
Vec* d4 = reinterpret_cast<Vec*>((T*)peer_ptrs[peer] + dst_off);
|
||||
d4[unit] = s4[unit];
|
||||
}
|
||||
} else {
|
||||
// Scalar fallback (unaligned / odd shapes).
|
||||
for (int64_t row = row_lo; row < row_hi; ++row) {
|
||||
int64_t src_off, dst_off;
|
||||
const int64_t peer = offsets(row, src_off, dst_off);
|
||||
const T* s_ptr = local_in + src_off;
|
||||
T* d_ptr = (T*)peer_ptrs[peer] + dst_off;
|
||||
for (int64_t i = tid; i < block_len; i += nthr) {
|
||||
d_ptr[i] = s_ptr[i];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The transfer mode is a compile-time template parameter so the address math
|
||||
// specializes and the coalesced slab decomposition is fully unrolled per mode.
|
||||
template <typename T, int NGPUS, int MODE>
|
||||
__global__ void __launch_bounds__(kUlyssesThreads, 1)
|
||||
ulysses_a2a_kernel(const T* __restrict__ local_in, ncclDevComm devComm, ncclWindow_t win,
|
||||
size_t win_offset, int rank, int B, int S_local, int H_local, int D) {
|
||||
// Resolved once; the movement loop would otherwise call this per 16B store.
|
||||
void* peer_ptrs[NGPUS];
|
||||
#pragma unroll
|
||||
for (int p = 0; p < NGPUS; ++p) {
|
||||
peer_ptrs[p] = ncclGetLsaPointer(win, win_offset, p);
|
||||
}
|
||||
|
||||
// Each CTA owns one barrier generation. Sharing index 0 across independently
|
||||
// scheduled CTAs races the generation counter and is unsupported by NCCL's
|
||||
// device API. The host reserves kMaxBlocks slots when it creates devComm.
|
||||
ncclLsaBarrierSession<ncclCoopCta> bar(
|
||||
ncclCoopCta(), devComm, ncclTeamTagLsa(), /*index=*/blockIdx.x);
|
||||
|
||||
// Every rank must have entered before anyone writes into peer buffers.
|
||||
bar.sync(ncclCoopCta(), cuda::memory_order_relaxed);
|
||||
ulysses_a2a_move<T, NGPUS, MODE>(local_in, peer_ptrs, rank, B, S_local, H_local, D);
|
||||
// Release-acquire: all peer writes visible before a rank reads its window.
|
||||
bar.sync(ncclCoopCta(), cuda::memory_order_acq_rel);
|
||||
}
|
||||
|
||||
} // namespace ulysses
|
||||
} // namespace comm
|
||||
} // namespace fastvideo
|
||||
|
||||
#endif // FASTVIDEO_COMM_ULYSSES_ALL_TO_ALL_CUH_
|
||||
@@ -1,7 +1,7 @@
|
||||
[build-system]
|
||||
requires = [
|
||||
"scikit-build-core>=0.10",
|
||||
"torch>=2.5.0",
|
||||
"torch==2.12.0",
|
||||
"setuptools>=61.0.0",
|
||||
"wheel"
|
||||
]
|
||||
@@ -9,7 +9,7 @@ build-backend = "scikit_build_core.build"
|
||||
|
||||
[project]
|
||||
name = "fastvideo-kernel"
|
||||
version = "0.3.2"
|
||||
version = "0.3.5"
|
||||
description = "Unified CUDA kernels for FastVideo"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
@@ -22,7 +22,7 @@ classifiers = [
|
||||
"Environment :: GPU :: NVIDIA CUDA",
|
||||
]
|
||||
dependencies = [
|
||||
"torch>=2.5.0",
|
||||
"torch==2.12.0",
|
||||
"triton>=2.0.0; sys_platform == 'linux'",
|
||||
]
|
||||
|
||||
|
||||
@@ -49,6 +49,36 @@ def _force_tk() -> bool:
|
||||
return os.environ.get("FASTVIDEO_VSA_TK", "0") == "1"
|
||||
|
||||
|
||||
def _force_sm100a() -> bool:
|
||||
"""True iff the sm_100a (Blackwell) forward is explicitly opted into.
|
||||
|
||||
Opt-in only (same env the H3 backend honors): the sm_100a extension is
|
||||
forward-only, so this routing pairs it with the Triton backward -- its lse
|
||||
is already in Triton's M format. Honored only when
|
||||
``block_sparse_attn_sm100a.is_supported`` passes. Unsupported 64-token
|
||||
metadata falls through to the default selection; unsupported 128-token
|
||||
metadata raises because Triton has no compatible fallback.
|
||||
``FASTVIDEO_VSA_TRITON`` still wins.
|
||||
"""
|
||||
return os.environ.get("FASTVIDEO_VSA_SM100A", "0") == "1"
|
||||
|
||||
|
||||
def _sm100a_is_supported(q: torch.Tensor, variable_block_sizes: torch.Tensor) -> bool:
|
||||
try:
|
||||
from fastvideo_kernel import block_sparse_attn_sm100a as vsa_sm100a
|
||||
except Exception:
|
||||
return False
|
||||
return vsa_sm100a.is_supported(q, variable_block_sizes)
|
||||
|
||||
|
||||
def _infer_block_size(q: torch.Tensor, variable_block_sizes: torch.Tensor) -> int:
|
||||
num_blocks = variable_block_sizes.numel()
|
||||
seq_len = q.shape[2]
|
||||
if num_blocks == 0 or seq_len % num_blocks != 0:
|
||||
return 0
|
||||
return seq_len // num_blocks
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Index helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -339,6 +369,76 @@ def _backward_sm90(ctx, grad_o, grad_lse):
|
||||
|
||||
block_sparse_attn_sm90.register_autograd(_backward_sm90, setup_context=_setup_context_sm90)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# SM100A backend custom op (index-native)
|
||||
#
|
||||
# Forward runs the sm_100a CUDA extension; backward reuses the Triton kernels.
|
||||
# The sm_100a forward emits lse in exactly Triton's M format (max*log2e +
|
||||
# log2(l)), so the pairing needs no conversion. The Triton backward is
|
||||
# hardcoded to 64-token blocks, hence the block-size assert below.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@torch.library.custom_op(
|
||||
"fastvideo_kernel::block_sparse_attn_sm100a",
|
||||
mutates_args=(),
|
||||
device_types="cuda",
|
||||
)
|
||||
def block_sparse_attn_sm100a_op(
|
||||
q: torch.Tensor,
|
||||
k: torch.Tensor,
|
||||
v: torch.Tensor,
|
||||
q2k_idx: torch.Tensor,
|
||||
q2k_num: torch.Tensor,
|
||||
variable_block_sizes: torch.Tensor,
|
||||
) -> Tuple[torch.Tensor, torch.Tensor]:
|
||||
from fastvideo_kernel.block_sparse_attn_sm100a import block_sparse_attn_sm100a
|
||||
|
||||
o, M = block_sparse_attn_sm100a(q, k, v, q2k_idx, q2k_num, variable_block_sizes,
|
||||
need_lse=True)
|
||||
return o, M
|
||||
|
||||
|
||||
@torch.library.register_fake("fastvideo_kernel::block_sparse_attn_sm100a")
|
||||
def _block_sparse_attn_sm100a_fake(
|
||||
q: torch.Tensor,
|
||||
k: torch.Tensor,
|
||||
v: torch.Tensor,
|
||||
q2k_idx: torch.Tensor,
|
||||
q2k_num: torch.Tensor,
|
||||
variable_block_sizes: torch.Tensor,
|
||||
) -> Tuple[torch.Tensor, torch.Tensor]:
|
||||
o = torch.empty_like(q)
|
||||
M = torch.empty(
|
||||
(q.shape[0], q.shape[1], q.shape[2]),
|
||||
device=q.device,
|
||||
dtype=torch.float32,
|
||||
)
|
||||
return o, M
|
||||
|
||||
|
||||
def _setup_context_sm100a(ctx, inputs, output):
|
||||
q, k, v, q2k_idx, q2k_num, variable_block_sizes = inputs
|
||||
o, M = output
|
||||
ctx.save_for_backward(q, k, v, o, M, q2k_idx, q2k_num, variable_block_sizes)
|
||||
|
||||
|
||||
def _backward_sm100a(ctx, grad_o, grad_M):
|
||||
q, k, v, o, M, q2k_idx, q2k_num, variable_block_sizes = ctx.saved_tensors
|
||||
block = q.shape[2] // variable_block_sizes.numel()
|
||||
if block != 64:
|
||||
raise RuntimeError(
|
||||
"block_sparse_attn_sm100a backward pairs the sm_100a forward with the "
|
||||
f"Triton backward, which is hardcoded to 64-token blocks; got {block}. "
|
||||
"Run 128-token-block metadata without grad, or use the Triton forward.")
|
||||
dq, dk, dv = block_sparse_attn_backward_triton(grad_o, q, k, v, o, M, q2k_idx,
|
||||
q2k_num, variable_block_sizes)
|
||||
return dq, dk, dv, None, None, None
|
||||
|
||||
|
||||
block_sparse_attn_sm100a_op.register_autograd(_backward_sm100a,
|
||||
setup_context=_setup_context_sm100a)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public API
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -364,11 +464,24 @@ def block_sparse_attn_from_indices(
|
||||
|
||||
# Backend resolution:
|
||||
# - FASTVIDEO_VSA_TRITON forces Triton everywhere.
|
||||
# - FASTVIDEO_VSA_SM100A opts into the sm_100a forward (Triton backward).
|
||||
# Unsupported 64-token metadata falls through; unsupported 128-token
|
||||
# metadata raises because Triton cannot consume it.
|
||||
# - FASTVIDEO_VSA_TK requests sm_90 TK; honored only when it's actually
|
||||
# available (else falls through to the default below).
|
||||
# - Otherwise: TK on sm_90 if available, else Triton.
|
||||
if _force_triton():
|
||||
use_sm90 = False
|
||||
elif _force_sm100a():
|
||||
if _sm100a_is_supported(q, variable_block_sizes):
|
||||
return block_sparse_attn_sm100a_op(q, k, v, q2k_idx, q2k_num,
|
||||
variable_block_sizes)
|
||||
if _infer_block_size(q, variable_block_sizes) == 128:
|
||||
raise NotImplementedError(
|
||||
"128-token block-sparse attention requires the sm_100a forward; "
|
||||
"the Triton fallback only supports 64-token blocks, and the "
|
||||
"sm_100a route is unavailable for this input.")
|
||||
use_sm90 = sm90_available
|
||||
elif _force_tk():
|
||||
use_sm90 = sm90_available
|
||||
else:
|
||||
|
||||
@@ -84,6 +84,107 @@ def is_supported(q: torch.Tensor, variable_block_sizes: torch.Tensor) -> bool:
|
||||
return True
|
||||
|
||||
|
||||
@torch.library.custom_op(
|
||||
"fastvideo_kernel::block_sparse_attn_sm100a_inference",
|
||||
mutates_args=(),
|
||||
device_types="cuda",
|
||||
)
|
||||
def _block_sparse_attn_sm100a_inference(
|
||||
q: torch.Tensor,
|
||||
k: torch.Tensor,
|
||||
v: torch.Tensor,
|
||||
q2k_idx: torch.Tensor,
|
||||
q2k_num: torch.Tensor,
|
||||
variable_block_sizes: torch.Tensor,
|
||||
) -> torch.Tensor:
|
||||
"""Opaque no-LSE launch used by the inference-only sm_100a route.
|
||||
|
||||
The extension is exposed as a raw pybind function rather than a dispatcher
|
||||
op. Calling it directly makes Dynamo descend through a Python/C++ boundary
|
||||
that has no fake implementation, so ``torch.compile(fullgraph=True)``
|
||||
cannot capture a sparse H3 block. Keep that boundary inside this custom op;
|
||||
its inputs have already been normalized by the public wrapper below.
|
||||
"""
|
||||
fwd = _FWD_BY_BLOCK[_block_size(q, variable_block_sizes)]
|
||||
sm_scale = 1.0 / (q.shape[-1]**0.5)
|
||||
res = fwd(q, k, v, None, q2k_idx, q2k_num, variable_block_sizes, sm_scale, False)
|
||||
return res[0]
|
||||
|
||||
|
||||
@torch.library.register_fake("fastvideo_kernel::block_sparse_attn_sm100a_inference")
|
||||
def _block_sparse_attn_sm100a_inference_fake(
|
||||
q: torch.Tensor,
|
||||
k: torch.Tensor,
|
||||
v: torch.Tensor,
|
||||
q2k_idx: torch.Tensor,
|
||||
q2k_num: torch.Tensor,
|
||||
variable_block_sizes: torch.Tensor,
|
||||
) -> torch.Tensor:
|
||||
# The C++ binding allocates its output with torch::empty_like(q).
|
||||
return torch.empty_like(q)
|
||||
|
||||
|
||||
@torch.library.custom_op(
|
||||
"fastvideo_kernel::block_sparse_attn_sm100a_from_mask_inference",
|
||||
mutates_args=(),
|
||||
device_types="cuda",
|
||||
)
|
||||
def _block_sparse_attn_sm100a_from_mask_inference(
|
||||
q: torch.Tensor,
|
||||
k: torch.Tensor,
|
||||
v: torch.Tensor,
|
||||
block_map: torch.Tensor,
|
||||
variable_block_sizes: torch.Tensor,
|
||||
) -> torch.Tensor:
|
||||
"""Opaque mask compaction plus no-LSE sm_100a launch for inference.
|
||||
|
||||
H3 naturally produces a bool block map. Its Triton ``map_to_index`` call
|
||||
must live behind the same opaque boundary as the raw pybind launch;
|
||||
otherwise Dynamo sees that kernel before reaching the index-native custom
|
||||
op and full-graph capture still fails.
|
||||
"""
|
||||
from fastvideo_kernel.triton_kernels.index import map_to_index
|
||||
|
||||
q2k_idx, q2k_num = map_to_index(block_map)
|
||||
return _block_sparse_attn_sm100a_inference(
|
||||
q,
|
||||
k,
|
||||
v,
|
||||
q2k_idx.to(torch.int32).contiguous(),
|
||||
q2k_num.to(torch.int32).contiguous(),
|
||||
variable_block_sizes,
|
||||
)
|
||||
|
||||
|
||||
@torch.library.register_fake("fastvideo_kernel::block_sparse_attn_sm100a_from_mask_inference")
|
||||
def _block_sparse_attn_sm100a_from_mask_inference_fake(
|
||||
q: torch.Tensor,
|
||||
k: torch.Tensor,
|
||||
v: torch.Tensor,
|
||||
block_map: torch.Tensor,
|
||||
variable_block_sizes: torch.Tensor,
|
||||
) -> torch.Tensor:
|
||||
return torch.empty_like(q)
|
||||
|
||||
|
||||
def block_sparse_attn_sm100a_from_mask(
|
||||
q: torch.Tensor,
|
||||
k: torch.Tensor,
|
||||
v: torch.Tensor,
|
||||
block_map: torch.Tensor,
|
||||
variable_block_sizes: torch.Tensor,
|
||||
) -> Tuple[torch.Tensor, None]:
|
||||
"""Inference forward from a bool block map, with compaction kept opaque."""
|
||||
out = _block_sparse_attn_sm100a_from_mask_inference(
|
||||
q.contiguous(),
|
||||
k.contiguous(),
|
||||
v.contiguous(),
|
||||
block_map.to(torch.bool).contiguous(),
|
||||
variable_block_sizes.to(torch.int32).contiguous(),
|
||||
)
|
||||
return out, None
|
||||
|
||||
|
||||
def block_sparse_attn_sm100a(
|
||||
q: torch.Tensor,
|
||||
k: torch.Tensor,
|
||||
@@ -92,13 +193,24 @@ def block_sparse_attn_sm100a(
|
||||
q2k_num: torch.Tensor,
|
||||
variable_block_sizes: torch.Tensor,
|
||||
need_lse: bool = True,
|
||||
) -> Tuple[torch.Tensor, torch.Tensor]:
|
||||
) -> Tuple[torch.Tensor, torch.Tensor | None]:
|
||||
"""Forward pass. Returns ``(out, lse)``; ``out`` has q's layout."""
|
||||
fwd = _FWD_BY_BLOCK[_block_size(q, variable_block_sizes)]
|
||||
q = q.contiguous()
|
||||
k = k.contiguous()
|
||||
v = v.contiguous()
|
||||
idx = q2k_idx.to(torch.int32).contiguous()
|
||||
num = q2k_num.to(torch.int32).contiguous()
|
||||
vbs = variable_block_sizes.to(torch.int32).contiguous()
|
||||
|
||||
if not need_lse:
|
||||
# This is the production inference path. The custom op keeps the raw
|
||||
# pybind launch opaque to Dynamo while its fake kernel carries output
|
||||
# metadata through full-graph capture.
|
||||
return _block_sparse_attn_sm100a_inference(q, k, v, idx, num, vbs), None
|
||||
|
||||
# Preserve the established LSE-producing path for correctness tests and
|
||||
# any future forward/backward pairing; only inference needs the opaque op.
|
||||
fwd = _FWD_BY_BLOCK[_block_size(q, vbs)]
|
||||
sm_scale = 1.0 / (q.shape[-1]**0.5)
|
||||
res = fwd(q.contiguous(), k.contiguous(), v.contiguous(), None,
|
||||
idx, num, vbs, sm_scale, need_lse)
|
||||
return (res[0], res[1]) if need_lse else (res[0], None)
|
||||
res = fwd(q, k, v, None, idx, num, vbs, sm_scale, True)
|
||||
return res[0], res[1]
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""Ulysses sequence-parallel all-to-all ops.
|
||||
|
||||
Thin wrappers over csrc/comm/ulysses_all_to_all.cu. The kernel stores directly
|
||||
into peers' memory through NCCL's device API, so the caller supplies an
|
||||
ncclComm_t for the group.
|
||||
"""
|
||||
|
||||
import torch
|
||||
|
||||
try:
|
||||
from fastvideo_kernel._C import fastvideo_kernel_ops as _ops
|
||||
except ImportError: # pragma: no cover - no compiled extension in this install
|
||||
_ops = None
|
||||
|
||||
_SUPPORTED_WORLD_SIZES = (2, 4, 6, 8)
|
||||
_REQUIRED_OPS = (
|
||||
"allocate_ulysses_a2a",
|
||||
"register_ulysses_a2a_window",
|
||||
"create_ulysses_a2a_dev_comm",
|
||||
"dispose_ulysses_a2a",
|
||||
"ulysses_lsa_covers_group",
|
||||
"ulysses_a2a",
|
||||
)
|
||||
|
||||
|
||||
def is_available() -> bool:
|
||||
"""Whether this wheel was built with the Ulysses all-to-all kernel."""
|
||||
return _ops is not None and all(hasattr(_ops, name) for name in _REQUIRED_OPS)
|
||||
|
||||
|
||||
def _require() -> None:
|
||||
if not is_available():
|
||||
raise RuntimeError(
|
||||
"the Ulysses all-to-all kernel is not present in this fastvideo-kernel build; "
|
||||
"rebuild with ./build.sh or install a wheel that includes csrc/comm/")
|
||||
|
||||
|
||||
def lsa_covers_group(comm_ptr: int, world_size: int) -> bool:
|
||||
"""Whether every rank in the group is load-store accessible to every other."""
|
||||
_require()
|
||||
return bool(_ops.ulysses_lsa_covers_group(int(comm_ptr), int(world_size)))
|
||||
|
||||
|
||||
def allocate(nbytes: int, rank: int, world_size: int, device_index: int) -> int:
|
||||
"""Allocate one rank's local symmetric window without a collective."""
|
||||
_require()
|
||||
if world_size not in _SUPPORTED_WORLD_SIZES:
|
||||
raise ValueError(f"ulysses a2a supports world sizes {_SUPPORTED_WORLD_SIZES}, "
|
||||
f"got {world_size}")
|
||||
return int(_ops.allocate_ulysses_a2a(int(nbytes), int(rank), int(world_size), int(device_index)))
|
||||
|
||||
|
||||
def register_window(handle: int, comm_ptr: int) -> None:
|
||||
"""Register an allocated window with the supplied communicator.
|
||||
|
||||
Every rank in ``comm_ptr`` must call this together.
|
||||
"""
|
||||
_require()
|
||||
_ops.register_ulysses_a2a_window(int(handle), int(comm_ptr))
|
||||
|
||||
|
||||
def create_dev_comm(handle: int) -> None:
|
||||
"""Create the device communicator for a registered window collectively."""
|
||||
_require()
|
||||
_ops.create_ulysses_a2a_dev_comm(int(handle))
|
||||
|
||||
|
||||
def dispose(handle: int) -> None:
|
||||
"""Release a handle from :func:`allocate`. It is dangling afterwards."""
|
||||
_require()
|
||||
_ops.dispose_ulysses_a2a(int(handle))
|
||||
|
||||
|
||||
def all_to_all(handle: int, inp: torch.Tensor, out: torch.Tensor, B: int, S_local: int, H: int,
|
||||
D: int, mode: int) -> None:
|
||||
"""Run one fused all-to-all on the current stream, writing into ``out``.
|
||||
|
||||
``mode == 0``: ``[B, S_local, H, D] -> [B, S_global, H_local, D]``
|
||||
``mode == 1``: ``[B, S_global, H_local, D] -> [B, S_local, H, D]``
|
||||
|
||||
``H`` is the global head count. Every rank must call with consistent
|
||||
geometry in the same order.
|
||||
"""
|
||||
_require()
|
||||
_ops.ulysses_a2a(int(handle), inp, out, int(B), int(S_local), int(H), int(D), int(mode))
|
||||
+11
-4
@@ -16,14 +16,21 @@ import triton.language as tl
|
||||
import math # small utility needed by the sparse wrapper
|
||||
# ──────────────────────────── SPARSE ADDITION END ─────────────────────────────
|
||||
|
||||
# We don't run auto-tuning every time to keep the tutorial fast. Keeping
|
||||
# the code below and commenting out the equivalent parameters is convenient for
|
||||
# re-tuning.
|
||||
# BLOCK_M / BLOCK_N are fixed at 64 because they are structural, not tunable:
|
||||
# the kernel indexes the top-k list per BLOCK_M q-tile and addresses keys as
|
||||
# kv_idx * BLOCK_N, so both must match the granularity q2k_index and
|
||||
# variable_block_sizes were built at.
|
||||
#
|
||||
# num_stages / num_warps ARE free, and the previous {3, 4, 7} was inherited from
|
||||
# the upstream tutorial rather than tuned here. It skips 5 and 6; on Blackwell
|
||||
# (sm_121) the optimum is num_stages=5, so the search could not reach it. Both
|
||||
# block paths independently select 5 once it is available. Autotune still picks
|
||||
# per architecture, so other GPUs re-tune rather than inheriting this choice.
|
||||
configs = [
|
||||
triton.Config({'BLOCK_M': BM, 'BLOCK_N': BN}, num_stages=s, num_warps=w) \
|
||||
for BM in [64]\
|
||||
for BN in [64]\
|
||||
for s in [3, 4, 7]\
|
||||
for s in [2, 3, 4, 5, 6, 7]\
|
||||
for w in [4, 8]\
|
||||
]
|
||||
|
||||
|
||||
@@ -1 +1 @@
|
||||
__version__ = "0.3.2"
|
||||
__version__ = "0.3.5"
|
||||
|
||||
+27
-1
@@ -44,6 +44,16 @@ _LEGACY_REQUEST_ALIASES = {
|
||||
_REQUEST_PIPELINE_OVERRIDE_FIELDS = frozenset({
|
||||
"embedded_cfg_scale",
|
||||
})
|
||||
REQUEST_BATCH_EXTRA_PASSTHROUGH_FIELDS = (
|
||||
"ltx2_audio_latents",
|
||||
"ltx2_audio_clean_latent",
|
||||
"ltx2_audio_denoise_mask",
|
||||
"audio_num_frames",
|
||||
"video_position_offset_sec",
|
||||
"vsa_mode",
|
||||
"vsa_dense_first_n_steps",
|
||||
"vsa_dense_layers",
|
||||
)
|
||||
# torch.compile kwargs that map to first-class CompileConfig fields.
|
||||
_COMPILE_TYPED_KEYS = ("backend", "fullgraph", "mode", "dynamic")
|
||||
# LTX-2 refine flat kwargs (init + per-request) known to FastVideoArgs.
|
||||
@@ -165,6 +175,10 @@ def legacy_from_pretrained_to_config(
|
||||
pipeline["workload_type"] = value
|
||||
elif key == "lora_path":
|
||||
components["lora_path"] = value
|
||||
elif key == "lora_nickname":
|
||||
components["lora_nickname"] = value
|
||||
elif key == "lora_strength":
|
||||
components["lora_strength"] = value
|
||||
elif key == "override_pipeline_cls_name":
|
||||
components["override_pipeline_cls_name"] = value
|
||||
elif key == "override_transformer_cls_name":
|
||||
@@ -283,6 +297,8 @@ def generator_config_to_fastvideo_args(config: GeneratorConfig | Mapping[str, An
|
||||
kwargs["pipeline_config"] = components.pipeline_config_path
|
||||
if components.lora_path is not None:
|
||||
kwargs["lora_path"] = components.lora_path
|
||||
kwargs["lora_nickname"] = components.lora_nickname
|
||||
kwargs["lora_strength"] = components.lora_strength
|
||||
if components.override_pipeline_cls_name is not None:
|
||||
kwargs["override_pipeline_cls_name"] = components.override_pipeline_cls_name
|
||||
if components.override_transformer_cls_name is not None:
|
||||
@@ -368,7 +384,7 @@ def request_to_sampling_param(
|
||||
for key, value in updates.items():
|
||||
if hasattr(sampling_param, key):
|
||||
setattr(sampling_param, key, deepcopy(value))
|
||||
elif key in _REQUEST_PIPELINE_OVERRIDE_FIELDS:
|
||||
elif key in _REQUEST_PIPELINE_OVERRIDE_FIELDS or key in REQUEST_BATCH_EXTRA_PASSTHROUGH_FIELDS:
|
||||
continue
|
||||
elif value == _SCHEMA_DEFAULT_UPDATES.get(key, _MISSING):
|
||||
# Schema-default field that isn't on SamplingParam; tolerated
|
||||
@@ -467,6 +483,14 @@ def request_to_pipeline_overrides(request: GenerationRequest) -> dict[str, Any]:
|
||||
return overrides
|
||||
|
||||
|
||||
def request_to_batch_extra(request: GenerationRequest) -> dict[str, Any]:
|
||||
"""Extract typed-request extensions consumed through ``ForwardBatch.extra``."""
|
||||
return {
|
||||
key: deepcopy(value)
|
||||
for key, value in explicit_request_updates(request).items() if key in REQUEST_BATCH_EXTRA_PASSTHROUGH_FIELDS
|
||||
}
|
||||
|
||||
|
||||
def explicit_request_updates(request: GenerationRequest) -> dict[str, Any]:
|
||||
"""Project a ``GenerationRequest`` down to *explicitly set* fields only.
|
||||
|
||||
@@ -640,6 +664,7 @@ def _validate_batched_input_length(
|
||||
|
||||
|
||||
__all__ = [
|
||||
"REQUEST_BATCH_EXTRA_PASSTHROUGH_FIELDS",
|
||||
"explicit_request_updates",
|
||||
"generator_config_to_fastvideo_args",
|
||||
"legacy_from_pretrained_to_config",
|
||||
@@ -648,6 +673,7 @@ __all__ = [
|
||||
"normalize_generation_request",
|
||||
"normalize_generator_config",
|
||||
"register_continuation_kind",
|
||||
"request_to_batch_extra",
|
||||
"request_to_pipeline_overrides",
|
||||
"request_to_sampling_param",
|
||||
]
|
||||
|
||||
@@ -10,6 +10,7 @@ class ServerConfig:
|
||||
host: str = "0.0.0.0"
|
||||
port: int = 8000
|
||||
output_dir: str = "outputs/"
|
||||
served_model_name: str | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -94,6 +95,8 @@ class ComponentConfig:
|
||||
vae_weights: str | None = None
|
||||
upsampler_weights: str | None = None
|
||||
lora_path: str | None = None
|
||||
lora_nickname: str = "default"
|
||||
lora_strength: float = 1.0
|
||||
override_pipeline_cls_name: str | None = None
|
||||
override_transformer_cls_name: str | None = None
|
||||
|
||||
|
||||
@@ -10,6 +10,14 @@ from fastvideo.attention.utils.flash_attn_default import (
|
||||
flash_attn_func_compilable,
|
||||
)
|
||||
|
||||
if fa_version == "4":
|
||||
# The FA4 varlen wrapper is already a compile-safe custom op. Keep the
|
||||
# import conditional so FA2/FA3 environments do not need flash_attn.cute.
|
||||
from fastvideo.attention.utils.flash_attn_cute import (
|
||||
flash_attn_varlen_func as flash_attn_varlen_func_compilable, )
|
||||
else:
|
||||
flash_attn_varlen_func_compilable = None
|
||||
|
||||
from fastvideo.attention.backends.abstract import (
|
||||
AttentionBackend,
|
||||
AttentionImpl,
|
||||
@@ -38,6 +46,21 @@ except ImportError:
|
||||
_FA4_FP4_AVAILABLE = False
|
||||
|
||||
_FA4_QUANT_OPS: tuple | None = None
|
||||
_FA4_PACKED_VARLEN_CONFIG_LOGGED = False
|
||||
|
||||
|
||||
def _log_fa4_packed_varlen_config() -> None:
|
||||
"""Emit one configuration receipt per worker process.
|
||||
|
||||
This intentionally does not claim that a particular forward used the
|
||||
kernel: masks, gradients, batch size, unequal Q/K lengths, and NVFP4 are
|
||||
runtime guards evaluated later in ``_forward_impl``.
|
||||
"""
|
||||
global _FA4_PACKED_VARLEN_CONFIG_LOGGED
|
||||
if not _FA4_PACKED_VARLEN_CONFIG_LOGGED:
|
||||
logger.info("MiniMax-H3 dense attention: FA4 packed-varlen route configured (runtime guards apply)",
|
||||
local_main_process_only=False)
|
||||
_FA4_PACKED_VARLEN_CONFIG_LOGGED = True
|
||||
|
||||
|
||||
def _import_fa4_quant_ops() -> tuple:
|
||||
@@ -228,7 +251,21 @@ class FlashAttentionImpl(AttentionImpl):
|
||||
) -> None:
|
||||
self.causal = causal
|
||||
self.softmax_scale = softmax_scale
|
||||
self.nvfp4_fa4 = extra_impl_args.get("nvfp4_fa4", False) or os.environ.get("FASTVIDEO_NVFP4_FA4", "0") == "1"
|
||||
# MiniMax-H3's dense DiT explicitly enables this faster FA4 entry
|
||||
# point. It remains off for every other model and for the H3 text
|
||||
# refiner; grad-enabled calls stay on the established fixed path.
|
||||
self.fa4_packed_varlen = bool(extra_impl_args.get("fa4_packed_varlen", False))
|
||||
if self.fa4_packed_varlen and fa_version == "4":
|
||||
_log_fa4_packed_varlen_config()
|
||||
# An explicit ``nvfp4_fa4`` impl arg wins over the process-wide
|
||||
# FASTVIDEO_NVFP4_FA4 env opt-in, so precision-sensitive layers (e.g.
|
||||
# the FP32-pinned H3 VAE attention) can force-disable FP4 Q/K
|
||||
# quantization while the DiT keeps it. When the arg is absent the env
|
||||
# keeps its previous semantics.
|
||||
nvfp4_fa4 = extra_impl_args.get("nvfp4_fa4")
|
||||
if nvfp4_fa4 is None:
|
||||
nvfp4_fa4 = os.environ.get("FASTVIDEO_NVFP4_FA4", "0") == "1"
|
||||
self.nvfp4_fa4 = bool(nvfp4_fa4)
|
||||
if self.nvfp4_fa4:
|
||||
cap = torch.cuda.get_device_capability()
|
||||
assert cap in [(10, 0), (10, 3)], (f"NVFP4 FA4 requires Blackwell (sm100a/sm103a), got sm{cap[0]}{cap[1]}")
|
||||
@@ -323,6 +360,29 @@ class FlashAttentionImpl(AttentionImpl):
|
||||
elif self.nvfp4_fa4:
|
||||
output = self._forward_nvfp4(query, key, value)
|
||||
|
||||
elif (self.fa4_packed_varlen and fa_version == "4" and not torch.is_grad_enabled() and query.shape[0] == 1
|
||||
and query.shape[1] == key.shape[1] == value.shape[1]):
|
||||
# FA4's packed-varlen entry point is materially faster for H3's
|
||||
# long, single-document self-attention. Flatten only the batch
|
||||
# dimension and describe that one sequence with CUDA int32
|
||||
# cumulative lengths; the existing custom-op wrapper keeps this
|
||||
# route traceable under torch.compile(fullgraph=True).
|
||||
assert flash_attn_varlen_func_compilable is not None
|
||||
sequence_length = query.shape[1]
|
||||
cu_seqlens = torch.arange(2, dtype=torch.int32, device=query.device) * sequence_length
|
||||
output = flash_attn_varlen_func_compilable(
|
||||
query.squeeze(0),
|
||||
key.squeeze(0),
|
||||
value.squeeze(0),
|
||||
cu_seqlens,
|
||||
cu_seqlens,
|
||||
sequence_length,
|
||||
sequence_length,
|
||||
dropout_p=0.0,
|
||||
softmax_scale=self.softmax_scale,
|
||||
causal=self.causal,
|
||||
).unsqueeze(0)
|
||||
|
||||
else:
|
||||
# Route through the compilable wrapper so dynamo sees a
|
||||
# registered op (no graph break) for FA2/FA3; identical
|
||||
|
||||
@@ -34,9 +34,13 @@ for the tile-64 FORWARD only: ``FASTVIDEO_VSA_SM100A=1`` sends no-grad
|
||||
forwards through the sm_100a CUDA block-sparse kernel
|
||||
(``fastvideo_kernel.block_sparse_attn_sm100a``, upstream PR #1719 plus
|
||||
our per-q-tile ``q2k_num`` fix) when the extension is built, the device
|
||||
is sm_100, and the geometry qualifies; grad-tracking forwards and every
|
||||
backward stay on Triton unchanged. If the env is set but a precondition
|
||||
fails, the route logs one warning and falls back.
|
||||
is sm_100, and the geometry qualifies. The CUDA kernel assigns adjacent
|
||||
pairs of query tiles to CTAs, so an odd logical tile count receives one
|
||||
internal, zero-valid partner tile for the no-grad call only. Score search,
|
||||
the trained mask, gate-compress, and the returned packed sequence remain on
|
||||
the original logical tiles. Grad-tracking forwards and every backward stay
|
||||
on Triton unchanged. If the env is set but a precondition fails, the route
|
||||
logs one warning and falls back.
|
||||
"""
|
||||
|
||||
import functools
|
||||
@@ -90,6 +94,65 @@ VSA_H3_TILE_SHAPES: dict[int, tuple[int, int, int]] = {
|
||||
}
|
||||
|
||||
|
||||
@torch.library.custom_op(
|
||||
"fastvideo::h3_vsa_sm100a_from_mask_compat",
|
||||
mutates_args=(),
|
||||
device_types="cuda",
|
||||
)
|
||||
def _h3_vsa_sm100a_from_mask_compat(
|
||||
q: torch.Tensor,
|
||||
k: torch.Tensor,
|
||||
v: torch.Tensor,
|
||||
block_map: torch.Tensor,
|
||||
variable_block_sizes: torch.Tensor,
|
||||
) -> torch.Tensor:
|
||||
"""Compile-safe mask adapter for kernel wheels predating the mask API."""
|
||||
if _sm100a is None or map_to_index is None:
|
||||
raise RuntimeError("The sm100a compatibility route requires the raw kernel and map_to_index")
|
||||
q2k_idx, q2k_num = map_to_index(block_map)
|
||||
out, _ = _sm100a.block_sparse_attn_sm100a(
|
||||
q,
|
||||
k,
|
||||
v,
|
||||
q2k_idx.to(torch.int32).contiguous(),
|
||||
q2k_num.to(torch.int32).contiguous(),
|
||||
variable_block_sizes.to(torch.int32).contiguous(),
|
||||
need_lse=False,
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
@torch.library.register_fake("fastvideo::h3_vsa_sm100a_from_mask_compat")
|
||||
def _h3_vsa_sm100a_from_mask_compat_fake(
|
||||
q: torch.Tensor,
|
||||
k: torch.Tensor,
|
||||
v: torch.Tensor,
|
||||
block_map: torch.Tensor,
|
||||
variable_block_sizes: torch.Tensor,
|
||||
) -> torch.Tensor:
|
||||
del k, v, block_map, variable_block_sizes
|
||||
return torch.empty_like(q)
|
||||
|
||||
|
||||
def _sm100a_has_compile_safe_mask_route(sm100a_mod: Any) -> bool:
|
||||
return (callable(getattr(sm100a_mod, "block_sparse_attn_sm100a_from_mask", None))
|
||||
or (callable(getattr(sm100a_mod, "block_sparse_attn_sm100a", None)) and map_to_index is not None))
|
||||
|
||||
|
||||
def _sm100a_from_mask(
|
||||
q: torch.Tensor,
|
||||
k: torch.Tensor,
|
||||
v: torch.Tensor,
|
||||
block_map: torch.Tensor,
|
||||
variable_block_sizes: torch.Tensor,
|
||||
) -> tuple[torch.Tensor, None]:
|
||||
"""Use the native mask entry when installed, otherwise the local adapter."""
|
||||
native = getattr(_sm100a, "block_sparse_attn_sm100a_from_mask", None)
|
||||
if callable(native):
|
||||
return native(q, k, v, block_map, variable_block_sizes)
|
||||
return _h3_vsa_sm100a_from_mask_compat(q, k, v, block_map, variable_block_sizes), None
|
||||
|
||||
|
||||
def token_tile_and_valid(variable_block_sizes: torch.Tensor,
|
||||
tile_elems: int = _TILE_ELEMS) -> tuple[torch.Tensor, torch.Tensor]:
|
||||
"""Per padded-token tile id and pad-validity mask.
|
||||
@@ -225,6 +288,14 @@ class MiniMaxH3VSABackend(AttentionBackend):
|
||||
return MiniMaxH3VSAMetadataBuilder
|
||||
|
||||
|
||||
class _MiniMaxH3VSATileBufferHolder:
|
||||
"""Builder-owned no-grad tile scratch and its active geometry."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.buffer: torch.Tensor | None = None
|
||||
self.untile_geometry: torch.Tensor | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class MiniMaxH3VSAMetadata(AttentionMetadata):
|
||||
total_seq_length: int
|
||||
@@ -233,23 +304,25 @@ class MiniMaxH3VSAMetadata(AttentionMetadata):
|
||||
exempt: bool
|
||||
variable_block_sizes: torch.Tensor
|
||||
untile_combined_index: torch.Tensor
|
||||
# Device-side copy of ``dense_layers``. Regional fullgraph capture uses
|
||||
# this tensor with each implementation's tensor-valued layer index so the
|
||||
# shared block code does not specialize once per Python ``layer_idx``.
|
||||
dense_layers_tensor: torch.Tensor
|
||||
# tokens per tile (256 or 64); selects the tile geometry AND the kernel
|
||||
# route in forward() (256 -> VSA-256 CuTe/Triton, 64 -> native Triton)
|
||||
tile_elems: int = _TILE_ELEMS
|
||||
# layers forced dense regardless of sparsity (probe-guided opt-outs)
|
||||
dense_layers: tuple[int, ...] = ()
|
||||
# Single-slot holder for the padded tile buffer, owned by the BUILDER so
|
||||
# one buffer serves the whole denoising loop (pad slots stay zero and
|
||||
# every non-pad slot is fully overwritten per tile(), so cross-step reuse
|
||||
# is valid; saves a ~1.4 GB alloc+memset per step at 720p). VSA-H3 runs
|
||||
# eager today — revisit the reuse if it ever goes under cudagraphs.
|
||||
tile_buf_holder: list = None # type: ignore[assignment]
|
||||
# Builder-owned padded tile buffer. It records the geometry that last
|
||||
# populated the allocation so a same-shaped geometry change can clear
|
||||
# stale pad rows once while steady-state denoising reuses the buffer.
|
||||
tile_buf_holder: _MiniMaxH3VSATileBufferHolder | None = None
|
||||
|
||||
|
||||
class MiniMaxH3VSAMetadataBuilder(AttentionMetadataBuilder):
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._tile_buf_holder: list = [None]
|
||||
self._tile_buf_holder = _MiniMaxH3VSATileBufferHolder()
|
||||
|
||||
def prepare(self) -> None:
|
||||
pass
|
||||
@@ -278,6 +351,7 @@ class MiniMaxH3VSAMetadataBuilder(AttentionMetadataBuilder):
|
||||
(_tile_partition_indices, variable_block_sizes, untile_combined_index, num_prefix_tiles,
|
||||
num_video_tiles) = _h3_tile_geometry(prefix_segments, dit_seq_shape, device, tile_shape)
|
||||
|
||||
dense_layers = tuple(int(layer) for layer in dense_layers)
|
||||
return MiniMaxH3VSAMetadata(
|
||||
current_timestep=current_timestep,
|
||||
VSA_sparsity=VSA_sparsity,
|
||||
@@ -288,7 +362,8 @@ class MiniMaxH3VSAMetadataBuilder(AttentionMetadataBuilder):
|
||||
variable_block_sizes=variable_block_sizes,
|
||||
untile_combined_index=untile_combined_index,
|
||||
tile_elems=int(tile_size),
|
||||
dense_layers=tuple(int(layer) for layer in dense_layers),
|
||||
dense_layers=dense_layers,
|
||||
dense_layers_tensor=torch.tensor(dense_layers, device=device, dtype=torch.int64),
|
||||
tile_buf_holder=self._tile_buf_holder,
|
||||
)
|
||||
|
||||
@@ -366,26 +441,109 @@ class MiniMaxH3VSAImpl(AttentionImpl):
|
||||
) -> None:
|
||||
self.prefix = prefix
|
||||
self.layer_idx = layer_idx_from_prefix(prefix, default=-1)
|
||||
self.head_size = head_size
|
||||
# None means the regional-compile preparation hook has not run. The
|
||||
# eager path deliberately ignores this cache and preserves its
|
||||
# request-time env/probe/fallback behavior; only Dynamo capture reads
|
||||
# the prepared, static route.
|
||||
self._regional_compile_sm100a_enabled: bool | None = None
|
||||
self._regional_compile_layer_idx: torch.Tensor | None = None
|
||||
|
||||
def prepare_for_regional_compile(self, device: torch.device) -> str | None:
|
||||
"""Resolve the inference-only sm_100a route before fullgraph capture.
|
||||
|
||||
The ordinary eager route probes the environment, extension, device,
|
||||
and tensor contract at every call so it can warn and fall back. Those
|
||||
Python/device-capability checks are not safe inside a regional
|
||||
``fullgraph=True`` block. Probe one representative tile-64 input on
|
||||
the loaded model's device now, then let ``forward`` specialize on the
|
||||
resulting plain bool while Dynamo is compiling.
|
||||
"""
|
||||
requested = os.environ.get(VSA_SM100A_ENV, "0") == "1"
|
||||
enabled = False
|
||||
reason = None if requested else f"{VSA_SM100A_ENV}=1 is required for compile-safe VSA-H3 attention"
|
||||
if requested:
|
||||
if _sm100a is None:
|
||||
reason = "fastvideo_kernel.block_sparse_attn_sm100a is not installed"
|
||||
elif not _sm100a_has_compile_safe_mask_route(_sm100a):
|
||||
reason = ("neither a native block_sparse_attn_sm100a_from_mask entry nor the raw sm100a "
|
||||
"kernel plus map_to_index compatibility route is installed")
|
||||
else:
|
||||
# Two 64-token blocks exercise the exact sm_100a inference
|
||||
# specialization while keeping the one-time probe tiny. The
|
||||
# kernel predicate checks extension presence, CUDA capability,
|
||||
# dtype/layout, head size, block size, and even block count
|
||||
# without reading metadata tensor contents.
|
||||
probe_query = torch.empty((1, 1, 128, self.head_size), device=device, dtype=torch.bfloat16)
|
||||
probe_block_sizes = torch.full((2, ), 64, device=device, dtype=torch.int32)
|
||||
reason = _sm100a_unavailable_reason(
|
||||
_sm100a,
|
||||
probe_query,
|
||||
probe_block_sizes,
|
||||
grad_mode=False,
|
||||
)
|
||||
enabled = reason is None
|
||||
|
||||
self._regional_compile_sm100a_enabled = enabled
|
||||
# Keep this marker unset when preparation fails. Generic/training
|
||||
# torch.compile must retain the established Triton attention route.
|
||||
self._regional_compile_layer_idx = (torch.tensor(self.layer_idx, device=device, dtype=torch.int64)
|
||||
if enabled else None)
|
||||
if enabled:
|
||||
route = ("native fastvideo-kernel mask entry" if callable(
|
||||
getattr(_sm100a, "block_sparse_attn_sm100a_from_mask", None)) else
|
||||
"FastVideo compatibility mask adapter")
|
||||
logger.info_once(f"VSA-H3 regional compile mask route: {route}")
|
||||
if requested and reason is not None:
|
||||
logger.warning_once(f"VSA-H3 regional compile is unavailable and will stay eager: {reason}")
|
||||
return reason
|
||||
|
||||
def tile(self, x: torch.Tensor, attn_metadata: MiniMaxH3VSAMetadata) -> torch.Tensor:
|
||||
"""Scatter rows into the padded tile buffer (pad positions stay zero).
|
||||
|
||||
The returned tensor aliases the builder-owned buffer; callers must
|
||||
consume it before the next ``tile()`` (both call sites in
|
||||
``forward()`` read it immediately).
|
||||
``forward()`` read it immediately). Odd tile-64 no-grad sm100a
|
||||
requests carry one additional all-zero tile internally; metadata and
|
||||
all observable outputs retain the logical geometry.
|
||||
"""
|
||||
if x.shape[1] != attn_metadata.total_seq_length:
|
||||
raise ValueError(f"VSA-H3 metadata was built for sequence length {attn_metadata.total_seq_length}, "
|
||||
f"got {x.shape[1]}. A non-packed sequence (e.g. the token refiner) is "
|
||||
"routed to the VSA-H3 backend; exclude it from the supported backends.")
|
||||
n_tiles = attn_metadata.variable_block_sizes.numel()
|
||||
target_shape = (x.shape[0], n_tiles * attn_metadata.tile_elems, x.shape[-2], x.shape[-1])
|
||||
grad_mode = torch.is_grad_enabled() and x.requires_grad
|
||||
compiling = torch.compiler.is_compiling()
|
||||
regional_compiling = compiling and self._regional_compile_layer_idx is not None
|
||||
if regional_compiling:
|
||||
sm100a_requested = bool(self._regional_compile_sm100a_enabled)
|
||||
elif compiling:
|
||||
# Training/generic compile keeps the long-standing Triton route.
|
||||
sm100a_requested = False
|
||||
else:
|
||||
sm100a_requested = os.environ.get(VSA_SM100A_ENV, "0") == "1"
|
||||
needs_sm100a_pair = (attn_metadata.tile_elems == 64 and n_tiles % 2 != 0 and not grad_mode and sm100a_requested)
|
||||
kernel_tiles = n_tiles + int(needs_sm100a_pair)
|
||||
target_shape = (x.shape[0], kernel_tiles * attn_metadata.tile_elems, x.shape[-2], x.shape[-1])
|
||||
|
||||
# single scatter: untile_combined_index maps original row i to its
|
||||
# padded slot, so this is exactly the inverse of postprocess_output
|
||||
# ``untile_combined_index`` maps each packed row to a logical tile
|
||||
# slot. Different geometries can share one transport shape; clear a
|
||||
# reused allocation once when the mapping identity changes so no old
|
||||
# valid row can survive as padding.
|
||||
holder = attn_metadata.tile_buf_holder
|
||||
holder[0] = scatter_into_tile_buf(x, target_shape, attn_metadata.untile_combined_index, holder[0])
|
||||
return holder[0]
|
||||
if holder is None:
|
||||
raise RuntimeError("VSA-H3 metadata has no builder-owned tile buffer holder")
|
||||
buffer_matches = (holder.buffer is not None and holder.buffer.shape == target_shape
|
||||
and holder.buffer.dtype == x.dtype and holder.buffer.device == x.device)
|
||||
if buffer_matches and holder.untile_geometry is not attn_metadata.untile_combined_index:
|
||||
holder.buffer.zero_()
|
||||
holder.buffer = scatter_into_tile_buf(x, target_shape, attn_metadata.untile_combined_index, holder.buffer)
|
||||
holder.untile_geometry = attn_metadata.untile_combined_index
|
||||
if needs_sm100a_pair:
|
||||
# A prior even geometry can reuse this allocation and may have
|
||||
# written the last tile as logical data.
|
||||
holder.buffer[:, n_tiles * attn_metadata.tile_elems:].zero_()
|
||||
return holder.buffer
|
||||
|
||||
def preprocess_qkv(self, qkv: torch.Tensor, attn_metadata: MiniMaxH3VSAMetadata) -> torch.Tensor:
|
||||
return self.tile(qkv, attn_metadata)
|
||||
@@ -401,28 +559,71 @@ class MiniMaxH3VSAImpl(AttentionImpl):
|
||||
gate_compress: torch.Tensor | None,
|
||||
attn_metadata: MiniMaxH3VSAMetadata,
|
||||
) -> torch.Tensor:
|
||||
compiling = torch.compiler.is_compiling()
|
||||
regional_compiling = compiling and self._regional_compile_layer_idx is not None
|
||||
|
||||
tile_elems = attn_metadata.tile_elems
|
||||
if regional_compiling and tile_elems != 64:
|
||||
raise RuntimeError("VSA-H3 regional fullgraph compile requires 64-token tiles; disable "
|
||||
"inference_torch_compile for tile-256/CuTe runs.")
|
||||
if tile_elems == 64:
|
||||
if block_sparse_attn_64_bhsd is None:
|
||||
raise NotImplementedError("fastvideo_kernel.block_sparse_attn is not installed")
|
||||
elif block_sparse_attn_256_bshd is None:
|
||||
raise NotImplementedError("fastvideo_kernel.block_sparse_attn_256 is not installed")
|
||||
|
||||
# probe-guided per-layer opt-out: diffuse layers run dense (all-True
|
||||
# mask) while the rest keep the configured sparsity
|
||||
layer_sparsity = 0.0 if self.layer_idx in attn_metadata.dense_layers else attn_metadata.VSA_sparsity
|
||||
probe_dir = probe_enabled()
|
||||
# Probe recording performs filesystem writes and host synchronizations,
|
||||
# so the loader keeps probe-enabled runs eager. Avoid even reading that
|
||||
# environment switch while Dynamo captures a regional full graph.
|
||||
# The metadata always describes the trained logical geometry.
|
||||
# ``tile()`` may append exactly one transport-only partner for an odd
|
||||
# tile-64 sm100a call. Keep score selection and the gate branch on the
|
||||
# logical prefix, and reject every other shape before a kernel sees it.
|
||||
n_tiles = attn_metadata.variable_block_sizes.numel()
|
||||
logical_seq_len = n_tiles * tile_elems
|
||||
pair_pad_seq_len = logical_seq_len + tile_elems
|
||||
pair_pad_is_valid = tile_elems == 64 and n_tiles % 2 != 0
|
||||
allowed_seq_lengths = (logical_seq_len, pair_pad_seq_len) if pair_pad_is_valid else (logical_seq_len, )
|
||||
if query.shape[1] not in allowed_seq_lengths:
|
||||
expected = (f"the logical length {logical_seq_len} or one sm100a partner tile "
|
||||
f"({pair_pad_seq_len})" if pair_pad_is_valid else f"the logical length {logical_seq_len}")
|
||||
raise ValueError(f"VSA-H3 tiled query has length {query.shape[1]}, expected {expected}.")
|
||||
has_sm100a_pair = query.shape[1] == pair_pad_seq_len
|
||||
for name, tensor in (("key", key), ("value", value)):
|
||||
if tensor.shape[1] != query.shape[1]:
|
||||
raise ValueError(f"VSA-H3 tiled {name} length {tensor.shape[1]} does not match query "
|
||||
f"length {query.shape[1]}.")
|
||||
if gate_compress is not None and gate_compress.shape[1] != query.shape[1]:
|
||||
raise ValueError(f"VSA-H3 tiled gate length {gate_compress.shape[1]} does not match query "
|
||||
f"length {query.shape[1]}.")
|
||||
|
||||
logical_query = query[:, :logical_seq_len]
|
||||
logical_key = key[:, :logical_seq_len]
|
||||
logical_value = value[:, :logical_seq_len]
|
||||
logical_gate = gate_compress[:, :logical_seq_len] if gate_compress is not None else None
|
||||
|
||||
# Probe-guided per-layer opt-out: diffuse layers run dense (all-True
|
||||
# mask) while the rest keep the configured sparsity. During regional
|
||||
# capture, keep the layer decision tensor-valued so the 50 block
|
||||
# instances reuse one graph instead of specializing on layer_idx.
|
||||
force_dense = None
|
||||
if regional_compiling:
|
||||
assert self._regional_compile_layer_idx is not None
|
||||
force_dense = (attn_metadata.dense_layers_tensor == self._regional_compile_layer_idx).any()
|
||||
layer_sparsity = attn_metadata.VSA_sparsity
|
||||
else:
|
||||
layer_sparsity = 0.0 if self.layer_idx in attn_metadata.dense_layers else attn_metadata.VSA_sparsity
|
||||
probe_dir = None if compiling else probe_enabled()
|
||||
|
||||
scores = None
|
||||
if layer_sparsity > 0.0 or gate_compress is not None or probe_dir is not None:
|
||||
q_pooled = _pool_tiles(query, attn_metadata.variable_block_sizes, tile_elems)
|
||||
k_pooled = _pool_tiles(key, attn_metadata.variable_block_sizes, tile_elems)
|
||||
q_pooled = _pool_tiles(logical_query, attn_metadata.variable_block_sizes, tile_elems)
|
||||
k_pooled = _pool_tiles(logical_key, attn_metadata.variable_block_sizes, tile_elems)
|
||||
scores = torch.matmul(q_pooled, k_pooled.transpose(-2, -1)) / (query.shape[-1]**0.5)
|
||||
if probe_dir is not None:
|
||||
record_probe(probe_dir, self.layer_idx, query, key, scores, attn_metadata)
|
||||
record_probe(probe_dir, self.layer_idx, logical_query, logical_key, scores, attn_metadata)
|
||||
|
||||
if scores is None:
|
||||
n_tiles = attn_metadata.variable_block_sizes.numel()
|
||||
mask = torch.ones(query.shape[0], query.shape[2], n_tiles, n_tiles, dtype=torch.bool, device=query.device)
|
||||
else:
|
||||
mask = _build_block_mask(
|
||||
@@ -432,6 +633,10 @@ class MiniMaxH3VSAImpl(AttentionImpl):
|
||||
layer_sparsity,
|
||||
attn_metadata.exempt,
|
||||
)
|
||||
if force_dense is not None:
|
||||
# A scalar bool tensor broadcasts over the block map. This exactly
|
||||
# preserves the eager dense-layer contract without a Python branch.
|
||||
mask = mask | force_dense
|
||||
|
||||
if tile_elems == 64:
|
||||
# Native 64-token path: the block map is already at the kernels'
|
||||
@@ -442,40 +647,87 @@ class MiniMaxH3VSAImpl(AttentionImpl):
|
||||
k_bhsd = key.transpose(1, 2).contiguous()
|
||||
v_bhsd = value.transpose(1, 2).contiguous()
|
||||
|
||||
sm100a_mask = mask
|
||||
sm100a_variable_block_sizes = attn_metadata.variable_block_sizes
|
||||
if has_sm100a_pair:
|
||||
# The synthetic tile is neither a logical query nor key. Its
|
||||
# all-False row yields q2k_num=0, the all-False column keeps it
|
||||
# out of real rows, and vbs=0 masks all of its key slots.
|
||||
sm100a_mask = torch.nn.functional.pad(mask, (0, 1, 0, 1), value=False)
|
||||
sm100a_variable_block_sizes = torch.nn.functional.pad(
|
||||
attn_metadata.variable_block_sizes,
|
||||
(0, 1),
|
||||
value=0,
|
||||
)
|
||||
|
||||
# Opt-in sm_100a CUDA forward (upstream PR #1719 + per-q-tile
|
||||
# q2k_num fix). Forward-only: grad-tracking calls stay on Triton
|
||||
# so autograd keeps the Triton fwd+bwd pairing untouched. The
|
||||
# kernel does return an LSE in Triton's M format, so a future
|
||||
# fwd/bwd pairing is possible, but it is not built here.
|
||||
grad_mode = torch.is_grad_enabled() and (query.requires_grad or key.requires_grad or value.requires_grad)
|
||||
use_sm100a = False
|
||||
if os.environ.get(VSA_SM100A_ENV, "0") == "1":
|
||||
grad_mode = torch.is_grad_enabled() and (query.requires_grad or key.requires_grad
|
||||
or value.requires_grad)
|
||||
reason = _sm100a_unavailable_reason(_sm100a, q_bhsd, attn_metadata.variable_block_sizes, grad_mode)
|
||||
if regional_compiling:
|
||||
if self._regional_compile_sm100a_enabled is None:
|
||||
raise RuntimeError(
|
||||
"VSA-H3 sm_100a routing was not resolved before torch.compile; "
|
||||
"call prepare_for_regional_compile(device) on every MiniMaxH3VSAImpl after loading weights.")
|
||||
# The preparation probe established module/device/kernel
|
||||
# support. Keep only static tensor/geometry facts here; no
|
||||
# env access, device-capability query, or is_supported call may
|
||||
# enter the Dynamo graph.
|
||||
if not (self._regional_compile_sm100a_enabled and not grad_mode and q_bhsd.dtype == torch.bfloat16
|
||||
and q_bhsd.shape[-1] == 128 and sm100a_variable_block_sizes.numel() % 2 == 0):
|
||||
raise RuntimeError(
|
||||
"VSA-H3 regional fullgraph compile requires the prepared sm_100a BF16/head-128 route "
|
||||
"on a supported device; disable inference_torch_compile for this request.")
|
||||
use_sm100a = True
|
||||
elif not compiling and os.environ.get(VSA_SM100A_ENV, "0") == "1":
|
||||
reason = _sm100a_unavailable_reason(_sm100a, q_bhsd, sm100a_variable_block_sizes, grad_mode)
|
||||
if reason is None and map_to_index is None:
|
||||
reason = "fastvideo_kernel.triton_kernels.index (map_to_index) is not importable"
|
||||
if reason is None:
|
||||
use_sm100a = True
|
||||
elif not torch.compiler.is_compiling():
|
||||
else:
|
||||
logger.warning_once(f"{VSA_SM100A_ENV}=1 but falling back to the Triton-64 kernels: {reason}")
|
||||
|
||||
if use_sm100a:
|
||||
# The sm_100a entry is index-native; compact the bool map the
|
||||
# same way the Triton bool entry does internally. Per-row
|
||||
# counts are NON-uniform here (prefix query tiles are dense,
|
||||
# video tiles run prefix+top-k) -- legal for the fixed kernel,
|
||||
# silently wrong on the pre-fix upstream one.
|
||||
q2k_idx, q2k_num = map_to_index(mask)
|
||||
out_bhsd, _ = _sm100a.block_sparse_attn_sm100a(
|
||||
q_bhsd,
|
||||
k_bhsd,
|
||||
v_bhsd,
|
||||
q2k_idx,
|
||||
q2k_num,
|
||||
attn_metadata.variable_block_sizes.to(torch.int32),
|
||||
need_lse=False,
|
||||
)
|
||||
# Regional preparation emits the compile-route receipt before
|
||||
# capture. Logging from this branch would itself break a
|
||||
# ``fullgraph=True`` forward.
|
||||
if not compiling:
|
||||
logger.info_once("MiniMax-H3 VSA tile-64 forward: using the sm100a CUDA block-sparse kernel")
|
||||
if regional_compiling:
|
||||
# The compile-safe wrapper keeps both Triton mask
|
||||
# compaction and the raw pybind launch behind one
|
||||
# fake-backed custom-op boundary.
|
||||
out_bhsd, _ = _sm100a_from_mask(
|
||||
q_bhsd,
|
||||
k_bhsd,
|
||||
v_bhsd,
|
||||
sm100a_mask,
|
||||
sm100a_variable_block_sizes,
|
||||
)
|
||||
else:
|
||||
# Preserve the established eager/index-native route and
|
||||
# compatibility with older kernel wheels. Per-row counts
|
||||
# are non-uniform (prefix queries are dense; video queries
|
||||
# run prefix+top-k), which the fixed kernel supports.
|
||||
q2k_idx, q2k_num = map_to_index(sm100a_mask)
|
||||
out_bhsd, _ = _sm100a.block_sparse_attn_sm100a(
|
||||
q_bhsd,
|
||||
k_bhsd,
|
||||
v_bhsd,
|
||||
q2k_idx,
|
||||
q2k_num,
|
||||
sm100a_variable_block_sizes.to(torch.int32),
|
||||
need_lse=False,
|
||||
)
|
||||
else:
|
||||
if has_sm100a_pair:
|
||||
q_bhsd = q_bhsd[:, :, :logical_seq_len].contiguous()
|
||||
k_bhsd = k_bhsd[:, :, :logical_seq_len].contiguous()
|
||||
v_bhsd = v_bhsd[:, :, :logical_seq_len].contiguous()
|
||||
out_bhsd, _ = block_sparse_attn_64_bhsd(
|
||||
q_bhsd,
|
||||
k_bhsd,
|
||||
@@ -483,25 +735,32 @@ class MiniMaxH3VSAImpl(AttentionImpl):
|
||||
mask,
|
||||
attn_metadata.variable_block_sizes,
|
||||
)
|
||||
if has_sm100a_pair and use_sm100a:
|
||||
out_bhsd = out_bhsd[:, :, :logical_seq_len]
|
||||
out = out_bhsd.transpose(1, 2).contiguous()
|
||||
else:
|
||||
out, _ = block_sparse_attn_256_bshd(query, key, value, mask, attn_metadata.variable_block_sizes)
|
||||
out, _ = block_sparse_attn_256_bshd(
|
||||
logical_query,
|
||||
logical_key,
|
||||
logical_value,
|
||||
mask,
|
||||
attn_metadata.variable_block_sizes,
|
||||
)
|
||||
|
||||
if gate_compress is not None:
|
||||
if logical_gate is not None:
|
||||
# Wan-style compression branch: dense attention over pooled tiles,
|
||||
# broadcast to each tile's rows, scaled by the learned gate
|
||||
# (zero-initialized for H3 => branch contributes nothing until
|
||||
# finetuned; the model layer skips it entirely for all-zero gates).
|
||||
v_pooled = _pool_tiles(value, attn_metadata.variable_block_sizes, tile_elems)
|
||||
v_pooled = _pool_tiles(logical_value, attn_metadata.variable_block_sizes, tile_elems)
|
||||
out_c = torch.matmul(torch.softmax(scores, dim=-1), v_pooled) # [B, H, n_tiles, D]
|
||||
out_c = out_c.permute(0, 2, 1, 3).to(out.dtype) # [B, n_tiles, H, D]
|
||||
batch, seq_len, heads, dim = out.shape
|
||||
n_tiles = attn_metadata.variable_block_sizes.numel()
|
||||
# Out-of-place: on the CuTe backend ``out`` is the tensor FA4's
|
||||
# autograd node saved for its backward, so an in-place add here
|
||||
# bumps its version counter and backward dies with "one of the
|
||||
# variables needed for gradient computation has been modified".
|
||||
out_tiled = out.view(batch, n_tiles, tile_elems, heads, dim)
|
||||
gate_tiled = gate_compress.view(batch, n_tiles, tile_elems, heads, dim)
|
||||
gate_tiled = logical_gate.view(batch, n_tiles, tile_elems, heads, dim)
|
||||
out = (out_tiled + out_c.unsqueeze(2) * gate_tiled).view(batch, seq_len, heads, dim)
|
||||
return out
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import os
|
||||
from functools import wraps
|
||||
|
||||
import torch
|
||||
import torch.nn as nn
|
||||
@@ -20,7 +21,7 @@ def _attention_compile_disabled() -> bool:
|
||||
|
||||
Defaults to ``True`` (the historical behavior: attention runs eager via
|
||||
``torch.compiler.disable``). Set ``FASTVIDEO_DISABLE_ATTENTION_COMPILE=0``
|
||||
to let attention be traced/compiled into the surrounding graph.
|
||||
to let attention instances constructed under that environment be traced.
|
||||
"""
|
||||
val = os.environ.get("FASTVIDEO_DISABLE_ATTENTION_COMPILE")
|
||||
if val is None:
|
||||
@@ -28,11 +29,32 @@ def _attention_compile_disabled() -> bool:
|
||||
return val.strip().lower() not in ("0", "false", "no", "off", "")
|
||||
|
||||
|
||||
def _attention_compile_explicitly_disabled() -> bool:
|
||||
"""Whether the environment explicitly requests the eager boundary.
|
||||
|
||||
Regional compile can override the historical default for one loaded
|
||||
transformer, but it must still honor an explicit debugging escape hatch.
|
||||
"""
|
||||
return "FASTVIDEO_DISABLE_ATTENTION_COMPILE" in os.environ and _attention_compile_disabled()
|
||||
|
||||
|
||||
def _maybe_compiler_disable(fn):
|
||||
"""Apply ``torch.compiler.disable`` unless disabled via env var."""
|
||||
if _attention_compile_disabled():
|
||||
return torch.compiler.disable(fn)
|
||||
return fn
|
||||
"""Defer the eager/traceable choice to each attention instance.
|
||||
|
||||
A class-definition-time choice makes a process-wide default the only
|
||||
option. The deferred wrapper keeps ordinary instances on the historical
|
||||
eager boundary while allowing the regional loader to opt in only the
|
||||
attention modules owned by the transformer it is compiling.
|
||||
"""
|
||||
disabled_fn = torch.compiler.disable(fn)
|
||||
|
||||
@wraps(fn)
|
||||
def _dispatch(self, *args, **kwargs):
|
||||
if self._compile_forward_enabled:
|
||||
return fn(self, *args, **kwargs)
|
||||
return disabled_fn(self, *args, **kwargs)
|
||||
|
||||
return _dispatch
|
||||
|
||||
|
||||
class DistributedAttention(nn.Module):
|
||||
@@ -77,6 +99,13 @@ class DistributedAttention(nn.Module):
|
||||
self.num_kv_heads = num_kv_heads
|
||||
self.backend = backend_name_to_enum(attn_backend.get_name())
|
||||
self.dtype = dtype
|
||||
# Preserve the historical compiler-disabled default. The regional
|
||||
# inference loader may enable this one instance after validating the
|
||||
# transformer's resolved backend; no process-global default changes.
|
||||
self._compile_forward_enabled = not _attention_compile_disabled()
|
||||
|
||||
def _set_compile_forward_enabled(self, enabled: bool) -> None:
|
||||
self._compile_forward_enabled = enabled
|
||||
|
||||
@_maybe_compiler_disable
|
||||
def forward(
|
||||
|
||||
@@ -5,6 +5,7 @@ import torch
|
||||
from torch.distributed import ProcessGroup
|
||||
|
||||
from fastvideo.distributed.device_communicators.base_device_communicator import (DeviceCommunicatorBase)
|
||||
from fastvideo.distributed.device_communicators.ulysses_a2a import maybe_create_helper
|
||||
|
||||
|
||||
class CudaCommunicator(DeviceCommunicatorBase):
|
||||
@@ -25,6 +26,10 @@ class CudaCommunicator(DeviceCommunicatorBase):
|
||||
device=self.device,
|
||||
)
|
||||
|
||||
# Capability is agreed once; the persistent window arms on first use.
|
||||
self.ulysses_a2a = maybe_create_helper(self.cpu_group, self.device_group, self.world_size, self.device,
|
||||
self.pynccl_comm)
|
||||
|
||||
def all_reduce(self, input_, op: torch.distributed.ReduceOp | None = None):
|
||||
pynccl_comm = self.pynccl_comm
|
||||
assert pynccl_comm is not None
|
||||
@@ -38,6 +43,14 @@ class CudaCommunicator(DeviceCommunicatorBase):
|
||||
torch.distributed.all_reduce(out, group=self.device_group, op=op)
|
||||
return out
|
||||
|
||||
def all_to_all_4D(self, input_: torch.Tensor, scatter_dim: int = 2, gather_dim: int = 1) -> torch.Tensor:
|
||||
"""All-to-all over the sequence parallel group, fused when available."""
|
||||
if self.ulysses_a2a is not None:
|
||||
output = self.ulysses_a2a.try_all_to_all_4D(input_, scatter_dim, gather_dim)
|
||||
if output is not None:
|
||||
return output
|
||||
return super().all_to_all_4D(input_, scatter_dim, gather_dim)
|
||||
|
||||
def send(self, tensor: torch.Tensor, dst: int | None = None) -> None:
|
||||
"""Sends a tensor to the destination rank in a non-blocking way"""
|
||||
"""NOTE: `dst` is the local rank of the destination rank."""
|
||||
@@ -65,5 +78,9 @@ class CudaCommunicator(DeviceCommunicatorBase):
|
||||
return tensor
|
||||
|
||||
def destroy(self) -> None:
|
||||
if self.ulysses_a2a is not None:
|
||||
# The helper still needs its NCCL communicator during teardown.
|
||||
self.ulysses_a2a.close()
|
||||
self.ulysses_a2a = None
|
||||
if self.pynccl_comm is not None:
|
||||
self.pynccl_comm = None
|
||||
|
||||
@@ -0,0 +1,424 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""Fused NVLink all-to-all for Ulysses sequence parallelism.
|
||||
|
||||
Drop-in replacement for DistributedAutograd.AllToAll4D when the group is a
|
||||
load-store accessible NVLink mesh: same layout, byte-identical results, fewer
|
||||
passes over local memory. Anything else falls back to the NCCL path.
|
||||
"""
|
||||
|
||||
import socket
|
||||
from array import array
|
||||
|
||||
import torch
|
||||
import torch.distributed as dist
|
||||
from torch.distributed import ProcessGroup
|
||||
|
||||
from fastvideo import envs
|
||||
from fastvideo.logger import init_logger
|
||||
|
||||
logger = init_logger(__name__)
|
||||
|
||||
# The kernel is template-specialized on the world size, so only these dispatch.
|
||||
SUPPORTED_WORLD_SIZES = (2, 4, 6, 8)
|
||||
|
||||
_DTYPE_CODES = {
|
||||
torch.float16: 1,
|
||||
torch.bfloat16: 2,
|
||||
torch.float32: 3,
|
||||
}
|
||||
|
||||
# Bound persistent registered memory per rank. Larger operands use NCCL instead
|
||||
# of growing the window without limit.
|
||||
MAX_WINDOW_BYTES = 1024**3
|
||||
|
||||
# (scatter_dim, gather_dim) -> kernel mode.
|
||||
# 0: [B, S_local, H, D] -> [B, S_global, H_local, D]
|
||||
# 1: [B, S_global, H_local, D] -> [B, S_local, H, D]
|
||||
_MODE_FROM_DIMS = {(2, 1): 0, (1, 2): 1}
|
||||
|
||||
|
||||
def is_enabled() -> bool:
|
||||
"""Whether the fused path is opted in via FASTVIDEO_ULYSSES_A2A."""
|
||||
return envs.FASTVIDEO_ULYSSES_A2A == "auto"
|
||||
|
||||
|
||||
class _FusedUlyssesA2A(torch.autograd.Function):
|
||||
"""Differentiable fused all-to-all.
|
||||
|
||||
The two directions are exact inverses, and Ulysses redistributes activations
|
||||
rather than reducing them, so backward is the opposite mode with no scaling.
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def forward(ctx, helper: "UlyssesA2AHelper", x: torch.Tensor, mode: int) -> torch.Tensor: # type: ignore[override]
|
||||
ctx.helper = helper
|
||||
ctx.mode = mode
|
||||
return helper.run_armed(x, mode)
|
||||
|
||||
@staticmethod
|
||||
def backward(ctx, grad_output: torch.Tensor): # type: ignore[override]
|
||||
# Same numel and dtype as the forward output, so the window is already
|
||||
# sized for it; only contiguity needs restoring.
|
||||
grad_input = ctx.helper.run_armed(grad_output.contiguous(), 1 - ctx.mode)
|
||||
return None, grad_input, None
|
||||
|
||||
|
||||
class UlyssesA2AHelper:
|
||||
"""Owns the fused all-to-all context for one sequence-parallel group.
|
||||
|
||||
Group capability is agreed during construction; the NCCL window is
|
||||
registered on first use, once an operand size is known.
|
||||
"""
|
||||
|
||||
def __init__(self, cpu_group: ProcessGroup, device_group: ProcessGroup, world_size: int, device: torch.device,
|
||||
pynccl_comm):
|
||||
self.cpu_group = cpu_group
|
||||
self.device_group = device_group
|
||||
self.world_size = world_size
|
||||
self.device = device
|
||||
self.pynccl_comm = pynccl_comm
|
||||
|
||||
self._handle: int | None = None
|
||||
# Reuse storage, but exchange the current contract on every call. A
|
||||
# rank-local cache hit cannot establish what peers are doing now.
|
||||
self._local_contract = array("q", [0] * 10)
|
||||
self._local_tensor = torch.frombuffer(self._local_contract, dtype=torch.int64)
|
||||
self._gathered_tensor = torch.empty(world_size * 10, dtype=torch.int64, device="cpu")
|
||||
self._nbytes = 0
|
||||
self._disabled_reason: str | None = None
|
||||
|
||||
if world_size not in SUPPORTED_WORLD_SIZES:
|
||||
self._disabled_reason = (f"world size {world_size} is not one of "
|
||||
f"{SUPPORTED_WORLD_SIZES}")
|
||||
|
||||
# -- lifecycle -----------------------------------------------------------
|
||||
|
||||
def _disable(self, reason: str) -> None:
|
||||
if self._disabled_reason is None:
|
||||
self._disabled_reason = reason
|
||||
logger.info("Ulysses fused all-to-all disabled: %s", reason)
|
||||
|
||||
def _comm_ptr(self) -> int:
|
||||
comm = self.pynccl_comm.comm
|
||||
return int(getattr(comm, "value", comm))
|
||||
|
||||
def _can_attempt(self) -> tuple[bool, str]:
|
||||
"""Check local capability only; the caller exchanges every rank's result."""
|
||||
try:
|
||||
from fastvideo_kernel import comm_ops
|
||||
if not comm_ops.is_available():
|
||||
return False, "fastvideo-kernel was built without the Ulysses a2a kernel"
|
||||
elif not comm_ops.lsa_covers_group(self._comm_ptr(), self.world_size):
|
||||
return False, "the group is not a load-store-accessible (NVLink) mesh"
|
||||
except Exception as e: # noqa: BLE001
|
||||
return False, f"backend unavailable ({type(e).__name__}: {e})"
|
||||
return True, ""
|
||||
|
||||
def _agree(self, ok: bool) -> bool:
|
||||
"""Reduce a local yes/no to a group-wide verdict: True only if all agree."""
|
||||
vote = torch.tensor([1 if ok else 0], dtype=torch.int32, device="cpu")
|
||||
dist.all_reduce(vote, op=dist.ReduceOp.MIN, group=self.cpu_group)
|
||||
return bool(vote.item())
|
||||
|
||||
def _allocate(self, nbytes: int) -> int:
|
||||
"""Allocate locally; split out so allocation-failure tests can inject."""
|
||||
from fastvideo_kernel import comm_ops
|
||||
|
||||
device_index = self.device.index
|
||||
if device_index is None:
|
||||
device_index = torch.cuda.current_device()
|
||||
return comm_ops.allocate(nbytes, self.pynccl_comm.rank, self.world_size, device_index)
|
||||
|
||||
def _register_window(self, handle: int) -> None:
|
||||
"""Register the user window collectively."""
|
||||
from fastvideo_kernel import comm_ops
|
||||
|
||||
comm_ops.register_window(handle, self._comm_ptr())
|
||||
|
||||
def _create_dev_comm(self, handle: int) -> None:
|
||||
"""Create the NCCL device communicator collectively."""
|
||||
from fastvideo_kernel import comm_ops
|
||||
|
||||
comm_ops.create_dev_comm(handle)
|
||||
|
||||
def _dispose(self, handle: int, *, synchronize: bool) -> None:
|
||||
from fastvideo_kernel import comm_ops
|
||||
|
||||
if synchronize:
|
||||
# Kernel launches and copy-out are asynchronous. Do not deregister a
|
||||
# window that a prior call on this device is still accessing.
|
||||
torch.cuda.synchronize(self.device)
|
||||
comm_ops.dispose(handle)
|
||||
|
||||
def _dispose_after_failure(self, handle: int | None) -> bool:
|
||||
"""Best-effort group cleanup after a setup phase failed.
|
||||
|
||||
Every rank votes after attempting cleanup, including a rank that never
|
||||
obtained a local allocation. This keeps the helper permanently disabled
|
||||
if teardown was not unanimous instead of re-entering with split state.
|
||||
"""
|
||||
cleanup_ok = True
|
||||
if handle is not None:
|
||||
try:
|
||||
self._dispose(handle, synchronize=False)
|
||||
except Exception: # noqa: BLE001 - converted to a group verdict below
|
||||
cleanup_ok = False
|
||||
logger.warning("Ulysses partial-context cleanup failed", exc_info=True)
|
||||
return self._agree(cleanup_ok)
|
||||
|
||||
def _call_signature(self, x: torch.Tensor, scatter_dim: int, gather_dim: int) -> tuple[tuple[int, ...], str]:
|
||||
"""Return a rank-comparable call contract and any local decline reason."""
|
||||
mode = _MODE_FROM_DIMS.get((scatter_dim, gather_dim))
|
||||
dtype_code = _DTYPE_CODES.get(x.dtype, 0)
|
||||
shape = tuple(int(dim) for dim in x.shape) if x.dim() == 4 else (0, 0, 0, 0)
|
||||
status = 1
|
||||
reason = ""
|
||||
|
||||
if self._disabled_reason is not None:
|
||||
status, reason = -1, self._disabled_reason
|
||||
elif not is_enabled():
|
||||
status, reason = 0, "FASTVIDEO_ULYSSES_A2A is not auto"
|
||||
elif x.is_cuda and torch.cuda.is_current_stream_capturing():
|
||||
status, reason = 0, "the current CUDA stream is being captured"
|
||||
elif mode is None:
|
||||
status, reason = 0, "unsupported scatter/gather dimensions"
|
||||
elif x.dim() != 4:
|
||||
status, reason = 0, "input is not 4-D"
|
||||
elif dtype_code == 0:
|
||||
status, reason = 0, f"unsupported dtype {x.dtype}"
|
||||
elif not x.is_cuda or x.device != self.device:
|
||||
status, reason = 0, f"input device {x.device} does not match {self.device}"
|
||||
elif not x.is_contiguous():
|
||||
status, reason = 0, "input is not contiguous"
|
||||
elif mode == 0 and shape[2] % self.world_size != 0:
|
||||
status, reason = 0, "head count is not divisible by the group"
|
||||
elif mode == 1 and shape[1] % self.world_size != 0:
|
||||
status, reason = 0, "sequence length is not divisible by the group"
|
||||
|
||||
nbytes = int(x.numel() * x.element_size())
|
||||
if status == 1 and nbytes == 0:
|
||||
status, reason = 0, "input is empty"
|
||||
elif status == 1 and nbytes > MAX_WINDOW_BYTES:
|
||||
status, reason = 0, f"operand exceeds the {MAX_WINDOW_BYTES}-byte window cap"
|
||||
|
||||
# status, armed, mode, dtype, B, S, H, D, bytes, capacity. Comparing the
|
||||
# whole vector prevents equal-size but differently-shaped ranks from
|
||||
# entering the fused kernel with incompatible address math. CUDA device
|
||||
# ordinals are deliberately absent: rank-local ordinals normally differ.
|
||||
signature = (status, int(self._handle is not None), -1 if mode is None else mode, dtype_code, *shape, nbytes,
|
||||
self._nbytes)
|
||||
return signature, reason
|
||||
|
||||
def _agree_call(self, signature: tuple[int, ...]) -> tuple[bool, bool, bool]:
|
||||
"""Agree on eligibility and the complete call signature across ranks.
|
||||
|
||||
Returns ``(use_fused, permanently_unavailable, lifecycle_consistent)``. This control
|
||||
collective is intentionally eager-only; compiled regions use the NCCL
|
||||
implementation before reaching here.
|
||||
"""
|
||||
# Host-side Gloo control keeps this agreement outside CUDA graph capture
|
||||
# and avoids inserting a second NCCL collective ahead of the data path.
|
||||
self._local_contract[:] = array("q", signature)
|
||||
dist.all_gather_into_tensor(self._gathered_tensor, self._local_tensor, group=self.cpu_group)
|
||||
values = self._gathered_tensor.tolist()
|
||||
contracts = [values[start:start + 10] for start in range(0, len(values), 10)]
|
||||
first = contracts[0]
|
||||
use_fused = first[0] == 1 and all(contract == first for contract in contracts)
|
||||
permanently_unavailable = any(contract[0] < 0 for contract in contracts)
|
||||
lifecycle_consistent = all(contract[1] == first[1] and contract[-1] == first[-1] for contract in contracts)
|
||||
return use_fused, permanently_unavailable, lifecycle_consistent
|
||||
|
||||
def _build(self, nbytes: int) -> bool:
|
||||
"""Collectively register the window. Returns True if it is armed."""
|
||||
handle: int | None = None
|
||||
allocation_reason = ""
|
||||
try:
|
||||
handle = self._allocate(nbytes)
|
||||
except Exception as e: # noqa: BLE001 - converted to a group verdict below
|
||||
allocation_reason = f"window allocation failed ({type(e).__name__}: {e})"
|
||||
|
||||
# Allocation is local, so vote before any rank enters registration.
|
||||
if not self._agree(handle is not None):
|
||||
cleanup_ok = self._dispose_after_failure(handle)
|
||||
reason = allocation_reason or "a peer rank could not allocate the window"
|
||||
if not cleanup_ok:
|
||||
reason += "; partial-context cleanup failed on a peer"
|
||||
self._disable(reason)
|
||||
return False
|
||||
|
||||
assert handle is not None
|
||||
window_registered = False
|
||||
registration_reason = ""
|
||||
try:
|
||||
self._register_window(handle)
|
||||
window_registered = True
|
||||
except Exception as e: # noqa: BLE001 - converted to a group verdict below
|
||||
registration_reason = f"window registration failed ({type(e).__name__}: {e})"
|
||||
|
||||
if not self._agree(window_registered):
|
||||
cleanup_ok = self._dispose_after_failure(handle)
|
||||
reason = registration_reason or "a peer rank could not register the window"
|
||||
if not cleanup_ok:
|
||||
reason += "; partial-context cleanup failed on a peer"
|
||||
self._disable(reason)
|
||||
return False
|
||||
|
||||
dev_comm_created = False
|
||||
creation_reason = ""
|
||||
try:
|
||||
self._create_dev_comm(handle)
|
||||
dev_comm_created = True
|
||||
except Exception as e: # noqa: BLE001 - converted to a group verdict below
|
||||
creation_reason = f"device communicator creation failed ({type(e).__name__}: {e})"
|
||||
|
||||
if not self._agree(dev_comm_created):
|
||||
cleanup_ok = self._dispose_after_failure(handle)
|
||||
reason = creation_reason or "a peer rank could not create the device communicator"
|
||||
if not cleanup_ok:
|
||||
reason += "; partial-context cleanup failed on a peer"
|
||||
self._disable(reason)
|
||||
return False
|
||||
|
||||
self._handle = handle
|
||||
self._nbytes = nbytes
|
||||
logger.info("Ulysses fused all-to-all armed: world_size=%d window=%.0f MiB", self.world_size, nbytes / 2**20)
|
||||
return True
|
||||
|
||||
def close(self) -> bool:
|
||||
"""Collectively destroy the device communicator and its window.
|
||||
|
||||
Returns whether all ranks completed teardown. An armed/unarmed split
|
||||
cannot safely enter NCCL window deregistration, so that exceptional
|
||||
state is leaked until process exit and permanently disabled instead of
|
||||
risking a distributed deadlock.
|
||||
"""
|
||||
handle = self._handle
|
||||
all_armed = self._agree(handle is not None)
|
||||
all_unarmed = self._agree(handle is None)
|
||||
if all_unarmed:
|
||||
self._nbytes = 0
|
||||
return True
|
||||
if not all_armed:
|
||||
self._handle = None
|
||||
self._nbytes = 0
|
||||
self._disable("ranks disagreed on whether a fused window was armed during teardown")
|
||||
return False
|
||||
|
||||
assert handle is not None
|
||||
synchronize_ok = True
|
||||
try:
|
||||
torch.cuda.synchronize(self.device)
|
||||
except Exception: # noqa: BLE001 - converted to a group verdict below
|
||||
synchronize_ok = False
|
||||
logger.warning("Ulysses pre-teardown synchronization failed", exc_info=True)
|
||||
if not self._agree(synchronize_ok):
|
||||
self._disable("a peer rank could not synchronize before fused-window teardown")
|
||||
return False
|
||||
|
||||
dispose_ok = True
|
||||
try:
|
||||
self._dispose(handle, synchronize=False)
|
||||
except Exception: # noqa: BLE001 - teardown must not mask a real error
|
||||
dispose_ok = False
|
||||
logger.warning("Ulysses window deregistration failed", exc_info=True)
|
||||
|
||||
group_ok = self._agree(dispose_ok)
|
||||
# The native disposer consumes the handle even when a cleanup call
|
||||
# reports an error, so never retry a potentially dangling pointer.
|
||||
self._handle = None
|
||||
self._nbytes = 0
|
||||
if not group_ok:
|
||||
self._disable("fused-window teardown failed on a peer rank")
|
||||
return group_ok
|
||||
|
||||
# -- collective ----------------------------------------------------------
|
||||
|
||||
def run_armed(self, x: torch.Tensor, mode: int) -> torch.Tensor:
|
||||
"""Run one collective on an already-armed context."""
|
||||
assert self._handle is not None, "run_armed called on an unarmed helper"
|
||||
from fastvideo_kernel import comm_ops
|
||||
|
||||
w = self.world_size
|
||||
if mode == 0:
|
||||
B, S_local, H, D = x.shape
|
||||
out = torch.empty(B, S_local * w, H // w, D, dtype=x.dtype, device=x.device)
|
||||
else:
|
||||
B, S_global, H_local, D = x.shape
|
||||
S_local, H = S_global // w, H_local * w
|
||||
out = torch.empty(B, S_local, H, D, dtype=x.dtype, device=x.device)
|
||||
comm_ops.all_to_all(self._handle, x, out, B, S_local, H, D, mode)
|
||||
return out
|
||||
|
||||
def try_all_to_all_4D(self, x: torch.Tensor, scatter_dim: int, gather_dim: int) -> torch.Tensor | None:
|
||||
"""Fused collective, or None to let the caller use the NCCL path."""
|
||||
if self._disabled_reason is not None:
|
||||
return None
|
||||
|
||||
# Python lifecycle checks, votes, and pybind calls are not valid inside
|
||||
# a fullgraph region. The inherited NCCL path is compiler-visible, so
|
||||
# regional compile stays fullgraph by declining before any tensor read.
|
||||
if torch.compiler.is_compiling():
|
||||
return None
|
||||
|
||||
signature, reason = self._call_signature(x, scatter_dim, gather_dim)
|
||||
use_fused, permanently_unavailable, lifecycle_consistent = self._agree_call(signature)
|
||||
if not use_fused:
|
||||
if not lifecycle_consistent:
|
||||
self.close()
|
||||
self._disable("ranks disagreed on the fused-window lifecycle")
|
||||
if permanently_unavailable:
|
||||
self._disable(reason or "a peer rank cannot use the fused path")
|
||||
return None
|
||||
|
||||
mode = signature[2]
|
||||
nbytes = signature[-2]
|
||||
if self._handle is None:
|
||||
if not self._build(nbytes):
|
||||
return None
|
||||
elif nbytes > self._nbytes:
|
||||
logger.info("Ulysses window grow: %d -> %d bytes", self._nbytes, nbytes)
|
||||
if not self.close():
|
||||
return None
|
||||
if not self._build(nbytes):
|
||||
return None
|
||||
|
||||
return _FusedUlyssesA2A.apply(self, x, mode)
|
||||
|
||||
|
||||
def maybe_create_helper(cpu_group: ProcessGroup | None, device_group: ProcessGroup | None, world_size: int,
|
||||
device: torch.device | None, pynccl_comm) -> UlyssesA2AHelper | None:
|
||||
"""Collectively create a helper only when every rank can use it."""
|
||||
if (world_size <= 1 or cpu_group is None or device_group is None or device is None or device.type != "cuda"):
|
||||
return None
|
||||
if not dist.is_initialized():
|
||||
return None
|
||||
|
||||
helper = None
|
||||
reason = ""
|
||||
if not is_enabled():
|
||||
reason = "FASTVIDEO_ULYSSES_A2A is not auto"
|
||||
elif world_size not in SUPPORTED_WORLD_SIZES:
|
||||
reason = f"world size {world_size} is not one of {SUPPORTED_WORLD_SIZES}"
|
||||
elif pynccl_comm is None or pynccl_comm.disabled:
|
||||
reason = "the group has no usable PyNccl communicator"
|
||||
else:
|
||||
try:
|
||||
candidate = UlyssesA2AHelper(cpu_group, device_group, world_size, device, pynccl_comm)
|
||||
can_attempt, reason = candidate._can_attempt()
|
||||
if can_attempt:
|
||||
helper = candidate
|
||||
except Exception as e: # noqa: BLE001 - converted to a group verdict below
|
||||
reason = f"helper construction failed ({type(e).__name__}: {e})"
|
||||
|
||||
# Every rank reaches the same exchange, including configuration, constructor,
|
||||
# and backend failures. LSA covers addressability, not single-host locality.
|
||||
gathered: list[tuple[str, bool]] = [("", False)] * world_size
|
||||
dist.all_gather_object(gathered, (socket.gethostname(), helper is not None), group=cpu_group)
|
||||
hostnames = {hostname for hostname, _ in gathered}
|
||||
if len(hostnames) != 1:
|
||||
reason = f"ranks span multiple hosts: {sorted(hostnames)}"
|
||||
if len(hostnames) != 1 or not all(ok for _, ok in gathered):
|
||||
if dist.get_rank(cpu_group) == 0:
|
||||
logger.info("Ulysses fused all-to-all unavailable: %s", reason or "a peer rank declined")
|
||||
return None
|
||||
return helper
|
||||
@@ -635,14 +635,16 @@ class GroupCoordinator:
|
||||
return self.device_communicator.recv(size, dtype, src)
|
||||
|
||||
def destroy(self) -> None:
|
||||
# First: communicator teardown can be collective, so it needs the
|
||||
# process groups alive.
|
||||
if self.device_communicator is not None:
|
||||
self.device_communicator.destroy()
|
||||
if self.device_group is not None:
|
||||
torch.distributed.destroy_process_group(self.device_group)
|
||||
self.device_group = None
|
||||
if self.cpu_group is not None:
|
||||
torch.distributed.destroy_process_group(self.cpu_group)
|
||||
self.cpu_group = None
|
||||
if self.device_communicator is not None:
|
||||
self.device_communicator.destroy()
|
||||
if self.mq_broadcaster is not None:
|
||||
self.mq_broadcaster = None
|
||||
|
||||
|
||||
@@ -626,7 +626,9 @@ async def benchmark(args: argparse.Namespace) -> None:
|
||||
) as resp:
|
||||
if resp.status == 200:
|
||||
info = await resp.json()
|
||||
if "model_path" in info and info["model_path"]:
|
||||
if info.get("served_model_name"):
|
||||
args.model = info["served_model_name"]
|
||||
elif info.get("model_path"):
|
||||
args.model = info["model_path"]
|
||||
logger.info("Updated model name from server: %s", args.model)
|
||||
except Exception as e:
|
||||
|
||||
@@ -56,6 +56,7 @@ class ServeSubcommand(CLISubcommand):
|
||||
port=serve_config.server.port,
|
||||
output_dir=serve_config.server.output_dir,
|
||||
default_request=serve_config.default_request,
|
||||
served_model_name=serve_config.server.served_model_name,
|
||||
)
|
||||
|
||||
def validate(self, args: argparse.Namespace) -> None:
|
||||
|
||||
@@ -3,10 +3,13 @@
|
||||
|
||||
from contextlib import asynccontextmanager
|
||||
from collections.abc import AsyncIterator
|
||||
import os
|
||||
|
||||
import uvicorn
|
||||
from fastapi import FastAPI
|
||||
from fastapi import FastAPI, HTTPException, Request
|
||||
from fastapi.exceptions import RequestValidationError
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from fastvideo.api.presets import validate_preset_selection
|
||||
from fastvideo.api.schema import GenerationRequest
|
||||
@@ -15,6 +18,7 @@ from fastvideo.entrypoints.openai.state import (
|
||||
clear_state,
|
||||
set_state,
|
||||
)
|
||||
from fastvideo.entrypoints.openai.serving_engine import OpenAIServingEngine
|
||||
from fastvideo.entrypoints.video_generator import VideoGenerator
|
||||
from fastvideo.fastvideo_args import FastVideoArgs
|
||||
from fastvideo.logger import init_logger
|
||||
@@ -53,26 +57,40 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]:
|
||||
"""Load model on startup, clean up on shutdown"""
|
||||
args: FastVideoArgs = app.state.fastvideo_args
|
||||
output_dir: str = app.state.output_dir
|
||||
served_model_name: str | None = app.state.served_model_name
|
||||
default_request: GenerationRequest | None = getattr(app.state, "default_request", None)
|
||||
|
||||
logger.info("Loading model from %s ...", args.model_path)
|
||||
generator = VideoGenerator.from_fastvideo_args(args)
|
||||
serving_engine = OpenAIServingEngine(generator)
|
||||
logger.info("Model loaded successfully.")
|
||||
|
||||
set_state(generator, args, output_dir, default_request=default_request)
|
||||
set_state(
|
||||
generator,
|
||||
serving_engine,
|
||||
args,
|
||||
output_dir,
|
||||
default_request=default_request,
|
||||
served_model_name=served_model_name,
|
||||
)
|
||||
|
||||
yield # server is running
|
||||
try:
|
||||
yield # server is running
|
||||
finally:
|
||||
logger.info("Shutting down — releasing model resources ...")
|
||||
from fastvideo.entrypoints.openai.video_api import shutdown_video_jobs
|
||||
|
||||
logger.info("Shutting down — releasing model resources ...")
|
||||
generator.shutdown()
|
||||
clear_state()
|
||||
logger.info("Shutdown complete.")
|
||||
await shutdown_video_jobs()
|
||||
await serving_engine.shutdown()
|
||||
clear_state()
|
||||
logger.info("Shutdown complete.")
|
||||
|
||||
|
||||
def create_app(
|
||||
fastvideo_args: FastVideoArgs,
|
||||
output_dir: str = DEFAULT_OUTPUT_DIR,
|
||||
default_request: GenerationRequest | None = None,
|
||||
served_model_name: str | None = None,
|
||||
) -> FastAPI:
|
||||
"""Build the FastAPI application with all routers mounted"""
|
||||
|
||||
@@ -84,6 +102,7 @@ def create_app(
|
||||
app.state.fastvideo_args = fastvideo_args
|
||||
app.state.output_dir = output_dir
|
||||
app.state.default_request = default_request
|
||||
app.state.served_model_name = served_model_name
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
@@ -93,6 +112,35 @@ def create_app(
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
@app.exception_handler(HTTPException)
|
||||
async def openai_http_error(_request: Request, exc: HTTPException) -> JSONResponse:
|
||||
"""Return the error envelope consumed by OpenAI-compatible clients."""
|
||||
message = exc.detail if isinstance(exc.detail, str) else str(exc.detail)
|
||||
return JSONResponse(
|
||||
status_code=exc.status_code,
|
||||
headers=exc.headers,
|
||||
content={
|
||||
"error": {
|
||||
"message": message,
|
||||
"type": "invalid_request_error" if exc.status_code < 500 else "server_error",
|
||||
"param": None,
|
||||
"code": exc.status_code,
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
@app.exception_handler(RequestValidationError)
|
||||
async def openai_validation_error(_request: Request, exc: RequestValidationError) -> JSONResponse:
|
||||
return JSONResponse(
|
||||
status_code=400,
|
||||
content={"error": {
|
||||
"message": str(exc),
|
||||
"type": "invalid_request_error",
|
||||
"param": None,
|
||||
"code": 400,
|
||||
}},
|
||||
)
|
||||
|
||||
# Import and mount routers
|
||||
from fastvideo.entrypoints.openai.common_api import router as common_router
|
||||
from fastvideo.entrypoints.openai.image_api import router as image_router
|
||||
@@ -104,6 +152,11 @@ def create_app(
|
||||
|
||||
@app.get("/health")
|
||||
async def health():
|
||||
from fastvideo.entrypoints.openai.state import get_serving_engine
|
||||
|
||||
engine = get_serving_engine()
|
||||
if not engine.healthy:
|
||||
raise HTTPException(status_code=503, detail=engine.unhealthy_reason or "generation engine is unhealthy")
|
||||
return {"status": "ok"}
|
||||
|
||||
return app
|
||||
@@ -137,8 +190,10 @@ def run_server(
|
||||
port: int = DEFAULT_PORT,
|
||||
output_dir: str = DEFAULT_OUTPUT_DIR,
|
||||
default_request: GenerationRequest | None = None,
|
||||
served_model_name: str | None = None,
|
||||
):
|
||||
"""Create the app and run it with uvicorn"""
|
||||
os.environ.setdefault("FASTVIDEO_STAGE_LOGGING", "1")
|
||||
if default_request is not None:
|
||||
_validate_default_request_against_preset(default_request, fastvideo_args.model_path)
|
||||
|
||||
@@ -146,6 +201,7 @@ def run_server(
|
||||
fastvideo_args,
|
||||
output_dir=output_dir,
|
||||
default_request=default_request,
|
||||
served_model_name=served_model_name,
|
||||
)
|
||||
|
||||
logger.info("Starting FastVideo server on %s:%d", host, port)
|
||||
|
||||
@@ -7,7 +7,7 @@ from fastapi import APIRouter
|
||||
from fastapi.responses import ORJSONResponse
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from fastvideo.entrypoints.openai.state import get_server_args
|
||||
from fastvideo.entrypoints.openai.state import get_served_model_name, get_server_args
|
||||
from fastvideo.logger import init_logger
|
||||
|
||||
router = APIRouter(prefix="/v1")
|
||||
@@ -28,15 +28,17 @@ class ModelCard(BaseModel):
|
||||
async def available_models():
|
||||
"""Show available models"""
|
||||
args = get_server_args()
|
||||
card = ModelCard(id=args.model_path, root=args.model_path)
|
||||
return {"object": "list", "data": [card.model_dump()]}
|
||||
cards = [ModelCard(id=get_served_model_name(), root=args.model_path)]
|
||||
return {"object": "list", "data": [card.model_dump() for card in cards]}
|
||||
|
||||
|
||||
@router.get("/models/{model:path}", response_class=ORJSONResponse)
|
||||
async def retrieve_model(model: str):
|
||||
"""Retrieve a model by name"""
|
||||
args = get_server_args()
|
||||
if model != args.model_path:
|
||||
served_model_name = get_served_model_name()
|
||||
available = {served_model_name}
|
||||
if model not in available:
|
||||
return ORJSONResponse(
|
||||
status_code=404,
|
||||
content={
|
||||
@@ -48,7 +50,7 @@ async def retrieve_model(model: str):
|
||||
}
|
||||
},
|
||||
)
|
||||
card = ModelCard(id=model, root=model)
|
||||
card = ModelCard(id=model, root=args.model_path)
|
||||
return card.model_dump()
|
||||
|
||||
|
||||
@@ -56,4 +58,14 @@ async def retrieve_model(model: str):
|
||||
async def model_info():
|
||||
"""Get basic model information"""
|
||||
args = get_server_args()
|
||||
return {"model_path": args.model_path}
|
||||
return {
|
||||
"model_path":
|
||||
args.model_path,
|
||||
"served_model_name":
|
||||
get_served_model_name(),
|
||||
"lora": ({
|
||||
"name": args.lora_nickname,
|
||||
"path": args.lora_path,
|
||||
"scale": args.lora_strength,
|
||||
} if args.lora_path else None),
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
# Adapted from SGLang
|
||||
# (https://github.com/sgl-project/sglang/blob/main/python/sglang/multimodal_gen/runtime/entrypoints/openai/image_api.py)
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import os
|
||||
import time
|
||||
@@ -12,8 +11,8 @@ from fastapi import (APIRouter, File, Form, HTTPException, Path, Query, UploadFi
|
||||
from fastapi.responses import FileResponse
|
||||
|
||||
from fastvideo.entrypoints.openai.state import (
|
||||
get_generator,
|
||||
get_output_dir,
|
||||
get_serving_engine,
|
||||
)
|
||||
from fastvideo.entrypoints.openai.protocol import (
|
||||
ImageGenerationsRequest,
|
||||
@@ -89,8 +88,7 @@ def _build_generation_kwargs(
|
||||
@router.post("", response_model=ImageResponse)
|
||||
async def generations(request: ImageGenerationsRequest):
|
||||
request_id = generate_request_id()
|
||||
generator = get_generator()
|
||||
loop = asyncio.get_running_loop()
|
||||
engine = get_serving_engine()
|
||||
|
||||
gen_kwargs = _build_generation_kwargs(
|
||||
request_id=request_id,
|
||||
@@ -109,7 +107,7 @@ async def generations(request: ImageGenerationsRequest):
|
||||
|
||||
start = time.perf_counter()
|
||||
try:
|
||||
await loop.run_in_executor(None, lambda: generator.generate_video(**gen_kwargs))
|
||||
await engine.run_serialized(engine.generator.generate_video, **gen_kwargs)
|
||||
except Exception as e:
|
||||
logger.error("Image generation failed: %s", e)
|
||||
raise HTTPException(status_code=500, detail=str(e)) from None
|
||||
@@ -173,8 +171,7 @@ async def edits(
|
||||
enable_teacache: bool | None = Form(False),
|
||||
):
|
||||
request_id = generate_request_id()
|
||||
generator = get_generator()
|
||||
loop = asyncio.get_running_loop()
|
||||
engine = get_serving_engine()
|
||||
|
||||
images = image or image_array
|
||||
urls = url or url_array
|
||||
@@ -213,7 +210,7 @@ async def edits(
|
||||
|
||||
start = time.perf_counter()
|
||||
try:
|
||||
await loop.run_in_executor(None, lambda: generator.generate_video(**gen_kwargs))
|
||||
await engine.run_serialized(engine.generator.generate_video, **gen_kwargs)
|
||||
except Exception as e:
|
||||
logger.error("Image edit failed: %s", e)
|
||||
raise HTTPException(status_code=500, detail=str(e)) from None
|
||||
|
||||
@@ -3,9 +3,10 @@
|
||||
|
||||
import time
|
||||
import uuid
|
||||
from typing import Any
|
||||
from enum import Enum
|
||||
from typing import Annotated, Any, Literal
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
from pydantic import BaseModel, ConfigDict, Field, StringConstraints, field_validator
|
||||
|
||||
|
||||
class ImageResponseData(BaseModel):
|
||||
@@ -43,47 +44,208 @@ class ImageGenerationsRequest(BaseModel):
|
||||
enable_teacache: bool | None = False
|
||||
|
||||
|
||||
class VideoResponse(BaseModel):
|
||||
id: str
|
||||
object: str = "video"
|
||||
model: str = ""
|
||||
status: str = "queued"
|
||||
progress: int = 100
|
||||
created_at: int = Field(default_factory=lambda: int(time.time()))
|
||||
size: str = ""
|
||||
seconds: str = "4"
|
||||
quality: str = "standard"
|
||||
url: str | None = None
|
||||
file_path: str | None = None
|
||||
completed_at: int | None = None
|
||||
error: dict[str, Any] | None = None
|
||||
peak_memory_mb: float | None = None
|
||||
inference_time_s: float | None = None
|
||||
_INT64_MIN = -(2**63)
|
||||
_INT64_MAX = 2**63 - 1
|
||||
|
||||
|
||||
class VideoGenerationsRequest(BaseModel):
|
||||
prompt: str
|
||||
class VideoGenerationStatus(str, Enum):
|
||||
QUEUED = "queued"
|
||||
IN_PROGRESS = "in_progress"
|
||||
COMPLETED = "completed"
|
||||
FAILED = "failed"
|
||||
|
||||
|
||||
SizeStr = Annotated[str, StringConstraints(pattern=r"^\d+x\d+$")]
|
||||
SecondStr = Annotated[str, StringConstraints(pattern=r"^[1-9]\d*$")]
|
||||
DEFAULT_FPS = 24
|
||||
|
||||
|
||||
class VideoParams(BaseModel):
|
||||
"""Optional vLLM-Omni-compatible video parameter block."""
|
||||
|
||||
width: int | None = Field(default=None, ge=1, le=_INT64_MAX)
|
||||
height: int | None = Field(default=None, ge=1, le=_INT64_MAX)
|
||||
num_frames: int | None = Field(default=None, ge=1, le=_INT64_MAX)
|
||||
fps: int | None = Field(default=None, ge=1, le=_INT64_MAX)
|
||||
|
||||
@property
|
||||
def size(self) -> str | None:
|
||||
if self.width is not None and self.height is not None:
|
||||
return f"{self.width}x{self.height}"
|
||||
return None
|
||||
|
||||
|
||||
class FileImageReference(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
file_id: str
|
||||
|
||||
|
||||
class UrlImageReference(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
image_url: str = Field(min_length=1)
|
||||
|
||||
|
||||
ImageReference = UrlImageReference | FileImageReference
|
||||
|
||||
|
||||
class FileVideoReference(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
file_id: str
|
||||
|
||||
|
||||
class UrlVideoReference(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
video_url: str = Field(min_length=1)
|
||||
|
||||
|
||||
VideoReference = UrlVideoReference | FileVideoReference
|
||||
|
||||
|
||||
class UrlAudioReference(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
audio_url: str = Field(min_length=1)
|
||||
|
||||
|
||||
AudioReference = UrlAudioReference
|
||||
|
||||
|
||||
class VideoGenerationRequest(BaseModel):
|
||||
"""OpenAI/vLLM-Omni-compatible video generation request.
|
||||
|
||||
Model-specific parameters belong in ``extra_params``. FastVideo keeps the
|
||||
legacy ``input_reference`` and ``reference_url`` fields for clients that
|
||||
predate vLLM-Omni's typed reference objects.
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
prompt: str = Field(min_length=1)
|
||||
model: str | None = None
|
||||
seconds: Annotated[int, Field(ge=1, le=_INT64_MAX)] | SecondStr | None = None
|
||||
size: SizeStr | None = None
|
||||
image_reference: ImageReference | list[ImageReference] | None = None
|
||||
video_reference: VideoReference | list[VideoReference] | None = None
|
||||
audio_reference: AudioReference | list[AudioReference] | None = None
|
||||
input_reference: str | None = None
|
||||
reference_url: str | None = None
|
||||
model: str | None = None
|
||||
seconds: int | None = 4
|
||||
size: str | None = ""
|
||||
fps: int | None = None
|
||||
num_frames: int | None = None
|
||||
seed: int | None = 1024
|
||||
# FastVideo extensions (SGLang-compatible)
|
||||
num_inference_steps: int | None = None
|
||||
guidance_scale: float | None = None
|
||||
guidance_scale_2: float | None = None
|
||||
true_cfg_scale: float | None = None
|
||||
# SGLang's legacy direct-video spellings.
|
||||
video_path: str | None = None
|
||||
video_url: str | None = None
|
||||
video_params: VideoParams | None = None
|
||||
user: str | None = None
|
||||
task: str | None = None
|
||||
|
||||
width: int | None = Field(default=None, ge=1, le=_INT64_MAX)
|
||||
height: int | None = Field(default=None, ge=1, le=_INT64_MAX)
|
||||
fps: int | None = Field(default=None, ge=1, le=_INT64_MAX)
|
||||
num_frames: int | None = Field(default=None, ge=1, le=_INT64_MAX)
|
||||
aspect_ratio: str | None = None
|
||||
short_edge: int | None = Field(default=None, ge=1, le=_INT64_MAX)
|
||||
num_outputs_per_prompt: int = Field(default=1, ge=1, le=10)
|
||||
# SGLang spelling retained as an alias-like input field.
|
||||
n: int | None = Field(default=None, ge=1, le=10)
|
||||
start_time_seconds: float | None = Field(default=None, ge=0.0)
|
||||
quality: Literal["auto", "default", "standard", "hd"] | None = None
|
||||
negative_prompt: str | None = None
|
||||
enable_teacache: bool | None = False
|
||||
output_path: str | None = None
|
||||
num_inference_steps: int | None = Field(default=None, ge=1, le=200)
|
||||
guidance_scale: float | None = Field(default=None, ge=0.0, le=20.0)
|
||||
guidance_scale_2: float | None = Field(default=None, ge=0.0, le=20.0)
|
||||
boundary_ratio: float | None = Field(default=None, ge=0.0, le=1.0)
|
||||
flow_shift: float | None = None
|
||||
true_cfg_scale: float | None = Field(default=None, ge=0.0, le=20.0)
|
||||
seed: int | None = Field(default=None, ge=_INT64_MIN, le=_INT64_MAX)
|
||||
generate_sound: bool = False
|
||||
sound_duration: float | None = Field(default=None, gt=0.0)
|
||||
enable_teacache: bool = False
|
||||
max_sequence_length: int | None = Field(default=None, ge=1)
|
||||
|
||||
enable_frame_interpolation: bool = False
|
||||
frame_interpolation_exp: int = Field(default=1, ge=1, le=_INT64_MAX)
|
||||
frame_interpolation_scale: float = Field(default=1.0, gt=0.0)
|
||||
frame_interpolation_model_path: str | None = None
|
||||
|
||||
lora: dict[str, Any] | None = None
|
||||
extra_params: dict[str, Any] | None = None
|
||||
|
||||
@field_validator("prompt")
|
||||
@classmethod
|
||||
def validate_prompt(cls, value: str) -> str:
|
||||
if not value.strip():
|
||||
raise ValueError("prompt must not be empty")
|
||||
return value
|
||||
|
||||
def resolve_video_params(self) -> VideoParams:
|
||||
"""Resolve top-level, nested, and ``size`` dimensions like vLLM-Omni."""
|
||||
params = VideoParams(
|
||||
width=self.width,
|
||||
height=self.height,
|
||||
fps=self.fps,
|
||||
num_frames=self.num_frames,
|
||||
)
|
||||
if self.video_params is not None:
|
||||
params.width = params.width or self.video_params.width
|
||||
params.height = params.height or self.video_params.height
|
||||
params.fps = params.fps or self.video_params.fps
|
||||
params.num_frames = params.num_frames or self.video_params.num_frames
|
||||
if self.size is not None:
|
||||
width, height = self.size.split("x", 1)
|
||||
params.width, params.height = int(width), int(height)
|
||||
if params.fps is None:
|
||||
params.fps = DEFAULT_FPS
|
||||
if params.num_frames is None and self.seconds is not None:
|
||||
params.num_frames = int(self.seconds) * params.fps
|
||||
return params
|
||||
|
||||
@property
|
||||
def resolved_num_outputs(self) -> int:
|
||||
return self.n if self.n is not None else self.num_outputs_per_prompt
|
||||
|
||||
|
||||
# Backward-compatible spelling used by the original FastVideo/SGLang surface.
|
||||
VideoGenerationsRequest = VideoGenerationRequest
|
||||
|
||||
|
||||
class VideoError(BaseModel):
|
||||
code: int | str = 500
|
||||
message: str
|
||||
|
||||
|
||||
class VideoResponse(BaseModel):
|
||||
id: str
|
||||
object: Literal["video"] = "video"
|
||||
model: str = ""
|
||||
prompt: str = ""
|
||||
status: VideoGenerationStatus = VideoGenerationStatus.QUEUED
|
||||
progress: int = 0
|
||||
created_at: int = Field(default_factory=lambda: int(time.time()))
|
||||
size: SizeStr | None = None
|
||||
seconds: SecondStr = "4"
|
||||
quality: str = "default"
|
||||
url: str | None = None
|
||||
remixed_from_video_id: str | None = None
|
||||
expires_at: int | None = None
|
||||
file_path: str | None = None
|
||||
file_name: str | None = None
|
||||
media_type: Literal["video/mp4"] = "video/mp4"
|
||||
completed_at: int | None = None
|
||||
error: VideoError | None = None
|
||||
peak_memory_mb: float | None = None
|
||||
inference_time_s: float | None = None
|
||||
stage_durations: dict[str, float] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class VideoDeleteResponse(BaseModel):
|
||||
id: str
|
||||
deleted: bool
|
||||
object: Literal["video.deleted"] = "video.deleted"
|
||||
|
||||
|
||||
class VideoListResponse(BaseModel):
|
||||
data: list[VideoResponse]
|
||||
object: str = "list"
|
||||
first_id: str | None = None
|
||||
last_id: str | None = None
|
||||
has_more: bool = False
|
||||
object: Literal["list"] = "list"
|
||||
|
||||
|
||||
def generate_request_id() -> str:
|
||||
|
||||
@@ -0,0 +1,426 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""Translate OpenAI/vLLM-Omni requests into FastVideo's typed request API."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import math
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
from fastvideo.api.compat import (
|
||||
REQUEST_BATCH_EXTRA_PASSTHROUGH_FIELDS,
|
||||
explicit_request_updates,
|
||||
legacy_generate_call_to_request,
|
||||
request_to_sampling_param,
|
||||
)
|
||||
from fastvideo.api.schema import GenerationRequest
|
||||
from fastvideo.entrypoints.openai.protocol import (
|
||||
FileImageReference,
|
||||
FileVideoReference,
|
||||
UrlImageReference,
|
||||
VideoGenerationRequest,
|
||||
)
|
||||
from fastvideo.entrypoints.openai.utils import save_image_to_path
|
||||
from fastvideo.fastvideo_args import FastVideoArgs
|
||||
from fastvideo.models.vision_utils import load_image
|
||||
from fastvideo.registry import get_preset_selection
|
||||
|
||||
|
||||
class RequestAdaptationError(ValueError):
|
||||
"""The transport request cannot be represented by the loaded pipeline."""
|
||||
|
||||
|
||||
def _as_list(value: Any | list[Any] | None) -> list[Any]:
|
||||
if value is None:
|
||||
return []
|
||||
return value if isinstance(value, list) else [value]
|
||||
|
||||
|
||||
def _image_sources(request: VideoGenerationRequest) -> list[str]:
|
||||
sources: list[str] = []
|
||||
for reference in _as_list(request.image_reference):
|
||||
if isinstance(reference, FileImageReference):
|
||||
raise RequestAdaptationError("file_id image references are not supported; provide image_url instead")
|
||||
sources.append(reference.image_url)
|
||||
legacy_sources = [source for source in (request.input_reference, request.reference_url) if source]
|
||||
if len(legacy_sources) > 1:
|
||||
raise RequestAdaptationError("Provide only one of input_reference or reference_url.")
|
||||
legacy = legacy_sources[0] if legacy_sources else None
|
||||
if legacy is not None:
|
||||
if sources:
|
||||
raise RequestAdaptationError("Provide only one of input_reference/reference_url or image_reference.")
|
||||
sources.append(legacy)
|
||||
return sources
|
||||
|
||||
|
||||
async def prepare_reference_media(
|
||||
request_id: str,
|
||||
request: VideoGenerationRequest,
|
||||
output_dir: str,
|
||||
) -> None:
|
||||
"""Materialize and decode image references before a job reaches workers."""
|
||||
_image_sources(request)
|
||||
uploads_dir = os.path.join(os.path.abspath(output_dir), "uploads")
|
||||
|
||||
async def materialize(source: str, index: int) -> str:
|
||||
source = source.strip()
|
||||
if not source:
|
||||
raise RequestAdaptationError("Image references must not be empty.")
|
||||
if source.lower().startswith(("http://", "https://", "data:image")):
|
||||
target = os.path.join(uploads_dir, f"{request_id}_{index}")
|
||||
try:
|
||||
local_path = await save_image_to_path(source, target)
|
||||
except Exception as error:
|
||||
raise RequestAdaptationError(f"Unable to fetch image reference: {error}") from error
|
||||
else:
|
||||
local_path = os.path.abspath(os.path.expanduser(source))
|
||||
if not os.path.isfile(local_path):
|
||||
raise RequestAdaptationError(f"Image reference does not exist: {source}")
|
||||
try:
|
||||
await asyncio.to_thread(load_image, local_path)
|
||||
except Exception as error:
|
||||
raise RequestAdaptationError(f"Unable to decode image reference: {error}") from error
|
||||
return local_path
|
||||
|
||||
index = 0
|
||||
references = _as_list(request.image_reference)
|
||||
for reference in references:
|
||||
if isinstance(reference, UrlImageReference):
|
||||
reference.image_url = await materialize(reference.image_url, index)
|
||||
index += 1
|
||||
if request.input_reference:
|
||||
request.input_reference = await materialize(request.input_reference, index)
|
||||
index += 1
|
||||
elif request.input_reference == "":
|
||||
request.input_reference = None
|
||||
if request.reference_url:
|
||||
request.reference_url = await materialize(request.reference_url, index)
|
||||
elif request.reference_url == "":
|
||||
request.reference_url = None
|
||||
|
||||
|
||||
def _video_sources(request: VideoGenerationRequest) -> list[str]:
|
||||
sources: list[str] = []
|
||||
for reference in _as_list(request.video_reference):
|
||||
if isinstance(reference, FileVideoReference):
|
||||
raise RequestAdaptationError("file_id video references are not supported; provide video_url instead")
|
||||
sources.append(reference.video_url)
|
||||
direct = request.video_path or request.video_url
|
||||
if direct is not None:
|
||||
if sources:
|
||||
raise RequestAdaptationError("Provide only one of video_reference or video_path/video_url.")
|
||||
sources.append(direct)
|
||||
return sources
|
||||
|
||||
|
||||
def _audio_sources(request: VideoGenerationRequest) -> list[str]:
|
||||
return [reference.audio_url for reference in _as_list(request.audio_reference)]
|
||||
|
||||
|
||||
def _parse_aspect_ratio(value: str) -> tuple[float, float]:
|
||||
try:
|
||||
width, height = value.split(":", 1)
|
||||
result = float(width), float(height)
|
||||
except (AttributeError, TypeError, ValueError) as error:
|
||||
raise RequestAdaptationError(f"Invalid aspect_ratio {value!r}; expected WIDTH:HEIGHT") from error
|
||||
if not all(math.isfinite(term) for term in result) or result[0] <= 0 or result[1] <= 0:
|
||||
raise RequestAdaptationError(f"Invalid aspect_ratio {value!r}; both terms must be positive")
|
||||
return result
|
||||
|
||||
|
||||
def _apply_aspect_ratio(
|
||||
kwargs: dict[str, Any],
|
||||
request: VideoGenerationRequest,
|
||||
*,
|
||||
model_family: str | None,
|
||||
) -> None:
|
||||
if request.aspect_ratio is None:
|
||||
return
|
||||
aspect_width, aspect_height = _parse_aspect_ratio(request.aspect_ratio)
|
||||
if model_family == "minimax_h3":
|
||||
from fastvideo.pipelines.basic.minimax_h3.packing import MINIMAX_H3_SHORT_EDGE, resolve_canvas_size
|
||||
|
||||
if request.short_edge is not None and request.short_edge != MINIMAX_H3_SHORT_EDGE:
|
||||
raise RequestAdaptationError(
|
||||
f"MiniMax-H3 currently uses a fixed short_edge={MINIMAX_H3_SHORT_EDGE}, got {request.short_edge}.")
|
||||
try:
|
||||
height, width = resolve_canvas_size(aspect_width, aspect_height)
|
||||
except ValueError as error:
|
||||
raise RequestAdaptationError(str(error)) from error
|
||||
elif request.short_edge is not None:
|
||||
if aspect_width >= aspect_height:
|
||||
height = request.short_edge
|
||||
width = round(request.short_edge * aspect_width / aspect_height)
|
||||
else:
|
||||
width = request.short_edge
|
||||
height = round(request.short_edge * aspect_height / aspect_width)
|
||||
else:
|
||||
return
|
||||
kwargs["width"], kwargs["height"] = width, height
|
||||
|
||||
|
||||
def validate_model_and_lora(
|
||||
request: VideoGenerationRequest,
|
||||
args: FastVideoArgs,
|
||||
served_model_name: str,
|
||||
) -> None:
|
||||
"""Validate vLLM-style model and LoRA selectors against startup state.
|
||||
|
||||
FastVideo's published FastH3 adapters include dense replacement tensors in
|
||||
addition to low-rank factors. Those tensors are applied while the model is
|
||||
loaded and cannot be swapped safely between concurrent requests. The API
|
||||
accepts vLLM's selector shape, but it must identify the startup adapter.
|
||||
"""
|
||||
allowed_models = {args.lora_nickname} if args.lora_path else {served_model_name}
|
||||
if request.model is not None and request.model not in allowed_models:
|
||||
choices = ", ".join(sorted(allowed_models))
|
||||
raise RequestAdaptationError(
|
||||
f"Model mismatch: request specifies {request.model!r}; this server provides {choices}.")
|
||||
|
||||
if request.lora is None:
|
||||
return
|
||||
if not args.lora_path:
|
||||
raise RequestAdaptationError(
|
||||
"This server has no startup LoRA. Configure generator.pipeline.components.lora_path before using "
|
||||
"the request lora selector.")
|
||||
|
||||
body = request.lora
|
||||
name = next((body[key] for key in ("name", "lora_name", "adapter") if body.get(key) is not None), None)
|
||||
path = next((body[key] for key in ("path", "lora_path", "local_path") if body.get(key) is not None), None)
|
||||
scale = next((body[key] for key in ("scale", "lora_scale") if body.get(key) is not None), None)
|
||||
if name is None and path is None:
|
||||
raise RequestAdaptationError("lora must provide a name or path")
|
||||
if name is not None and str(name) != args.lora_nickname:
|
||||
raise RequestAdaptationError(f"Requested LoRA {name!r} is not the startup adapter {args.lora_nickname!r}.")
|
||||
if path is not None and str(path) != args.lora_path:
|
||||
raise RequestAdaptationError(
|
||||
f"Requested LoRA path {path!r} does not match the startup adapter {args.lora_path!r}.")
|
||||
if scale is not None:
|
||||
try:
|
||||
scale_value = float(scale)
|
||||
except (TypeError, ValueError) as error:
|
||||
raise RequestAdaptationError(f"Invalid LoRA scale {scale!r}") from error
|
||||
if not math.isclose(scale_value, args.lora_strength, rel_tol=0.0, abs_tol=1e-8):
|
||||
raise RequestAdaptationError(
|
||||
f"Requested LoRA scale {scale_value:g} does not match startup strength {args.lora_strength:g}.")
|
||||
|
||||
|
||||
def _apply_reference_inputs(
|
||||
kwargs: dict[str, Any],
|
||||
request: VideoGenerationRequest,
|
||||
args: FastVideoArgs,
|
||||
*,
|
||||
model_family: str | None,
|
||||
) -> None:
|
||||
images = _image_sources(request)
|
||||
videos = _video_sources(request)
|
||||
audios = _audio_sources(request)
|
||||
ref2va = model_family == "minimax_h3" and "ref2va" in (args.override_pipeline_cls_name or "").lower()
|
||||
|
||||
if model_family == "minimax_h3" and request.task is not None:
|
||||
normalized_task = request.task.lower()
|
||||
if normalized_task not in {"t2va", "fl2va", "ref2va"}:
|
||||
raise RequestAdaptationError("MiniMax-H3 task must be one of t2va, fl2va, or ref2va.")
|
||||
if normalized_task == "ref2va" and not ref2va:
|
||||
raise RequestAdaptationError(
|
||||
"MiniMax-H3 task='ref2va' requires MiniMaxH3Ref2VAModularPipeline at server startup.")
|
||||
if normalized_task != "ref2va" and ref2va:
|
||||
raise RequestAdaptationError(
|
||||
f"This server is configured for MiniMax-H3 Ref2VA, not task={normalized_task!r}.")
|
||||
if normalized_task == "t2va" and (images or videos or audios):
|
||||
raise RequestAdaptationError("MiniMax-H3 task='t2va' does not accept reference media.")
|
||||
if normalized_task == "fl2va" and not images:
|
||||
raise RequestAdaptationError("MiniMax-H3 task='fl2va' requires one or two image references.")
|
||||
|
||||
if ref2va:
|
||||
from fastvideo.pipelines.basic.minimax_h3 import MiniMaxH3Reference
|
||||
from fastvideo.pipelines.basic.minimax_h3.reference import validate_references
|
||||
|
||||
references = [MiniMaxH3Reference(source=source, media_type="image") for source in images]
|
||||
references.extend(MiniMaxH3Reference(source=source, media_type="video") for source in videos)
|
||||
references.extend(MiniMaxH3Reference(source=source, media_type="audio") for source in audios)
|
||||
if references:
|
||||
try:
|
||||
kwargs["references"] = validate_references(references)
|
||||
except (TypeError, ValueError) as error:
|
||||
raise RequestAdaptationError(str(error)) from error
|
||||
return
|
||||
|
||||
if request.task is not None and model_family != "minimax_h3":
|
||||
raise RequestAdaptationError("The task selector is only defined for MiniMax-H3 servers.")
|
||||
|
||||
if model_family == "minimax_h3" and (videos or audios):
|
||||
raise RequestAdaptationError("MiniMax-H3 video/audio references require a server configured with "
|
||||
"override_pipeline_cls_name=MiniMaxH3Ref2VAModularPipeline.")
|
||||
if len(images) > 2:
|
||||
raise RequestAdaptationError("The loaded pipeline accepts at most first and last image references.")
|
||||
if images:
|
||||
kwargs["image_path"] = images[0]
|
||||
if len(images) == 2:
|
||||
kwargs["last_image"] = load_image(images[1])
|
||||
if len(videos) > 1:
|
||||
raise RequestAdaptationError("The loaded pipeline accepts at most one video reference.")
|
||||
if videos:
|
||||
kwargs["video_path"] = videos[0]
|
||||
if audios:
|
||||
raise RequestAdaptationError("The loaded pipeline does not accept audio reference inputs.")
|
||||
|
||||
|
||||
def build_generation_request(
|
||||
request_id: str,
|
||||
request: VideoGenerationRequest,
|
||||
args: FastVideoArgs,
|
||||
*,
|
||||
served_model_name: str,
|
||||
output_dir: str,
|
||||
default_request: GenerationRequest | None = None,
|
||||
) -> GenerationRequest:
|
||||
"""Build one tracked FastVideo request using explicit-field precedence."""
|
||||
validate_model_and_lora(request, args, served_model_name)
|
||||
kwargs: dict[str, Any] = {}
|
||||
if default_request is not None:
|
||||
kwargs.update(explicit_request_updates(default_request))
|
||||
|
||||
body_set = request.model_fields_set
|
||||
nested_set = request.video_params.model_fields_set if request.video_params is not None else set()
|
||||
if "size" in body_set and request.size is not None:
|
||||
width, height = request.size.split("x", 1)
|
||||
kwargs["width"], kwargs["height"] = int(width), int(height)
|
||||
else:
|
||||
if "width" in body_set and request.width is not None:
|
||||
kwargs["width"] = request.width
|
||||
elif "video_params" in body_set and "width" in nested_set and request.video_params.width is not None:
|
||||
kwargs["width"] = request.video_params.width
|
||||
if "height" in body_set and request.height is not None:
|
||||
kwargs["height"] = request.height
|
||||
elif "video_params" in body_set and "height" in nested_set and request.video_params.height is not None:
|
||||
kwargs["height"] = request.video_params.height
|
||||
|
||||
fps_explicit = ("fps" in body_set
|
||||
and request.fps is not None) or ("video_params" in body_set and "fps" in nested_set
|
||||
and request.video_params.fps is not None)
|
||||
if fps_explicit:
|
||||
fps = request.fps if "fps" in body_set else request.video_params.fps
|
||||
if fps is not None:
|
||||
kwargs["fps"] = fps
|
||||
kwargs.setdefault("fps", 24)
|
||||
|
||||
frames_explicit = ("num_frames" in body_set
|
||||
and request.num_frames is not None) or ("video_params" in body_set and "num_frames" in nested_set
|
||||
and request.video_params.num_frames is not None)
|
||||
if frames_explicit:
|
||||
num_frames = request.num_frames if "num_frames" in body_set else request.video_params.num_frames
|
||||
if num_frames is not None:
|
||||
kwargs["num_frames"] = num_frames
|
||||
elif "seconds" in body_set and request.seconds is not None:
|
||||
kwargs["num_frames"] = int(request.seconds) * int(kwargs["fps"])
|
||||
|
||||
direct_fields = (
|
||||
"seed",
|
||||
"num_inference_steps",
|
||||
"guidance_scale",
|
||||
"guidance_scale_2",
|
||||
"true_cfg_scale",
|
||||
"negative_prompt",
|
||||
"enable_teacache",
|
||||
"max_sequence_length",
|
||||
"boundary_ratio",
|
||||
)
|
||||
for name in direct_fields:
|
||||
if name in body_set:
|
||||
value = getattr(request, name)
|
||||
if value is not None:
|
||||
kwargs[name] = value
|
||||
if "n" in body_set or "num_outputs_per_prompt" in body_set:
|
||||
kwargs["num_videos_per_prompt"] = request.resolved_num_outputs
|
||||
|
||||
try:
|
||||
_, model_family = get_preset_selection(args.model_path)
|
||||
except (RuntimeError, ValueError):
|
||||
model_family = None
|
||||
if request.resolved_num_outputs != 1:
|
||||
raise RequestAdaptationError("FastVideo serving currently supports exactly one video output per request.")
|
||||
if "short_edge" in body_set and request.short_edge is not None and request.aspect_ratio is None:
|
||||
raise RequestAdaptationError("short_edge requires aspect_ratio.")
|
||||
_apply_aspect_ratio(kwargs, request, model_family=model_family)
|
||||
_apply_reference_inputs(kwargs, request, args, model_family=model_family)
|
||||
|
||||
extension_fields = ("flow_shift", "sound_duration", "start_time_seconds")
|
||||
for name in extension_fields:
|
||||
if name in body_set and getattr(request, name) is not None:
|
||||
kwargs[name] = getattr(request, name)
|
||||
if "generate_sound" in body_set and request.generate_sound and model_family != "minimax_h3":
|
||||
kwargs["generate_sound"] = True
|
||||
if "enable_frame_interpolation" in body_set and request.enable_frame_interpolation:
|
||||
kwargs["enable_frame_interpolation"] = True
|
||||
for name in (
|
||||
"frame_interpolation_exp",
|
||||
"frame_interpolation_scale",
|
||||
"frame_interpolation_model_path",
|
||||
):
|
||||
kwargs[name] = getattr(request, name)
|
||||
if request.extra_params:
|
||||
unknown_extra_params = sorted(set(request.extra_params) - set(REQUEST_BATCH_EXTRA_PASSTHROUGH_FIELDS))
|
||||
if unknown_extra_params:
|
||||
raise RequestAdaptationError("Unsupported extra_params fields: " + ", ".join(unknown_extra_params))
|
||||
kwargs.update(request.extra_params)
|
||||
|
||||
width = kwargs.get("width")
|
||||
height = kwargs.get("height")
|
||||
if width is not None and (not isinstance(width, int) or width <= 0):
|
||||
raise RequestAdaptationError(f"width must be a positive integer, got {width!r}")
|
||||
if height is not None and (not isinstance(height, int) or height <= 0):
|
||||
raise RequestAdaptationError(f"height must be a positive integer, got {height!r}")
|
||||
|
||||
if model_family == "minimax_h3":
|
||||
from fastvideo.pipelines.basic.minimax_h3.packing import (
|
||||
MINIMAX_H3_CANVAS_MULTIPLE,
|
||||
MINIMAX_H3_MAX_PIXELS,
|
||||
)
|
||||
from fastvideo.pipelines.basic.minimax_h3.stages.minimax_h3_input_preparation import (
|
||||
resolve_target_num_frames, )
|
||||
|
||||
if kwargs["fps"] != 24:
|
||||
raise RequestAdaptationError(f"MiniMax-H3 requires fps=24, got {kwargs['fps']}.")
|
||||
if width is None or height is None:
|
||||
raise RequestAdaptationError("MiniMax-H3 requires both width and height.")
|
||||
if width % MINIMAX_H3_CANVAS_MULTIPLE or height % MINIMAX_H3_CANVAS_MULTIPLE:
|
||||
raise RequestAdaptationError("MiniMax-H3 width and height must be positive multiples of "
|
||||
f"{MINIMAX_H3_CANVAS_MULTIPLE}, got {width}x{height}.")
|
||||
if width * height > MINIMAX_H3_MAX_PIXELS:
|
||||
raise RequestAdaptationError(
|
||||
f"MiniMax-H3 canvas exceeds the {MINIMAX_H3_MAX_PIXELS}-pixel limit: {width}x{height}.")
|
||||
try:
|
||||
requested_num_frames = kwargs.get("num_frames")
|
||||
aligned_num_frames = resolve_target_num_frames(requested_num_frames)
|
||||
except (TypeError, ValueError) as error:
|
||||
raise RequestAdaptationError(str(error)) from error
|
||||
if frames_explicit and aligned_num_frames != requested_num_frames:
|
||||
raise RequestAdaptationError("MiniMax-H3 num_frames must be on the causal-VAE grid (17 * n + 5); "
|
||||
f"got {requested_num_frames}, next valid value is {aligned_num_frames}.")
|
||||
kwargs["num_frames"] = aligned_num_frames
|
||||
|
||||
output_path = os.path.join(os.path.abspath(output_dir), "videos", f"{request_id}.mp4")
|
||||
os.makedirs(os.path.dirname(output_path), exist_ok=True)
|
||||
kwargs.update({
|
||||
"output_path": output_path,
|
||||
"save_video": True,
|
||||
"return_frames": False,
|
||||
})
|
||||
generation_request = legacy_generate_call_to_request(request.prompt, None, legacy_kwargs=kwargs)
|
||||
try:
|
||||
# Resolve once at admission time so unsupported model-specific fields
|
||||
# are a deterministic 400, rather than an asynchronous failed job.
|
||||
request_to_sampling_param(generation_request, model_path=args.model_path)
|
||||
except (TypeError, ValueError) as error:
|
||||
raise RequestAdaptationError(str(error)) from error
|
||||
return generation_request
|
||||
|
||||
|
||||
__all__ = [
|
||||
"RequestAdaptationError",
|
||||
"build_generation_request",
|
||||
"prepare_reference_media",
|
||||
"validate_model_and_lora",
|
||||
]
|
||||
@@ -0,0 +1,130 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""Shared asynchronous execution substrate for OpenAI-compatible routes."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from collections.abc import Awaitable, Callable
|
||||
from typing import Any, TypeVar
|
||||
|
||||
from fastvideo.api.schema import GenerationRequest
|
||||
from fastvideo.entrypoints.video_generator import VideoGenerator
|
||||
|
||||
_T = TypeVar("_T")
|
||||
|
||||
|
||||
class OpenAIServingEngine:
|
||||
"""Own generator lifecycle and serialize access to its mutable pipeline.
|
||||
|
||||
FastVideo pipelines contain request-mutated sampling state and some LoRA
|
||||
implementations merge weights in place. Running two Python threads through
|
||||
one pipeline is therefore unsafe even if the HTTP layer accepts requests
|
||||
concurrently. This engine gives every OpenAI route one model-agnostic async
|
||||
entrypoint while preserving that invariant. A future scheduler can replace
|
||||
the lock without changing the transport contract.
|
||||
"""
|
||||
|
||||
def __init__(self, generator: VideoGenerator) -> None:
|
||||
self._generator = generator
|
||||
self._generation_lock = asyncio.Lock()
|
||||
self._closed = False
|
||||
self._unhealthy_reason: str | None = None
|
||||
|
||||
@property
|
||||
def generator(self) -> VideoGenerator:
|
||||
return self._generator
|
||||
|
||||
@property
|
||||
def closed(self) -> bool:
|
||||
return self._closed
|
||||
|
||||
@property
|
||||
def healthy(self) -> bool:
|
||||
if self._closed or self._unhealthy_reason is not None:
|
||||
return False
|
||||
executor = getattr(self._generator, "executor", None)
|
||||
workers = getattr(executor, "workers", None)
|
||||
if workers is None:
|
||||
return True
|
||||
return bool(workers) and all(worker.proc.is_alive() for worker in workers)
|
||||
|
||||
@property
|
||||
def unhealthy_reason(self) -> str | None:
|
||||
if self._unhealthy_reason is not None:
|
||||
return self._unhealthy_reason
|
||||
if not self.healthy:
|
||||
return "one or more generation workers are not alive"
|
||||
return None
|
||||
|
||||
async def generate(
|
||||
self,
|
||||
request: GenerationRequest,
|
||||
*,
|
||||
on_start: Callable[[], Awaitable[None]] | None = None,
|
||||
) -> Any:
|
||||
"""Generate one typed request without blocking the event loop."""
|
||||
return await self.run_serialized(self._generator.generate, request, on_start=on_start)
|
||||
|
||||
async def run_serialized(
|
||||
self,
|
||||
function: Callable[..., _T],
|
||||
*args: Any,
|
||||
on_start: Callable[[], Awaitable[None]] | None = None,
|
||||
**kwargs: Any,
|
||||
) -> _T:
|
||||
"""Run a synchronous pipeline operation under the serving lock."""
|
||||
if self._closed:
|
||||
raise RuntimeError("FastVideo serving engine is shutting down")
|
||||
async with self._generation_lock:
|
||||
if self._closed:
|
||||
raise RuntimeError("FastVideo serving engine is shutting down")
|
||||
if on_start is not None:
|
||||
await on_start()
|
||||
worker = asyncio.create_task(asyncio.to_thread(function, *args, **kwargs))
|
||||
try:
|
||||
return await asyncio.shield(worker)
|
||||
except asyncio.CancelledError:
|
||||
# Python cannot stop a running worker thread. Keep the lock
|
||||
# until the pipeline call really exits so cancellation cannot
|
||||
# expose mutable model state to a second request.
|
||||
await self._wait_after_cancellation(worker)
|
||||
raise
|
||||
except (BrokenPipeError, EOFError) as error:
|
||||
self._unhealthy_reason = str(error)
|
||||
raise
|
||||
|
||||
async def run_async_serialized(self, function: Callable[[], Awaitable[_T]]) -> _T:
|
||||
"""Run an async operation under the same pipeline lock."""
|
||||
if self._closed:
|
||||
raise RuntimeError("FastVideo serving engine is shutting down")
|
||||
async with self._generation_lock:
|
||||
if self._closed:
|
||||
raise RuntimeError("FastVideo serving engine is shutting down")
|
||||
worker: asyncio.Future[_T] = asyncio.ensure_future(function())
|
||||
try:
|
||||
return await asyncio.shield(worker)
|
||||
except asyncio.CancelledError:
|
||||
await self._wait_after_cancellation(worker)
|
||||
raise
|
||||
|
||||
@staticmethod
|
||||
async def _wait_after_cancellation(worker: asyncio.Future[Any]) -> None:
|
||||
"""Keep waiting for an uninterruptible worker despite repeated cancellation."""
|
||||
while not worker.done():
|
||||
try:
|
||||
await asyncio.shield(worker)
|
||||
except asyncio.CancelledError:
|
||||
continue
|
||||
except Exception:
|
||||
break
|
||||
if not worker.cancelled():
|
||||
worker.exception()
|
||||
|
||||
async def shutdown(self) -> None:
|
||||
"""Stop accepting requests and release the generator after in-flight work."""
|
||||
self._closed = True
|
||||
async with self._generation_lock:
|
||||
await asyncio.to_thread(self._generator.shutdown)
|
||||
|
||||
|
||||
__all__ = ["OpenAIServingEngine"]
|
||||
@@ -11,14 +11,17 @@ from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from fastvideo.api.schema import GenerationRequest
|
||||
from fastvideo.entrypoints.openai.serving_engine import OpenAIServingEngine
|
||||
from fastvideo.entrypoints.video_generator import VideoGenerator
|
||||
from fastvideo.fastvideo_args import FastVideoArgs
|
||||
|
||||
DEFAULT_OUTPUT_DIR = "outputs"
|
||||
|
||||
_generator: VideoGenerator | None = None
|
||||
_serving_engine: OpenAIServingEngine | None = None
|
||||
_fastvideo_args: FastVideoArgs | None = None
|
||||
_output_dir: str = DEFAULT_OUTPUT_DIR
|
||||
_served_model_name: str | None = None
|
||||
_default_request: GenerationRequest | None = None
|
||||
|
||||
|
||||
@@ -28,6 +31,12 @@ def get_generator() -> VideoGenerator:
|
||||
return _generator
|
||||
|
||||
|
||||
def get_serving_engine() -> OpenAIServingEngine:
|
||||
"""Return the shared model-agnostic OpenAI serving engine."""
|
||||
assert _serving_engine is not None, "Server not initialized — serving engine is None"
|
||||
return _serving_engine
|
||||
|
||||
|
||||
def get_server_args() -> FastVideoArgs:
|
||||
"""Return the global FastVideoArgs (set during startup)."""
|
||||
assert _fastvideo_args is not None, "Server not initialized — args is None"
|
||||
@@ -39,6 +48,14 @@ def get_output_dir() -> str:
|
||||
return _output_dir
|
||||
|
||||
|
||||
def get_served_model_name() -> str:
|
||||
"""Return the public model id advertised by the OpenAI server."""
|
||||
args = get_server_args()
|
||||
if args.lora_path:
|
||||
return args.lora_nickname
|
||||
return _served_model_name or args.model_path
|
||||
|
||||
|
||||
def get_default_request() -> GenerationRequest | None:
|
||||
"""Return the ServeConfig.default_request set at startup, if any."""
|
||||
return _default_request
|
||||
@@ -46,21 +63,27 @@ def get_default_request() -> GenerationRequest | None:
|
||||
|
||||
def set_state(
|
||||
generator: VideoGenerator,
|
||||
serving_engine: OpenAIServingEngine,
|
||||
fastvideo_args: FastVideoArgs,
|
||||
output_dir: str,
|
||||
default_request: GenerationRequest | None = None,
|
||||
served_model_name: str | None = None,
|
||||
) -> None:
|
||||
"""Set all server state at once (called from lifespan)."""
|
||||
global _generator, _fastvideo_args, _output_dir, _default_request
|
||||
global _generator, _serving_engine, _fastvideo_args, _output_dir, _served_model_name, _default_request
|
||||
_generator = generator
|
||||
_serving_engine = serving_engine
|
||||
_fastvideo_args = fastvideo_args
|
||||
_output_dir = output_dir
|
||||
_served_model_name = served_model_name
|
||||
_default_request = default_request
|
||||
|
||||
|
||||
def clear_state() -> None:
|
||||
"""Clear server state on shutdown."""
|
||||
global _generator, _fastvideo_args, _default_request
|
||||
global _generator, _serving_engine, _fastvideo_args, _served_model_name, _default_request
|
||||
_generator = None
|
||||
_serving_engine = None
|
||||
_fastvideo_args = None
|
||||
_served_model_name = None
|
||||
_default_request = None
|
||||
|
||||
@@ -41,6 +41,10 @@ class AsyncDictStore:
|
||||
async with self._lock:
|
||||
return list(self._items.values())
|
||||
|
||||
async def clear(self) -> None:
|
||||
async with self._lock:
|
||||
self._items.clear()
|
||||
|
||||
|
||||
# Global stores shared by OpenAI entrypoints
|
||||
VIDEO_STORE = AsyncDictStore()
|
||||
|
||||
@@ -1,373 +1,485 @@
|
||||
# Adapted from SGLang
|
||||
# (https://github.com/sgl-project/sglang/blob/main/python/sglang/multimodal_gen/runtime/entrypoints/openai/video_api.py)
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""OpenAI/vLLM-Omni-compatible video generation routes."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
from contextlib import suppress
|
||||
from typing import Any
|
||||
|
||||
from fastapi import (
|
||||
APIRouter,
|
||||
File,
|
||||
Form,
|
||||
HTTPException,
|
||||
Path,
|
||||
Query,
|
||||
Request,
|
||||
UploadFile,
|
||||
)
|
||||
from fastapi.responses import FileResponse
|
||||
from fastapi import APIRouter, HTTPException, Path, Query, Request
|
||||
from fastapi.responses import FileResponse, JSONResponse
|
||||
from pydantic import ValidationError
|
||||
from starlette.background import BackgroundTask
|
||||
from starlette.datastructures import UploadFile
|
||||
|
||||
from fastvideo.api.compat import explicit_request_updates
|
||||
from fastvideo.api.compat import explicit_request_updates, request_to_sampling_param
|
||||
from fastvideo.api.schema import GenerationRequest
|
||||
from fastvideo.entrypoints.openai.state import (
|
||||
get_default_request,
|
||||
get_generator,
|
||||
get_output_dir,
|
||||
get_server_args,
|
||||
)
|
||||
from fastvideo.entrypoints.openai.protocol import (
|
||||
VideoGenerationsRequest,
|
||||
VideoDeleteResponse,
|
||||
VideoGenerationRequest,
|
||||
VideoGenerationStatus,
|
||||
VideoListResponse,
|
||||
VideoResponse,
|
||||
generate_request_id,
|
||||
)
|
||||
from fastvideo.entrypoints.openai.stores import VIDEO_STORE
|
||||
from fastvideo.entrypoints.openai.utils import (
|
||||
merge_image_input_list,
|
||||
parse_size,
|
||||
save_image_to_path,
|
||||
from fastvideo.entrypoints.openai.request_adapter import (
|
||||
build_generation_request,
|
||||
prepare_reference_media,
|
||||
validate_model_and_lora,
|
||||
)
|
||||
from fastvideo.entrypoints.openai.state import (
|
||||
get_default_request,
|
||||
get_output_dir,
|
||||
get_served_model_name,
|
||||
get_server_args,
|
||||
get_serving_engine,
|
||||
)
|
||||
from fastvideo.entrypoints.openai.stores import VIDEO_STORE
|
||||
from fastvideo.entrypoints.openai.utils import parse_size, save_image_to_path
|
||||
from fastvideo.logger import init_logger
|
||||
|
||||
logger = init_logger(__name__)
|
||||
router = APIRouter(prefix="/v1/videos", tags=["videos"])
|
||||
|
||||
_VIDEO_JOB_TASKS: dict[str, asyncio.Task[None]] = {}
|
||||
_DELETED_VIDEO_IDS: set[str] = set()
|
||||
_JSON_FORM_FIELDS = {
|
||||
"image_reference",
|
||||
"video_reference",
|
||||
"audio_reference",
|
||||
"video_params",
|
||||
"lora",
|
||||
"extra_params",
|
||||
}
|
||||
_VIDEO_EXTENSIONS = {".avi", ".mkv", ".mov", ".mp4", ".mpeg", ".mpg", ".webm"}
|
||||
|
||||
|
||||
def _build_generation_kwargs(
|
||||
request_id: str,
|
||||
req: VideoGenerationsRequest,
|
||||
req: VideoGenerationRequest,
|
||||
default_request: GenerationRequest | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Build a flat kwargs dict for ``generator.generate_video``.
|
||||
"""Backward-compatible flat projection used by helper-level callers.
|
||||
|
||||
Precedence (highest to lowest):
|
||||
1. Request body — only fields the client explicitly sent
|
||||
(``req.model_fields_set``, Pydantic v2).
|
||||
2. ``default_request`` — only fields the operator explicitly set in
|
||||
the serve YAML, projected via ``explicit_request_updates``. Schema
|
||||
defaults on the dataclass are *not* treated as defaults here.
|
||||
3. Hardcoded fallback (e.g. ``fps=24`` when neither side set it).
|
||||
|
||||
Why gate on ``model_fields_set`` / explicit paths? Both the request
|
||||
Pydantic model and the ``GenerationRequest`` dataclass carry schema
|
||||
defaults (e.g. ``seed=1024``, ``num_frames=125``). Without the gate
|
||||
those would masquerade as intent and shadow the other side — the
|
||||
gate preserves "operator pinned it" vs. "dataclass happened to have
|
||||
that default."
|
||||
Runtime serving uses :func:`build_generation_request` and the typed
|
||||
``VideoGenerator.generate`` API. Keeping this helper avoids breaking code
|
||||
that imported the original FastVideo adapter directly.
|
||||
"""
|
||||
kwargs: dict[str, Any] = {}
|
||||
if default_request is not None:
|
||||
kwargs.update(explicit_request_updates(default_request))
|
||||
|
||||
body_set = req.model_fields_set
|
||||
nested_set = req.video_params.model_fields_set if req.video_params is not None else set()
|
||||
kwargs["prompt"] = req.prompt
|
||||
|
||||
if "size" in body_set and req.size:
|
||||
w, h = parse_size(req.size)
|
||||
if w is not None and h is not None:
|
||||
kwargs["width"] = w
|
||||
kwargs["height"] = h
|
||||
width, height = parse_size(req.size)
|
||||
if width is not None and height is not None:
|
||||
kwargs["width"], kwargs["height"] = width, height
|
||||
else:
|
||||
if "width" in body_set and req.width is not None:
|
||||
kwargs["width"] = req.width
|
||||
elif "video_params" in body_set and "width" in nested_set and req.video_params.width is not None:
|
||||
kwargs["width"] = req.video_params.width
|
||||
if "height" in body_set and req.height is not None:
|
||||
kwargs["height"] = req.height
|
||||
elif "video_params" in body_set and "height" in nested_set and req.video_params.height is not None:
|
||||
kwargs["height"] = req.video_params.height
|
||||
|
||||
if "fps" in body_set and req.fps is not None:
|
||||
kwargs["fps"] = req.fps
|
||||
elif "video_params" in body_set and "fps" in nested_set and req.video_params.fps is not None:
|
||||
kwargs["fps"] = req.video_params.fps
|
||||
kwargs.setdefault("fps", 24)
|
||||
|
||||
if "num_frames" in body_set and req.num_frames is not None:
|
||||
kwargs["num_frames"] = req.num_frames
|
||||
elif "video_params" in body_set and "num_frames" in nested_set and req.video_params.num_frames is not None:
|
||||
kwargs["num_frames"] = req.video_params.num_frames
|
||||
elif "seconds" in body_set and req.seconds is not None:
|
||||
fps = kwargs.get("fps", 24)
|
||||
kwargs["num_frames"] = fps * req.seconds
|
||||
|
||||
if "seed" in body_set and req.seed is not None:
|
||||
kwargs["seed"] = req.seed
|
||||
if ("num_inference_steps" in body_set and req.num_inference_steps is not None):
|
||||
kwargs["num_inference_steps"] = req.num_inference_steps
|
||||
if "guidance_scale" in body_set and req.guidance_scale is not None:
|
||||
kwargs["guidance_scale"] = req.guidance_scale
|
||||
if "guidance_scale_2" in body_set and req.guidance_scale_2 is not None:
|
||||
kwargs["guidance_scale_2"] = req.guidance_scale_2
|
||||
if "negative_prompt" in body_set and req.negative_prompt is not None:
|
||||
kwargs["negative_prompt"] = req.negative_prompt
|
||||
if "enable_teacache" in body_set and req.enable_teacache:
|
||||
kwargs["enable_teacache"] = True
|
||||
if "true_cfg_scale" in body_set and req.true_cfg_scale is not None:
|
||||
kwargs["true_cfg_scale"] = req.true_cfg_scale
|
||||
kwargs["num_frames"] = int(req.seconds) * int(kwargs["fps"])
|
||||
|
||||
for name in (
|
||||
"seed",
|
||||
"num_inference_steps",
|
||||
"guidance_scale",
|
||||
"guidance_scale_2",
|
||||
"true_cfg_scale",
|
||||
"negative_prompt",
|
||||
"enable_teacache",
|
||||
"max_sequence_length",
|
||||
"boundary_ratio",
|
||||
):
|
||||
if name in body_set and getattr(req, name) is not None:
|
||||
kwargs[name] = getattr(req, name)
|
||||
if "n" in body_set or "num_outputs_per_prompt" in body_set:
|
||||
kwargs["num_videos_per_prompt"] = req.resolved_num_outputs
|
||||
if "input_reference" in body_set and req.input_reference is not None:
|
||||
kwargs["image_path"] = req.input_reference
|
||||
|
||||
kwargs.setdefault("fps", 24)
|
||||
|
||||
default_output_path = kwargs.pop("output_path", None)
|
||||
body_output_dir = req.output_path if "output_path" in body_set else None
|
||||
output_dir = body_output_dir or default_output_path or os.path.join(get_output_dir(), "videos")
|
||||
kwargs.pop("output_path", None)
|
||||
output_dir = os.path.join(os.path.abspath(get_output_dir()), "videos")
|
||||
os.makedirs(output_dir, exist_ok=True)
|
||||
kwargs["output_path"] = os.path.join(output_dir, f"{request_id}.mp4")
|
||||
kwargs["save_video"] = True
|
||||
|
||||
return kwargs
|
||||
|
||||
|
||||
def _result_value(result: Any, name: str, default: Any = None) -> Any:
|
||||
if isinstance(result, dict):
|
||||
return result.get(name, default)
|
||||
return getattr(result, name, default)
|
||||
|
||||
|
||||
def _remove_artifact(file_path: str | None) -> None:
|
||||
if not file_path or not os.path.isfile(file_path):
|
||||
return
|
||||
try:
|
||||
os.unlink(file_path)
|
||||
except OSError:
|
||||
logger.warning("Failed to delete video artifact %s", file_path, exc_info=True)
|
||||
|
||||
|
||||
def _stage_durations(result: Any) -> dict[str, float]:
|
||||
logging_info = _result_value(result, "logging_info")
|
||||
stages = getattr(logging_info, "stages", None)
|
||||
if not isinstance(stages, dict):
|
||||
return {}
|
||||
durations: dict[str, float] = {}
|
||||
for stage_name, metrics in stages.items():
|
||||
if isinstance(metrics, dict) and metrics.get("execution_time") is not None:
|
||||
durations[str(stage_name)] = float(metrics["execution_time"])
|
||||
return durations
|
||||
|
||||
|
||||
def _make_video_job(
|
||||
request_id: str,
|
||||
req: VideoGenerationsRequest,
|
||||
kwargs: dict[str, Any],
|
||||
req: VideoGenerationRequest,
|
||||
generation_request: GenerationRequest,
|
||||
) -> dict[str, Any]:
|
||||
"""Build the initial job dict stored in VIDEO_STORE."""
|
||||
w = kwargs.get("width", 0)
|
||||
h = kwargs.get("height", 0)
|
||||
size_str = f"{w}x{h}" if w and h else ""
|
||||
num_frames = kwargs.get("num_frames", 0)
|
||||
fps = kwargs.get("fps", 24)
|
||||
seconds = int(round(num_frames / fps)) if fps else 0
|
||||
sampling = request_to_sampling_param(generation_request, model_path=get_server_args().model_path)
|
||||
size = f"{sampling.width}x{sampling.height}" if sampling.width and sampling.height else None
|
||||
seconds = int(round(sampling.num_frames / sampling.fps)) if sampling.fps else int(req.seconds or 4)
|
||||
return {
|
||||
"id": request_id,
|
||||
"object": "video",
|
||||
"model": req.model or get_server_args().model_path,
|
||||
"status": "queued",
|
||||
"model": req.model or get_served_model_name(),
|
||||
"prompt": req.prompt,
|
||||
"status": VideoGenerationStatus.QUEUED,
|
||||
"progress": 0,
|
||||
"created_at": int(time.time()),
|
||||
"size": size_str,
|
||||
"seconds": str(seconds),
|
||||
"quality": "standard",
|
||||
"file_path": kwargs.get("output_path"),
|
||||
"size": size,
|
||||
"seconds": str(max(1, seconds)),
|
||||
"quality": req.quality or "default",
|
||||
# ``file_path`` is a FastVideo compatibility extension. vLLM-Omni's
|
||||
# public job shape uses ``file_name`` after completion.
|
||||
"file_path": None,
|
||||
"_sequence": time.monotonic_ns(),
|
||||
}
|
||||
|
||||
|
||||
async def _run_generation(request_id: str, kwargs: dict[str, Any]) -> None:
|
||||
"""
|
||||
Run video generation in a background thread (VideoGenerator.generate_video
|
||||
is synchronous) and update the store on completion or failure.
|
||||
"""
|
||||
generator = get_generator()
|
||||
loop = asyncio.get_running_loop()
|
||||
async def _run_generation(
|
||||
request_id: str,
|
||||
generation_request: GenerationRequest,
|
||||
) -> None:
|
||||
started = 0.0
|
||||
video_path = generation_request.output.output_path
|
||||
|
||||
try:
|
||||
start = time.perf_counter()
|
||||
|
||||
result = await loop.run_in_executor(
|
||||
None,
|
||||
lambda: generator.generate_video(**kwargs),
|
||||
)
|
||||
|
||||
elapsed = time.perf_counter() - start
|
||||
update: dict[str, Any] = {
|
||||
"status": "completed",
|
||||
"progress": 100,
|
||||
"completed_at": int(time.time()),
|
||||
"inference_time_s": elapsed,
|
||||
}
|
||||
|
||||
if isinstance(result, dict):
|
||||
gen_time = result.get("generation_time")
|
||||
if gen_time is not None:
|
||||
update["inference_time_s"] = gen_time
|
||||
peak_mem = result.get("peak_memory_mb")
|
||||
if peak_mem is not None:
|
||||
update["peak_memory_mb"] = peak_mem
|
||||
|
||||
await VIDEO_STORE.update_fields(request_id, update)
|
||||
logger.info("Video %s completed in %.2fs", request_id, elapsed)
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Video generation failed for %s: %s", request_id, e)
|
||||
async def mark_started() -> None:
|
||||
nonlocal started
|
||||
started = time.perf_counter()
|
||||
await VIDEO_STORE.update_fields(
|
||||
request_id,
|
||||
{
|
||||
"status": "failed",
|
||||
"error": {
|
||||
"message": str(e)
|
||||
}
|
||||
"status": VideoGenerationStatus.IN_PROGRESS,
|
||||
"progress": 0
|
||||
},
|
||||
)
|
||||
|
||||
try:
|
||||
result = await get_serving_engine().generate(generation_request, on_start=mark_started)
|
||||
if isinstance(result, list):
|
||||
if not result:
|
||||
raise RuntimeError("FastVideo returned no generation results")
|
||||
result = result[0]
|
||||
elapsed = time.perf_counter() - started
|
||||
video_path = _result_value(result, "video_path") or generation_request.output.output_path
|
||||
generation_time = _result_value(result, "generation_time", elapsed)
|
||||
await VIDEO_STORE.update_fields(
|
||||
request_id,
|
||||
{
|
||||
"status": VideoGenerationStatus.COMPLETED,
|
||||
"progress": 100,
|
||||
"completed_at": int(time.time()),
|
||||
"file_path": video_path,
|
||||
"file_name": os.path.basename(video_path) if video_path else None,
|
||||
"inference_time_s": float(generation_time or elapsed),
|
||||
"peak_memory_mb": _result_value(result, "peak_memory_mb"),
|
||||
"stage_durations": _stage_durations(result),
|
||||
},
|
||||
)
|
||||
logger.info("Video %s completed in %.2fs", request_id, elapsed)
|
||||
except asyncio.CancelledError:
|
||||
logger.info("Video %s was cancelled", request_id)
|
||||
raise
|
||||
except Exception as error:
|
||||
logger.exception("Video generation failed for %s", request_id)
|
||||
await VIDEO_STORE.update_fields(
|
||||
request_id,
|
||||
{
|
||||
"status": VideoGenerationStatus.FAILED,
|
||||
"error": {
|
||||
"code": 500,
|
||||
"message": str(error)
|
||||
},
|
||||
"inference_time_s": time.perf_counter() - started if started else 0.0,
|
||||
},
|
||||
)
|
||||
finally:
|
||||
if request_id in _DELETED_VIDEO_IDS:
|
||||
_DELETED_VIDEO_IDS.discard(request_id)
|
||||
if video_path and os.path.isfile(video_path):
|
||||
try:
|
||||
os.unlink(video_path)
|
||||
except OSError:
|
||||
logger.warning("Failed to clean up deleted video artifact %s", video_path, exc_info=True)
|
||||
|
||||
# Endpoints
|
||||
|
||||
def _track_video_job(request_id: str, task: asyncio.Task[None]) -> None:
|
||||
_VIDEO_JOB_TASKS[request_id] = task
|
||||
|
||||
def discard(completed: asyncio.Task[None]) -> None:
|
||||
if _VIDEO_JOB_TASKS.get(request_id) is completed:
|
||||
_VIDEO_JOB_TASKS.pop(request_id, None)
|
||||
|
||||
task.add_done_callback(discard)
|
||||
|
||||
|
||||
async def shutdown_video_jobs() -> None:
|
||||
"""Cancel all transport tasks before the serving engine shuts down."""
|
||||
tasks = list(_VIDEO_JOB_TASKS.values())
|
||||
_VIDEO_JOB_TASKS.clear()
|
||||
for task in tasks:
|
||||
task.cancel()
|
||||
for task in tasks:
|
||||
with suppress(asyncio.CancelledError):
|
||||
await task
|
||||
|
||||
|
||||
def _parse_json_form_value(name: str, value: Any) -> Any:
|
||||
if value is None or not isinstance(value, str) or name not in _JSON_FORM_FIELDS:
|
||||
return value
|
||||
try:
|
||||
return json.loads(value)
|
||||
except json.JSONDecodeError as error:
|
||||
raise HTTPException(status_code=400, detail=f"{name} is not valid JSON") from error
|
||||
|
||||
|
||||
async def _parse_video_request(raw_request: Request) -> VideoGenerationRequest:
|
||||
content_type = raw_request.headers.get("content-type", "").lower()
|
||||
if "multipart/form-data" in content_type or "application/x-www-form-urlencoded" in content_type:
|
||||
form = await raw_request.form()
|
||||
payload: dict[str, Any] = {}
|
||||
for name, value in form.multi_items():
|
||||
if name == "input_reference" and isinstance(value, UploadFile):
|
||||
uploads_dir = os.path.join(get_output_dir(), "uploads")
|
||||
filename = os.path.basename(value.filename or "reference")
|
||||
target = os.path.join(uploads_dir, f"{generate_request_id()}_{filename}")
|
||||
saved_path = await save_image_to_path(value, target)
|
||||
upload_ext = os.path.splitext(filename)[1].lower()
|
||||
if (value.content_type or "").lower().startswith("video/") or upload_ext in _VIDEO_EXTENSIONS:
|
||||
payload["video_reference"] = {"video_url": saved_path}
|
||||
else:
|
||||
payload["input_reference"] = saved_path
|
||||
continue
|
||||
parsed = _parse_json_form_value(name, value)
|
||||
if name in payload:
|
||||
current = payload[name]
|
||||
payload[name] = current + [parsed] if isinstance(current, list) else [current, parsed]
|
||||
else:
|
||||
payload[name] = parsed
|
||||
else:
|
||||
try:
|
||||
body = await raw_request.json()
|
||||
except Exception as error:
|
||||
raise HTTPException(status_code=400, detail="Request body must be valid JSON") from error
|
||||
if not isinstance(body, dict):
|
||||
raise HTTPException(status_code=400, detail="Request body must be a JSON object")
|
||||
payload = dict(body)
|
||||
|
||||
for name in ("extra_body", "extra_json"):
|
||||
extra = payload.pop(name, None)
|
||||
if isinstance(extra, str):
|
||||
try:
|
||||
extra = json.loads(extra)
|
||||
except json.JSONDecodeError as error:
|
||||
raise HTTPException(status_code=400, detail=f"{name} is not valid JSON") from error
|
||||
if extra is not None and not isinstance(extra, dict):
|
||||
raise HTTPException(status_code=400, detail=f"{name} must be a JSON object")
|
||||
if extra:
|
||||
payload.update(extra)
|
||||
|
||||
try:
|
||||
return VideoGenerationRequest(**payload)
|
||||
except ValidationError as error:
|
||||
raise HTTPException(status_code=400, detail=f"Invalid request body: {error}") from error
|
||||
|
||||
|
||||
async def _adapt_request(request_id: str, request: VideoGenerationRequest) -> GenerationRequest:
|
||||
try:
|
||||
validate_model_and_lora(request, get_server_args(), get_served_model_name())
|
||||
await prepare_reference_media(request_id, request, get_output_dir())
|
||||
return await asyncio.to_thread(
|
||||
build_generation_request,
|
||||
request_id,
|
||||
request,
|
||||
get_server_args(),
|
||||
served_model_name=get_served_model_name(),
|
||||
output_dir=get_output_dir(),
|
||||
default_request=get_default_request(),
|
||||
)
|
||||
except Exception as error:
|
||||
raise HTTPException(status_code=400, detail=str(error)) from error
|
||||
|
||||
|
||||
@router.post("", response_model=VideoResponse)
|
||||
async def create_video(
|
||||
request: Request,
|
||||
# multipart/form-data fields
|
||||
prompt: str | None = Form(None),
|
||||
input_reference: UploadFile | None = File(None), # noqa: B008
|
||||
reference_url: str | None = Form(None),
|
||||
model: str | None = Form(None),
|
||||
seconds: int | None = Form(None),
|
||||
size: str | None = Form(None),
|
||||
fps: int | None = Form(None),
|
||||
num_frames: int | None = Form(None),
|
||||
seed: int | None = Form(1024),
|
||||
negative_prompt: str | None = Form(None),
|
||||
guidance_scale: float | None = Form(None),
|
||||
num_inference_steps: int | None = Form(None),
|
||||
enable_teacache: bool | None = Form(False),
|
||||
extra_body: str | None = Form(None),
|
||||
):
|
||||
content_type = request.headers.get("content-type", "").lower()
|
||||
request_id = generate_request_id()
|
||||
|
||||
if "multipart/form-data" in content_type:
|
||||
if not prompt:
|
||||
raise HTTPException(status_code=400, detail="prompt is required")
|
||||
|
||||
input_path = None
|
||||
image_list = merge_image_input_list(input_reference, reference_url)
|
||||
if image_list:
|
||||
image = image_list[0]
|
||||
uploads_dir = os.path.join(get_output_dir(), "uploads")
|
||||
os.makedirs(uploads_dir, exist_ok=True)
|
||||
filename = getattr(image, "filename", "url_image")
|
||||
input_path = os.path.join(uploads_dir, f"{request_id}_{filename}")
|
||||
try:
|
||||
input_path = await save_image_to_path(image, input_path)
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Failed to process image: {e}",
|
||||
) from None
|
||||
|
||||
extra: dict[str, Any] = {}
|
||||
if extra_body:
|
||||
try:
|
||||
extra = json.loads(extra_body)
|
||||
except Exception:
|
||||
extra = {}
|
||||
|
||||
req = VideoGenerationsRequest(
|
||||
prompt=prompt,
|
||||
input_reference=input_path,
|
||||
model=model,
|
||||
seconds=seconds if seconds is not None else 4,
|
||||
size=size,
|
||||
fps=fps if fps is not None else extra.get("fps"),
|
||||
num_frames=(num_frames if num_frames is not None else extra.get("num_frames")),
|
||||
seed=seed,
|
||||
negative_prompt=negative_prompt,
|
||||
num_inference_steps=num_inference_steps,
|
||||
enable_teacache=enable_teacache,
|
||||
**({
|
||||
"guidance_scale": guidance_scale
|
||||
} if guidance_scale is not None else {}),
|
||||
)
|
||||
else:
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
|
||||
payload: dict[str, Any] = dict(body or {})
|
||||
for key in ("extra_body", "extra_json"):
|
||||
extra = payload.pop(key, None)
|
||||
if isinstance(extra, dict):
|
||||
payload.update(extra)
|
||||
|
||||
if payload.get("reference_url"):
|
||||
image_list = merge_image_input_list(payload.get("reference_url"))
|
||||
if image_list:
|
||||
image = image_list[0]
|
||||
uploads_dir = os.path.join(get_output_dir(), "uploads")
|
||||
os.makedirs(uploads_dir, exist_ok=True)
|
||||
input_path = os.path.join(uploads_dir, f"{request_id}_url_image")
|
||||
try:
|
||||
input_path = await save_image_to_path(image, input_path)
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Failed to process image: {e}",
|
||||
) from None
|
||||
payload["input_reference"] = input_path
|
||||
|
||||
try:
|
||||
req = VideoGenerationsRequest(**payload)
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Invalid request body: {e}",
|
||||
) from None
|
||||
|
||||
logger.info("Video generation request %s: prompt=%s", request_id, req.prompt[:100])
|
||||
|
||||
# default_request was validated at server startup (run_server) and is
|
||||
# read-only on the request hot path — _build_generation_kwargs and
|
||||
# explicit_request_updates only read, so no per-request deepcopy needed.
|
||||
default_request = get_default_request()
|
||||
|
||||
gen_kwargs = _build_generation_kwargs(request_id, req, default_request=default_request)
|
||||
job = _make_video_job(request_id, req, gen_kwargs)
|
||||
@router.post("/generations", response_model=VideoResponse, include_in_schema=False)
|
||||
async def create_video(raw_request: Request) -> VideoResponse:
|
||||
"""Create an asynchronous video generation job."""
|
||||
request = await _parse_video_request(raw_request)
|
||||
request_id = f"video_gen_{generate_request_id()}"
|
||||
generation_request = await _adapt_request(request_id, request)
|
||||
job = _make_video_job(request_id, request, generation_request)
|
||||
await VIDEO_STORE.upsert(request_id, job)
|
||||
|
||||
asyncio.create_task(_run_generation(request_id, gen_kwargs))
|
||||
|
||||
task = asyncio.create_task(_run_generation(request_id, generation_request), name=f"video-job-{request_id}")
|
||||
_track_video_job(request_id, task)
|
||||
return VideoResponse(**job)
|
||||
|
||||
|
||||
@router.post("/sync")
|
||||
async def create_video_sync(raw_request: Request) -> FileResponse:
|
||||
"""Generate synchronously and return raw MP4 bytes with vLLM headers."""
|
||||
request = await _parse_video_request(raw_request)
|
||||
request_id = f"video_sync-{generate_request_id()}"
|
||||
generation_request = await _adapt_request(request_id, request)
|
||||
started = time.perf_counter()
|
||||
try:
|
||||
result = await get_serving_engine().generate(generation_request)
|
||||
except Exception as error:
|
||||
logger.exception("Sync video generation failed for %s", request_id)
|
||||
raise HTTPException(status_code=500, detail=f"Video generation failed: {error}") from error
|
||||
if isinstance(result, list):
|
||||
if not result:
|
||||
raise HTTPException(status_code=500, detail="FastVideo returned no generation results")
|
||||
result = result[0]
|
||||
elapsed = time.perf_counter() - started
|
||||
video_path = _result_value(result, "video_path") or generation_request.output.output_path
|
||||
if not video_path or not os.path.exists(video_path):
|
||||
raise HTTPException(status_code=500, detail="FastVideo did not produce an MP4 file")
|
||||
return FileResponse(
|
||||
video_path,
|
||||
media_type="video/mp4",
|
||||
filename=os.path.basename(video_path),
|
||||
headers={
|
||||
"X-Request-Id": request_id,
|
||||
"X-Model": get_served_model_name(),
|
||||
"X-Inference-Time-S": f"{elapsed:.3f}",
|
||||
"X-Stage-Durations": json.dumps(_stage_durations(result), separators=(",", ":")),
|
||||
"X-Peak-Memory-MB": f"{float(_result_value(result, 'peak_memory_mb', 0.0) or 0.0):.3f}",
|
||||
},
|
||||
background=BackgroundTask(_remove_artifact, video_path),
|
||||
)
|
||||
|
||||
|
||||
@router.get("", response_model=VideoListResponse)
|
||||
async def list_videos(
|
||||
after: str | None = Query(None),
|
||||
limit: int | None = Query(None, ge=1, le=100),
|
||||
order: str | None = Query("desc"),
|
||||
):
|
||||
order = (order or "desc").lower()
|
||||
if order not in ("asc", "desc"):
|
||||
order = "desc"
|
||||
order: str = Query("desc"),
|
||||
) -> VideoListResponse:
|
||||
order = order.lower()
|
||||
if order not in {"asc", "desc"}:
|
||||
raise HTTPException(status_code=400, detail="order must be 'asc' or 'desc'")
|
||||
jobs = await VIDEO_STORE.list_values()
|
||||
jobs.sort(key=lambda j: j.get("created_at", 0), reverse=(order != "asc"))
|
||||
|
||||
jobs.sort(key=lambda job: (job.get("created_at", 0), job.get("_sequence", 0)), reverse=order == "desc")
|
||||
if after is not None:
|
||||
try:
|
||||
idx = next(i for i, j in enumerate(jobs) if j["id"] == after)
|
||||
jobs = jobs[idx + 1:]
|
||||
except StopIteration:
|
||||
jobs = []
|
||||
|
||||
index = next((i for i, job in enumerate(jobs) if job.get("id") == after), None)
|
||||
jobs = [] if index is None else jobs[index + 1:]
|
||||
has_more = limit is not None and len(jobs) > limit
|
||||
if limit is not None:
|
||||
jobs = jobs[:limit]
|
||||
return VideoListResponse(data=[VideoResponse(**j) for j in jobs])
|
||||
responses = [VideoResponse(**job) for job in jobs]
|
||||
return VideoListResponse(
|
||||
data=responses,
|
||||
first_id=responses[0].id if responses else None,
|
||||
last_id=responses[-1].id if responses else None,
|
||||
has_more=has_more,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{video_id}", response_model=VideoResponse)
|
||||
async def retrieve_video(video_id: str = Path(...)):
|
||||
@router.get("/{video_id}", response_model=None)
|
||||
async def retrieve_video(video_id: str = Path(...)) -> VideoResponse | JSONResponse:
|
||||
job = await VIDEO_STORE.get(video_id)
|
||||
if not job:
|
||||
if job is None:
|
||||
raise HTTPException(status_code=404, detail="Video not found")
|
||||
return VideoResponse(**job)
|
||||
response = VideoResponse(**job)
|
||||
if response.status is VideoGenerationStatus.FAILED:
|
||||
return JSONResponse(status_code=500, content=response.model_dump(mode="json"))
|
||||
return response
|
||||
|
||||
|
||||
@router.delete("/{video_id}", response_model=VideoResponse)
|
||||
async def delete_video(video_id: str = Path(...)):
|
||||
job = await VIDEO_STORE.pop(video_id)
|
||||
if not job:
|
||||
@router.delete("/{video_id}", response_model=VideoDeleteResponse)
|
||||
async def delete_video(video_id: str = Path(...)) -> VideoDeleteResponse:
|
||||
job = await VIDEO_STORE.get(video_id)
|
||||
if job is None:
|
||||
raise HTTPException(status_code=404, detail="Video not found")
|
||||
job["status"] = "deleted"
|
||||
return VideoResponse(**job)
|
||||
task = _VIDEO_JOB_TASKS.get(video_id)
|
||||
status = VideoGenerationStatus(job.get("status", VideoGenerationStatus.QUEUED))
|
||||
if task is not None and not task.done():
|
||||
# The current synchronous generator cannot abort a CUDA call once it
|
||||
# starts. Remove the API resource immediately and let the tracked task
|
||||
# clean up its artifact on exit while the serving lock stays held.
|
||||
if status is VideoGenerationStatus.QUEUED:
|
||||
task.cancel()
|
||||
else:
|
||||
_DELETED_VIDEO_IDS.add(video_id)
|
||||
task.cancel()
|
||||
popped = await VIDEO_STORE.pop(video_id)
|
||||
file_path = None if popped is None else popped.get("file_path")
|
||||
if status in {VideoGenerationStatus.COMPLETED, VideoGenerationStatus.FAILED}:
|
||||
_remove_artifact(file_path)
|
||||
return VideoDeleteResponse(id=video_id, deleted=True)
|
||||
|
||||
|
||||
@router.get("/{video_id}/content")
|
||||
async def download_video_content(video_id: str = Path(...), variant: str | None = Query(None)):
|
||||
async def download_video_content(video_id: str = Path(...), variant: str | None = Query(None)) -> FileResponse:
|
||||
del variant
|
||||
job = await VIDEO_STORE.get(video_id)
|
||||
if not job:
|
||||
if job is None:
|
||||
raise HTTPException(status_code=404, detail="Video not found")
|
||||
|
||||
status = VideoGenerationStatus(job.get("status", VideoGenerationStatus.QUEUED))
|
||||
if status is VideoGenerationStatus.FAILED:
|
||||
raise HTTPException(status_code=422, detail="Video generation failed. Check job status for error details.")
|
||||
file_path = job.get("file_path")
|
||||
if not file_path or not os.path.exists(file_path):
|
||||
if job.get("status") == "failed":
|
||||
raise HTTPException(status_code=500, detail="Video generation failed")
|
||||
raise HTTPException(status_code=404, detail="Video still being generated")
|
||||
if status is not VideoGenerationStatus.COMPLETED or not file_path:
|
||||
raise HTTPException(status_code=404, detail="Generation is still in-progress")
|
||||
if not os.path.exists(file_path):
|
||||
raise HTTPException(status_code=404, detail="Generated video file not found on disk")
|
||||
return FileResponse(path=file_path, media_type="video/mp4", filename=os.path.basename(file_path))
|
||||
|
||||
return FileResponse(
|
||||
path=file_path,
|
||||
media_type="video/mp4",
|
||||
filename=os.path.basename(file_path),
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"_build_generation_kwargs",
|
||||
"create_video",
|
||||
"create_video_sync",
|
||||
"delete_video",
|
||||
"download_video_content",
|
||||
"list_videos",
|
||||
"retrieve_video",
|
||||
"router",
|
||||
"shutdown_video_jobs",
|
||||
]
|
||||
|
||||
@@ -27,6 +27,7 @@ import torchvision
|
||||
from einops import rearrange
|
||||
|
||||
from fastvideo.api.compat import (
|
||||
REQUEST_BATCH_EXTRA_PASSTHROUGH_FIELDS,
|
||||
expand_request_prompt_batch,
|
||||
generator_config_to_fastvideo_args,
|
||||
legacy_from_pretrained_to_config,
|
||||
@@ -34,6 +35,7 @@ from fastvideo.api.compat import (
|
||||
load_generator_config_from_file,
|
||||
normalize_generation_request,
|
||||
normalize_generator_config,
|
||||
request_to_batch_extra,
|
||||
request_to_pipeline_overrides,
|
||||
request_to_sampling_param,
|
||||
)
|
||||
@@ -65,18 +67,7 @@ except ImportError:
|
||||
logger = init_logger(__name__)
|
||||
_FFMPEG_ENCODER_OPTION_CACHE: dict[tuple[str, str, str], bool] = {}
|
||||
|
||||
_BATCH_EXTRA_PASSTHROUGH_KEYS: tuple[str, ...] = (
|
||||
"ltx2_audio_latents",
|
||||
"ltx2_audio_clean_latent",
|
||||
"ltx2_audio_denoise_mask",
|
||||
"audio_num_frames",
|
||||
"video_position_offset_sec",
|
||||
# MiniMax-H3 VSA per-request knobs (read by the H3 denoising stage;
|
||||
# sparsity itself flows through the existing ForwardBatch.VSA_sparsity)
|
||||
"vsa_mode",
|
||||
"vsa_dense_first_n_steps",
|
||||
"vsa_dense_layers",
|
||||
)
|
||||
_BATCH_EXTRA_PASSTHROUGH_KEYS = tuple(REQUEST_BATCH_EXTRA_PASSTHROUGH_FIELDS)
|
||||
|
||||
_FROM_PRETRAINED_CONVENIENCE_KWARGS = frozenset({
|
||||
"num_gpus",
|
||||
@@ -99,6 +90,8 @@ _FROM_PRETRAINED_CONVENIENCE_KWARGS = frozenset({
|
||||
"pin_cpu_memory",
|
||||
"enable_torch_compile",
|
||||
"torch_compile_kwargs",
|
||||
"lora_path",
|
||||
"lora_strength",
|
||||
"output_type",
|
||||
"nvfp4_fa4",
|
||||
})
|
||||
@@ -518,10 +511,12 @@ class VideoGenerator:
|
||||
request,
|
||||
model_path=self.fastvideo_args.model_path,
|
||||
)
|
||||
batch_extra = request_to_batch_extra(request)
|
||||
result = self._generate_video_impl(
|
||||
prompt=request.prompt,
|
||||
sampling_param=sampling_param,
|
||||
fastvideo_args=fastvideo_args,
|
||||
**batch_extra,
|
||||
)
|
||||
return self._wrap_legacy_result(result)
|
||||
|
||||
|
||||
@@ -21,10 +21,13 @@ if TYPE_CHECKING:
|
||||
FASTVIDEO_TRACE_FUNCTION: int = 0
|
||||
FASTVIDEO_ATTENTION_BACKEND: str | None = None
|
||||
FASTVIDEO_FA4: bool = False
|
||||
FASTVIDEO_MINIMAX_H3_FA4_PACKED_VARLEN: bool = False
|
||||
FASTVIDEO_INFERENCE_TORCH_COMPILE: bool = False
|
||||
FASTVIDEO_MINIMAX_H3_FUSIONS: str = ""
|
||||
FASTVIDEO_VAE_PARALLEL_DECODE: bool = False
|
||||
FASTVIDEO_VAE_PARALLEL_ENCODE: bool = False
|
||||
FASTVIDEO_VAE_PARALLEL_DECODE_STRATEGY: str | None = None
|
||||
FASTVIDEO_ULYSSES_A2A: str = "off"
|
||||
FASTVIDEO_WORKER_MULTIPROC_METHOD: str = "spawn"
|
||||
FASTVIDEO_TARGET_DEVICE: str = "cuda"
|
||||
MAX_JOBS: str | None = None
|
||||
@@ -222,6 +225,24 @@ environment_variables: dict[str, Callable[[], Any]] = {
|
||||
"FASTVIDEO_FA4":
|
||||
lambda: os.getenv("FASTVIDEO_FA4", "0") != "0",
|
||||
|
||||
# Use FA4's packed-varlen entry point for the long, single-document
|
||||
# MiniMax-H3 dense DiT self-attention path. This changes floating-point
|
||||
# reduction order relative to the fixed-length entry point, so it remains
|
||||
# an explicit inference-only speed/quality opt-in.
|
||||
"FASTVIDEO_MINIMAX_H3_FA4_PACKED_VARLEN":
|
||||
lambda: os.getenv("FASTVIDEO_MINIMAX_H3_FA4_PACKED_VARLEN", "0") != "0",
|
||||
|
||||
# If set (=1), enable regional (per-transformer-block) fullgraph
|
||||
# torch.compile for the DiT at inference — the inference-side counterpart
|
||||
# of the training regional-compile port of hao-ai-lab/FastVideo#1718.
|
||||
# Equivalent to FastVideoArgs.inference_torch_compile=True (e.g. via
|
||||
# PipelineSelection.experimental={"inference_torch_compile": True}). VSA
|
||||
# and other non-fullgraph-traceable attention backends degrade to eager
|
||||
# with one warning; see _regional_compile_unsupported_reason in
|
||||
# fastvideo/models/loader/fsdp_load.py.
|
||||
"FASTVIDEO_INFERENCE_TORCH_COMPILE":
|
||||
lambda: os.getenv("FASTVIDEO_INFERENCE_TORCH_COMPILE", "0") != "0",
|
||||
|
||||
# If set (=1), MiniMax-H3 VAE decode (and, with the ENCODE variant,
|
||||
# reference-video encode) round-robins its temporal chunks across the
|
||||
# sequence-parallel ranks instead of running serially on the output rank.
|
||||
@@ -242,6 +263,13 @@ environment_variables: dict[str, Callable[[], Any]] = {
|
||||
"FASTVIDEO_MINIMAX_H3_FUSIONS":
|
||||
lambda: os.getenv("FASTVIDEO_MINIMAX_H3_FUSIONS", ""),
|
||||
|
||||
# Sequence-parallel all-to-all backend.
|
||||
# - "off" (default): the NCCL path in DistributedAutograd.AllToAll4D
|
||||
# - "auto": fused NVLink kernel when the group is a load-store accessible
|
||||
# mesh of 2/4/6/8 ranks in eager execution, else the NCCL path.
|
||||
"FASTVIDEO_ULYSSES_A2A":
|
||||
lambda: os.getenv("FASTVIDEO_ULYSSES_A2A", "off").strip().lower(),
|
||||
|
||||
# Use dedicated multiprocess context for workers.
|
||||
"FASTVIDEO_WORKER_MULTIPROC_METHOD":
|
||||
lambda: os.getenv("FASTVIDEO_WORKER_MULTIPROC_METHOD", "spawn"),
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user