Compare commits

..
11 changed files with 631 additions and 280 deletions
-1
View File
@@ -8,4 +8,3 @@
{"name": "decompose-pipeline-pr", "description": "Decompose an oversized FastVideo pipeline PR into a stack of independently-reviewable PRs. Tiers the diff by blast radius (invisible / dead code / cross-cutting infra / activation), produces a branch graph and worktree bootstrap, drafts the AGENTS.md manifest, flags missing tests on cross-cutting infra changes, and extracts lessons from the PR body. Worked example: PR #1280 daVinci-MagiHuman (9.8k LOC) decomposed into 10 stacked PRs.", "path": "decompose-pipeline-pr/SKILL.md", "status": "tested", "trust": "medium"}
{"name": "reseed-performance-baseline", "description": "Re-seed the HF performance-tracking baseline for an intentional runtime, dependency, or environment-caused benchmark shift. Use when performance CI fails because metrics such as latency, throughput, component time, or peak memory changed for an accepted reason and the rolling median baseline must be advanced by replicating one reviewed shifted source result into three success=true records, or five records when explicitly requested", "path": "reseed-performance-baseline/SKILL.md", "status": "draft", "trust": "low"}
{"name": "add-model", "description": "Add a new model (or variant) to FastVideo: DiT + configs + pipeline + presets + registry + tests. Walks through FastVideo's single stage-based pipeline architecture with exact file paths and registration hooks.", "path": "add-model/SKILL.md", "status": "draft", "trust": "low"}
{"name": "release", "description": "Cut a new FastVideo release. Bumps the version across the three authoritative files (fastvideo/version.py, pyproject.toml, pyproject_other.toml), opens a [chore]: release PR, and documents the post-merge tag + GitHub release ritual. Triggers on requests like \"release X.Y.Z\", \"cut a release\", \"bump version to X.Y.Z\", \"publish to PyPI\".", "path": "release/SKILL.md", "status": "draft", "trust": "low"}
-248
View File
@@ -1,248 +0,0 @@
---
name: release
description: Cut a new FastVideo release. Bumps the version across the three authoritative files (fastvideo/version.py, pyproject.toml, pyproject_other.toml), opens a [chore]: release PR, and documents the post-merge tag + GitHub release ritual. Triggers on requests like "release X.Y.Z", "cut a release", "bump version to X.Y.Z", "publish to PyPI".
---
# FastVideo release skill
End-to-end recipe for cutting a FastVideo release. The PyPI publish is automatic — pushing a `pyproject.toml` version change to `main` triggers `.github/workflows/publish-fastvideo.yml`. Your job is to land the version bump cleanly and follow up with a git tag + GitHub Release for the changelog.
## Inputs
- `${NEW}` — the new version (e.g. `0.2.0`). Required.
- `${OLD}` — the current version. Auto-detect with: `grep -oP '__version__ = "\K[^"]+' fastvideo/version.py` from the repo root.
## When to use
Trigger phrases: "release X.Y.Z", "cut a release", "bump version to X.Y.Z", "publish to PyPI", "tag a release".
## Files to update (3 — the authoritative list)
These are the ONLY files that carry the version as a Python/package declaration:
| File | Line | Change |
|---|---|---|
| `fastvideo/version.py` | 1 | `__version__ = "${OLD}"` → `__version__ = "${NEW}"` |
| `pyproject.toml` | 7 | `version = "${OLD}"` → `version = "${NEW}"` |
| `pyproject_other.toml` | 7 | `version = "${OLD}"` → `version = "${NEW}"` |
`fastvideo/__init__.py` re-exports `__version__` from `fastvideo.version`, so no edit needed there.
## Files NOT to touch
- `apps/dreamverse/pyproject.toml` — declares `"fastvideo>=X.Y.Z"` as a floor. A new release usually still satisfies the floor; bumping it is a separate policy call (does dreamverse strictly require the new version?). Leave alone unless explicitly asked.
- `.agents/memory/**/*.md` — historical notes; the version strings in there are snapshots, not declarations.
- `examples/`, `docs/` — version mentions are illustrative; not authoritative.
- `uv.lock` — main does NOT track a `uv.lock`. Do NOT run `uv lock` as part of a release.
## Workflow
### 1. Verify clean state
```bash
# From the primary FastVideo jj workspace
jj git fetch
OLD=$(grep -oP '__version__ = "\K[^"]+' fastvideo/version.py)
echo "current: $OLD → target: $NEW"
```
Confirm `$NEW > $OLD` follows semver. Check prior tags for the pattern:
```bash
gh release list --repo hao-ai-lab/FastVideo --limit 5
```
### 2. Create a dedicated jj workspace + bookmark
```bash
WS=/home/william5lin/FastVideo_release_${NEW//./_}
jj workspace add --name release-${NEW//./-} "$WS"
cd "$WS"
jj new main@origin -m "[chore]: release v${NEW}"
jj bookmark create chore/release-${NEW} -r @
```
### 3. Apply the 3-file bump
Use the `edit` tool or `sed -i` with exact context. Example with sed:
```bash
sed -i "s/__version__ = \"${OLD}\"/__version__ = \"${NEW}\"/" fastvideo/version.py
sed -i "0,/version = \"${OLD}\"/s//version = \"${NEW}\"/" pyproject.toml
sed -i "0,/version = \"${OLD}\"/s//version = \"${NEW}\"/" pyproject_other.toml
```
(The `0,/.../s//.../` form replaces only the FIRST match in each `pyproject*.toml`, since `${OLD}` might appear elsewhere as a constraint.)
### 4. Verify
```bash
jj diff --name-only -r @ # MUST be exactly 3 files
jj diff --stat -r @ # MUST be +3 / -3
grep -nE "${OLD//./\\.}" fastvideo/version.py pyproject.toml pyproject_other.toml
# expect NO matches in the three files
```
### 5. Lint
```bash
pre-commit run --files fastvideo/version.py pyproject.toml pyproject_other.toml
```
Must pass. Never `--no-verify`.
### 6. Describe + push
```bash
jj describe -m "[chore]: release v${NEW}
Bumps FastVideo from ${OLD} to ${NEW}.
Files updated:
fastvideo/version.py
pyproject.toml
pyproject_other.toml
Note: pushing this to main triggers .github/workflows/publish-fastvideo.yml,
which detects the pyproject.toml version change and publishes to PyPI.
Tag v${NEW} + GitHub release notes follow merge."
jj git push --bookmark chore/release-${NEW}
```
### 7. Open PR
```bash
gh pr create \
--repo hao-ai-lab/FastVideo \
--base main \
--head chore/release-${NEW} \
--title "[chore]: release v${NEW}" \
--body-file - <<EOF
## Summary
Bumps FastVideo from \`${OLD}\` to \`${NEW}\`.
## Files updated (3)
- \`fastvideo/version.py\`
- \`pyproject.toml\`
- \`pyproject_other.toml\`
## Out of scope
\`apps/dreamverse/pyproject.toml\` floor (\`fastvideo>=${OLD}\`) — \`${NEW}\` satisfies it; bumping is a separate policy call.
## After merge
\`.github/workflows/publish-fastvideo.yml\` auto-publishes to PyPI on push-to-main when \`pyproject.toml\` changes.
Manual follow-up:
- Tag the merge commit: \`git tag v${NEW} <merge-sha> && git push origin v${NEW}\`
- Create GitHub Release \`v${NEW}\` matching the prior \`Release X.Y.Z\` pattern.
EOF
```
### 8. Post-merge ritual (do AFTER the PR merges)
1. **Tag the merge commit**:
```bash
git fetch origin
MERGE_SHA=$(gh pr view <PR-NUMBER> --repo hao-ai-lab/FastVideo --json mergeCommit --jq .mergeCommit.oid)
git tag v${NEW} ${MERGE_SHA}
git push origin v${NEW}
```
2. **Confirm PyPI publish workflow ran**:
```bash
gh run list --repo hao-ai-lab/FastVideo --workflow publish-fastvideo.yml --limit 3
```
3. **Create the GitHub Release**:
```bash
gh release create v${NEW} \
--repo hao-ai-lab/FastVideo \
--title "Release ${NEW}" \
--notes "<changelog highlights — what shipped since v${OLD}>" \
--target main
```
Use `gh release view v${OLD}` to mirror tone/structure from the prior release.
4. **Cleanup**: after merge + tag + release land, tear down the workspace:
```bash
jj workspace forget release-${NEW//./-}
rm -rf "$WS"
jj bookmark delete chore/release-${NEW}
```
## Verification gates (must all pass before pushing)
- `jj diff --name-only -r @` returns exactly 3 files
- `jj diff --stat -r @` shows `+3 / -3`
- `grep -E "${OLD//./\\.}" fastvideo/version.py pyproject.toml pyproject_other.toml` returns no matches
- `pre-commit run --files <the-three>` passes
- No `uv.lock` in the change
- No source-code files touched
## Conventions (enforced)
- Commit subject: `[chore]: release v${NEW}` (under 72 chars).
- NEVER add AI co-author trailers (`Co-Authored-By: Claude`, "Generated with…", etc.).
- NEVER `--no-verify`.
- NEVER `uv lock` as part of a release — main doesn't track the lockfile.
- Tag format: `vX.Y.Z` (with leading `v`), matching prior releases.
## Why three files?
`pyproject.toml` and `pyproject_other.toml` are two co-existing project metadata files (the project ships both — the latter is a slimmer variant without dreamverse/job-runner extras). Both carry an authoritative `version = "X.Y.Z"` field and must stay in lock-step. `fastvideo/version.py` is the runtime source of truth re-exported by `fastvideo/__init__.py`.
## Publish workflow contract
`.github/workflows/publish-fastvideo.yml` triggers on `push` to `main` when `pyproject.toml` changes. It compares the new `version` field to the previous commit's `version` field and, if different, builds + publishes to PyPI. The version bump in `pyproject_other.toml` does NOT trigger the workflow (only `pyproject.toml` is in the `paths:` filter), but keeping the two in sync prevents installer surprises for users of the alternate metadata file.
## PyPI publish failure modes
The publish workflow ran on the merge commit but the PyPI upload can still fail at the OIDC trusted-publishing exchange. Always verify the workflow succeeded — do not assume "merge implies published":
```bash
gh run list --repo hao-ai-lab/FastVideo --workflow publish-fastvideo.yml --limit 3
```
Look for the run on the release merge commit. If it shows `failure`, dump the failed log:
```bash
gh run view <run-id> --repo hao-ai-lab/FastVideo --log-failed | tail -80
```
### Known failure: `invalid-publisher` (Trusted Publisher claim mismatch)
The most common failure surfaces as:
```
Trusted publishing exchange failure:
* `invalid-publisher`: valid token, but no corresponding publisher
(Publisher with matching claims was not found)
* environment: MISSING
```
This means the PyPI Trusted Publisher registered for the project expects an `environment` claim (e.g. `pypi`) that the workflow job does not set. Two recovery paths:
**A. Fix the trusted publisher + re-run the workflow** (cleaner long-term):
1. On `pypi.org/manage/project/fastvideo/settings/publishing/`, either remove the `Environment name` field from the registered publisher, OR add `environment: pypi` (matching the existing PyPI config) to the `build-publish-main` job in `.github/workflows/publish-fastvideo.yml`.
2. Re-run the failed workflow:
```bash
gh run rerun <run-id> --repo hao-ai-lab/FastVideo --failed
```
**B. Manual one-shot publish** (faster, no infra change):
```bash
git checkout <merge-sha> # the v${NEW} merge commit on main
uv build # builds sdist + wheel into dist/
uv publish --token <PYPI_TOKEN> # or: twine upload dist/*
```
PyPI is **immutable per version** — if any artifact for `${NEW}` got uploaded (sdist or wheel), you cannot re-upload it. Check before retrying:
```bash
curl -s https://pypi.org/pypi/fastvideo/${NEW}/json | python3 -c "import sys,json; d=json.load(sys.stdin); print('on pypi:', list(d['urls'][0].keys()) if d.get('urls') else 'NOT_PUBLISHED')"
```
If `NOT_PUBLISHED`, either recovery path works. If anything is already up, you have to cut a `${NEW}.postN` patch release instead.
### Tag and GitHub Release are independent
The `git tag v${NEW}` and `gh release create v${NEW}` steps are **independent of PyPI publish success**. If you created the tag + release before noticing the publish failure, that's fine — keep them; just complete the PyPI publish via path A or B above. Do NOT delete and re-create the tag, because doing so will cause confusion in dependents that pin to the tag.
+1 -1
View File
@@ -334,7 +334,7 @@ class TestLtx2Presets:
import fastvideo.registry # noqa: F401
presets = get_presets_for_family("ltx2")
names = {p.name for p in presets}
assert names == {"ltx2_base", "ltx2_3_base", "ltx2_distilled", "ltx2_two_stage"}
assert names == {"ltx2_base", "ltx2_distilled", "ltx2_two_stage"}
def test_ltx2_base_lookup(self) -> None:
import fastvideo.registry # noqa: F401
+17 -17
View File
@@ -15,16 +15,16 @@ classifiers = [
dependencies = [
# Core Libraries
"scipy>=1.14.1",
"six>=1.16.0",
"h5py>=3.12.1",
"scipy==1.14.1",
"six==1.16.0",
"h5py==3.12.1",
"requests>=2.32.2",
# Machine Learning & Transformers
"transformers>=4.57.3",
"transformers==4.57.3",
"tokenizers>=0.20.1",
"sentencepiece>=0.2.0",
"timm>=1.0.11",
"sentencepiece==0.2.0",
"timm==1.0.11",
"peft>=0.15.0",
"diffusers>=0.33.1",
"torch==2.11.0",
@@ -35,21 +35,21 @@ dependencies = [
"accelerate==1.0.1",
# Computer Vision & Image Processing
"opencv-python>=4.10.0.84",
"opencv-python==4.10.0.84",
"pillow>=10.3.0",
"imageio>=2.36.0",
"imageio-ffmpeg>=0.5.1",
"imageio==2.36.0",
"imageio-ffmpeg==0.5.1",
"einops",
# Experiment Tracking & Logging
"wandb>=0.21.0",
"loguru",
"test-tube>=0.7.5",
"test-tube==0.7.5",
# Miscellaneous Utilities
"tqdm",
"pytest",
"PyYAML>=6.0.1",
"PyYAML==6.0.1",
"protobuf>=5.28.3",
"gradio==5.32.0",
"moviepy>=2.0.0",
@@ -73,17 +73,17 @@ dependencies = [
# Training Dependencies
"torchdata",
"pyarrow",
"datasets>=4.0.0",
"datasets==4.0.0",
"av",
# Preprocessing Dependencies
"torchcodec",
"ray>=2.49.1",
"ftfy>=6.3.1",
"ftfy==6.3.1",
# Job Runner
"fastapi>=0.129.0",
"uvicorn>=0.41.0"
"fastapi==0.129.0",
"uvicorn==0.41.0"
]
[tool.uv]
@@ -130,7 +130,7 @@ lint = [
test = [
"av",
"pytorch-msssim>=1.0.0",
"pytorch-msssim==1.0.0",
"pytest",
"pandas",
"plotly",
@@ -170,7 +170,7 @@ streaming = [
]
dreamverse = [
"uvicorn[standard]>=0.41.0",
"uvicorn[standard]==0.41.0",
"cerebras-cloud-sdk",
"flash-attn-cute @ git+https://github.com/XOR-op/flash-attention.git@fa4-compile#subdirectory=flash_attn/cute",
"flashinfer-python",
+13 -13
View File
@@ -15,16 +15,16 @@ classifiers = [
dependencies = [
# Core Libraries
"scipy>=1.14.1",
"six>=1.16.0",
"h5py>=3.12.1",
"scipy==1.14.1",
"six==1.16.0",
"h5py==3.12.1",
"requests>=2.32.2",
# Machine Learning & Transformers
"transformers>=4.57.3",
"transformers==4.57.3",
"tokenizers>=0.20.1",
"sentencepiece>=0.2.0",
"timm>=1.0.11",
"sentencepiece==0.2.0",
"timm==1.0.11",
"peft>=0.15.0",
"diffusers>=0.33.1",
"torch==2.11.0",
@@ -34,21 +34,21 @@ dependencies = [
"accelerate==1.0.1",
# Computer Vision & Image Processing
"opencv-python>=4.10.0.84",
"opencv-python==4.10.0.84",
"pillow>=10.3.0",
"imageio>=2.36.0",
"imageio-ffmpeg>=0.5.1",
"imageio==2.36.0",
"imageio-ffmpeg==0.5.1",
"einops",
# Experiment Tracking & Logging
"wandb>=0.21.0",
"loguru",
"test-tube>=0.7.5",
"test-tube==0.7.5",
# Miscellaneous Utilities
"tqdm",
"pytest",
"PyYAML>=6.0.1",
"PyYAML==6.0.1",
"protobuf>=5.28.3",
"gradio==5.32.0",
"moviepy>=2.0.0",
@@ -71,7 +71,7 @@ dependencies = [
# Training Dependencies
"torchdata",
"pyarrow",
"datasets>=4.0.0",
"datasets==4.0.0",
"av",
# Preprocessing Dependencies
@@ -93,7 +93,7 @@ lint = [
test = [
"av",
"pytorch-msssim>=1.0.0",
"pytorch-msssim==1.0.0",
"pytest",
]
@@ -0,0 +1,51 @@
{
"benchmark_id": "fastwan-t2v-1.3b-vsa-dmd-gb10-1gpu",
"description": "FastWan2.1 T2V 1.3B DMD/VSA local Nsight profile for one DGX Spark GB10 GPU",
"model": {
"model_path": "FastVideo/FastWan2.1-T2V-1.3B-Diffusers",
"model_short_name": "FastWan2.1-T2V-1.3B-DMD-VSA"
},
"init_kwargs": {
"num_gpus": 1,
"sp_size": 1,
"tp_size": 1,
"vae_sp": false,
"vae_tiling": true,
"VSA_sparsity": 0.8,
"dmd_denoising_steps": [1000, 757, 522],
"enable_torch_compile": false,
"dit_cpu_offload": false,
"dit_layerwise_offload": false,
"vae_cpu_offload": false,
"text_encoder_cpu_offload": true,
"pin_cpu_memory": false,
"text_encoder_precisions": ["fp32"]
},
"generation_kwargs": {
"height": 480,
"width": 832,
"num_frames": 81,
"num_inference_steps": 3,
"seed": 1024,
"fps": 16,
"neg_prompt": "Bright tones, overexposed, static, blurred details, subtitles, style, works, paintings, images, static, overall gray, worst quality, low quality, JPEG compression residue, ugly, incomplete, extra fingers, poorly drawn hands, poorly drawn faces, deformed, disfigured, misshapen limbs, fused fingers, still picture, messy background, three legs, many people in the background, walking backwards"
},
"test_prompts": [
"Will Smith casually eats noodles, his relaxed demeanor contrasting with the energetic background of a bustling street food market. The scene captures a mix of humor and authenticity. Mid-shot framing, vibrant lighting."
],
"run_config": {
"num_warmup_runs": 2,
"num_measurement_runs": 5,
"required_gpus": 1
},
"thresholds": {
"GB10": {
"max_generation_time_s": 120.0,
"max_peak_memory_mb": 30000.0
},
"default": {
"max_generation_time_s": 120.0,
"max_peak_memory_mb": 30000.0
}
}
}
@@ -0,0 +1,46 @@
{
"benchmark_id": "wan-t2v-1.3b-gb10-1gpu",
"description": "Wan2.1 T2V 1.3B local Nsight profile for one DGX Spark GB10 GPU",
"model": {
"model_path": "Wan-AI/Wan2.1-T2V-1.3B-Diffusers",
"model_short_name": "Wan2.1-T2V-1.3B"
},
"init_kwargs": {
"num_gpus": 1,
"flow_shift": 7.0,
"sp_size": 1,
"tp_size": 1,
"vae_sp": false,
"vae_tiling": true,
"text_encoder_precisions": ["fp32"]
},
"generation_kwargs": {
"height": 480,
"width": 832,
"num_frames": 45,
"num_inference_steps": 4,
"guidance_scale": 3,
"embedded_cfg_scale": 6,
"seed": 1024,
"fps": 24,
"neg_prompt": "Bright tones, overexposed, static, blurred details, subtitles, style, works, paintings, images, static, overall gray, worst quality, low quality, JPEG compression residue, ugly, incomplete, extra fingers, poorly drawn hands, poorly drawn faces, deformed, disfigured, misshapen limbs, fused fingers, still picture, messy background, three legs, many people in the background, walking backwards"
},
"test_prompts": [
"Will Smith casually eats noodles, his relaxed demeanor contrasting with the energetic background of a bustling street food market. The scene captures a mix of humor and authenticity. Mid-shot framing, vibrant lighting."
],
"run_config": {
"num_warmup_runs": 2,
"num_measurement_runs": 5,
"required_gpus": 1
},
"thresholds": {
"GB10": {
"max_generation_time_s": 120.0,
"max_peak_memory_mb": 30000.0
},
"default": {
"max_generation_time_s": 120.0,
"max_peak_memory_mb": 30000.0
}
}
}
@@ -0,0 +1,11 @@
#!/usr/bin/env bash
# Profile FastWan2.1 T2V 1.3B DMD/VSA on one DGX Spark GB10 GPU with Nsight Systems.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
export CONFIG_PATH="${CONFIG_PATH:-${SCRIPT_DIR}/configs/fastwan-t2v-1.3b-vsa-dmd-gb10-1gpu.json}"
export OUTPUT_ROOT="${OUTPUT_ROOT:-outputs/nsys/gb10-vsa}"
export FASTVIDEO_ATTENTION_BACKEND="${FASTVIDEO_ATTENTION_BACKEND:-VIDEO_SPARSE_ATTN}"
exec "${SCRIPT_DIR}/profile_wan_t2v_1_3b_nsys.sh" "$@"
+10
View File
@@ -0,0 +1,10 @@
#!/usr/bin/env bash
# Profile Wan2.1 T2V 1.3B on one DGX Spark GB10 GPU with Nsight Systems.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
export CONFIG_PATH="${CONFIG_PATH:-${SCRIPT_DIR}/configs/wan-t2v-1.3b-gb10-1gpu.json}"
export OUTPUT_ROOT="${OUTPUT_ROOT:-outputs/nsys/gb10}"
exec "${SCRIPT_DIR}/profile_wan_t2v_1_3b_nsys.sh" "$@"
+134
View File
@@ -0,0 +1,134 @@
#!/usr/bin/env bash
# Profile a Wan2.1 T2V 1.3B inference benchmark with Nsight Systems.
#
# Intended environment:
# - FastVideo Python 3.10-3.12 environment
# - CUDA-visible NVIDIA GPU(s)
# - Nsight Systems CLI available on PATH
#
# Usage:
# bash scripts/performance/profile_wan_t2v_1_3b_nsys.sh
#
# Optional overrides:
# PYTHON_BIN=python3.12
# NSYS_BIN=nsys
# CONFIG_PATH=.buildkite/performance-benchmarks/tests/wan-t2v-1.3b.json
# OUTPUT_ROOT=outputs/nsys
# NSYS_EXTRA_ARGS="--some-nsys-flag=value"
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
cd "${REPO_ROOT}"
PYTHON_BIN="${PYTHON_BIN:-python}"
NSYS_BIN="${NSYS_BIN:-nsys}"
CONFIG_PATH="${CONFIG_PATH:-.buildkite/performance-benchmarks/tests/wan-t2v-1.3b.json}"
OUTPUT_ROOT="${OUTPUT_ROOT:-outputs/nsys}"
if ! command -v "${PYTHON_BIN}" >/dev/null 2>&1; then
echo "Python executable not found: ${PYTHON_BIN}" >&2
exit 1
fi
"${PYTHON_BIN}" - <<'PY'
import sys
if not ((3, 10) <= sys.version_info[:2] <= (3, 12)):
raise SystemExit(f"Expected Python 3.10-3.12, got {sys.version.split()[0]}")
PY
if ! command -v "${NSYS_BIN}" >/dev/null 2>&1; then
echo "Nsight Systems CLI not found: ${NSYS_BIN}. Install Nsight Systems before profiling." >&2
exit 1
fi
if [[ ! -f "${CONFIG_PATH}" ]]; then
echo "Benchmark config not found: ${CONFIG_PATH}" >&2
exit 1
fi
BENCHMARK_ID="$("${PYTHON_BIN}" - <<'PY' "${CONFIG_PATH}"
import json
import sys
with open(sys.argv[1], encoding="utf-8") as f:
print(json.load(f)["benchmark_id"])
PY
)"
TIMESTAMP="$(date -u +%Y%m%dT%H%M%SZ)"
RUN_DIR="${OUTPUT_ROOT}/${BENCHMARK_ID}/${TIMESTAMP}"
mkdir -p "${RUN_DIR}"
NSYS_ARGS=(
profile
--force-overwrite=true
--capture-range=cudaProfilerApi
--capture-range-end=stop
--sample=none
--cpuctxsw=none
--stats=true
--output "${RUN_DIR}/${BENCHMARK_ID}"
)
NSYS_TRACE="${NSYS_TRACE:-cuda,nvtx,cudnn,cublas,osrt}"
if [[ "${NSYS_ENABLE_NCCL_TRACE:-0}" == "1" ]]; then
if "${NSYS_BIN}" profile --help 2>&1 | grep -Eq "(^|[[:space:],'])nccl([[:space:],']|$)"; then
NSYS_TRACE="${NSYS_TRACE},nccl"
else
echo "Skipping NCCL trace: ${NSYS_BIN} does not list nccl as a supported trace domain."
fi
fi
NSYS_ARGS+=(--trace="${NSYS_TRACE}")
if "${NSYS_BIN}" profile --help 2>&1 | grep -q -- "--cuda-trace-scope"; then
NSYS_ARGS+=(--cuda-trace-scope=process-tree)
fi
if "${NSYS_BIN}" profile --help 2>&1 | grep -q -- "--trace-fork-before-exec"; then
NSYS_ARGS+=(--trace-fork-before-exec=true)
fi
if [[ -n "${NSYS_EXTRA_ARGS:-}" ]]; then
# shellcheck disable=SC2206
EXTRA_ARGS=(${NSYS_EXTRA_ARGS})
NSYS_ARGS+=("${EXTRA_ARGS[@]}")
fi
export TOKENIZERS_PARALLELISM="${TOKENIZERS_PARALLELISM:-false}"
export FASTVIDEO_STAGE_LOGGING="${FASTVIDEO_STAGE_LOGGING:-1}"
echo "Repo root: ${REPO_ROOT}"
echo "Python: $("${PYTHON_BIN}" --version)"
echo "Nsight Systems: $("${NSYS_BIN}" --version | head -n 1)"
echo "Config: ${CONFIG_PATH}"
echo "Output: ${RUN_DIR}"
"${PYTHON_BIN}" - <<'PY'
try:
import torch
except ImportError:
print("Torch: unavailable")
else:
print(f"Torch: {torch.__version__}")
print(f"Torch CUDA runtime: {torch.version.cuda}")
print(f"CUDA available: {torch.cuda.is_available()}")
if torch.cuda.is_available():
print(f"CUDA device count: {torch.cuda.device_count()}")
for idx in range(torch.cuda.device_count()):
print(
f"CUDA device {idx}: {torch.cuda.get_device_name(idx)} "
f"capability={torch.cuda.get_device_capability(idx)}"
)
PY
echo "Starting nsys profile..."
"${NSYS_BIN}" "${NSYS_ARGS[@]}" \
"${PYTHON_BIN}" scripts/performance/run_inference_profile_from_config.py \
--config "${CONFIG_PATH}" \
--output-dir "${RUN_DIR}/generated_videos" \
--summary-path "${RUN_DIR}/profile_summary.json"
echo "Done. Profile outputs are under ${RUN_DIR}"
@@ -0,0 +1,348 @@
# SPDX-License-Identifier: Apache-2.0
"""Run one config-driven inference benchmark under an nsys capture range."""
from __future__ import annotations
import argparse
import ctypes
import json
import logging
import os
import time
from collections.abc import Mapping
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
logger = logging.getLogger(__name__)
STAGE_METRIC_MAP: dict[str, str] = {
"TextEncodingStage": "text_encoder_time_s",
"DenoisingStage": "dit_time_s",
"DmdDenoisingStage": "dit_time_s",
"DecodingStage": "vae_decode_time_s",
}
_CUDART: ctypes.CDLL | None = None
_CUDART_LIBRARY_NAMES: tuple[str, ...] = (
"libcudart.so",
"libcudart.so.13",
"libcudart.so.12",
"libcudart.so.11.0",
)
def _load_cudart() -> ctypes.CDLL:
global _CUDART
if _CUDART is not None:
return _CUDART
errors: list[str] = []
for name in _CUDART_LIBRARY_NAMES:
try:
lib = ctypes.CDLL(name)
except OSError as exc:
errors.append(f"{name}: {exc}")
continue
lib.cudaProfilerStart.restype = ctypes.c_int
lib.cudaProfilerStop.restype = ctypes.c_int
_CUDART = lib
return lib
raise RuntimeError("Unable to load CUDA runtime for cudaProfilerApi capture range: " + "; ".join(errors))
def _cuda_profiler_start() -> None:
err = _load_cudart().cudaProfilerStart()
if err != 0:
raise RuntimeError(f"cudaProfilerStart failed with CUDA error code {err}")
def _cuda_profiler_stop() -> None:
err = _load_cudart().cudaProfilerStop()
if err != 0:
raise RuntimeError(f"cudaProfilerStop failed with CUDA error code {err}")
def _make_worker_cuda_profiler_start_payload() -> bytes:
import cloudpickle
library_names = _CUDART_LIBRARY_NAMES
def worker_cuda_profiler_start(worker_wrapper: Any) -> dict[str, Any]:
import ctypes
import os
for name in library_names:
try:
lib = ctypes.CDLL(name)
break
except OSError:
lib = None
if lib is None:
raise RuntimeError("Unable to load CUDA runtime in worker")
lib.cudaProfilerStart.restype = ctypes.c_int
err = lib.cudaProfilerStart()
if err != 0:
raise RuntimeError(f"cudaProfilerStart failed with CUDA error code {err}")
return {
"pid": os.getpid(),
"rank": getattr(worker_wrapper, "rpc_rank", None),
"event": "cudaProfilerStart",
}
return cloudpickle.dumps(worker_cuda_profiler_start)
def _make_worker_cuda_profiler_stop_payload() -> bytes:
import cloudpickle
library_names = _CUDART_LIBRARY_NAMES
def worker_cuda_profiler_stop(worker_wrapper: Any) -> dict[str, Any]:
import ctypes
import os
import torch
torch.cuda.synchronize()
for name in library_names:
try:
lib = ctypes.CDLL(name)
break
except OSError:
lib = None
if lib is None:
raise RuntimeError("Unable to load CUDA runtime in worker")
lib.cudaProfilerStop.restype = ctypes.c_int
err = lib.cudaProfilerStop()
if err != 0:
raise RuntimeError(f"cudaProfilerStop failed with CUDA error code {err}")
return {
"pid": os.getpid(),
"rank": getattr(worker_wrapper, "rpc_rank", None),
"event": "cudaProfilerStop",
}
return cloudpickle.dumps(worker_cuda_profiler_stop)
def _load_config(path: Path) -> dict[str, Any]:
with path.open(encoding="utf-8") as f:
return json.load(f)
def _remap_init_kwargs(init_kwargs: dict[str, Any]) -> dict[str, Any]:
remapped = dict(init_kwargs)
text_enc_prec = remapped.pop("text_encoder_precisions", None)
if text_enc_prec is not None:
remapped["text_encoder_precisions"] = tuple(text_enc_prec)
return remapped
def _extract_component_times(result: dict[str, Any]) -> dict[str, float | None]:
component_times: dict[str, float | None] = {
"text_encoder_time_s": None,
"dit_time_s": None,
"vae_decode_time_s": None,
}
logging_info = result.get("logging_info")
if logging_info is None:
return component_times
if isinstance(logging_info, Mapping):
stages: dict[str, Any] = logging_info.get("stages", {}) or {}
else:
stages = getattr(logging_info, "stages", {}) or {}
for stage_name, stage_data in stages.items():
metric_key = STAGE_METRIC_MAP.get(stage_name)
if metric_key is None:
continue
elapsed = stage_data.get("execution_time")
if elapsed is None:
continue
existing = component_times[metric_key]
component_times[metric_key] = elapsed if existing is None else existing + elapsed
return component_times
def _run_generation(
generator: Any,
prompt: str,
generation_kwargs: dict[str, Any],
) -> tuple[float, float, dict[str, float | None]]:
import torch
torch.cuda.synchronize()
start = time.perf_counter()
result = generator.generate_video(prompt, **generation_kwargs)
torch.cuda.synchronize()
elapsed = time.perf_counter() - start
peak_memory_mb = result.get("peak_memory_mb", 0.0) or 0.0
return elapsed, peak_memory_mb, _extract_component_times(result)
def _shutdown_executor(generator: Any | None) -> None:
from fastvideo.worker.multiproc_executor import MultiprocExecutor
if generator is None:
return
if isinstance(generator.executor, MultiprocExecutor):
generator.executor.shutdown()
def _start_capture(generator: Any) -> list[dict[str, Any]]:
from fastvideo.worker.multiproc_executor import MultiprocExecutor
worker_payload = _make_worker_cuda_profiler_start_payload()
_cuda_profiler_start()
responses: list[dict[str, Any]] = []
if isinstance(generator.executor, MultiprocExecutor):
responses = generator.executor.collective_rpc(worker_payload)
return responses
def _stop_capture(generator: Any) -> list[dict[str, Any]]:
import torch
from fastvideo.worker.multiproc_executor import MultiprocExecutor
worker_payload = _make_worker_cuda_profiler_stop_payload()
responses: list[dict[str, Any]] = []
if isinstance(generator.executor, MultiprocExecutor):
responses = generator.executor.collective_rpc(worker_payload)
torch.cuda.synchronize()
_cuda_profiler_stop()
return responses
def _write_summary(path: Path, summary: dict[str, Any]) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
with path.open("w", encoding="utf-8") as f:
json.dump(summary, f, indent=2)
f.write("\n")
def _collect_torch_runtime_info(torch_module: Any, num_devices: int) -> dict[str, Any]:
return {
"torch_version": torch_module.__version__,
"torch_cuda_version": torch_module.version.cuda,
"cuda_device_count": torch_module.cuda.device_count(),
"cuda_arch_list": torch_module.cuda.get_arch_list(),
"device_capabilities": [
list(torch_module.cuda.get_device_capability(i))
for i in range(num_devices)
],
"fastvideo_attention_backend": os.environ.get("FASTVIDEO_ATTENTION_BACKEND"),
}
def run_profile(args: argparse.Namespace) -> None:
global logger
import torch
from fastvideo import VideoGenerator
from fastvideo.logger import init_logger
logger = init_logger(__name__)
cfg = _load_config(args.config)
run_config = cfg.get("run_config", {})
required_gpus = int(run_config.get("required_gpus", 1))
available_gpus = torch.cuda.device_count()
if available_gpus < required_gpus:
raise RuntimeError(f"Need {required_gpus} CUDA GPUs for {cfg['benchmark_id']}, found {available_gpus}")
model_info = cfg["model"]
init_kwargs = _remap_init_kwargs(cfg.get("init_kwargs", {}))
generation_kwargs = dict(cfg.get("generation_kwargs", {}))
generation_kwargs["output_path"] = str(args.output_dir)
prompt = cfg.get("test_prompts", ["A cinematic video."])[0]
num_warmup = int(run_config.get("num_warmup_runs", 1))
args.output_dir.mkdir(parents=True, exist_ok=True)
os.environ["FASTVIDEO_STAGE_LOGGING"] = "1"
generator: VideoGenerator | None = None
capture_started = False
worker_start_events: list[dict[str, Any]] = []
worker_stop_events: list[dict[str, Any]] = []
try:
logger.info("Loading model %s with init kwargs: %s", model_info["model_path"], init_kwargs)
generator = VideoGenerator.from_pretrained(
model_path=model_info["model_path"],
**init_kwargs,
)
for i in range(num_warmup):
logger.info("Warmup run %d/%d", i + 1, num_warmup)
_run_generation(generator, prompt, generation_kwargs)
logger.info("Starting cudaProfilerApi capture range")
worker_start_events = _start_capture(generator)
capture_started = True
elapsed, peak_mb, component_times = _run_generation(generator, prompt, generation_kwargs)
finally:
if capture_started and generator is not None:
logger.info("Stopping cudaProfilerApi capture range")
worker_stop_events = _stop_capture(generator)
_shutdown_executor(generator)
device_names = [torch.cuda.get_device_name(i) for i in range(required_gpus)]
runtime_info = _collect_torch_runtime_info(torch, required_gpus)
throughput_fps = None
num_frames = generation_kwargs.get("num_frames")
if isinstance(num_frames, (int, float)) and elapsed > 0:
throughput_fps = num_frames / elapsed
summary = {
"benchmark_id": cfg["benchmark_id"],
"model_short_name": model_info.get("model_short_name", ""),
"model_path": model_info["model_path"],
"config_path": str(args.config),
"timestamp": datetime.now(timezone.utc).isoformat(),
"device_names": device_names,
"device_capabilities": runtime_info["device_capabilities"],
"available_gpus": available_gpus,
"required_gpus": required_gpus,
"torch_version": runtime_info["torch_version"],
"torch_cuda_version": runtime_info["torch_cuda_version"],
"cuda_arch_list": runtime_info["cuda_arch_list"],
"fastvideo_attention_backend": runtime_info["fastvideo_attention_backend"],
"num_warmup_runs": num_warmup,
"num_profiled_runs": 1,
"profiled_generation_time_s": round(elapsed, 3),
"throughput_fps": round(throughput_fps, 3) if throughput_fps is not None else None,
"peak_memory_mb": round(peak_mb, 1),
"component_times_s": component_times,
"generation_kwargs": generation_kwargs,
"worker_start_events": worker_start_events,
"worker_stop_events": worker_stop_events,
}
_write_summary(args.summary_path, summary)
logger.info("Profile summary written to %s", args.summary_path)
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
"--config",
type=Path,
default=Path(".buildkite/performance-benchmarks/tests/wan-t2v-1.3b.json"),
help="Benchmark JSON config to profile.",
)
parser.add_argument(
"--output-dir",
type=Path,
required=True,
help="Directory for generated videos.",
)
parser.add_argument(
"--summary-path",
type=Path,
required=True,
help="Path for the profile summary JSON.",
)
return parser.parse_args()
if __name__ == "__main__":
run_profile(parse_args())