Compare commits

..
Author SHA1 Message Date
William Lin de873eae2d [bugfix] Pass disable_autocast through to FastVideoArgs (#2056) 2026-11-02 13:42:08 -08:00
4f80c5a3da [feat] Open-H3-Regenerate-2K: regenerate FastH3 QAD, Trim and V2 drafts at 2K (text to 2K 3.1-3.4x faster) (#2037)
Co-authored-by: Aryan Kumar <aryan5v@users.noreply.github.com>
Co-authored-by: Aryan <aryan@aryan5v.dev>
2026-11-02 03:44:28 -08:00
Aryan KumarandAryan Kumar b0047cfedc [feat] Open-H3-Regenerate-2K: NVFP4 H3 video decoder (decode 23 s to 5 s at 2K) (#2036)
Co-authored-by: Aryan Kumar <aryan5v@users.noreply.github.com>
2026-11-01 22:51:41 -08:00
Aryan KumarandAryan Kumar ac81198d33 [new-model] Open-H3-Regenerate-2K: 768P H3 draft to 2560x1440, served on the OpenAI video routes (#2035)
Co-authored-by: Aryan Kumar <aryan5v@users.noreply.github.com>
2026-11-01 22:45:09 -08:00
Aryan KumarandAryan Kumar cb02062930 [new-model] Add Pika H3 Flex inference support (#2038)
Co-authored-by: Aryan Kumar <aryan5v@users.noreply.github.com>
2026-11-01 22:33:10 -08:00
Aryan KumarandAryan Kumar d954fad9ec [perf] MiniMax H3: faster FP4 attention padding, uint8 VAE pixel path and cuDNN FP4 GEMMs (#2046)
Co-authored-by: Aryan Kumar <aryan5v@users.noreply.github.com>
2026-11-01 22:23:10 -08:00
Aryan KumarandAryan Kumar 166b9b8a07 [perf] MiniMax H3: fuse VAE decoder QK RMSNorm and partial RoPE (#2041)
Co-authored-by: Aryan Kumar <aryan5v@users.noreply.github.com>
2026-11-01 22:15:35 -08:00
Aryan KumarandAryan Kumar 7e1ed5e28e [perf] MiniMax H3: decode eight VAE spatial tiles per call by default (#2040)
Co-authored-by: Aryan Kumar <aryan5v@users.noreply.github.com>
2026-11-01 22:06:23 -08:00
Aryan KumarandAryan Kumar cb6a9a13e1 [bugfix] FA4 JIT cache vs tuning env, MXFP8 import and scale dtype, H3 VSA gate double quantization (#2049)
Co-authored-by: Aryan Kumar <aryan5v@users.noreply.github.com>
2026-11-01 19:11:25 -08:00
Aryan KumarandAryan Kumar 0d1244a869 [perf] MiniMax H3: opt-in faster kernel tier for the DiT fused ops (#2042)
Co-authored-by: Aryan Kumar <aryan5v@users.noreply.github.com>
2026-11-01 15:22:09 -08:00
Aryan KumarandAryan Kumar 18b2a35106 [bugfix] FA4: stop forwarding max_seqlen to the varlen kernel (#2043)
Co-authored-by: Aryan Kumar <aryan5v@users.noreply.github.com>
2026-11-01 15:21:38 -08:00
Aryan KumarandAryan Kumar 4d0a10d989 [docs] Fix the docs build, cookbook memory status, and broken commands (#2023)
Co-authored-by: Aryan Kumar <aryan5v@users.noreply.github.com>
2026-10-30 17:08:41 -07:00
Lifa Ha d9e6c3eb82 [bugfix] Fix extra work in VSA64 layout benchmark (#2027) 2026-10-30 02:30:07 +00:00
Aryan Kumar 7203b4976e [bugfix]: compile only the LoRA delta, not the wrapped base layer
BaseLayerWithLoRA.forward was decorated with torch.compile, so wrapping a linear also compiled its base layer.
Compiling a packed NVFP4/FP8 linear on its own changes its numerics, so a wrapped layer at strength 0 could differ
from the unwrapped layer. forward now adds no compiled region: the base layer runs exactly as it would unwrapped
(eagerly, or traced with a caller's compiled block), and only the adapter delta is compiled.
2026-10-28 14:49:15 -07:00
Aryan Kumar 6bb444eef1 [bugfix]: run the base layer while a LoRA wrapper has no adapter 2026-10-28 14:49:15 -07:00
Aryan Kumar 6743f4b4b7 Revert "[new-model] Add PAN2 text-to-video and image-to-video" (#2025) 2026-10-27 18:03:59 -07:00
234 changed files with 9936 additions and 11410 deletions
@@ -49,7 +49,6 @@ export default function HardwarePicker({
value,
onChange,
gpuCount = 1,
purpose = "target",
reload,
}: {
backend: string;
@@ -59,7 +58,6 @@ export default function HardwarePicker({
value: HardwareSelection;
onChange: (selection: HardwareSelection) => void;
gpuCount?: number;
purpose?: "target" | "filter";
reload?: () => void;
}) {
const id = useId();
@@ -71,9 +69,9 @@ export default function HardwarePicker({
const [adding, setAdding] = useState(false);
const [draft, setDraft] = useState<Draft>(() => draftFrom(value.custom, backend));
const record = resolveHardware(value, catalog);
const unknown = !!value.id && value.id !== "custom" && !record;
const candidates =
purpose === "filter" ? catalog : catalog.filter((item) => item.backend === (backend === "mlx" ? "mlx" : "cuda"));
const unknown = status === "ready" && !!value.id && value.id !== "custom" && !record;
const loading = status === "loading" && !!value.id && value.id !== "custom";
const candidates = catalog.filter((item) => item.backend === (backend === "mlx" ? "mlx" : "cuda"));
const matches = searchHardware(candidates, query).slice(0, 20);
const draftMemory = amount(draft.memory);
const draftBudget = amount(draft.budget);
@@ -144,8 +142,7 @@ export default function HardwarePicker({
</span>
{record.usable_budget_gb ? (
<span className="hardware-selected-meta">
{value.id === "custom" ? "Your budget" : "Usable budget"}: {record.usable_budget_gb}{" "}
{record.memory_unit}
Usable memory: {record.usable_budget_gb} {record.memory_unit}
</span>
) : null}
{record.host_ram_gb && record.memory_type !== "unified" ? (
@@ -155,20 +152,24 @@ export default function HardwarePicker({
) : null}
</div>
<button type="button" className="hardware-clear" onClick={() => onChange({ id: "" })}>
Clear hardware {purpose}
Clear hardware
</button>
</div>
) : value.id === "custom" ? (
<p className="hardware-current" role="status">
Add this device's memory to finish selecting it.
</p>
) : loading ? (
<p className="hardware-current" role="status">
Loading hardware catalog…
</p>
) : unknown ? (
<p className="hardware-current" role="status">
Saved hardware target ({value.id}) is not in this catalog.
The saved device ({value.id}) is not in this catalog.
</p>
) : (
<p className="hardware-current" role="status">
No hardware {purpose} selected.
No hardware selected.
</p>
)}
{record ? (
@@ -184,7 +185,7 @@ export default function HardwarePicker({
<div className="hardware-picker-actions">
{!record && value.id ? (
<button type="button" className="hardware-clear" onClick={() => onChange({ id: "" })}>
Clear hardware {purpose}
Clear hardware
</button>
) : null}
<button
@@ -198,21 +199,11 @@ export default function HardwarePicker({
setAdding(false);
}}
>
{record
? "Change hardware"
: purpose === "filter"
? "Choose hardware"
: backend === "mlx"
? "+ Find or add a Mac"
: "+ Find or add a GPU"}
{record ? "Change hardware" : backend === "mlx" ? "+ Find or add a Mac" : "+ Find or add a GPU"}
</button>
</div>
{record || open ? (
<p className="hardware-picker-caption">
{purpose === "filter"
? "Filters by runtime. Memory fit is not estimated."
: "Single-GPU devices set GPU count to 1. Nothing else in the configuration changes."}
</p>
<p className="hardware-picker-caption">Used for the setup link and, on single-unit systems, the GPU count.</p>
) : null}
{open ? (
<div className="hardware-lookup" id={panelId} onKeyDown={(event) => event.key === "Escape" && close()}>
@@ -296,13 +287,13 @@ export default function HardwarePicker({
value={draft.memory_type}
onChange={(event) => setDraft({ ...draft, memory_type: event.target.value as MemoryType })}
>
<option value="dedicated">Dedicated GPU memory (VRAM)</option>
<option value="unified">Unified memory (CPU + GPU)</option>
<option value="system">System memory (host RAM)</option>
<option value="dedicated">GPU memory (VRAM)</option>
<option value="unified">Unified memory</option>
<option value="system">System memory (RAM)</option>
</select>
</label>
<label className="hardware-form-field">
<span>Nominal memory</span>
<span>Total memory</span>
<Input
type="number"
min="1"
@@ -323,7 +314,7 @@ export default function HardwarePicker({
</select>
</label>
<label className="hardware-form-field">
<span>Usable budget (optional)</span>
<span>Usable memory (optional)</span>
<Input
type="number"
min="1"
@@ -348,16 +339,14 @@ export default function HardwarePicker({
</label>
) : null}
</div>
{budgetTooLarge ? (
<p className="hardware-form-error">The usable budget cannot exceed the nominal memory.</p>
) : null}
{budgetTooLarge ? <p className="hardware-form-error">Usable memory cannot exceed total memory.</p> : null}
<p className="hardware-form-note">
Nominal memory is device capacity, not what FastVideo can use. A budget is your own limit and is not
verified. Unified memory is shared with the CPU; it is not dedicated VRAM.
Total memory is the device's capacity. Usable memory is the part FastVideo may use, such as a MIG slice
or a limit you set. Unified memory is shared with the CPU.
</p>
<div className="hardware-form-actions">
<button type="submit" disabled={!validDraft}>
Use this device
Save device
</button>
<button
type="button"
+34 -36
View File
@@ -1,8 +1,8 @@
import { useState } from "react";
import { useId, useState } from "react";
import type { MemoryAssessment } from "@/lib/capacity";
export type Validity = "valid" | "invalid" | "loading";
export type Fit = "fits" | "tight" | "exceeds" | "unsupported" | "unknown";
export type Evidence = "recorded" | "none" | "stale";
const validityLabel: Record<Validity, string> = {
valid: "Configuration valid",
@@ -12,48 +12,51 @@ const validityLabel: Record<Validity, string> = {
const fitLabel: Record<Fit, string> = {
fits: "Fits estimate",
tight: "Tight fit",
exceeds: "Likely exceeds memory",
unsupported: "Unsupported combination",
unknown: "Fit unknown",
exceeds: "Exceeds memory",
unsupported: "Unsupported",
unknown: "Memory not estimated",
};
const evidenceLabel: Record<Evidence, string> = {
recorded: "Run evidence available",
none: "No recorded run",
stale: "Run evidence does not match these settings",
const weightsLabel: Record<Fit, string> = {
fits: "Weights fit",
tight: "Weights fit, little headroom",
exceeds: "Weights exceed memory",
unsupported: "Weights unsupported",
unknown: "Weights not estimated",
};
/**
* Three separate facts: the recipe schema accepts the values, a memory estimate
* covers the configuration, and a recorded run matches it. No positive claim is
* made until the shared API supplies the corresponding fact.
* Two facts, each with its own evidence: the recipe schema accepts the values,
* and the analytical estimate covers the selected device. Neither is a promise
* that the recipe runs.
*/
export default function RecipeFacts({
validity,
fit = "unknown",
evidence = "none",
memory,
subject = "configuration",
detail,
validityText,
validityDetail,
detail,
}: {
validity: Validity;
fit?: Fit;
evidence?: Evidence;
memory?: MemoryAssessment;
subject?: "configuration" | "example";
detail?: string;
validityText?: string;
validityDetail?: string;
detail?: string;
}) {
const [open, setOpen] = useState(false);
const id = `recipe-facts-${validity}-${fit}-${evidence}`;
const id = useId();
const fit: Fit = memory?.status ?? "unknown";
const estimated = fit === "fits" || fit === "tight" || fit === "exceeds";
const weightsBasis = memory?.basis === "weights";
const label = weightsBasis ? weightsLabel[fit] : fitLabel[fit];
return (
<div className="recipe-facts">
<div className="recipe-fact-list" aria-label="Recipe facts">
<div className="recipe-fact-list" aria-label="Recipe checks">
<span data-state={validity}>
{validity === "valid" ? (validityText ?? `Valid ${subject}`) : validityLabel[validity]}
</span>
<span data-state={fit}>{fitLabel[fit]}</span>
<span data-state={evidence}>{evidenceLabel[evidence]}</span>
<span data-state={fit}>{label}</span>
</div>
<button
type="button"
@@ -70,24 +73,19 @@ export default function RecipeFacts({
<strong>{validityLabel[validity]}.</strong>{" "}
{validityDetail ??
(validity === "valid"
? "Selected values pass the recipe schema and runtime topology checks."
? "These values pass the recipe schema and runtime topology checks."
: validity === "invalid"
? "Output is withheld until the highlighted fields are corrected."
? "Output is hidden until the highlighted fields are fixed."
: "Values are still being checked.")}
</p>
<p>
<strong>{fitLabel[fit]}.</strong>{" "}
{fit === "unknown"
? "No memory estimate covers this deployment, device, and request yet. This is not a fit claim."
: "The shared capacity API supplied this estimate; it is not a measured guarantee."}
</p>
<p>
<strong>{evidenceLabel[evidence]}.</strong>{" "}
{evidence === "none"
? "Successful runs on other checkpoints, settings, software revisions, or GPU layouts do not establish this configuration."
: evidence === "stale"
? "A recorded run exists, but edited settings fall outside what it covers."
: "A recorded run matches the deployment and settings shown."}
<strong>{label}.</strong>{" "}
{weightsBasis
? `${memory?.reason ?? ""} Resident weights need ${memory?.requiredGiB?.toFixed(1)} GiB of ${memory?.capacityGiB?.toFixed(1)} GiB available.`
: estimated
? `The analytical estimate needs ${memory?.requiredGiB?.toFixed(1)} GiB of ${memory?.capacityGiB?.toFixed(1)} GiB available. It models weights, encoders, and the configured workload. It is not a measured guarantee.`
: (memory?.reason ??
"FastVideo has no capacity model for this checkpoint, so it does not estimate memory use here.")}
</p>
{detail && <p>{detail}</p>}
</div>
+6 -1
View File
@@ -4,12 +4,17 @@ description: Recipes for video, image, audio, and world generation.
mode: wide
---
Pick a model, choose your hardware, and copy the command. Every recipe starts from checked-in YAML, so the settings you see are the settings that run.
Before you pick a device, check [weights and quantization](weights/) for measured
checkpoint sizes and the FastH3 precision variants.
<a id="inference-cookbook"></a>
<a id="planned-families-heading"></a>
<a id="video-models-heading"></a>
<a id="image-models-heading"></a>
<a id="audio-models-heading"></a>
<a id="world-models-heading"></a>
<a id="planned-families-heading"></a>
<Cookbook />
@@ -1,7 +1,14 @@
---
title: FastH3 OmniRef
description: Configure FastH3 OmniRef reference-to-video serving on an RTX 5090, RTX PRO 6000, or DGX Spark.
title: FastH3 Omni v1
description: Configure FastH3 Omni v1 reference-to-video serving and copy inference or serving commands.
mode: frame
---
<Cookbook modelId="fasth3-omniref" />
## Apple silicon
The MLX path composes the BF16 Omni release with the NVFP4 text encoder, converts
the DiT to INT6, and runs the same image and audio references on Metal. Follow
[FastH3 Omni on MLX](/inference/fasth3_omniref_mlx).
For copyable local commands, open the [MLX recipe](/cookbook/minimax-h3/models/fasth3-omniref-mlx).
@@ -0,0 +1,7 @@
---
title: Open-H3-Regenerate-2K
description: Configure Open-H3-Regenerate-2K serving, which regenerates a 768P MiniMax-H3 draft at 2560x1440.
mode: frame
---
<Cookbook modelId="open-h3-regenerate-2k" />
-7
View File
@@ -1,7 +0,0 @@
---
title: PAN 2
description: Configure text-to-video and first-frame image-to-video generation with PAN 2.
mode: frame
---
<Cookbook modelId="pan2" />
+112
View File
@@ -0,0 +1,112 @@
---
title: Weights and quantization
description: Measured checkpoint sizes for every cookbook model, plus the FastH3 quantized variants and which one fits a given GPU.
mode: wide
---
Every number here is the size of the safetensors files in the model repository,
measured on 2026-10-27 with the Hugging Face tree API. Treat it as the weight
floor. A run also needs activations, VSA workspace, and decode buffers, so
weights that fit do not guarantee a run that fits.
The cookbook editor starts from each recipe's maintained deployment. Changing
a quantization control does not select one of the published exports below.
## Catalog weights
Sizes are GiB at the checkpoint's runtime precision. Repositories that store
F32 weights are converted to BF16 here, which is what the loader uses.
| Model | Weights | Transformer | Text encoder | VAE |
| ----------------------- | ------- | ----------- | ------------ | ---- |
| FastH3 8-Step V2 | 137.7 | 65.3 | 62.1 | 10.3 |
| FastH3 4-Step V1 | 137.7 | 65.3 | 62.1 | 10.3 |
| FastH3 Trim | 56.8 | 34.9 | 15.3 | 6.5 |
| FastH3 Omni v1 | 92.5 | 65.3 | 15.3 | 11.9 |
| Pika H3 Flex | 136.7 | 64.3 | 62.1 | 10.3 |
| FastWan2.1 1.3B | 13.6 | 2.8 | 10.6 | 0.3 |
| Wan2.1 T2V 14B | 37.4 | 26.6 | 10.6 | 0.3 |
| Wan2.1 I2V 14B | 41.9 | 30.5 | 10.6 | 0.3 |
| Wan2.2 I2V A14B | 58.8 | 53.2 | 5.3 | 0.3 |
| Hunyuan 1.5 I2V 480p | 32.2 | 15.5 | 13.6 | 2.3 |
| Hunyuan 1.5 T2V 480p | 24.9 | 15.5 | 7.0 | 2.4 |
| Kandinsky 5 Lite | 11.1 | 2.2 | 8.5 | 0.5 |
| Kandinsky 6 Pro | 41.9 | 32.5 | 8.0 | 1.4 |
| Kandinsky 6 Pro distill | 37.5 | 28.1 | 8.0 | 1.4 |
| LTX2 Distilled | 66.9 | 35.2 | 25.4 | 3.6 |
Pika H3 Flex's transformer is the 61.7 GiB H3 trunk plus its 2.6 GiB task adapter.
Ref2VA swaps in `transformer_ref` and the `ref2va` adapter, which are the same size.
The FastVideo organization publishes the same FastH3 model at several
precisions. The transformer shrinks with precision; the text encoder and VAE
only shrink when the repository ships them quantized too.
| Checkpoint | Variant | Weights | Transformer | Text encoder | VAE |
| ----------------- | -------------- | ------- | ----------- | ------------ | ---- |
| 8-Step V2 | BF16 | 137.7 | 65.3 | 62.1 | 10.3 |
| 8-Step V2 | FP8 | 69.2 | 47.4 | 15.3 | 6.5 |
| 8-Step V2 | NVFP4 | 114.5 | 49.8 | 62.1 | 2.5 |
| 8-Step V2 | NVFP4 Consumer | 58.7 | 36.9 | 15.3 | 6.5 |
| Trim | BF16 | 56.8 | 34.9 | 15.3 | 6.5 |
| Trim | FP8 | 41.7 | 19.9 | 15.3 | 6.5 |
| Trim | NVFP4 | 32.9 | 11.1 | 15.3 | 6.5 |
| Trim | MLX INT6 | 33.0 | 14.3 | 14.2 | 4.5 |
| 4-Step V1 | BF16 | 137.7 | 65.3 | 62.1 | 10.3 |
| 4-Step V1 | NVFP4 | 114.5 | 49.8 | 62.1 | 2.5 |
| Omni v1 | BF16 DiT | 65.3 | 65.3 | n/a | n/a |
| Omni v1 | FP8 | 67.2 | 47.4 | 15.3 | 4.5 |
| Omni v1 | NVFP4 QAD | 63.3 | 37.0 | 15.3 | 11.0 |
| NVFP4 VAE decoder | quality | 5.8 | n/a | n/a | 5.8 |
| NVFP4 VAE decoder | fast | 1.3 | n/a | n/a | 1.3 |
The NVFP4 checkpoint keeps the BF16 text encoder, which is why it is larger
than FP8. The Consumer variant pairs the smaller NVFP4 transformer with the
FP8 text encoder and VAE, which brings the listed weights to 58.7 GiB. These
export sizes do not establish device fit. Loading, offload, quantization
profiles, and temporary buffers affect runtime residency. The decoder-only
rows exclude the VAE encoder and can contain master weights that the loader
packs before inference.
## Pick by GPU
| GPU family | Examples | Use | Why |
| ---------------- | ----------------------------------- | ----------------------------------------------- | ----------------------------------------------------------------- |
| Blackwell | RTX 5090, RTX PRO 6000, B200, GB200 | NVFP4 or FP8 with a matching maintained profile | Native FP4 tensor cores cut the transformer to about half of BF16 |
| Hopper | H100, H200 | FP8 | FP8 tensor cores; the NVFP4 path requires Blackwell |
| Ada | RTX 4090, L40S | FP8 | FP8 works, NVFP4 does not |
| Ampere and older | A100, RTX 3090 | BF16 | No FP8 or FP4 hardware |
| Apple silicon | M-series | MLX INT6 or INT8 | The MLX conversions are INT6 and INT8, not FP4 |
| DGX Spark | GB10 | NVFP4 with sequential load | Unified memory, so the host RAM counts toward the budget |
## What the cookbook does now
For configurations without transformer quantization or a decoder swap, FastH3
pages compare checkpoint weights with the selected device. This is a weights
check, not a request peak. Host-only RAM cannot establish GPU fit. Quantized transformers, TAEH3, and NVFP4 decoder swaps stay
unestimated until metadata covers the effective layer profile, retained
components, and packed runtime storage. The editor explains the missing
coverage instead of substituting an unrelated export or decoder file size.
The transformer quantization control writes
`generator.engine.quantization.transformer_quant`, so the copied YAML names the
precision. Encoder and decoder choices use component paths and stay visible in
the YAML too.
## Automatic selection
The editor does not pick a precision from the device yet. When it does, the
rule is:
1. Read the device architecture and memory from the hardware catalog.
2. Prefer the smallest variant the architecture accelerates: NVFP4 on
Blackwell, FP8 on Hopper and Ada, INT6 on Apple, BF16 everywhere else.
3. Keep the BF16 text encoder when the quantized checkpoint ships one, and
switch the decoder to NVFP4 only on Blackwell.
4. Expose the choice as a deployment setting, not a hidden default, so the
copied command still shows which checkpoint runs.
That needs one deployment per variant in
[`docs/cookbook/recipes`](https://github.com/hao-ai-lab/FastVideo/tree/main/docs/cookbook/recipes)
and a match rule in the editor. Until then, select the precision by hand and
read the weights fact.
+129 -73
View File
@@ -2,7 +2,7 @@ import { useEffect, useId, useRef, useState, type ReactNode } from "react";
import { MotionConfig } from "motion/react";
import { ArrowUpRight } from "lucide-react";
import { Tabs } from "radix-ui";
import { modelDirectory, recipePages, sitePath, type DirectoryModel } from "@/lib/cookbook-pages";
import { modelDirectory, recipePages, recentlyAdded, sitePath, type DirectoryModel } from "@/lib/cookbook-pages";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
@@ -19,6 +19,7 @@ import {
CardCurtainRevealTitle,
} from "@/components/ui/card-curtain-reveal";
import RecipeFacts from "@/components/cookbook/RecipeFacts";
import { assessRecipe, assessWeightsRecipe, useCapacityModel, weightsFor } from "@/lib/capacity";
import { configAndLaunch, inferenceOutput, sdkCommand } from "@/lib/cookbook-commands";
import {
gpuCountFrom,
@@ -130,7 +131,7 @@ function FieldControl({
</div>
{choices ? (
<div role="group" aria-label={label} className="option-chips">
{value === undefined && (
{value === undefined && !choices.some((choice) => choice === null) && (
<button type="button" aria-pressed={!changed} onClick={() => update(field.path, undefined, field)}>
Default
</button>
@@ -142,18 +143,9 @@ function FieldControl({
aria-pressed={value === choice}
onClick={() => update(field.path, JSON.stringify(choice), field)}
>
{choiceLabel(field.path, choice)}
{choice === null ? "Default" : choiceLabel(field.path, choice)}
</button>
))}
{nullable && !choices.includes(null) && (
<button
type="button"
aria-pressed={value === null}
onClick={() => update(field.path, "null", { ...field, schema: { ...field.schema, enum: [null] } })}
>
null
</button>
)}
</div>
) : kind.includes("object") || kind.includes("array") ? (
<Textarea
@@ -313,6 +305,41 @@ function Editor({
const hardwareRecord = resolveHardware(hardware, hardwareCatalog.records);
const gpuCount = gpuCountFrom(result?.config);
const model = modelDirectory.find((model) => model.id === catalog.id.split("/")[0])!;
const capacityModel = useCapacityModel(model.id);
const weights = weightsFor(model.id);
const memory =
result && !failure
? hardwareRecord
? capacityModel.model
? assessRecipe({
model: capacityModel.model,
example: { workload: catalog.workload, env: catalog.env },
hardware: hardwareRecord,
config: result.config,
budgetGb: hardwareRecord.usable_budget_gb,
hostGb: hardwareRecord.host_ram_gb,
})
: catalog.runtime.backend === "mlx" && capacityModel.status !== "loading"
? {
status: "unknown" as const,
reason: "MLX checkpoints use a different precision and load pattern; memory is not estimated.",
}
: capacityModel.status !== "loading" && weights
? assessWeightsRecipe({
weights,
config: result.config,
hardware: hardwareRecord,
budgetGb: hardwareRecord.usable_budget_gb,
})
: {
status: "unknown" as const,
reason:
capacityModel.status === "loading"
? "Loading the memory estimate."
: "No capacity model ships for this checkpoint.",
}
: { status: "unknown" as const, reason: "Select a device to estimate memory for this recipe." }
: undefined;
const renderField = (field: Field) => (
<FieldControl
key={`${reset}-${field.path}`}
@@ -341,12 +368,16 @@ function Editor({
window.addEventListener("popstate", restore);
return () => window.removeEventListener("popstate", restore);
}, []);
const modes = mlx || catalog.runtime.interface === "websocket" ? ["serving"] : ["sdk", "inference", "serving"];
const videoInput = catalog.workload === "v2v";
const modes =
mlx || catalog.runtime.interface === "websocket" || videoInput ? ["serving"] : ["sdk", "inference", "serving"];
const modeNote = mlx
? "Apple Silicon runs through the native MLX server in this recipe. There is no CUDA CLI or Python SDK launch for this deployment."
: catalog.runtime.interface === "websocket"
? "This deployment uses the native WebSocket server. Only serving is recorded for it."
: "";
: videoInput
? "This deployment needs a draft video. Send it to the server as video_reference; generated CLI and Python SDK configs cannot carry it yet."
: "";
const advancedGroups = groups.filter(
(group) => group === "server" || group.includes("parallelism") || group.startsWith("streaming"),
);
@@ -560,7 +591,7 @@ function Editor({
</a>
</div>
</div>
<RecipeFacts validity={failure ? "invalid" : result ? "valid" : "loading"} fit="unknown" evidence="none" />
<RecipeFacts validity={failure ? "invalid" : result ? "valid" : "loading"} memory={memory} />
</MatrixRow>
{catalog.workload === "i2v" && runMode !== "serving" && (
<MatrixRow title="Inputs">
@@ -581,7 +612,7 @@ function Editor({
}}
/>
<p id={`${sourceImageId}-hint`} className="matrix-note">
Path to an image on the machine running FastVideo. Included as request.inputs.image_path.
Local path to the first frame. FastVideo reads it on the machine that runs the command.
</p>
</div>
</MatrixRow>
@@ -671,6 +702,11 @@ function MatrixRow({ title, children }: { title: string; children: ReactNode })
);
}
function deploymentFromUrl() {
const requested = new URL(window.location.href).searchParams.get("deployment");
return requested === "fasth3-omniref/cuda-rest" ? "fasth3-omniref/rtx5090-rest" : requested;
}
export default function Cookbook({ modelId }: { modelId?: string }) {
const [mounted, setMounted] = useState(false);
useEffect(() => setMounted(true), []);
@@ -693,7 +729,7 @@ export default function Cookbook({ modelId }: { modelId?: string }) {
throw new Error("No cookbook deployments are available. Check the catalog and retry.");
}
setIndex(data);
const requested = new URL(window.location.href).searchParams.get("deployment");
const requested = deploymentFromUrl();
const requestedModel = data.models.find((model) =>
model.deployments.some((deployment) => deployment.id === requested),
);
@@ -739,7 +775,7 @@ export default function Cookbook({ modelId }: { modelId?: string }) {
}
useEffect(() => {
const restore = () => {
const requested = new URL(window.location.href).searchParams.get("deployment");
const requested = deploymentFromUrl();
const available = index?.models.find((item) => item.id === modelId)?.deployments ?? [];
const next = available.find((deployment) => deployment.id === requested)?.id ?? available[0]?.id;
if (!next) return;
@@ -806,11 +842,8 @@ export default function Cookbook({ modelId }: { modelId?: string }) {
}
const GPU_COUNT_PATH = "generator.engine.num_gpus";
const SINGLE_GPU_FORM_FACTOR = /desktop|laptop|workstation|system|soc/;
/** Desktop and workstation cards, DGX Spark, and Macs are one-GPU machines; datacenter parts keep the recipe default. */
function singleGpuDevice(record: { form_factor?: string }) {
return SINGLE_GPU_FORM_FACTOR.test(record.form_factor ?? "");
function singleGpuDevice(record: { form_factor?: string; kind?: string }) {
return record.kind === "system" || record.form_factor === "soc" || record.form_factor === "laptop";
}
/** Returns new edits where every fixed parallel size that no longer divides `gpus` becomes automatic. */
@@ -833,13 +866,19 @@ function deploymentLabel(deployment: DeploymentChoice, deployments: DeploymentCh
}
/** Mirrors the editor: MLX and WebSocket deployments offer serving only. */
function modelModes(index: Index | undefined, model: DirectoryModel): string[] | undefined {
const modes = model.configurable ? configurableModes(index, model.id) : exampleModes(model);
const hasMlx = model.recipes.some((recipe) => recipe.gpu_types.includes("Apple Silicon"));
if (hasMlx && modes && !modes.some((mode) => /mlx/i.test(mode))) return [...modes, "MLX"];
return modes;
}
function configurableModes(index: Index | undefined, modelId: string): string[] | undefined {
const deployments = index?.models.find((model) => model.id === modelId)?.deployments;
if (!deployments?.length) return undefined;
const servingOnly = (runtime: string) => runtime.includes("mlx") || runtime.includes("streaming");
return deployments.some((deployment) => !servingOnly(deployment.runtime))
? ["Python SDK", "CLI", "Serving"]
: ["Serving"];
const servingOnly = (deployment: { runtime: string; workload: string }) =>
deployment.runtime.includes("mlx") || deployment.runtime.includes("streaming") || deployment.workload === "v2v";
return deployments.some((deployment) => !servingOnly(deployment)) ? ["Python SDK", "CLI", "Serving"] : ["Serving"];
}
/** Mirrors the source recipe page: Python SDK and CLI always, serving when a recipe declares it. */
@@ -847,8 +886,20 @@ function exampleModes(model: DirectoryModel): string[] {
return ["Python SDK", "CLI", ...(model.recipes.some((recipe) => recipe.serving) ? ["Serving"] : [])];
}
const FASTH3_SECTION = new Set(["fasth3-omniref", "fasth3-8step", "fasth3-4step", "fasth3-trim"]);
/** Rest state shows identity only; hover or keyboard focus opens a curtain over the details. */
function RecipeCard({ model, href, modes }: { model: DirectoryModel; href: string; modes?: string[] }) {
function RecipeCard({
model,
href,
modes,
badge,
}: {
model: DirectoryModel;
href: string;
modes?: string[];
badge?: string;
}) {
const [focused, setFocused] = useState(false);
const details = useRef<HTMLDivElement>(null);
const [restOffset, setRestOffset] = useState(72);
@@ -869,6 +920,7 @@ function RecipeCard({ model, href, modes }: { model: DirectoryModel; href: strin
<img src={sitePath(`/assets/logos/${model.logo}.webp`)} alt="" width="40" height="40" />
<span className="recipe-card-kicker">{model.provider}</span>
{model.featured ? <span className="recipe-featured">Featured</span> : null}
{badge ? <span className="recipe-featured recipe-new">{badge}</span> : null}
</div>
<CardCurtainRevealTitle
as={model.configurable ? "h3" : "h4"}
@@ -932,6 +984,7 @@ function RecipeDirectory({ index, error, retry }: { index?: Index; error: string
};
const visible = modelDirectory.filter(
(model) =>
!model.hidden &&
(category === "All models" || model.category === category) &&
(family === "All families" || model.family === family) &&
(task === "All tasks" || model.task === task) &&
@@ -947,9 +1000,10 @@ function RecipeDirectory({ index, error, retry }: { index?: Index; error: string
setRuntime("all");
setTask("All tasks");
}
const configurable = visible.filter((model) => model.configurable);
const examples = visible.filter((model) => !model.configurable);
const exampleFamilies = [...new Set(examples.map((model) => model.family))];
const fastH3 = visible.filter((model) => FASTH3_SECTION.has(model.id));
const recent = visible.filter((model) => !FASTH3_SECTION.has(model.id) && recentlyAdded(model.model));
const rest = visible.filter((model) => !FASTH3_SECTION.has(model.id) && !recentlyAdded(model.model));
const families = [...new Set(rest.map((model) => model.family))];
const filtered =
!!query.trim() ||
category !== "All models" ||
@@ -957,7 +1011,7 @@ function RecipeDirectory({ index, error, retry }: { index?: Index; error: string
runtime !== "all" ||
task !== "All tasks";
const categoryCount = (value: string) =>
modelDirectory.filter((model) => value === "All models" || model.category === value).length;
modelDirectory.filter((model) => !model.hidden && (value === "All models" || model.category === value)).length;
return (
<MotionConfig reducedMotion="user">
<div className="cookbook not-prose recipe-directory">
@@ -991,7 +1045,6 @@ function RecipeDirectory({ index, error, retry }: { index?: Index; error: string
<div className="recipe-directory-subbar">
<p role="status">
{visible.length} {visible.length === 1 ? "model" : "models"}
{configurable.length ? ` · ${configurable.length} configurable` : ""}
{runtime !== "all" ? ` · ${runtime === "mlx" ? "MLX" : "CUDA"} only` : ""}
{task !== "All tasks" ? ` · ${task}` : ""}
{filtered && visible.length ? (
@@ -1043,59 +1096,62 @@ function RecipeDirectory({ index, error, retry }: { index?: Index; error: string
</Button>
</div>
) : null}
{configurable.length ? (
<section className="recipe-section" aria-labelledby="recipe-configurable-heading">
{fastH3.length ? (
<section className="recipe-section" aria-labelledby="recipe-fasth3-heading">
<header className="recipe-section-head">
<h2 id="recipe-configurable-heading">Configurable recipes</h2>
<p>Edit settings, then copy one config for the Python SDK, the CLI, or an OpenAI-compatible server.</p>
<h2 id="recipe-fasth3-heading">FastVideo FastH3</h2>
</header>
<div className="recipe-card-grid recipe-card-grid-configurable">
{configurable.map((model) => (
{fastH3.map((model) => (
<RecipeCard key={model.id} model={model} href={sitePath(model.path)} modes={modelModes(index, model)} />
))}
</div>
</section>
) : null}
{recent.length ? (
<section className="recipe-section" aria-labelledby="recipe-new-heading">
<header className="recipe-section-head">
<h2 id="recipe-new-heading">New</h2>
</header>
<div className="recipe-card-grid recipe-card-grid-configurable">
{recent.map((model) => (
<RecipeCard
key={model.id}
model={model}
href={sitePath(model.path)}
modes={configurableModes(index, model.id)}
modes={modelModes(index, model)}
badge="New"
/>
))}
</div>
</section>
) : null}
{examples.length ? (
<section className="recipe-section" aria-labelledby="recipe-examples-heading">
<header className="recipe-section-head">
<h2 id="recipe-examples-heading">Example recipes</h2>
<p>
Maintained example scripts, grouped by model family. Adjust their documented settings and run them as
written.
</p>
</header>
{exampleFamilies.map((name) => {
const members = examples.filter((model) => model.family === name);
return (
<div className="recipe-family-group" key={name}>
<div className="recipe-family-head">
<h3>{name}</h3>
<span>
{members.length} {members.length === 1 ? "recipe" : "recipes"}
</span>
<a href={sitePath(members[0].familyPath)}>Family guide →</a>
</div>
<div className="recipe-card-grid">
{members.map((model) => (
<RecipeCard
key={model.id}
model={model}
href={sitePath(model.path)}
modes={exampleModes(model)}
/>
))}
</div>
{families.map((name) => {
const members = rest.filter((model) => model.family === name);
return (
<section className="recipe-section" key={name} aria-label={name}>
<div className="recipe-family-group">
<div className="recipe-family-head">
<h3>{name}</h3>
<span>
{members.length} {members.length === 1 ? "recipe" : "recipes"}
</span>
<a href={sitePath(members[0].familyPath)}>Family guide →</a>
</div>
);
})}
</section>
) : null}
<div className="recipe-card-grid">
{members.map((model) => (
<RecipeCard
key={model.id}
model={model}
href={sitePath(model.path)}
modes={modelModes(index, model)}
/>
))}
</div>
</div>
</section>
);
})}
{!visible.length ? (
<div className="recipe-empty">
<h2>No matching recipes</h2>
+3 -3
View File
@@ -14,7 +14,7 @@ export default function FastModels() {
models: group.models.filter(
(model) =>
(family === "All models" || family === group.name) &&
`${group.name} ${group.summary} ${model.id}`.toLowerCase().includes(query.trim().toLowerCase()),
`${group.name} ${model.id}`.toLowerCase().includes(query.trim().toLowerCase()),
),
}))
.filter((group) => group.models.length);
@@ -48,7 +48,7 @@ export default function FastModels() {
<header className="model-catalog-heading">
<div>
<h2 id="checkpoint-catalog">Browse checkpoints</h2>
<p>Find the export your runtime needs.</p>
<p>Pick the checkpoint that matches your runtime.</p>
</div>
<a href="https://huggingface.co/collections/FastVideo/fastvideo-fasth3">
Earlier FastH3 releases <span aria-hidden="true">↗</span>
@@ -67,7 +67,7 @@ export default function FastModels() {
<input
id={id}
type="search"
placeholder="Name, precision, or runtime…"
placeholder="Name, family, or precision…"
value={query}
onChange={(event) => setQuery(event.target.value)}
/>
+5 -4
View File
@@ -4,17 +4,18 @@ import { modelDirectory, sitePath } from "@/lib/cookbook-pages";
export default function RecipeFamily({ family }: { family: string }) {
const models = modelDirectory.filter(
(model) =>
model.recipes.some((recipe) => recipe.family === family) ||
(family === "minimax_h3" && model.family === "FastH3"),
!model.hidden &&
(model.recipes.some((recipe) => recipe.family === family) ||
(family === "minimax_h3" && (model.family === "FastH3" || model.familyPath === "/cookbook/minimax-h3"))),
);
return (
<div className="recipe-directory not-prose">
<a className="recipe-back" href={sitePath("/cookbook")}>
← All models
← All recipes
</a>
<div className="recipe-card-grid">
{models.map((model) => (
<a className="recipe-card" key={model.id} href={sitePath(model.path)}>
<a className="recipe-card recipe-card-static" key={model.id} href={sitePath(model.path)}>
<div className="recipe-card-top">
<img src={sitePath(`/assets/logos/${model.logo}.webp`)} alt="" width="40" height="40" />
<span className="recipe-card-kicker">{model.provider}</span>
+1 -3
View File
@@ -484,9 +484,7 @@ export default function SourceRecipe({ model }: { model: DirectoryModel }) {
<RecipeFacts
validity={failure ? "invalid" : example ? "valid" : "loading"}
validityText={yamlSource ? "YAML parses" : "Example settings applied"}
validityDetail="This page checks that the YAML parses and that edited numeric fields are finite and supported by the example. It does not run the full recipe schema or runtime topology validation."
fit="unknown"
evidence="none"
validityDetail="Checks that the YAML parses and that edited numbers are valid. It does not run the recipe."
subject={yamlSource ? "configuration" : "example"}
/>
</div>
+226
View File
@@ -0,0 +1,226 @@
import { useEffect, useState } from "react";
// @ts-expect-error The shared ES module has no TypeScript declarations.
import { assessCapacity } from "../../docs/js/cookbook-memory.mjs";
import weightsData from "../../docs/assets/cookbook-weights.json";
export type MemoryStatus = "fits" | "tight" | "exceeds" | "unsupported" | "unknown";
/** The subset of an `assessCapacity` result the UI shows. */
export interface MemoryAssessment {
status: MemoryStatus;
reason?: string;
requiredGiB?: number;
capacityGiB?: number;
basis?: string;
parts?: Record<string, number>;
}
export interface WeightsModel {
parts: Record<string, number>;
alternates?: Record<string, number>;
sources?: string[];
}
const WEIGHTS = weightsData as { models: Record<string, WeightsModel> };
/** Measured checkpoint sizes for recipes that have no full capacity model yet. */
export function weightsFor(modelId: string | undefined): WeightsModel | undefined {
return modelId ? WEIGHTS.models[modelId] : undefined;
}
/**
* Compare resident checkpoint weights with one device. This answers whether the
* weights load, not whether a request peaks below the device limit: activations,
* VSA workspace, and decode buffers are not modeled.
*/
export function assessWeightsRecipe({
weights,
config,
hardware,
budgetGb,
}: {
weights: WeightsModel;
config: Record<string, unknown>;
hardware: { memory_gb?: number; usable_budget_gb?: number; memory_unit?: string; memory_type?: string };
budgetGb?: number;
}): MemoryAssessment | undefined {
if (hardware.memory_type !== "dedicated" && hardware.memory_type !== "unified") {
return {
status: "unknown",
reason:
"A weights fit check requires dedicated GPU memory or unified memory. Host-only system RAM is not GPU capacity.",
};
}
const generator = (config.generator ?? {}) as {
engine?: { offload?: Record<string, boolean> };
pipeline?: {
components?: Record<string, string | null>;
experimental?: Record<string, unknown>;
};
};
const parts = { ...weights.parts };
const components = generator.pipeline?.components ?? {};
const experimental = generator.pipeline?.experimental ?? {};
if (experimental.video_decode_backend === "taeh3") {
return {
status: "unknown",
reason: "TAEH3 decoder weights are not modeled. This backend does not load the bundled H3 video VAE.",
};
}
if (experimental.h3_vae_nvfp4_decoder) {
return {
status: "unknown",
reason:
"NVFP4 decoder residency is not modeled: the VAE retains its encoder and packs decoder weights after loading. Decoder file size is not the resident VAE size.",
};
}
if (components.text_encoder_weights && weights.alternates?.text_encoder_quantized !== undefined) {
parts.text_encoder = weights.alternates.text_encoder_quantized;
}
const quant = (config.generator as { engine?: { quantization?: { transformer_quant?: string | null } } })?.engine
?.quantization?.transformer_quant;
if (quant) {
return {
status: "unknown",
reason:
"Transformer quantization residency is not modeled for this checkpoint and layer profile. Published export sizes do not describe on-the-fly quantization of this configuration.",
};
}
const offload = generator.engine?.offload ?? {};
if (offload.text_encoder) delete parts.text_encoder;
const resident = Object.values(parts).reduce((sum, value) => sum + value, 0);
const capacityGb = budgetGb ?? hardware.usable_budget_gb ?? hardware.memory_gb;
if (!capacityGb) return undefined;
const capacityGiB = hardware.memory_unit === "GiB" ? capacityGb : (capacityGb * 1e9) / GIB;
const label: Record<string, string> = {
transformer: "transformer",
text_encoder: "text encoder",
vae: "VAE",
audio_vae: "audio VAE",
};
const split = Object.entries(parts)
.map(([role, value]) => `${label[role] ?? role} ${value.toFixed(1)} GiB`)
.join(", ");
const status: MemoryStatus = resident > capacityGiB ? "exceeds" : resident > capacityGiB * 0.85 ? "tight" : "fits";
const offloaded = offload.text_encoder ? " The text encoder is offloaded." : "";
return {
status,
basis: "weights",
requiredGiB: resident,
capacityGiB,
parts,
reason: `Weights only: ${split}.${offloaded} Activations, VSA workspace, and decode buffers are not modeled.`,
};
}
interface CapacityModel {
id: string;
name?: string;
examples?: { deployment_id?: string }[];
reference_runs?: unknown[];
[key: string]: unknown;
}
interface CapacityState {
status: "loading" | "ready" | "unavailable";
model?: CapacityModel;
}
const CAPACITY_INDEX = `${import.meta.env.BASE_URL.replace(/\/$/, "")}/assets/cookbook-config/capacity/index.json`;
const GIB = 1024 ** 3;
let indexPromise: Promise<{ models: { id: string; catalog_url: string }[] }> | null = null;
function loadIndex() {
indexPromise ??= fetch(CAPACITY_INDEX)
.then((response) => {
if (!response.ok) throw new Error(`Capacity index: HTTP ${response.status}`);
return response.json() as Promise<{ models: { id: string; catalog_url: string }[] }>;
})
.catch((error) => {
indexPromise = null;
throw error;
});
return indexPromise;
}
/**
* Load the capacity model for one cookbook entry. Most recipes do not have one;
* the hook stays `unavailable` and the UI says so instead of guessing.
*/
export function useCapacityModel(modelId: string | undefined): CapacityState {
const [state, setState] = useState<CapacityState>({ status: "loading" });
useEffect(() => {
if (!modelId) {
setState({ status: "unavailable" });
return;
}
let live = true;
setState({ status: "loading" });
loadIndex()
.then(async (index) => {
const entry = index.models.find((model) => model.id === modelId);
if (!entry) throw new Error("No capacity model ships for this checkpoint.");
const url = new URL(entry.catalog_url, new URL(CAPACITY_INDEX, window.location.origin));
const response = await fetch(url);
if (!response.ok) throw new Error(`Capacity model: HTTP ${response.status}`);
const model = (await response.json()) as CapacityModel;
if (live) setState({ status: "ready", model });
})
.catch(() => {
if (live) setState({ status: "unavailable" });
});
return () => {
live = false;
};
}, [modelId]);
return state;
}
/**
* Run the analytical estimate for the edited configuration. Returns `undefined`
* when no capacity model covers the checkpoint, so the caller can say so.
*/
export function assessRecipe({
model,
example,
hardware,
config,
budgetGb,
hostGb,
}: {
model: CapacityModel;
example: { workload?: string; env?: Record<string, string> };
hardware?: {
memory_gb?: number;
memory_unit?: string;
memory_type?: string;
usable_budget_gb?: number;
host_ram_gb?: number;
id?: string;
};
config?: Record<string, unknown>;
budgetGb?: number;
hostGb?: number;
}): MemoryAssessment | undefined {
if (!config || !hardware?.memory_gb) return undefined;
try {
const result = assessCapacity({
model,
example,
hardware,
config,
memoryBudgetGiB: budgetGb ? (hardware.memory_unit === "GiB" ? budgetGb : (budgetGb * 1e9) / GIB) : undefined,
hostMemoryGb: hostGb,
}) as MemoryAssessment;
return {
status: (result.status ?? "unknown") as MemoryStatus,
reason: result.reason,
requiredGiB: result.requiredGiB,
capacityGiB: result.capacityGiB,
basis: result.basis,
};
} catch {
return undefined;
}
}
+98 -76
View File
@@ -2,6 +2,15 @@ import sourceCatalog from "../../docs/assets/cookbook-recipes.json" with { type:
/** Presentation metadata only. Deployments and editable fields come from the cookbook backend. */
export const recipePages = [
{
id: "fasth3-preview",
family: "MiniMax H3",
title: "FastH3 Preview V0.2",
mark: "H3",
path: "/cookbook/minimax-h3/fasth3-preview",
description: "The few-step FastH3 preview checkpoint on CUDA or native Apple Silicon MLX.",
tags: ["Text to video", "Audio", "4-step"],
},
{
id: "fasth3-8step",
family: "MiniMax H3",
@@ -20,15 +29,6 @@ export const recipePages = [
description: "The smaller FastH3 model for eight-step video generation with synchronized audio.",
tags: ["Text to video", "Audio", "NVFP4", "8-step"],
},
{
id: "fasth3-preview",
family: "MiniMax H3",
title: "FastH3 Preview V0.2",
mark: "H3",
path: "/cookbook/minimax-h3/fasth3-preview",
description: "The few-step FastH3 preview checkpoint on CUDA or native Apple Silicon MLX.",
tags: ["Text to video", "Audio", "4-step"],
},
{
id: "fasth3-4step",
family: "MiniMax H3",
@@ -41,13 +41,22 @@ export const recipePages = [
{
id: "fasth3-omniref",
family: "MiniMax H3",
title: "FastH3 OmniRef",
title: "FastH3 Omni v1",
mark: "H3",
path: "/cookbook/minimax-h3/fasth3-omniref",
description: "Video and audio from a prompt plus ordered image, video, and audio references.",
tags: ["Reference to video", "Audio", "NVFP4"],
featured: true,
},
{
id: "open-h3-regenerate-2k",
family: "MiniMax H3",
title: "Open-H3-Regenerate-2K",
mark: "H3",
path: "/cookbook/minimax-h3/open-h3-regenerate-2k",
description: "Regenerate a 768P MiniMax-H3 draft at 2560x1440 with a three-step refiner.",
tags: ["Video upscale", "2K", "3-step"],
},
{
id: "fastwan21",
family: "Wan",
@@ -66,15 +75,6 @@ export const recipePages = [
description: "Turn a reference image into video with Wan2.1.",
tags: ["Image to video", "14B", "480p"],
},
{
id: "pan2",
family: "PAN 2",
title: "PAN 2",
mark: "PAN",
path: "/cookbook/pan2/pan2",
description: "Text-to-video and first-frame image-to-video from the 29B PAN 2 model.",
tags: ["Text or image to video", "29B"],
},
{
id: "ltx2-distilled",
family: "LTX",
@@ -114,12 +114,12 @@ export interface SourceRecipe {
export const sourceRecipes = sourceCatalog.recipes as SourceRecipe[];
const familyInfo: Record<string, { title: string; path: string; logo: string; category: string }> = {
minimax_h3: { title: "MiniMax H3", path: "minimax-h3", logo: "minimax", category: "Video" },
pika_h3_flex: { title: "MiniMax H3", path: "minimax-h3", logo: "pika", category: "Video" },
wan: { title: "Wan", path: "wan", logo: "wan-ai", category: "Video" },
ltx2: { title: "LTX", path: "ltx", logo: "ltx", category: "Video" },
hunyuan: { title: "Hunyuan", path: "hunyuan", logo: "tencent-hunyuan", category: "Video" },
kandinsky5: { title: "Kandinsky 5", path: "kandinsky5", logo: "kandinsky", category: "Video" },
kandinsky6: { title: "Kandinsky 6", path: "kandinsky6", logo: "kandinsky", category: "Video" },
pan2: { title: "PAN 2", path: "pan2", logo: "ifm", category: "Video" },
flux: { title: "FLUX", path: "flux", logo: "black-forest-labs", category: "Image" },
qwen_image21: { title: "Qwen-Image-2.1", path: "qwen-image21", logo: "qwen", category: "Image" },
sd35: { title: "Stable Diffusion", path: "stable-diffusion", logo: "stabilityai", category: "Image" },
@@ -134,11 +134,21 @@ const backendModels: Record<string, string> = {
"FastVideo/FastVideo-Minimax-FastH3-Preview-v0.2": "fasth3-preview",
"FastVideo/FastVideo-FastH3-4-step-Preview-v1-VSA-DataFree": "fasth3-4step",
"FastVideo/FastH3-OmniRef-NVFP4-QAD": "fasth3-omniref",
"Efficient-Large-Model/Open-H3-Regenerate-2K": "open-h3-regenerate-2k",
"FastVideo/FastWan2.1-T2V-1.3B-Diffusers": "fastwan21",
"Wan-AI/Wan2.1-I2V-14B-480P-Diffusers": "wan21-i2v",
"FastVideo/LTX2-Distilled-Diffusers": "ltx2-distilled",
"IFM/PAN2": "pan2",
};
const hiddenBackends = new Set(["fasth3-preview"]);
const modelAddedOn: Record<string, string> = {
"Qwen/Qwen-Image-2.1": "2026-10-25",
};
export function recentlyAdded(model: string) {
const added = modelAddedOn[model];
return !!added && Date.now() - Date.parse(added) < 30 * 86_400_000;
}
const TASK_GROUPS: Array<[RegExp, string]> = [
[/world/, "Text to world"],
[/video\/text to audio|video.*to audio/, "Video to audio"],
@@ -149,7 +159,7 @@ const TASK_GROUPS: Array<[RegExp, string]> = [
[/first\/last frame to video/, "Image to video"],
[/text or image to video/, "Text/image to video"],
[/image to video/, "Image to video"],
[/super-resolution/, "Video upscale"],
[/super-resolution|upscale/, "Video upscale"],
[/video/, "Text to video"],
];
/** A small discovery vocabulary derived from the recipe task, not a capability claim. */
@@ -158,58 +168,70 @@ export function taskLabel(value: string): string {
return TASK_GROUPS.find(([pattern]) => pattern.test(normalized))?.[1] ?? "Text to video";
}
export const modelDirectory = [...new Set(sourceRecipes.map((recipe) => recipe.model))]
.map((model) => {
const recipes = sourceRecipes.filter((recipe) => recipe.model === model);
const first = recipes[0];
const family = familyInfo[first.family];
const backend = recipePages.find((page) => page.id === backendModels[model]);
const ownModel = model.startsWith("FastVideo/Fast");
const logo = ownModel ? "fastvideo" : family.logo;
const providers: Record<string, string> = {
fastvideo: "FastVideo",
minimax: "MiniMax",
"wan-ai": "Wan AI",
ltx: "Lightricks",
"tencent-hunyuan": "Tencent",
kandinsky: "Kandinsky Lab",
"black-forest-labs": "Black Forest Labs",
qwen: "Qwen",
ifm: "IFM",
stabilityai: "Stability AI",
nvidia: "NVIDIA",
};
return {
id: backend?.id ?? first.id,
model,
recipes,
title:
backend?.title ??
(model.includes("FastH3-4-step")
? "FastH3 4-Step V1"
: model.includes("FastH3-Preview-v0.2")
? "FastH3 Preview V0.2"
: model === "MiniMaxAI/MiniMax-H3"
? "MiniMax H3"
: first.label.split(" on ")[0]),
family: backend?.family ?? family.title,
familyPath: `/cookbook/${family.path}`,
path: backend?.path ?? `/cookbook/${family.path}/models/${first.id}`,
logo,
provider: providers[logo],
category: family.category,
description: backend?.description ?? first.summary,
tags: backend?.tags ?? [...new Set(recipes.map((recipe) => recipe.task))],
task: taskLabel(backend?.tags[0] ?? first.task),
platforms: [...new Set(recipes.flatMap((recipe) => recipe.gpu_types))],
configurable: !!backend,
featured: backend?.featured ?? false,
};
})
.sort(
(a, b) =>
Number(b.featured) - Number(a.featured) ||
Number(b.family === "MiniMax H3") - Number(a.family === "MiniMax H3") ||
Number(b.configurable) - Number(a.configurable),
);
const mappedModels = [...new Set(sourceRecipes.map((recipe) => recipe.model))].map((model) => {
const recipes = sourceRecipes.filter((recipe) => recipe.model === model);
const first = recipes[0];
const family = familyInfo[first.family];
const backend = recipePages.find((page) => page.id === backendModels[model]);
const ownModel = model.startsWith("FastVideo/Fast");
const logo = ownModel ? "fastvideo" : family.logo;
const providers: Record<string, string> = {
fastvideo: "FastVideo",
minimax: "MiniMax",
pika: "Pika",
"wan-ai": "Wan AI",
ltx: "Lightricks",
"tencent-hunyuan": "Tencent",
kandinsky: "Kandinsky Lab",
"black-forest-labs": "Black Forest Labs",
qwen: "Qwen",
stabilityai: "Stability AI",
nvidia: "NVIDIA",
};
return {
id: backend?.id ?? first.id,
model,
recipes,
title:
backend?.title ??
(model.includes("FastH3-4-step")
? "FastH3 4-Step V1"
: model.includes("FastH3-Preview-v0.2")
? "FastH3 Preview V0.2"
: model === "MiniMaxAI/MiniMax-H3"
? "MiniMax H3"
: first.label.split(" on ")[0]),
family: backend?.family ?? family.title,
familyPath: `/cookbook/${family.path}`,
path: backend?.path ?? `/cookbook/${family.path}/models/${first.id}`,
logo,
provider: providers[logo],
category: family.category,
description: backend?.description ?? first.summary,
tags: backend?.tags ?? [...new Set(recipes.map((recipe) => recipe.task))],
task: taskLabel(backend?.tags[0] ?? first.task),
platforms: [...new Set(recipes.flatMap((recipe) => recipe.gpu_types))],
configurable: !!backend,
hidden: hiddenBackends.has(backend?.id ?? ""),
featured: backend?.featured ?? false,
};
});
const byPage = new Map<string, (typeof mappedModels)[number]>();
for (const entry of mappedModels) {
const existing = byPage.get(entry.id);
if (!existing) {
byPage.set(entry.id, entry);
continue;
}
existing.recipes.push(...entry.recipes.filter((recipe) => !existing.recipes.some((seen) => seen.id === recipe.id)));
existing.platforms = [...new Set([...existing.platforms, ...entry.platforms])];
}
export const modelDirectory = [...byPage.values()].sort(
(a, b) =>
Number(b.featured) - Number(a.featured) ||
Number(b.family === "MiniMax H3") - Number(a.family === "MiniMax H3") ||
Number(b.configurable) - Number(a.configurable),
);
export type DirectoryModel = (typeof modelDirectory)[number];
+45
View File
@@ -0,0 +1,45 @@
const NEWS_HEADINGS = ["news", "recent updates", "what's new", "latest news", "announcements"];
const PROJECT_HEADINGS = [
"awesome work using fastvideo or our research projects",
"projects using fastvideo",
"built on fastvideo",
"awesome work",
];
function normalizeHeading(line) {
return line
.replace(/^#{2,3}\s+/, "")
.replace(/[`*_]/g, "")
.replace(/[^\p{L}\p{N}\s'’-]/gu, "")
.replace(/\s+/g, " ")
.trim()
.toLowerCase();
}
function sectionBody(readme, names) {
const lines = readme.split("\n");
const start = lines.findIndex((line) => /^##\s+/.test(line) && names.includes(normalizeHeading(line)));
if (start < 0) return null;
const rest = lines.slice(start + 1);
const end = rest.findIndex((line) => /^##\s+/.test(line));
return (end < 0 ? rest : rest.slice(0, end)).join("\n").trim();
}
const BULLET = /^-\s+(?:`(\d{4})[/-](\d{2})[/-](\d{2})`:|\*\*(\d{4})[/-](\d{2})[/-](\d{2}):?\*\*:?)\s*(.+)$/;
export function readReadmeSections(readme) {
const news = sectionBody(readme, NEWS_HEADINGS);
const projects = sectionBody(readme, PROJECT_HEADINGS);
if (news === null || projects === null) return { news: null, projects: null };
const items = [];
for (const line of news.split("\n")) {
const match = BULLET.exec(line.trim());
if (!match) continue;
const [, y1, m1, d1, y2, m2, d2, text] = match;
items.push({ date: `${y1 ?? y2}/${m1 ?? m2}/${d1 ?? d2}`, text: text.trim() });
}
return { news: items, projects };
}
export const README_HEADINGS = { news: NEWS_HEADINGS, projects: PROJECT_HEADINGS };
File diff suppressed because one or more lines are too long
+11 -34
View File
@@ -127,10 +127,6 @@
],
"display": "group"
},
{
"label": "PAN 2 text/image to video",
"href": "/inference/pan2"
},
{
"label": "Examples",
"href": "/inference/examples/examples_inference_index"
@@ -145,21 +141,17 @@
"label": "All recipes",
"href": "/cookbook"
},
{
"label": "H3 server and playground",
"href": "/cookbook/openai-api"
},
{
"label": "MiniMax H3",
"display": "group",
"collapsed": false,
"collapsed": true,
"items": [
{
"label": "MiniMax H3",
"href": "/cookbook/minimax-h3/models/minimax-h3-t2v"
},
{
"label": "FastH3 OmniRef",
"label": "FastH3 Omni v1",
"href": "/cookbook/minimax-h3/fasth3-omniref"
},
{
@@ -170,14 +162,18 @@
"label": "FastH3 Trim",
"href": "/cookbook/minimax-h3/fasth3-trim"
},
{
"label": "FastH3 Preview V0.2",
"href": "/cookbook/minimax-h3/fasth3-preview"
},
{
"label": "FastH3 4-Step V1",
"href": "/cookbook/minimax-h3/fasth3-4step"
},
{
"label": "Pika H3 Flex",
"href": "/cookbook/minimax-h3/models/pika-h3-flex-t2va"
},
{
"label": "Open-H3-Regenerate-2K",
"href": "/cookbook/minimax-h3/open-h3-regenerate-2k"
},
{
"label": "Recipes and setup",
"href": "/cookbook/minimax-h3"
@@ -191,7 +187,7 @@
{
"label": "Wan",
"display": "group",
"collapsed": false,
"collapsed": true,
"items": [
{
"label": "FastWan2.1 1.3B",
@@ -253,25 +249,6 @@
}
]
},
{
"label": "PAN 2",
"display": "group",
"collapsed": true,
"items": [
{
"label": "PAN 2",
"href": "/cookbook/pan2/pan2"
},
{
"label": "Family guide",
"href": "/cookbook/pan2"
},
{
"label": "Model reference",
"href": "/inference/pan2"
}
]
},
{
"label": "FLUX",
"href": "/cookbook/flux"
+1 -1
View File
@@ -15,7 +15,7 @@
"test": "playwright test",
"format": "prettier --write .",
"format:check": "prettier --check .",
"test:content": "node --test tests/content.test.mjs tests/commands.test.mjs"
"test:content": "node --test tests/content.test.mjs tests/commands.test.mjs tests/readme-sections.test.mjs"
},
"dependencies": {
"blume": "2.2.3",
+11 -6
View File
@@ -6,6 +6,7 @@ import { cp, mkdir, readdir, readFile, rm, stat, writeFile } from "node:fs/promi
import { dirname, join, relative, resolve, sep } from "node:path";
import { fileURLToPath } from "node:url";
import { parse, stringify } from "yaml";
import { readReadmeSections, README_HEADINGS } from "../lib/readme.mjs";
const site = fileURLToPath(new URL("../", import.meta.url));
const repo = resolve(site, "..");
@@ -176,12 +177,11 @@ export async function transferDocs() {
return (await stat(path)).isDirectory() || /\.mdx?$/.test(path);
},
});
// Native callout directives and islands compile as MDX, while source links keep .md filenames.
for (const file of await files(content)) {
if (!file.endsWith(".md")) continue;
const text = await readFile(file, "utf8");
if (/^:::(?:note|tip|warning|danger|info|success)\b/m.test(text) || text.includes("<RecipeFamily ")) {
await writeFile(file.replace(/\.md$/, ".mdx"), text);
await writeFile(file.replace(/\.md$/, ".mdx"), text.replace(/\{#([^}]+)\}/g, "[#$1]"));
await rm(file);
}
}
@@ -251,10 +251,15 @@ export async function transferDocs() {
// The README owns announcements and the community list; the welcome page renders a short news digest.
const readme = await readFile(join(repo, "README.md"), "utf8");
const news = readme.match(/^## NEWS\n([\s\S]*?)(?=^##|^###)/m)?.[1];
const projects = readme.match(/^## Awesome work using FastVideo or our research projects\n([\s\S]*?)(?=^## )/m)?.[1];
if (!news || !projects) throw new Error("The README news or project section boundary needs updating.");
const recent = [...news.matchAll(/^- `([\d/]+)`: (.+)$/gm)].slice(0, 2).map(([, date, text]) => {
const { news, projects } = readReadmeSections(readme);
if (!news || !projects) {
const accepted = (names) => names.map((name) => `"## ${name.replace(/\b\w/g, (c) => c.toUpperCase())}"`).join(", ");
throw new Error(
`README section not found. Accepted news headings: ${accepted(README_HEADINGS.news)}. ` +
`Accepted project headings: ${accepted(README_HEADINGS.projects)}.`,
);
}
const recent = news.slice(0, 2).map(({ date, text }) => {
const links = [...text.matchAll(/\[([^\]]+)\]\(([^)]+)\)/g)];
const headline = text
.replace(/\[([^\]]+)\]\([^)]+\)/g, "$1")
+1 -1
View File
@@ -31,7 +31,7 @@ test("converted guides render admonitions, preserve content, and publish local i
const home = await readFile(new URL("dist/index.html", site), "utf8");
assert.ok(home.includes("Welcome to FastVideo"));
assert.ok(home.includes("/FastVideo/assets/logos/logo.svg"));
assert.ok(home.includes("FastVideo is an inference and post-training framework for diffusion models."));
assert.ok(home.includes("FastVideo is an inference and post-training framework for video diffusion models."));
assert.ok(!home.includes("About FastVideo and performance overview"));
await readFile(new URL("dist/assets/logos/logo.svg", site));
});
+225 -54
View File
@@ -224,17 +224,17 @@ test("catalog discovery opens dedicated model recipes and filters by family and
await page.goto("/FastVideo/cookbook");
const cards = page.locator(".recipe-card");
await expect(cards).toHaveCount(38);
await expect(cards.first()).toContainText("FastH3 OmniRef");
await expect(cards.first()).toContainText("FastH3 Omni v1");
await page.getByLabel("Model family", { exact: true }).selectOption("Wan");
await page.getByLabel("Search recipes").fill("Wan2.1 I2V");
await expect(cards).toHaveCount(1);
await cards.click();
await expect(page).toHaveURL(/cookbook\/wan\/wan21-i2v$/);
await expect(page).toHaveURL(/cookbook\/wan\/wan21-i2v\/?$/);
await expect(page.getByLabel("Base model")).toHaveCount(0);
await page.getByRole("link", { name: "← All recipes", exact: true }).click();
await page.getByLabel("Search recipes").fill("FastWan2.1");
await page.locator(".recipe-card").click();
await expect(page).toHaveURL(/cookbook\/wan\/fastwan21$/);
await expect(page).toHaveURL(/cookbook\/wan\/fastwan21\/?$/);
await expect(page.getByRole("heading", { level: 1 })).toHaveText("FastWan2.1 1.3B");
});
@@ -253,8 +253,7 @@ test("hardware preferences stay distinct from schema-backed GPU count and topolo
await expect(yaml).toContainText('"num_gpus": 1');
await expect(yaml).not.toContainText('"hardware"');
await expect(page.getByText("Valid configuration", { exact: true })).toBeVisible();
await expect(page.getByText("Fit unknown", { exact: true })).toBeVisible();
await expect(page.getByText("No recorded run", { exact: true })).toBeVisible();
await expect(page.getByText("Weights fit", { exact: true })).toBeVisible();
await page.getByLabel("GPU count", { exact: true }).fill("0");
await expect(yaml).toHaveCount(0);
await expect(page.getByLabel("GPU count", { exact: true })).toHaveAttribute("aria-invalid", "true");
@@ -395,6 +394,35 @@ test("source-backed controls update the copied command without changing models",
await expect(page.getByLabel("Inference command", { exact: true })).toContainText("--num-gpus '2'");
});
test("Pika H3 Flex knobs select the tier, bucket, duration, task, and NVFP4 decoder", async ({ page }) => {
await page.goto("/FastVideo/cookbook/minimax-h3");
const pika = page.locator(".recipe-card").filter({ hasText: "Pika H3 Flex" });
await expect(pika.locator("img")).toHaveAttribute("src", /\/pika\.webp$/);
await expect(pika).toContainText("Pika");
await pika.click();
await expect(page).toHaveURL(/cookbook\/minimax-h3\/models\/pika-h3-flex-t2va$/);
const command = page.getByLabel("Inference command", { exact: true });
await expect(command).toContainText("FASTVIDEO_ATTENTION_BACKEND=FLASH_ATTN FASTVIDEO_FA4=1");
await chip(page, "Resolution", "480p");
await chip(page, "Aspect ratio", "9:16");
await chip(page, "Duration", "10 s (243 frames)");
await chip(page, "VAE decoder", "NVFP4 36L quality");
await expect(command).toContainText("--resolution '480p'");
await expect(command).toContainText("--aspect '9:16'");
await expect(command).toContainText("--duration '10'");
await expect(command).toContainText("--nvfp4-decoder './fasth3-nvfp4-vae/quality-36L/decoder-nvfp4.safetensors'");
await page
.getByRole("group", { name: "Recipe variant", exact: true })
.getByRole("button", { name: "Pika H3 Flex keyframes", exact: true })
.click();
await expect(command).toContainText("--task fl2va --offload resident");
await chip(page, "Task", "First frame (I2VA)");
await chip(page, "Memory", "Lazy load + offload");
await expect(command).toContainText("--task 'i2va'");
await expect(command).toContainText("--offload 'lazy'");
await expect(page.getByRole("button", { name: "Copy example + command", exact: true })).toBeEnabled();
});
test("Wan I2V requires an image for CLI and SDK examples without changing server defaults", async ({ page }) => {
await page.goto("/FastVideo/cookbook/wan/wan21-i2v");
await chip(page, "Run mode", "CLI");
@@ -443,26 +471,6 @@ test("source-backed serving pages expose their actual server settings", async ({
await expect(page.getByRole("button", { name: "Copy config + command", exact: true })).toBeEnabled();
});
test("PAN 2 has a family card with the IFM logo and an editable serving deployment", async ({ page }) => {
await page.goto("/FastVideo/cookbook");
await page.getByLabel("Search recipes").fill("PAN 2");
const pan2 = page.locator(".recipe-card");
await expect(pan2).toHaveCount(1);
await expect(pan2.locator("img")).toHaveAttribute("src", /\/ifm\.webp$/);
await expect(pan2.locator(".recipe-card-kicker")).toHaveText("IFM");
await pan2.click();
await expect(page).toHaveURL(/cookbook\/pan2\/pan2$/);
await expect(page.locator(".recipe-matrix")).toBeVisible({ timeout: 30000 });
await expect(page.getByRole("heading", { level: 1 })).toHaveText("PAN 2");
await settings(page, "Serving");
await page.getByLabel("Port", { exact: true }).fill("9010");
await output(page, "config.yaml");
await expect(page.getByLabel("Generated YAML", { exact: true })).toContainText(/"port": 9010/);
await expect(page.getByLabel("Generated YAML", { exact: true })).toContainText(/"model_path": "IFM\/PAN2"/);
await expect(page.getByLabel("Generated YAML", { exact: true })).toContainText(/"dit_layerwise": false/);
await expect(page.getByRole("button", { name: "Download YAML", exact: false })).toBeEnabled();
});
test("source model hardware preferences survive hydration and reload", async ({ page }) => {
const errors: string[] = [];
page.on("pageerror", (error) => errors.push(error.message));
@@ -482,16 +490,16 @@ test("custom devices persist and no preview control can block generated YAML", a
await page.getByRole("button", { name: "Can't find it? Add a device", exact: true }).click();
await page.getByLabel("Device name", { exact: true }).fill("RTX 4090");
await page.getByRole("combobox", { name: "Memory type", exact: true }).selectOption("dedicated");
await page.getByRole("spinbutton", { name: "Nominal memory", exact: true }).fill("24");
await page.getByRole("spinbutton", { name: "Usable budget (optional)", exact: true }).fill("32");
await expect(page.getByText("The usable budget cannot exceed the nominal memory.", { exact: true })).toBeVisible();
await expect(page.getByRole("button", { name: "Use this device", exact: true })).toBeDisabled();
await page.getByRole("spinbutton", { name: "Usable budget (optional)", exact: true }).fill("22");
await page.getByRole("spinbutton", { name: "Total memory", exact: true }).fill("24");
await page.getByRole("spinbutton", { name: "Usable memory (optional)", exact: true }).fill("32");
await expect(page.getByText("Usable memory cannot exceed total memory.", { exact: true })).toBeVisible();
await expect(page.getByRole("button", { name: "Save device", exact: true })).toBeDisabled();
await page.getByRole("spinbutton", { name: "Usable memory (optional)", exact: true }).fill("22");
await page.getByRole("spinbutton", { name: "Host RAM (optional)", exact: true }).fill("64");
await page.getByRole("button", { name: "Use this device", exact: true }).click();
await page.getByRole("button", { name: "Save device", exact: true }).click();
await expect(page.getByText("RTX 4090", { exact: true })).toBeVisible();
await expect(page.locator(".hardware-selected")).toContainText("24 GB VRAM");
await expect(page.locator(".hardware-selected")).toContainText("Your budget: 22 GB");
await expect(page.locator(".hardware-selected")).toContainText("Usable memory: 22 GB");
await expect(page.locator(".hardware-selected")).toContainText("Host RAM: 64 GB");
await expect(page).toHaveURL(/hardware=custom/);
await expect(page).toHaveURL(/hwName=RTX\+4090/);
@@ -500,28 +508,26 @@ test("custom devices persist and no preview control can block generated YAML", a
await expect(page.locator(".hardware-selected")).toContainText("24 GB VRAM");
await expect(page.locator(".component-plan")).toHaveCount(0);
await expect(page.getByRole("button", { name: "Download YAML", exact: true })).toBeEnabled();
await page.getByRole("button", { name: "Clear hardware target", exact: true }).click();
await page.getByRole("button", { name: "Clear hardware", exact: true }).click();
await expect(page.getByText("RTX 4090", { exact: true })).toHaveCount(0);
await expect(page.getByText("No hardware target selected.", { exact: true })).toBeVisible();
await expect(page.getByText("No hardware selected.", { exact: true })).toBeVisible();
});
test("hardware is never preselected; a single-GPU device sets one GPU with valid parallelism", async ({ page }) => {
test("hardware is never preselected and a desktop card keeps the recipe GPU count", async ({ page }) => {
await page.goto(route);
await expect(page.locator(".hardware-picker")).toBeVisible({ timeout: 30000 });
await expect(page.getByText("No hardware target selected.", { exact: true })).toBeVisible();
await expect(page.getByText("No hardware selected.", { exact: true })).toBeVisible();
await output(page, "config.yaml");
const yaml = page.getByLabel("Generated YAML", { exact: true });
await expect(yaml).toContainText('"num_gpus": 4');
await chooseHardware(page, "RTX 5090");
await expect(page).toHaveURL(/hardware=geforce-rtx-5090-32gb/);
await expect(page.getByText(/GeForce RTX 5090 .*32 GB VRAM/)).toBeVisible();
await expect(page.getByText(/Set to 1 GPU for .*RTX 5090/)).toBeVisible();
await expect(yaml).toContainText('"num_gpus": 1');
await expect(yaml).toContainText('"sp_size": -1');
await expect(yaml).toContainText('"num_gpus": 4');
await expect(yaml).not.toContainText('"hardware"');
await expect(page.getByText("Valid configuration", { exact: true })).toBeVisible();
await page.getByRole("button", { name: "Clear hardware target", exact: true }).click();
await expect(page.getByText("No hardware target selected.", { exact: true })).toBeVisible();
await page.getByRole("button", { name: "Clear hardware", exact: true }).click();
await expect(page.getByText("No hardware selected.", { exact: true })).toBeVisible();
});
test("lowering the GPU count resets parallelism that no longer fits", async ({ page }) => {
@@ -542,7 +548,7 @@ test("browser back after a hardware-only change keeps the editor loaded", async
await expect(page).toHaveURL(/hardware=geforce-rtx-5090-32gb/);
await page.goBack();
await expect(page).not.toHaveURL(/hardware=/);
await expect(page.getByText("No hardware target selected.", { exact: true })).toBeVisible();
await expect(page.getByText("No hardware selected.", { exact: true })).toBeVisible();
await expect(page.getByRole("button", { name: "Download YAML", exact: true })).toBeEnabled();
await expect(page.getByText("Loading deployment settings…")).toHaveCount(0);
});
@@ -555,15 +561,135 @@ test("hardware links resolve catalog aliases and non-CUDA devices are not CUDA t
await expect(page.getByRole("radio", { name: /Arc B580/ })).toHaveCount(0);
});
test("recipe facts separate validity, fit, and run evidence with inspectable assumptions", async ({ page }) => {
test("recipe facts state validity and why memory has no estimate", async ({ page }) => {
await page.goto(route);
const facts = page.getByLabel("Recipe facts");
const facts = page.locator(".recipe-facts");
await expect(facts.getByText("Valid configuration", { exact: true })).toBeVisible();
await expect(facts.getByText("Fit unknown", { exact: true })).toBeVisible();
await expect(facts.getByText("No recorded run", { exact: true })).toBeVisible();
await expect(facts.getByText("Memory not estimated", { exact: true })).toBeVisible();
await page.getByRole("button", { name: "Details", exact: true }).click();
await expect(page.getByText(/This is not a fit claim/)).toBeVisible();
await expect(page.getByText(/do not establish this configuration/)).toBeVisible();
await expect(page.getByText(/Select a device to estimate memory/)).toBeVisible();
});
test("host-only system RAM cannot produce a GPU weights fit verdict", async ({ page }) => {
await page.goto(`${route}?hardware=custom&hwName=Host&hwType=system&hwMemory=128&hwUnit=GB&hwBackend=cuda`);
const facts = page.locator(".recipe-facts");
await expect(facts.getByText("Memory not estimated", { exact: true })).toBeVisible();
await page.getByRole("button", { name: "Details", exact: true }).click();
await expect(facts.getByText(/Host-only system RAM is not GPU capacity/)).toBeVisible();
await expect(facts.getByText(/Resident weights need/)).toHaveCount(0);
await expect(page.getByRole("button", { name: "Download YAML", exact: false })).toBeEnabled();
});
test("TAEH3 stops using the bundled VAE weights in memory facts", async ({ page }) => {
await page.goto(`${route}?hardware=custom&hwName=GPU&hwType=dedicated&hwMemory=80&hwUnit=GB&hwBackend=cuda`);
const facts = page.locator(".recipe-facts");
await expect(facts.getByText("Weights exceed memory", { exact: true })).toBeVisible();
await chip(page, "Video decoder", "taeh3");
await expect(facts.getByText("Memory not estimated", { exact: true })).toBeVisible();
await page.getByRole("button", { name: "Details", exact: true }).click();
await expect(facts.getByText(/TAEH3 decoder weights are not modeled/)).toBeVisible();
await expect(facts.getByText(/Resident weights need/)).toHaveCount(0);
await output(page, "config.yaml");
await expect(page.getByLabel("Generated YAML")).toContainText('"video_decode_backend": "taeh3"');
await chip(page, "Video decoder", "h3-vae");
await expect(facts.getByText("Weights exceed memory", { exact: true })).toBeVisible();
});
test("FastH3 keeps unmodeled quantization and decoder residency unknown", async ({ page }) => {
await page.goto(route);
await chooseHardware(page, "RTX 5090");
const facts = page.locator(".recipe-facts");
await expect(facts.getByText("Weights exceed memory", { exact: true })).toBeVisible();
await page.getByRole("button", { name: "Details", exact: true }).click();
await expect(page.getByText(/Resident weights need 75\.6 GiB of 29\.8 GiB/)).toBeVisible();
await page
.getByRole("group", { name: "Transformer quantization", exact: true })
.getByRole("button", { name: "NVFP4", exact: true })
.click();
await expect(facts.getByText("Memory not estimated", { exact: true })).toBeVisible();
await expect(facts.getByText(/checkpoint and layer profile/)).toBeVisible();
await expect(facts.getByText(/Resident weights need/)).toHaveCount(0);
await page
.getByRole("group", { name: "NVFP4 video decoder", exact: true })
.getByRole("button", { name: /8L · fast/ })
.click();
await expect(facts.getByText("Memory not estimated", { exact: true })).toBeVisible();
await expect(facts.getByText(/VAE retains its encoder and packs decoder weights/)).toBeVisible();
await expect(facts.getByText(/Resident weights need/)).toHaveCount(0);
await output(page, "config.yaml");
await expect(page.getByLabel("Generated YAML")).toContainText('"transformer_quant": "NVFP4"');
await expect(page.getByLabel("Generated YAML")).toContainText("fast-8L/decoder-nvfp4.safetensors");
});
test("4-Step quantization and full-depth decoder swaps also stay unestimated", async ({ page }) => {
await page.goto("/FastVideo/cookbook/minimax-h3/fasth3-4step");
await chooseHardware(page, "RTX 5090");
const facts = page.locator(".recipe-facts");
await page.getByRole("button", { name: "Details", exact: true }).click();
await chip(page, "Transformer quantization", "FP8");
await expect(facts.getByText(/checkpoint and layer profile/)).toBeVisible();
await chip(page, "Transformer quantization", "Default");
await page
.getByRole("group", { name: "NVFP4 video decoder", exact: true })
.getByRole("button", { name: /36L/ })
.click();
await expect(facts.getByText(/VAE retains its encoder and packs decoder weights/)).toBeVisible();
await expect(facts.getByText(/Resident weights need/)).toHaveCount(0);
});
test("the retired Preview route and deployment links keep their checkpoint", async ({ page }) => {
await page.goto("/FastVideo/cookbook/minimax-h3/fasth3-preview");
await output(page, "config.yaml");
await expect(page.getByLabel("Generated YAML")).toContainText("FastVideo-Minimax-FastH3-Preview-v0.2");
await page.goto("/FastVideo/cookbook?deployment=fasth3-preview%2Fcuda-rest");
await expect(page).toHaveURL(/minimax-h3\/fasth3-preview/);
await output(page, "config.yaml");
await expect(page.getByLabel("Generated YAML")).toContainText("FastVideo-Minimax-FastH3-Preview-v0.2");
});
for (const [deployment, label, sequential, tileBatch] of [
["rtx5090-rest", "RTX 5090", true, "4"],
["rtx-pro6000-rest", "RTX PRO 6000", false, "8"],
["spark-rest", "DGX Spark", false, "12"],
["cuda-rest", "RTX 5090", true, "4"],
] as const) {
test(`Omni deployment link ${deployment} preserves its hardware baseline`, async ({ page }) => {
await page.goto(`/FastVideo/cookbook?deployment=fasth3-omniref%2F${deployment}`);
await expect(page).toHaveURL(/minimax-h3\/fasth3-omniref/);
await expect(page.getByRole("button", { name: label, exact: true })).toHaveAttribute("aria-pressed", "true");
await page.getByRole("button", { name: "Serving", exact: true }).click();
await output(page, "config.yaml");
const yaml = page.getByLabel("Generated YAML");
await expect(yaml).toContainText(`"h3_sequential_load": ${sequential}`);
await expect(yaml).toContainText('"layer_profile": "h3_dit_vsa"');
await output(page, "Commands");
await expect(page.getByLabel("Server command")).toContainText(`FASTVIDEO_H3_VAE_TILE_BATCH=${tileBatch}`);
if (deployment === "spark-rest")
await expect(page.getByLabel("Server command")).toContainText("FASTVIDEO_NVFP4_MM_BACKEND=cudnn");
});
}
test("packed FastH3 checkpoints keep their fixed precision", async ({ page }) => {
await page.goto("/FastVideo/cookbook/minimax-h3/fasth3-trim");
await page.getByRole("tab", { name: "config.yaml" }).click();
await expect(page.getByLabel("Generated YAML")).toContainText('"transformer_quant": "NVFP4"');
await expect(page.getByLabel("Generated YAML")).toContainText('"layer_profile": "h3_dit_vsa"');
await expect(page.getByText("Valid configuration", { exact: true })).toBeVisible();
await expect(page.getByRole("group", { name: "Transformer quantization", exact: true })).toHaveCount(0);
});
test("MLX FastH3 stays unestimated and a GiB device keeps its unit", async ({ page }) => {
await page.goto("/FastVideo/cookbook/minimax-h3/fasth3-8step?deployment=fasth3-8step%2Fmlx-rest");
await page.getByRole("button", { name: "+ Find or add a Mac", exact: true }).click();
await page.getByLabel("Search hardware", { exact: true }).fill("M4 Max");
await page.getByRole("radio", { name: /Apple M4 Max 128 GB/ }).click();
await expect(page.getByText("Memory not estimated", { exact: true })).toBeVisible();
await page.getByRole("button", { name: "Details", exact: true }).click();
await expect(page.getByText(/MLX checkpoints use a different precision/)).toBeVisible();
await page.goto(
"/FastVideo/cookbook/minimax-h3/fasth3-8step?hardware=custom&hwName=Big&hwType=dedicated&hwMemory=80&hwUnit=GiB&hwBackend=cuda",
);
await expect(page.getByText("Weights fit, little headroom", { exact: true })).toBeVisible();
});
test("catalog lookup resolves aliases, variants, and unified memory without pooling", async ({ page }) => {
@@ -572,7 +698,7 @@ test("catalog lookup resolves aliases, variants, and unified memory without pool
await expect(page).toHaveURL(/hardware=rtx-pro-6000-blackwell-workstation-96gb/);
await expect(page.locator(".hardware-selected")).toContainText("96 GB VRAM");
await expect(page.locator(".hardware-address-note")).toContainText("Per device, not a combined pool");
await page.getByRole("button", { name: "Clear hardware target", exact: true }).click();
await page.getByRole("button", { name: "Clear hardware", exact: true }).click();
await page.getByRole("button", { name: "+ Find or add a GPU", exact: true }).click();
await page.getByLabel("Search hardware", { exact: true }).fill("GB10");
await page.getByRole("radio", { name: /DGX Spark/ }).click();
@@ -592,9 +718,9 @@ test("MLX lookup uses unified memory and a custom Mac stays reusable", async ({
await page.getByRole("radio", { name: /Apple M4 Max/ }).click();
await expect(page.locator(".hardware-selected")).toContainText("128 GB unified memory");
await expect(page.locator(".hardware-selected")).toContainText("Apple MLX");
await expect(page.locator(".hardware-selected")).toContainText("Usable budget: 96 GB");
await expect(page.locator(".hardware-selected")).toContainText("Usable memory: 96 GB");
await expect(page.locator(".hardware-address-note")).toContainText("not dedicated VRAM");
await expect(page.getByRole("button", { name: "Clear hardware target", exact: true })).toBeVisible();
await expect(page.getByRole("button", { name: "Clear hardware", exact: true })).toBeVisible();
});
test("the directory filters by runtime without a hardware picker", async ({ page }) => {
@@ -608,6 +734,33 @@ test("the directory filters by runtime without a hardware picker", async ({ page
await expect(page.locator(".recipe-card").filter({ hasText: "FastWan2.1 1.3B" })).toHaveCount(0);
});
test("MLX Omni discovery and its original route expose the MLX launch command", async ({ page }) => {
await page.goto("/FastVideo/cookbook");
await page.getByLabel("Runtime", { exact: true }).selectOption("mlx");
await page.getByLabel("Search recipes").fill("FastH3 Omni");
const cards = page.locator(".recipe-card");
await expect(cards).toHaveCount(1);
await expect(cards).toHaveAttribute("href", "/FastVideo/cookbook/minimax-h3/models/fasth3-omniref-mlx");
await cards.click();
await expect(page.getByLabel("Inference command")).toContainText("convert_minimax_h3_mlx.py");
await expect(page.getByLabel("Inference command")).toContainText("--mlx-checkpoint ./FastH3-OmniRef-MLX/int6");
await expect(page.getByLabel("Inference command")).not.toContainText("fastvideo serve");
await page.reload();
await expect(page.getByLabel("Inference command")).toContainText("--mlx-checkpoint ./FastH3-OmniRef-MLX/int6");
});
test("category counts match visible cards and exclude retired Preview", async ({ page }) => {
await page.goto("/FastVideo/cookbook");
await expect(page.locator(".recipe-card")).toHaveCount(38);
for (const category of ["All models", "Video", "Image", "Audio", "World"]) {
const button = page.getByRole("button", { name: category, exact: true });
const count = Number(await button.locator(".recipe-filter-count").innerText());
await button.click();
await expect(page.locator(".recipe-card")).toHaveCount(count);
await expect(page.locator(".recipe-card").filter({ hasText: "FastH3 Preview V0.2" })).toHaveCount(0);
}
});
test("task discovery filters the directory and round-trips through the URL", async ({ page }) => {
await page.goto("/FastVideo/cookbook");
await page.getByLabel("Task", { exact: true }).selectOption("Image to video");
@@ -633,10 +786,10 @@ test("directory filters round-trip through the URL", async ({ page }) => {
test("a saved target missing from the catalog stays visible and clearable", async ({ page }) => {
await page.goto(`${route}?hardware=retired-gpu`);
await expect(page.getByText(/Saved hardware target \(retired-gpu\) is not in this catalog/)).toBeVisible();
await page.getByRole("button", { name: "Clear hardware target", exact: true }).click();
await expect(page.getByText(/The saved device \(retired-gpu\) is not in this catalog/)).toBeVisible();
await page.getByRole("button", { name: "Clear hardware", exact: true }).click();
await expect(page).not.toHaveURL(/hardware=/);
await expect(page.getByText("No hardware target selected.", { exact: true })).toBeVisible();
await expect(page.getByText("No hardware selected.", { exact: true })).toBeVisible();
});
test("catalog search renders one plain no-results message", async ({ page }) => {
@@ -666,3 +819,21 @@ test("WebSocket deployments expose only the serving interface", async ({ page })
await expect(modes.getByRole("button", { name: "CLI", exact: true })).toHaveCount(0);
await expect(page.getByText(/Only serving is recorded for it/)).toBeVisible();
});
test("video-to-video deployments expose only the serving interface", async ({ page }) => {
await page.goto("/FastVideo/cookbook/minimax-h3/open-h3-regenerate-2k");
const modes = page.getByRole("group", { name: "Run mode" });
await expect(modes.getByRole("button")).toHaveCount(1);
await expect(modes.getByRole("button", { name: "Serving", exact: true })).toHaveAttribute("aria-pressed", "true");
await expect(page.getByText(/Send it to the server as video_reference/)).toBeVisible();
});
test("Open-H3-Regenerate-2K is discoverable as a video upscale with its serving page", async ({ page }) => {
await page.goto("/FastVideo/cookbook");
await page.getByLabel("Task", { exact: true }).selectOption("Video upscale");
const card = page.locator(".recipe-card").filter({ hasText: "Open-H3-Regenerate-2K" });
await expect(card).toHaveCount(1);
await card.click();
await expect(page).toHaveURL(/cookbook\/minimax-h3\/open-h3-regenerate-2k\/?$/);
await expect(page.getByRole("heading", { level: 1 })).toHaveText("Open-H3-Regenerate-2K");
});
+21 -1
View File
@@ -1,5 +1,19 @@
import { expect, test } from "@playwright/test";
test("Omni Apple silicon links open the published guide and MLX recipe", async ({ page }) => {
for (const slash of ["", "/"]) {
await page.goto(`/FastVideo/cookbook/minimax-h3/fasth3-omniref${slash}`);
const guide = page.getByRole("link", { name: "FastH3 Omni on MLX", exact: true });
await expect(guide).toHaveAttribute("href", "/FastVideo/inference/fasth3_omniref_mlx");
await guide.click();
await expect(page).toHaveURL(/\/FastVideo\/inference\/fasth3_omniref_mlx\/?$/);
await expect(page.getByRole("heading", { level: 1 })).toBeVisible();
}
await page.goto("/FastVideo/cookbook/minimax-h3/fasth3-omniref");
await page.getByRole("link", { name: "MLX recipe", exact: true }).click();
await expect(page.getByLabel("Inference command")).toContainText("--mlx-checkpoint");
});
test("GitHub stars refresh automatically and survive a failed refresh", async ({ page }) => {
await page.clock.install();
let requests = 0;
@@ -159,6 +173,12 @@ test("the model catalog filters by family and checkpoint name", async ({ page })
await expect(catalog.getByRole("status")).toHaveText("3 checkpoints");
await page.getByRole("searchbox", { name: "Search checkpoints", exact: true }).fill("14B");
await expect(catalog.getByRole("status")).toHaveText("1 checkpoint");
await page.getByRole("button", { name: "All models", exact: true }).click();
await page.getByRole("searchbox").fill("NVFP4");
const codes = await catalog.locator(".model-checkpoints code").allInnerTexts();
expect(codes.length).toBeGreaterThan(0);
expect(codes.every((text) => text.includes("NVFP4"))).toBe(true);
await expect(catalog.getByText("FastVideo/FastVideo-FastH3-8-Step-V2-FP8", { exact: true })).toHaveCount(0);
await page.getByRole("searchbox").fill("no-such-model");
await expect(catalog.getByRole("status")).toHaveText("0 checkpoints");
await page.getByRole("button", { name: "Clear filters", exact: true }).click();
@@ -178,7 +198,7 @@ test("family runbooks use React model cards without the old cookbook runtime", a
await page.goto("/FastVideo/cookbook/wan/");
await expect(page.locator(".recipe-card").first()).toBeVisible();
await expect(page.getByRole("button", { name: "Search", exact: true })).toBeVisible();
await expect(page.getByRole("link", { name: "← All models", exact: true })).toHaveAttribute(
await expect(page.getByRole("link", { name: "← All recipes", exact: true })).toHaveAttribute(
"href",
/\/FastVideo\/cookbook$/,
);
+64
View File
@@ -0,0 +1,64 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import { readReadmeSections } from "../lib/readme.mjs";
const legacy = `# FastVideo
## NEWS
- \`2026/10/06\`: FastH3 V2 runs on a single consumer machine.
- \`2026/09/15\`: Release FastH3 8-Step V2.
## Awesome work using FastVideo or our research projects
- [Some project](https://example.com)
## Community
- Slack
`;
const rewritten = `# FastVideo
## Overview
FastVideo is a framework.
## Recent updates
- **2026-10-06:** FastH3 V2 runs on a single consumer machine.
- **2026-09-15:** Release FastH3 8-Step V2.
## Projects using FastVideo
- [Some project](https://example.com)
## Community and contributing
- Slack
`;
test("reads the legacy README headings and dates", () => {
const { news, projects } = readReadmeSections(legacy);
assert.equal(news.length, 2);
assert.deepEqual(
news.map((item) => item.date),
["2026/10/06", "2026/09/15"],
);
assert.match(news[0].text, /^FastH3 V2/);
assert.match(projects, /Some project/);
});
test("reads a rewritten README with sentence case headings and bold dates", () => {
const { news, projects } = readReadmeSections(rewritten);
assert.equal(news?.length, 2);
assert.deepEqual(
news.map((item) => item.date),
["2026/10/06", "2026/09/15"],
);
assert.doesNotMatch(news[0].text, /\*\*/);
assert.match(projects, /Some project/);
assert.doesNotMatch(projects, /Slack/);
});
test("stops at the next heading and ignores other sections", () => {
const { news } = readReadmeSections(legacy);
assert.doesNotMatch(news[1].text, /Awesome work/);
});
test("returns null when a section is missing", () => {
assert.deepEqual(readReadmeSections("# FastVideo\n\n## Overview\n"), { news: null, projects: null });
});
+55
View File
@@ -798,6 +798,39 @@
.recipe-card-grid-configurable .recipe-card {
border-block-start: 2px solid var(--blume-accent);
}
/* Family runbook cards are static: no curtain, so they need their own rhythm. */
.recipe-card-static {
display: flex;
flex-direction: column;
gap: 0.5rem;
min-block-size: 13.5rem;
padding: 1.25rem 1.25rem 1rem;
}
.recipe-card-static .recipe-card-top {
margin-block-end: 0;
padding-block-end: 0;
}
.recipe-card-static h2 {
margin: 0;
font-size: 1rem;
line-height: 1.35;
font-weight: 600;
letter-spacing: -0.01em;
}
.recipe-card-static footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
margin-block-start: auto;
margin-inline: 0;
padding-block: 0.75rem 0;
border-block-start: 1px solid var(--recipe-line);
font-size: 0.6875rem;
}
.recipe-card-static footer span:first-child {
color: var(--blume-muted-foreground);
}
.recipe-empty {
padding-block: 2rem;
text-align: center;
@@ -1622,3 +1655,25 @@
.recipe-request-code summary {
padding-block: 0.25rem;
}
/* Article rhythm.
Blume's prose gives <details> no vertical margin, so consecutive cookbook
sections touch. Tables inherit the body line height, which makes dense cells
taller than the prose around them. Both are set here, once, instead of in
each page's markup. */
.prose details {
margin-block: 1rem;
}
.prose details + details {
margin-top: 1rem;
}
.prose summary {
cursor: pointer;
}
.prose table {
line-height: 1.5;
}
.prose td,
.prose th {
vertical-align: top;
}
+339 -27
View File
@@ -381,33 +381,6 @@
"expected_artifact": "Upscaled MP4 under outputs_video/kandinsky6_sr/",
"related": ["kandinsky6-vsr", "kandinsky6-ti2va-piflow"]
},
{
"id": "pan2-ti2v",
"family": "pan2",
"stage": "inference",
"task": "Text or image to video",
"label": "PAN 2",
"summary": "Generate a 13-second, 704 × 1248 video at 24 fps from text, or set IMAGE_PATH in the maintained example to condition on a first frame.",
"model": "IFM/PAN2",
"source": "examples/inference/basic/basic_pan2.py",
"serving": {
"source": "examples/serving/openai_pan2.yaml",
"install": "UV_TORCH_BACKEND=cu130 uv pip install -e .",
"task": "Text to video",
"limitations": [
"This server walkthrough sends a text prompt only. The server API also accepts an image reference (image_reference or input_reference) and then runs image-to-video from that first frame, but the checked-in clients do not upload one."
]
},
"command": "python examples/inference/basic/basic_pan2.py",
"gpu_types": ["NVIDIA"],
"hardware": {"platform": "cuda", "gpu_count": 1, "evidence": "source-configured"},
"evidence": "Source-backed",
"expected_artifact": "MP4 video under video_samples_pan2/",
"limitations": [
"The full IFM/PAN2 checkpoint is not public yet; numerical parity is checked with a public random-weight checkpoint.",
"The example keeps the 29B DiT resident in bf16 and offloads only the text encoder. GPU memory requirements have not been established for this recipe; set PAN2_NUM_GPUS to shard video tokens with sequence parallelism, which does not shard the DiT weights."
]
},
{
"id": "flux2-klein-t2i",
"family": "flux",
@@ -787,6 +760,345 @@
],
"limitations": ["Pass --reference-audio for an additional audio reference. The checked-in source defaults to four GPUs."]
},
{
"id": "pika-h3-flex-t2va",
"family": "pika_h3_flex",
"stage": "inference",
"task": "Text to video (with audio)",
"label": "Pika H3 Flex",
"summary": "Pika's post-trained MiniMax-H3: synchronized video and stereo audio from text in six forwards, at 480p or 768p, 5-15 s, with optional NVFP4 decoder and text encoder on Blackwell.",
"model": "Pika-Labs/Pika-H3-Flex",
"source": "examples/inference/basic/basic_pika_h3_flex.py",
"command": "FASTVIDEO_ATTENTION_BACKEND=FLASH_ATTN FASTVIDEO_FA4=1 python examples/inference/basic/basic_pika_h3_flex.py --task t2va --offload resident --prompt 'A paper boat drifts down a rainy city street at night; rain patters on the water.'",
"gpu_types": ["NVIDIA"],
"hardware": {
"platform": "cuda",
"gpu_count": 1,
"accelerator": "NVIDIA GB200",
"peak_memory": "About 129 GiB resident (BF16, 768p, 5 s)",
"evidence": "validated",
"evidence_url": "https://github.com/hao-ai-lab/FastVideo/pull/2038"
},
"evidence": "Verified",
"expected_artifact": "MP4 with synchronized stereo audio at outputs/pika_h3_flex/pika_h3_flex_t2va.mp4",
"modes": ["T2VA"],
"knobs": [
{
"key": "resolution",
"label": "Resolution",
"hint": "Pika resolution tier",
"flag": "--resolution",
"options": [{"value": "768p", "label": "768p"}, {"value": "480p", "label": "480p"}],
"default": "768p"
},
{
"key": "aspect",
"label": "Aspect ratio",
"hint": "Bucket within the tier",
"flag": "--aspect",
"options": ["21:9", "16:9", "3:2", "4:3", "5:4", "1:1", "4:5", "3:4", "2:3", "9:16", "9:21"],
"default": "16:9"
},
{
"key": "duration",
"label": "Duration",
"hint": "Rounded up to the 17n+5 frame grid",
"flag": "--duration",
"options": [
{"value": 5, "label": "5 s (124 frames)"},
{"value": 10, "label": "10 s (243 frames)"},
{"value": 15, "label": "15 s (362 frames)"}
],
"default": 5
},
{
"key": "nvfp4_decoder",
"label": "VAE decoder",
"hint": "NVFP4 needs Blackwell",
"flag": "--nvfp4-decoder",
"options": [
{"value": "", "label": "BF16 H3 VAE"},
{"value": "./fasth3-nvfp4-vae/quality-36L/decoder-nvfp4.safetensors", "label": "NVFP4 36L quality"},
{"value": "./fasth3-nvfp4-vae/fast-8L/decoder-nvfp4.safetensors", "label": "NVFP4 8L fast"}
],
"default": ""
},
{
"key": "nvfp4_text_encoder",
"label": "Text encoder",
"hint": "NVFP4 needs Blackwell",
"flag": "--nvfp4-text-encoder",
"options": [
{"value": "", "label": "BF16 Qwen3-VL"},
{"value": "./fasth3-nvfp4-encoder/text_encoder", "label": "NVFP4 Qwen3-VL"}
],
"default": ""
},
{
"key": "offload",
"label": "Memory",
"hint": "Resident is how the GB200 timings were measured",
"flag": "--offload",
"options": [
{"value": "resident", "label": "All resident (fastest)"},
{"value": "lazy", "label": "Lazy load + offload"}
],
"default": "resident"
},
{
"key": "num_gpus",
"label": "GPUs",
"hint": "Sequence-parallel degree",
"flag": "--num-gpus",
"options": [1, 2, 4, 8],
"default": 1
}
],
"limitations": [
"Measured on one GB200 at 768p 16:9, 5 s, warm: official pika-h3-pro CLI 59.7 s; FastVideo BF16 26.2 s; with the NVFP4 quality-36L decoder 18.0 s; with the fast-8L decoder 17.4 s. The 36L decoder measured 40.5 dB PSNR against the BF16 decoder.",
"Peak resident memory was about 129 GiB for the all-BF16 run and 104.6 GiB with the NVFP4 text encoder (25.49 s) at 768p / 5 s on GB200; the official CLI's sampling peak was 71.7 GiB, a different measurement. The NVFP4 text encoder's first request in a fresh process pays a one-time cost of about 183 s.",
"--offload resident keeps every component on the GPU (104.6-108.5 GiB peak with the NVFP4 text encoder, about 129 GiB all-BF16, on GB200). --offload lazy loads components on demand for smaller devices; on GB200 it took 48.06 s for the same T2VA request.",
"FASTVIDEO_ATTENTION_BACKEND=FLASH_ATTN FASTVIDEO_FA4=1 selects FlashAttention-4 on Hopper or Blackwell; FA4 is never enabled just because it is installed. Without FA4, attention uses FA3/FA2 if installed and otherwise falls back to SDPA, which is slow and can run out of memory at 768p.",
"The NVFP4 decoder and text encoder are Blackwell-only. Download them before selecting them: hf download FastVideo/FastH3-NVFP4-VAE-Decoder --include 'quality-36L/decoder-nvfp4.safetensors' 'fast-8L/decoder-nvfp4.safetensors' --local-dir fasth3-nvfp4-vae; hf download FastVideo/FastVideo-FastH3-8-Step-V2-NVFP4-Consumer --include 'text_encoder/*' --local-dir fasth3-nvfp4-encoder. The 8L decoder is a distilled student; validate it on your prompts.",
"The six-forward re-noise ladder is part of the adapter contract; the example exposes no step-count flag."
],
"related": ["pika-h3-flex-keyframes", "pika-h3-flex-ref2va"]
},
{
"id": "pika-h3-flex-keyframes",
"family": "pika_h3_flex",
"stage": "inference",
"task": "First/last frame to video (with audio)",
"label": "Pika H3 Flex keyframes",
"summary": "Animate from a first frame (I2VA), toward a last frame (L2VA), or between both (FL2VA). Pika center-crops each keyframe to the selected bucket.",
"model": "Pika-Labs/Pika-H3-Flex",
"source": "examples/inference/basic/basic_pika_h3_flex.py",
"command": "FASTVIDEO_ATTENTION_BACKEND=FLASH_ATTN FASTVIDEO_FA4=1 python examples/inference/basic/basic_pika_h3_flex.py --task fl2va --offload resident --image assets/images/natural.jpg --last-image assets/images/peninsula.jpg --prompt 'The camera drifts from the misty mountain ridge down to the coastal cliffs above the sea.'",
"gpu_types": ["NVIDIA"],
"hardware": {
"platform": "cuda",
"gpu_count": 1,
"accelerator": "NVIDIA GB200",
"peak_memory": "105.2 GiB FL2VA, 104.9 GiB I2VA (NVFP4 text encoder, 768p, 5 s)",
"evidence": "validated",
"evidence_url": "https://github.com/hao-ai-lab/FastVideo/pull/2038"
},
"evidence": "Verified",
"expected_artifact": "MP4 with synchronized stereo audio at outputs/pika_h3_flex/pika_h3_flex_<task>.mp4",
"modes": ["I2VA", "L2VA", "FL2VA"],
"knobs": [
{
"key": "task",
"label": "Task",
"hint": "Which keyframes condition the clip",
"flag": "--task",
"options": [
{"value": "i2va", "label": "First frame (I2VA)"},
{"value": "l2va", "label": "Last frame (L2VA)"},
{"value": "fl2va", "label": "First and last (FL2VA)"}
],
"default": "fl2va"
},
{
"key": "resolution",
"label": "Resolution",
"hint": "Pika resolution tier",
"flag": "--resolution",
"options": [{"value": "768p", "label": "768p"}, {"value": "480p", "label": "480p"}],
"default": "768p"
},
{
"key": "aspect",
"label": "Aspect ratio",
"hint": "Bucket within the tier",
"flag": "--aspect",
"options": ["21:9", "16:9", "3:2", "4:3", "5:4", "1:1", "4:5", "3:4", "2:3", "9:16", "9:21"],
"default": "16:9"
},
{
"key": "duration",
"label": "Duration",
"hint": "Rounded up to the 17n+5 frame grid",
"flag": "--duration",
"options": [
{"value": 5, "label": "5 s (124 frames)"},
{"value": 10, "label": "10 s (243 frames)"},
{"value": 15, "label": "15 s (362 frames)"}
],
"default": 5
},
{
"key": "nvfp4_decoder",
"label": "VAE decoder",
"hint": "NVFP4 needs Blackwell",
"flag": "--nvfp4-decoder",
"options": [
{"value": "", "label": "BF16 H3 VAE"},
{"value": "./fasth3-nvfp4-vae/quality-36L/decoder-nvfp4.safetensors", "label": "NVFP4 36L quality"},
{"value": "./fasth3-nvfp4-vae/fast-8L/decoder-nvfp4.safetensors", "label": "NVFP4 8L fast"}
],
"default": ""
},
{
"key": "nvfp4_text_encoder",
"label": "Text encoder",
"hint": "NVFP4 needs Blackwell",
"flag": "--nvfp4-text-encoder",
"options": [
{"value": "", "label": "BF16 Qwen3-VL"},
{"value": "./fasth3-nvfp4-encoder/text_encoder", "label": "NVFP4 Qwen3-VL"}
],
"default": ""
},
{
"key": "offload",
"label": "Memory",
"hint": "Resident is how the GB200 timings were measured",
"flag": "--offload",
"options": [
{"value": "resident", "label": "All resident (fastest)"},
{"value": "lazy", "label": "Lazy load + offload"}
],
"default": "resident"
},
{
"key": "num_gpus",
"label": "GPUs",
"hint": "Sequence-parallel degree",
"flag": "--num-gpus",
"options": [1, 2, 4, 8],
"default": 1
}
],
"limitations": [
"Measured on one GB200 at 768p, 5 s, seed 42, NVFP4 text encoder, all resident: FL2VA 28.95 s (first frame 39.6 dB / SSIM 0.971 and last frame 29.1 dB / 0.819 against the inputs); I2VA 27.37 s (first frame 26.5 dB / 0.715). L2VA was not measured separately.",
"The command passes both keyframes; the example uses only the ones the selected task needs. Replace them with your own images.",
"--offload resident keeps every component on the GPU (104.6-108.5 GiB peak with the NVFP4 text encoder, about 129 GiB all-BF16, on GB200). --offload lazy loads components on demand for smaller devices; on GB200 it took 48.06 s for the same T2VA request.",
"FASTVIDEO_ATTENTION_BACKEND=FLASH_ATTN FASTVIDEO_FA4=1 selects FlashAttention-4 on Hopper or Blackwell; FA4 is never enabled just because it is installed. Without FA4, attention uses FA3/FA2 if installed and otherwise falls back to SDPA, which is slow and can run out of memory at 768p.",
"The NVFP4 decoder and text encoder are Blackwell-only. Download them before selecting them: hf download FastVideo/FastH3-NVFP4-VAE-Decoder --include 'quality-36L/decoder-nvfp4.safetensors' 'fast-8L/decoder-nvfp4.safetensors' --local-dir fasth3-nvfp4-vae; hf download FastVideo/FastVideo-FastH3-8-Step-V2-NVFP4-Consumer --include 'text_encoder/*' --local-dir fasth3-nvfp4-encoder. The 8L decoder is a distilled student; validate it on your prompts."
],
"related": ["pika-h3-flex-t2va", "pika-h3-flex-ref2va"]
},
{
"id": "pika-h3-flex-ref2va",
"family": "pika_h3_flex",
"stage": "inference",
"task": "Reference media to video (with audio)",
"label": "Pika H3 Flex references",
"summary": "Generate from ordered image, video, and audio references through Pika's ref2va adapter: up to 9 images, 3 videos, and 3 audio clips.",
"model": "Pika-Labs/Pika-H3-Flex",
"source": "examples/inference/basic/basic_pika_h3_flex.py",
"command": "FASTVIDEO_ATTENTION_BACKEND=FLASH_ATTN FASTVIDEO_FA4=1 python examples/inference/basic/basic_pika_h3_flex.py --task ref2va --offload resident --reference image:assets/images/natural.jpg --reference image:assets/images/bus_terminal.jpg --prompt 'Start on the misty mountain ridge from the first reference, then cut to the night bus terminal from the second.'",
"gpu_types": ["NVIDIA"],
"hardware": {
"platform": "cuda",
"gpu_count": 1,
"accelerator": "NVIDIA GB200",
"peak_memory": "108.5 GiB with two image references (NVFP4 text encoder, 768p, 5 s)",
"evidence": "validated",
"evidence_url": "https://github.com/hao-ai-lab/FastVideo/pull/2038"
},
"evidence": "Verified",
"expected_artifact": "MP4 with synchronized stereo audio at outputs/pika_h3_flex/pika_h3_flex_ref2va.mp4",
"modes": ["Ref2VA"],
"knobs": [
{
"key": "resolution",
"label": "Resolution",
"hint": "Pika resolution tier",
"flag": "--resolution",
"options": [{"value": "768p", "label": "768p"}, {"value": "480p", "label": "480p"}],
"default": "768p"
},
{
"key": "aspect",
"label": "Aspect ratio",
"hint": "Bucket within the tier",
"flag": "--aspect",
"options": ["21:9", "16:9", "3:2", "4:3", "5:4", "1:1", "4:5", "3:4", "2:3", "9:16", "9:21"],
"default": "16:9"
},
{
"key": "duration",
"label": "Duration",
"hint": "Rounded up to the 17n+5 frame grid",
"flag": "--duration",
"options": [
{"value": 5, "label": "5 s (124 frames)"},
{"value": 10, "label": "10 s (243 frames)"},
{"value": 15, "label": "15 s (362 frames)"}
],
"default": 5
},
{
"key": "nvfp4_decoder",
"label": "VAE decoder",
"hint": "NVFP4 needs Blackwell",
"flag": "--nvfp4-decoder",
"options": [
{"value": "", "label": "BF16 H3 VAE"},
{"value": "./fasth3-nvfp4-vae/quality-36L/decoder-nvfp4.safetensors", "label": "NVFP4 36L quality"},
{"value": "./fasth3-nvfp4-vae/fast-8L/decoder-nvfp4.safetensors", "label": "NVFP4 8L fast"}
],
"default": ""
},
{
"key": "nvfp4_text_encoder",
"label": "Text encoder",
"hint": "NVFP4 needs Blackwell",
"flag": "--nvfp4-text-encoder",
"options": [
{"value": "", "label": "BF16 Qwen3-VL"},
{"value": "./fasth3-nvfp4-encoder/text_encoder", "label": "NVFP4 Qwen3-VL"}
],
"default": ""
},
{
"key": "offload",
"label": "Memory",
"hint": "Resident is how the GB200 timings were measured",
"flag": "--offload",
"options": [
{"value": "resident", "label": "All resident (fastest)"},
{"value": "lazy", "label": "Lazy load + offload"}
],
"default": "resident"
},
{
"key": "num_gpus",
"label": "GPUs",
"hint": "Sequence-parallel degree",
"flag": "--num-gpus",
"options": [1, 2, 4, 8],
"default": 1
}
],
"limitations": [
"Measured on one GB200 at 768p, 5 s, seed 42, NVFP4 text encoder, all resident, two image references: 51.17 s (38.42 s denoise, 64,241 packed tokens); both references appear in the output.",
"Repeat --reference image:PATH, video:PATH, or audio:PATH in order. At least one image or video is required; a video reference needs at least 22 frames at 24 fps.",
"Ref2VA loads transformer_ref and the ref2va adapter, a separate 61.7 GiB trunk download. Reference images are encoded at up to a 1024 (480p) or 2048 (768p) short edge, so conditioning memory is higher than T2VA's.",
"--offload resident keeps every component on the GPU (104.6-108.5 GiB peak with the NVFP4 text encoder, about 129 GiB all-BF16, on GB200). --offload lazy loads components on demand for smaller devices; on GB200 it took 48.06 s for the same T2VA request.",
"FASTVIDEO_ATTENTION_BACKEND=FLASH_ATTN FASTVIDEO_FA4=1 selects FlashAttention-4 on Hopper or Blackwell; FA4 is never enabled just because it is installed. Without FA4, attention uses FA3/FA2 if installed and otherwise falls back to SDPA, which is slow and can run out of memory at 768p.",
"The NVFP4 decoder and text encoder are Blackwell-only. Download them before selecting them: hf download FastVideo/FastH3-NVFP4-VAE-Decoder --include 'quality-36L/decoder-nvfp4.safetensors' 'fast-8L/decoder-nvfp4.safetensors' --local-dir fasth3-nvfp4-vae; hf download FastVideo/FastVideo-FastH3-8-Step-V2-NVFP4-Consumer --include 'text_encoder/*' --local-dir fasth3-nvfp4-encoder. The 8L decoder is a distilled student; validate it on your prompts."
],
"related": ["pika-h3-flex-t2va", "pika-h3-flex-keyframes"]
},
{
"id": "open-h3-regenerate-2k",
"family": "minimax_h3",
"stage": "inference",
"task": "Video super-resolution (2K regeneration)",
"label": "Open-H3-Regenerate-2K",
"summary": "Regenerate a 768P MiniMax-H3 draft at 2560x1440 with NVlabs' three-step refiner. The output is silent.",
"model": "Efficient-Large-Model/Open-H3-Regenerate-2K",
"source": "examples/inference/basic/basic_open_h3_regenerate_2k.py",
"command": ": \"${INPUT_VIDEO:?Set INPUT_VIDEO to a 768P MiniMax-H3 draft}\"\npython examples/inference/basic/basic_open_h3_regenerate_2k.py --video \"$INPUT_VIDEO\" --prompt \"A red fox trots through fresh snow in a pine forest at golden hour.\"",
"gpu_types": ["NVIDIA"],
"hardware": {"platform": "cuda", "gpu_count": 1, "evidence": "source-configured"},
"evidence": "Source-backed",
"expected_artifact": "2560x1440 MP4 at outputs/open_h3_regenerate_2k/regen2k_0.mp4",
"related": ["minimax-h3-t2v", "fasth3-8step-v2-cuda"]
},
{
"id": "fasth3-lora-preview",
"family": "minimax_h3",
+92
View File
@@ -0,0 +1,92 @@
{
"generated": "2026-10-28",
"note": "Measured from Hugging Face safetensors files. Sizes are GiB at the checkpoint's own precision.",
"models": {
"fasth3-8step": {
"parts": {
"transformer": 65.29,
"text_encoder": 62.13,
"vae": 9.7,
"audio_vae": 0.56
},
"alternates": {
"transformer_nvfp4": 36.91,
"transformer_fp8": 47.35,
"vae_nvfp4_quality": 5.82,
"vae_nvfp4_fast": 1.33,
"text_encoder_quantized": 15.33
},
"sources": [
"https://huggingface.co/FastVideo/FastVideo-FastH3-8-Step-V2/tree/main"
]
},
"fasth3-4step": {
"parts": {
"transformer": 65.29,
"text_encoder": 62.13,
"vae": 9.7,
"audio_vae": 0.56
},
"alternates": {
"transformer_nvfp4": 36.91,
"transformer_fp8": 47.35,
"vae_nvfp4_quality": 5.82,
"vae_nvfp4_fast": 1.33,
"text_encoder_quantized": 15.33
},
"sources": [
"https://huggingface.co/FastVideo/FastVideo-FastH3-4-step-Preview-v1-VSA-DataFree/tree/main"
]
},
"fasth3-trim": {
"parts": {
"transformer": 34.94,
"text_encoder": 15.33,
"vae": 5.93,
"audio_vae": 0.56
},
"alternates": {
"transformer_nvfp4": 11.1,
"transformer_fp8": 19.88,
"vae_nvfp4_quality": 5.82,
"vae_nvfp4_fast": 1.33
},
"sources": [
"https://huggingface.co/FastVideo/FastVideo-FastH3-Trim-8-Step/tree/main"
]
},
"fasth3-omniref": {
"parts": {
"transformer": 65.33,
"text_encoder": 15.33,
"vae": 9.7,
"audio_vae": 0.56
},
"alternates": {
"transformer_nvfp4": 36.95,
"transformer_fp8": 47.39,
"vae_nvfp4_quality": 5.82,
"vae_nvfp4_fast": 1.33
},
"sources": [
"https://huggingface.co/FastVideo/FastVideo-FastH3-Omni-8-Step-V1/tree/main",
"https://huggingface.co/FastVideo/FastH3-Omni-NVFP4-QAD/tree/main"
]
},
"pika-h3-flex-t2va": {
"parts": {
"transformer": 61.73,
"adapter": 2.58,
"text_encoder": 62.13,
"vae": 9.7,
"audio_vae": 0.56
},
"alternates": {
"vae_nvfp4_quality": 5.82,
"vae_nvfp4_fast": 1.33,
"text_encoder_quantized": 15.33
},
"sources": ["https://huggingface.co/Pika-Labs/Pika-H3-Flex/tree/main"]
}
}
}
+1 -1
View File
@@ -13,9 +13,9 @@ plain typographic tile instead — that tile is a UI placeholder, not a logo.
| `nvidia.webp` | [Official NVIDIA Hugging Face organization avatar](https://huggingface.co/nvidia) | Cosmos and GEN3C cards, Cosmos page header |
| `kandinsky.webp` | [Official Kandinsky Lab Hugging Face organization avatar](https://huggingface.co/kandinskylab) | Kandinsky 5 and Kandinsky 6 cards and page headers |
| `black-forest-labs.webp` | [Official Black Forest Labs Hugging Face organization avatar](https://huggingface.co/black-forest-labs) | FLUX family card and page header |
| `ifm.webp` | [Official Institute of Foundation Models (IFM) Hugging Face organization avatar](https://huggingface.co/IFM) | PAN 2 family card and page header |
| `qwen.webp` | [Official Qwen Hugging Face organization avatar](https://huggingface.co/Qwen) | Qwen-Image-2.1 family card and page header |
| `minimax.webp` | [Official MiniMax Hugging Face organization avatar](https://huggingface.co/MiniMaxAI) | MiniMax H3 family card and page header |
| `pika.webp` | [Official Pika-Labs Hugging Face organization avatar](https://huggingface.co/Pika-Labs) | Pika H3 Flex card and page header |
| `stabilityai.webp` | [Official Stability AI Hugging Face organization avatar](https://huggingface.co/stabilityai) | Stable Diffusion and Stable Audio cards and page headers |
| `fastvideo.webp` | [Official FastVideo Hugging Face organization avatar](https://huggingface.co/FastVideo) | Matrix Game and MMAudio cards (converted weights published by this org) |
Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

+13 -6
View File
@@ -157,15 +157,22 @@ __all__ = [
## 4. Register the Backend
Update `fastvideo/attention/backends/__init__.py` to export your new class.
Registration is two edits:
```python
from .mynew_attn import MyNewAttnBackend
```
1. Add a member to `AttentionBackendEnum` in `fastvideo/platforms/interface.py`.
2. Add the selection branch in `fastvideo/platforms/cuda.py`, next to the
existing backends, so `--attention-backend` maps the enum to your class.
## 5. Platform Integration
`fastvideo/attention/backends/__init__.py` is empty and does not register
anything.
If your backend requires specific platform checks (e.g., checking for H100 support), handle that in `fastvideo/platforms/cuda.py` or within your backend's `__init__`.
## 5. Platform integration
If your backend needs platform checks, such as an H100 guard, put them in
`fastvideo/platforms/cuda.py` or in the backend constructor.
The maintained version of this guide is
[Add an attention backend](../../contributing/attention_backend.md).
## 6. Add Documentation
+4 -4
View File
@@ -85,10 +85,10 @@ from fastvideo_kernel import video_sparse_attn
# topk: Number of blocks to attend
output = video_sparse_attn(
q, k, v,
block_sizes,
block_sizes,
topk=32
q, k, v,
variable_block_sizes,
q_variable_block_sizes,
topk=32,
)
```
+36 -1
View File
@@ -49,7 +49,7 @@ node docs/js/build-cookbook-capacity.mjs --check
node docs/js/build-cookbook-capacity.mjs --preview fastwan21
```
The check currently prints `Validated 1 capacity model(s) and 50 hardware variant(s).`
The check currently prints `Validated 1 capacity model(s) and 52 hardware variant(s).`
The preview prints the model with component references expanded and each
example's baseline under `base_config`. Inspect its identity, counts, geometry,
and evidence. Both commands validate deployments and capacity metadata without
@@ -404,6 +404,41 @@ matching metric, scope, phases, and execution context. One point can calibrate a
existing scaling assumption, not establish accuracy across modes. Missing startup
evidence leaves startup coverage unknown. Keep observations intact when deriving coefficients.
## FastH3 coverage
No FastH3 checkpoint has a full capacity model yet. A weights-only preflight
is live on the FastH3 pages: it compares measured resident checkpoint weights
with the selected device and labels the result as weights, not a request peak.
Host-only system RAM is not accepted as GPU capacity. Transformer quantization,
TAEH3, and NVFP4 decoder swaps return unknown until their
effective layer profiles, retained components, and packed storage are modeled.
A full estimate still stops early: `checkCoverage()` in `docs/js/cookbook-memory.mjs` still stops before the
estimate for every FastH3 serving profile.
| Profile | First stop | Why |
| --- | --- | --- |
| OmniRef | Workload | Runs image-to-video; the estimator models text-to-video only |
| 8-Step CUDA | Topology | Defaults to four GPUs and Ulysses sequence parallelism |
| Trim Spark | Attention | `VIDEO_SPARSE_ATTN_H3` is not a dense backend, so VSA workspace is unmodeled |
| 4-Step Spark | Execution | `h3_sequential_load` loads components on demand |
| Omni NVFP4 decoder | Precision | The decoder swap changes decoder precision |
To make a FastH3 estimate possible, in order:
1. Add capacity entries for each checkpoint: transformer parameter counts and
geometry, the text encoder, the VAE, and the NVFP4 decoder variants.
2. Model an image-to-video request: reference latents and audio tokens change
the working set, and OmniRef passes ordered image, video, and audio inputs.
3. Model per-rank sharding for four-GPU Ulysses, including replicated weights
and per-rank sequence slices, so the estimate compares against one device.
4. Add a VSA-H3 workspace term, or a dense upper bound that is labeled as one.
5. Support quantized decoder precision instead of rejecting the configuration.
6. Record successful runs on the RTX 5090, RTX PRO 6000, and DGX Spark profiles
so the analytical estimate can be calibrated rather than trusted blindly.
Until those land, the cookbook reports a weights fact for FastH3 instead of a
peak estimate. That is the correct result, not a bug.
## Static consumers
[`cookbook-capacity.mjs`](../js/cookbook-capacity.mjs) exposes these async loaders,
+12 -7
View File
@@ -13,7 +13,9 @@ test in the same pull request.
them directly with `os.environ.get("NAME")`, and the name must be in the external-variable allowlist
(`EXTERNAL_ALLOWLIST` in the contract test). When FastVideo sets such a variable for the other tool, it calls
`envs.set_external`, `envs.setdefault_external`, or `envs.unset_external`, and the name must be in
`EXTERNAL_WRITE_ALLOWLIST`. Variables that FastVideo's CI and CI tooling define (for example `TEST_SCOPE` and
`EXTERNAL_WRITE_ALLOWLIST`. Code that needs every variable of a family that another tool owns, such as FA4's
`FA4_*` tuning knobs, calls `envs.external_with_prefixes("FA4_")` with literal prefixes that the allowlist
covers. Variables that FastVideo's CI and CI tooling define (for example `TEST_SCOPE` and
`PERF_RUN_SOURCE`) keep their names, and test code under `fastvideo/tests/` reads them directly; they are listed
in `CI_ONLY_VARIABLES` in the contract test, together with the file that sets each one.
2. **Read with `envs.NAME.get()`, write with `envs.NAME.set()`, and change a value in tests with
@@ -194,6 +196,7 @@ longer exists also fails the test, so the fixing pull request deletes its entry.
| `FASTVIDEO_ATTENTION_BACKEND` | str | unset | attention | Attention backend, as an AttentionBackendEnum name such as TORCH_SDPA, FLASH_ATTN, VIDEO_SPARSE_ATTN, SAGE_ATTN, or SAGE_ATTN_THREE. FastVideoArgs uses it when FastVideoArgs.attention_backend is unset. |
| `FASTVIDEO_FA4` | bool | `0` | attention | The FLASH_ATTN backend uses FlashAttention-4 (flash_attn.cute) instead of FA3 or FA2. |
| `FASTVIDEO_MINIMAX_H3_FA4_PACKED_VARLEN` | bool | `0` | attention | MiniMax-H3 dense DiT self-attention uses the FlashAttention-4 packed-varlen entry point. This changes the floating-point reduction order, so it is an inference-only opt-in. |
| `FASTVIDEO_MINIMAX_H3_DENSE_ATTN` | one of flash, cudnn | `flash` | attention | Kernel for MiniMax-H3 dense DiT self-attention: flash keeps the selected FlashAttention backend; with FLASH_ATTN, cudnn sends the no-mask, no-grad dense call through cuDNN's fused SDPA. cuDNN has a different reduction order, so this is an inference-only opt-in. |
| `FASTVIDEO_VSA_PTX_BLACKWELL` | bool | `0` | attention | VIDEO_SPARSE_ATTN_H3 sends tile-64 forwards to the data-center Blackwell (sm_100a / sm_103a) kernel; grad-tracking forwards need its autograd operator, else Triton. fastvideo-kernel reads the same variable with the same rule. Deprecated names: `FASTVIDEO_VSA_SM100A`. |
| `FASTVIDEO_VSA_TRITON` | bool | `0` | attention | Force the Triton MiniMax-H3 sparse attention kernel. fastvideo-kernel reads the same variable. |
| `FASTVIDEO_NVFP4_FA4` | bool | `0` | attention | FlashAttention-4 quantizes Q and K to NVFP4. An explicit nvfp4_fa4 attention implementation argument takes precedence. |
@@ -208,7 +211,7 @@ longer exists also fails the test, so the fixing pull request deletes its entry.
| `FASTVIDEO_VAE_PARALLEL_DECODE_STRATEGY` | str | unset | performance | Collective that moves chunks in parallel VAE decode: gather (used when unset) or all_gather. |
| `FASTVIDEO_H3_ENCODER_SPLIT` | bool | `0` | distributed | MiniMax-H3 runs the Qwen3-VL text encoder on a dedicated group of nodes, so the denoising ranks never load it. Same as FastVideoArgs.h3_encoder_split=True. |
| `FASTVIDEO_H3_ENCODER_NODES` | int | `1` | distributed | Number of leading nodes that FASTVIDEO_H3_ENCODER_SPLIT dedicates to the MiniMax-H3 text encoder. Values below 1 count as 1. |
| `FASTVIDEO_MINIMAX_H3_FUSIONS` | str | `""` | performance | MiniMax-H3 inference-only Triton fusions: all, 1, or a comma-separated subset of modulate,qknorm_rope,swiglu. Empty, 0, or none keeps the eager implementation. |
| `FASTVIDEO_MINIMAX_H3_FUSIONS` | str | `""` | performance | MiniMax-H3 inference-only Triton fusions: all, 1, or a comma-separated subset of modulate,qknorm_rope,swiglu. The whole-tier value mk selects the MotionKernel kernels instead of the Sol-Engine ones. Empty, 0, or none keeps the eager implementation. |
| `FASTVIDEO_MINIMAX_H3_EXACT_KERNELS` | str | `""` | performance | MiniMax-H3 inference-only Triton kernels that reproduce the eager output bit for bit: all, 1, or a comma-separated subset of rope,modulate,swiglu. An op whose FASTVIDEO_MINIMAX_H3_FUSIONS fusion is on keeps the fusion. Empty, 0, or none keeps the eager implementation. |
| `FASTVIDEO_FSDP2_AUTOWRAP` | bool | `0` | performance | FSDP2 shards modules by parameter count instead of the model's shard conditions. Not supported by self-forcing distillation. |
| `FASTVIDEO_FSDP2_MIN_PARAMS` | int | `10000000` | performance | Minimum parameter count of a module that FASTVIDEO_FSDP2_AUTOWRAP shards. |
@@ -259,7 +262,6 @@ longer exists also fails the test, so the fixing pull request deletes its entry.
| `FASTVIDEO_H3_VSA_FP4_K_SMOOTH` | bool | `0` | attention | Subtract the per-head key mean before FP4 key quantization, matching the emulator and QAD training numerics. Exact for the softmax; it only changes quantization error. |
| `FASTVIDEO_H3_GATE_CHUNK_ROWS` | int | `0` | performance | Rows per chunk when the VSA FP4 gate branch expands the pooled compression output back to token rows. 0 keeps the single full-size index_select; a positive value caps the transient at chunk_rows\*heads\*head_dim and is bit-identical. |
| `FASTVIDEO_H3_VSA_FINE_ATTENTION` | one of , emulator-mix8, emulator-mixmx, emulator-qk, emulator-bf16 | `""` | attention | Replace tile-128 fine attention, preserving VSA selection and compression, with the Triton emulator of the sparse FP4 kernel: emulator-mix8 = NVFP4 Q/K video, per-token FP8 Q/K prefix; emulator-mixmx = MXFP8 prefix; emulator-qk = NVFP4 Q/K everywhere; emulator-bf16 = unquantized BF16. emulator-bf16 runs on any CUDA GPU. Nonempty overrides take priority over FASTVIDEO_H3_VSA_FP4. Empty keeps the native route. |
| `FASTVIDEO_WAN22_VAE_DECODE_DIR` | path | `""` | performance | Directory of the Wan2.2 VAE decoder kernels (fastvideo-kernel/wan22_vae_decode) that AutoencoderKLWan loads, and builds on first use, when its config sets decode_kernel. Empty takes the copy in this checkout. |
| `FASTVIDEO_H3_VSA_HEADS_FIRST_TILE` | bool | `0` | performance | VSA-H3 64/128-token tiles: scatter rows straight into the heads-first layout the sparse kernels read, removing their per-call transpose copy of query, key and value. Bit-identical; no-grad, uncompiled CUDA only. |
| `FASTVIDEO_H3_VSA_TILE_FIRST` | bool | `0` | attention | Single-rank MiniMax-H3 VSA with one tile gather of the block input instead of separate Q/K/V/gate scatters. |
| `FASTVIDEO_H3_VSA_SM89_KERNEL` | one of original, bf16, int8 | `original` | attention | Fine-attention kernel for MiniMax-H3 VSA on sm_89: original, bf16, or int8 (INT8 QK, BF16 PV). |
@@ -268,7 +270,7 @@ longer exists also fails the test, so the fixing pull request deletes its entry.
| `FASTVIDEO_H3_FP8_ATTENTION` | bool | `0` | performance | With NVFP4 layer_profile h3_dit_ffn, run MiniMax-H3 attention projections in FP8. |
| `FASTVIDEO_H3_FP8_GRANULARITY` | one of tensor, channel | `tensor` | performance | FP8 scaling granularity for FASTVIDEO_H3_FP8_ATTENTION. |
| `FASTVIDEO_LORA_SIDE_BRANCH` | bool | `0` | performance | Apply inference LoRA adapters as a bf16 side branch next to every target linear instead of merging them, so the adapter strength can change at runtime. Packed NVFP4 or FP8 linears without their original dense weight always use the side branch. |
| `FASTVIDEO_NVFP4_MM_BACKEND` | str | `auto` | performance | FlashInfer mm_fp4 backend for NVFP4 linears, e.g. auto or cutlass. |
| `FASTVIDEO_NVFP4_MM_BACKEND` | str | `auto` | performance | FlashInfer mm_fp4 backend for NVFP4 linears: auto (cudnn for MiniMax-H3 DiT profiles on sm_100), cutlass or cudnn. |
| `FASTVIDEO_NVFP4_MM_ROW_SPLIT` | one of off, table, auto | `off` | performance | Run large cuDNN NVFP4 GEMMs over row chunks where its plan heuristic is slow: off, table (fixed per-shape chunk counts) or auto (time 1/2/4 chunks once per shape; not deterministic across runs). |
| `FASTVIDEO_NVFP4_ACT_AMAX` | path | unset | performance | JSON of calibrated NVFP4 input amax per linear, keyed b&lt;block&gt;.&lt;sub&gt; or full prefix; sets a static activation scale. |
| `FASTVIDEO_NVFP4_DYNAMIC_ACT` | str | `""` | performance | NVFP4 linears that derive the activation scale per call: all, or comma-separated layer-name suffixes such as ff.fc_out. |
@@ -279,15 +281,19 @@ longer exists also fails the test, so the fixing pull request deletes its entry.
| `FASTVIDEO_H3_SPLICE_FROM_STEP` | int | `4` | eval | First denoising step run by FASTVIDEO_H3_SPLICE_TRANSFORMER. |
| `FASTVIDEO_H3_ENCODER_LAYERWISE` | bool | `0` | performance | Stream MiniMax-H3 text-encoder language layers through exact-size pinned host memory (text-only prompts). |
| `FASTVIDEO_H3_ENCODER_FUSED_DEQUANT` | bool | `0` | performance | Expand the serialized NVFP4 MiniMax-H3 text encoder with one fused Triton pass on GPUs without FP4 GEMM. |
| `FASTVIDEO_H3_VAE_TILE_BATCH` | int | `1` | performance | Spatial tiles per MiniMax-H3 video VAE decoder call; 1 decodes per tile. |
| `FASTVIDEO_H3_VAE_TILE_BATCH` | int | `8` | performance | Spatial tiles per MiniMax-H3 video VAE decoder call. The ViT decoder treats batch entries independently, so batching tiles matches per-tile decoding, except for NVFP4 decoders with dynamic activation scales, which always decode per tile. On GB200 at 768x1344, 8 tiles per call decodes 107 frames in 3.7 s instead of 5.4 s and 362 frames in 12.8 s instead of 18.3 s (+0.7 GiB peak). 1 decodes per tile. |
| `FASTVIDEO_H3_VAE_FUSED_QK_NORM_ROPE` | bool | `0` | performance | Fuse the MiniMax-H3 video VAE decoder per-head QK RMSNorm and partial RoPE into one Triton pass per attention layer (same fp32/inference-dtype arithmetic; the RMSNorm reduction order differs, like the DiT FASTVIDEO_MINIMAX_H3_FUSIONS qknorm_rope). |
| `FASTVIDEO_H3_REF2VA_MEMO_ENTRIES` | int | `0` | performance | Entries in each content-keyed MiniMax-H3 memo of reference encodes (Qwen3-VL Ref2VA presentation, VAE keyframe latents); repeat references are reused exactly. 0 disables. |
| `FASTVIDEO_H3_VAE_TILE_PARALLEL` | bool | `0` | performance | Split MiniMax-H3 VAE spatial tiles (not only temporal chunks) across the sequence-parallel ranks in the parallel decode and keyframe encode; bitwise equal to the serial path. |
| `FASTVIDEO_H3_VAE_TILE_PARALLEL` | bool | `0` | performance | Split MiniMax-H3 VAE spatial tiles (not only temporal chunks) across the sequence-parallel ranks in the parallel decode and keyframe encode; bitwise equal to the serial path. Requires FASTVIDEO_H3_VAE_TILE_BATCH=1 (the split replays the per-tile path). |
| `FASTVIDEO_H3_VAE_NVFP4_FUSED` | bool | `1` | performance | Run NVFP4 MiniMax-H3 VAE decoder blocks through the bit-exact fused Triton kernels at inference. |
| `FASTVIDEO_H3_VAE_NVFP4_CUDA_GRAPH` | bool | `1` | performance | Replay small fused NVFP4 MiniMax-H3 VAE decoder calls (at most four 1797-token tiles) as per-shape CUDA graphs. |
| `FASTVIDEO_H3_VAE_NVFP4_DECODER` | path | unset | performance | Exported NVFP4 MiniMax-H3 video decoder (.pt, or .safetensors with bf16 masters or pre-packed NVFP4 weights) that replaces the VAE decoder at load; skips the INT8 ConvRot overlay. |
| `FASTVIDEO_H3_VAE_INT8_OVERLAY` | bool | `1` | performance | Apply the INT8 ConvRot decoder overlay when the MiniMax-H3 VAE folder ships one; 0 decodes with the dense weights. |
| `FASTVIDEO_H3_VAE_INT8_SHARED_QKV` | bool | `0` | performance | Share the INT8 activation rotation and quantization across the MiniMax-H3 VAE Q/K/V projections. |
| `FASTVIDEO_H3_VAE_INT8_TRANSPOSE_VIEW` | bool | `0` | performance | Use transposed weight views in the MiniMax-H3 VAE INT8 projections. |
| `FASTVIDEO_H3_VAE_COMPILE_BLOCKS` | bool | `0` | performance | Run the MiniMax-H3 video VAE decoder transformer blocks through one regional torch.compile graph: Inductor fuses the fp32 residual scale/add pairs, the RMSNorm+cast and the SwiGLU silu\*mul. |
| `FASTVIDEO_H3_VAE_UINT8_PIXELS` | bool | `1` | performance | Quantize single-GPU MiniMax-H3 video VAE output to uint8 on the GPU per chunk and stream uint8 into the host buffer; frames are bitwise identical to the fp32 path. 0 streams fp32. |
| `FASTVIDEO_H3_VAE_ENCODE_TILE_BATCH` | int | `1` | performance | Spatial tiles per MiniMax-H3 video VAE encoder call (reference and keyframe encodes); 1 encodes per tile. Batched tiles differ from per-tile encoding only in reduction order. |
| `FASTVIDEO_H3_VAE_INT8_FUSED_DEQUANT` | bool | `0` | performance | Fused dequantization epilogue for the MiniMax-H3 VAE INT8 projections. |
| `FASTVIDEO_H3_PINNED_SWAP` | bool | `1` | performance | Swap offloaded MiniMax-H3 modules through exact-size pinned host arenas. |
| `FASTVIDEO_H3_PARK_MODULES` | str | unset | performance | Comma-separated MiniMax-H3 denoise modules parked on the host while the text encoder runs, e.g. vae,audio_vae. |
@@ -329,7 +335,6 @@ longer exists also fails the test, so the fixing pull request deletes its entry.
| `FASTVIDEO_TEST_MINIMAX_H3_GATE_LAYER` | int | `0` | test | Transformer layer that the MiniMax-H3 golden-gate test checks. Deprecated names: `MINIMAX_H3_GATE_LAYER`. |
| `FASTVIDEO_TEST_MINIMAX_H3_MODEL_ROOT` | str | unset | test | Local MiniMax-H3 checkpoint for the golden-gate test. Deprecated names: `MINIMAX_H3_MODEL_ROOT`. |
| `FASTVIDEO_TEST_SD35_MODEL_DIR` | str | `stabilityai/stable-diffusion-3.5-medium` | test | Model for the Stable Diffusion 3.5 SSIM test. Deprecated names: `SD35_MODEL_DIR`. |
| `FASTVIDEO_TEST_PAN2_MODEL_DIR` | path | unset | test | PAN2 checkpoint root (with vae/) for the Wan2.2 VAE decode-kernel GPU tests; unset skips them. |
| `FASTVIDEO_TEST_TAEH3_REFERENCE_DIR` | str | unset | test | Upstream taehv checkout for the MLX TAEH3 parity test. Deprecated names: `TAEH3_REFERENCE_DIR`. |
| `FASTVIDEO_TEST_WAN_ANIMATE_MODEL_DIR` | str | unset | test | Local Wan2.2-Animate-14B checkpoint for the Wan-Animate weight tests. Deprecated names: `WAN_ANIMATE_MODEL_PATH`. |
-1
View File
@@ -6,7 +6,6 @@ The interactive catalog groups models by family and generates YAML for supported
- [FastH3](minimax-h3.md)
- [Wan and FastMetal](wan.md)
- [LTX](ltx.md)
- [PAN 2](pan2.md)
- [FLUX](flux.md)
- [Qwen-Image-2.1](qwen-image21.md)
- [Stable Diffusion](stable-diffusion.md)
+29 -3
View File
@@ -59,7 +59,7 @@
</tr>
<tr>
<td>DGX Spark</td>
<td>FastH3 V1 on one GB10, or two Sparks with Ray sequence parallel (<code>sp_size=2</code>) over QSFP RoCE. Select NVIDIA DGX Spark, then 1 Spark or 2 Sparks.</td>
<td>FastH3 V1 on one GB10. Two Sparks run one clip faster over QSFP RoCE (<code>sp_size=2</code>), but this page configures one Spark.</td>
<td>Not wired</td>
</tr>
<tr>
@@ -72,6 +72,32 @@
</div>
</details>
<details id="pika-h3-flex">
<summary>Pika H3 Flex</summary>
<div >
<p>Pika H3 Flex is Pika's post-trained MiniMax-H3: the H3 components plus a rank-128 adapter that FastVideo applies automatically, sampled with a fixed six-forward re-noise ladder. One recipe page covers T2VA, the keyframe tasks (I2VA, L2VA, FL2VA), and Ref2VA. Its builder selects the resolution tier (480p or 768p), the aspect bucket, the duration (5, 10, or 15 s), and the optional Blackwell NVFP4 decoder and text encoder. The <a href="../../inference/pika_h3_flex/">Pika H3 Flex runbook</a> has the full sampling and geometry contract.</p>
<pre><code>FASTVIDEO_ATTENTION_BACKEND=FLASH_ATTN FASTVIDEO_FA4=1 \
python examples/inference/basic/basic_pika_h3_flex.py --task t2va --offload resident \
--resolution 768p --aspect 16:9 --duration 5 \
--prompt "A paper boat drifts down a rainy city street at night."</code></pre>
<table>
<thead>
<tr>
<th>T2VA on one GB200, 768p 16:9, 5 s, warm, all resident</th>
<th>Time</th>
</tr>
</thead>
<tbody>
<tr><td>Official <code>pika-h3-pro</code> CLI</td><td>59.7 s</td></tr>
<tr><td>FastVideo BF16</td><td>26.2 s</td></tr>
<tr><td>+ NVFP4 <code>quality-36L</code> decoder (40.5 dB PSNR vs BF16 decoder)</td><td>18.0 s</td></tr>
<tr><td>+ NVFP4 <code>fast-8L</code> decoder</td><td>17.4 s</td></tr>
</tbody>
</table>
<p>The BF16 run peaked at about 129 GiB resident on GB200; the official CLI's sampling peak was 71.7 GiB. With the NVFP4 text encoder, I2VA took 27.37 s, FL2VA 28.95 s, and Ref2VA with two image references 51.17 s, at 104.9 to 108.5 GiB peak. The example's default <code>--offload lazy</code> fits smaller devices but reloads components per request (48.06 s for the same T2VA request on GB200). FlashAttention-4 needs <code>FASTVIDEO_FA4=1</code> even when it is installed. Without it, attention uses FA3 or FA2 if one is installed and otherwise falls back to SDPA, which is slow and can run out of memory at 768p. GB10 has no FA4, so drop the prefix on DGX Spark.</p>
</div>
</details>
<details id="cookbook-setup">
<summary>Setup</summary>
<div >
@@ -100,7 +126,7 @@ cd FastVideo</code></pre>
<li>The FastH3 CUDA performance profile was measured on four GB200 GPUs. Use its strict profile when exact operation order matters more than the measured performance configuration.</li>
<li>The MLX source runtime supports T2VA, optional temporal <code>--fast</code>, optional spatial <code>--fast-spatial</code>, and opt-in VSA on <code>--include-vsa</code> checkpoints. FastH3 V2 MLX converts with <code>--include-vsa</code> and runs eight forwards. FL2VA, Ref2VA, and two-pass refinement are not wired.</li>
<li>GPU count and VAE decode backend are configurable in the builder above for FastH3 CUDA recipes. Only the value shown by default has a recorded run; other supported values are unmeasured here.</li>
<li>DGX Spark is a runtime on FastH3 V1, not a separate family card. Select NVIDIA DGX Spark, then 1 Spark or 2 Sparks. The CUDA GPU-count knob does not apply to Spark.</li>
<li>DGX Spark is a runtime on FastH3 V1, not a separate family card. This page configures one Spark. For two Sparks, run <code>examples/inference/basic/basic_fasth3_spark_pair.yaml</code>; there is no serving baseline. The CUDA GPU-count knob does not apply to Spark.</li>
<li>CompactH3 NVFP4 is one Blackwell GPU. RTX 5090 (32 GB) parks the encoder in pinned host RAM. RTX PRO 6000 Blackwell (96 GB) keeps encoder, DiT, and VAE resident. Keep <code>FASTVIDEO_ATTENTION_BACKEND=ATTN_QAT_INFER</code>, <code>FASTVIDEO_FA4=0</code>, <code>FASTVIDEO_VSA_PTX_BLACKWELL=0</code>, and <code>FLASHINFER_CUDA_ARCH_LIST=12.0a</code>. On PRO 6000 enable VAE compile and leave DiT <code>inference_torch_compile</code> off. CompactH3 is a dense prune; do not enable <code>VIDEO_SPARSE_ATTN_H3</code> until a VSA-trained student exists.</li>
<li>GB10 has no FA4 / ptx_blackwell VSA kernel. Keep <code>FASTVIDEO_FA4=0</code> and <code>FASTVIDEO_VSA_PTX_BLACKWELL=0</code>. Legal <code>num_frames</code> values are <code>17n+5</code>, capped at 362 (15.08 s). A 345-frame request on one Spark can OOM. Native 16:9 sizes include 832×480 and 1344×768.</li>
<li>Gated or missing checkpoints: run <code>huggingface-cli login</code> and confirm you accepted the model's license on Hugging Face.</li>
@@ -111,6 +137,6 @@ cd FastVideo</code></pre>
<details id="cookbook-evidence">
<summary>Evidence status</summary>
<div >
<p>Every command, model ID, and flag on this page maps to a checked-in FastVideo source. Recipes marked <strong>Verified</strong> also have a recorded hardware path in linked FastVideo evidence. The full H3 CUDA examples remain <strong>Source-backed</strong> where the source records a GPU count but no GPU model or memory requirement. Unlisted hardware is unknown, not unsupported.</p>
<p>Every command, model ID, and flag on this page maps to a checked-in FastVideo source. No recipe here carries a recorded run for a specific device, so the editor does not claim one. The full H3 CUDA examples remain <strong>Source-backed</strong> where the source records a GPU count but no GPU model or memory requirement. Unlisted hardware is unknown, not unsupported.</p>
</div>
</details>
+17 -20
View File
@@ -1,30 +1,27 @@
# Run H3 with a server and playground
Start FastH3 on CUDA, one DGX Spark, or Apple Silicon MLX, then iterate on prompts in a browser,
with cURL, or from your app. The server and clients can run on the same machine.
Start FastH3 on CUDA, one DGX Spark, or Apple Silicon MLX, then send requests from a
browser, cURL, or your app. Several clients can share one server process.
The server supports the OpenAI-compatible video-job API. The Python and
JavaScript client examples use that interface, but requests go to FastVideo.
You do not need an OpenAI account or cloud key.
CUDA requests reuse a loaded `VideoGenerator`. MLX keeps one pipeline object and
a prompt-embedding cache, but loads and releases heavyweight components between
phases to fit unified memory. MLX serving still pays that component-loading cost
on subsequent requests.
The [H3 recipe selector](minimax-h3.md) provides the same workflow with runtime
selection. This guide covers FastH3 V1 (four forwards) and FastH3 V2
(eight forwards). Start a server, then iterate in the playground or with the
OpenAI Python client. Other H3 recipes keep their direct Python commands.
The server implements the OpenAI-compatible video-job API. The Python and
JavaScript clients use that API, but requests go to FastVideo. You do not need an
OpenAI account or cloud key.
CUDA requests reuse one loaded `VideoGenerator`. The Python SDK can do the same
when you reuse the generator across `generate()` calls. MLX keeps one
`MiniMaxH3MLXPipeline` and a prompt-embedding cache, but still loads and releases
components between phases to fit unified memory. MLX serving does not keep all
weights resident or remove phase-loading time. Use a server when you want
separate clients to share the process without restarting scripts.
This guide covers FastH3 V1 (four forwards) and FastH3 V2 (eight forwards). For
direct Python generation, see the [H3 cookbook](minimax-h3.md).
## Install and start the server
All runtimes need a FastVideo clone and an activated Python environment.
### CUDA
Use a FastVideo clone and an activated Python environment. Complete the
[CUDA installation requirements](../getting_started/installation/gpu.md)
Complete the [CUDA installation requirements](../getting_started/installation/gpu.md)
before running these commands:
```bash
@@ -33,9 +30,9 @@ fastvideo serve --config examples/serving/openai_fasth3.yaml --server.host 127.0
```
The configuration loads `FastVideo/FastVideo-Minimax-FastH3-Preview-v0.2` and
advertises it as `fasth3`. It configures four CUDA GPUs but does not record
a GPU model or VRAM requirement. This is a source-backed server profile, not
the measured GB200 Python performance profile. Compilation is disabled.
advertises it as `fasth3`. It configures four CUDA GPUs and does not record a GPU
model or VRAM requirement, so treat it as a starting point, not a measured
profile. Compilation is disabled.
For FastH3 V2, use the same install and the 8-step config (nine sigma
points, eight DiT forwards, VSA sparsity 0.8):
+9 -1
View File
@@ -44,7 +44,7 @@ runtimes:
host: 0.0.0.0
port: 8000
served_model_name: null
workloads: [t2v, i2v]
workloads: [t2v, i2v, v2v]
options: &cuda_options
server.served_model_name:
anyOf:
@@ -97,6 +97,14 @@ runtimes:
title: Compile transformer
type: boolean
default: false
generator.engine.quantization.transformer_quant:
title: Transformer quantization
description: >-
Quantizes the DiT at load time. NVFP4 needs a Blackwell GPU. FP8 runs on Hopper and Ada.
Leave empty to use the checkpoint's own precision.
type: [string, "null"]
enum: [null, NVFP4, FP8]
default: null
# MiniMax-H3 only (fastvideo_args.video_decode_backend). TAEH3 fetches its own SHA-pinned weights.
generator.pipeline.experimental.video_decode_backend:
title: Video decoder
-76
View File
@@ -1,76 +0,0 @@
# PAN 2 recipes
<RecipeFamily family="pan2" />
Generate a video from text, or from a first frame, with `IFM/PAN2`. One pipeline
serves both modes: leave out the first frame for text-to-video and supply one
for image-to-video. The defaults are 704×1248, 313 frames (13 s at 24 fps),
50 steps and guidance 3.0. See [PAN 2 Text/Image to Video](../inference/pan2.md)
for the model, sampler and parallelism details.
## Run on a GPU
Follow the [CUDA installation guide](../getting_started/installation/gpu.md), then run
from the repository root. The full `IFM/PAN2` checkpoint is not public yet; pass a
local directory or private repo you can access with `PAN2_MODEL_PATH`.
```bash
python examples/inference/basic/basic_pan2.py
```
Set `IMAGE_PATH` in the script to a first frame for image-to-video. With more
GPUs, `PAN2_NUM_GPUS=4` shards the video tokens with sequence parallelism; the
count must divide PAN 2's 32 attention heads. Each rank still holds the full
29B DiT, so extra GPUs speed up generation but do not reduce per-device weight
memory. See [CPU offloading](../inference/offloading.md) for devices that cannot
hold the DiT.
## Serve with the OpenAI-compatible API
`examples/serving/openai_pan2.yaml` serves PAN 2 through the shared
`/v1/videos` routes. It mirrors the Python example: one GPU, the text encoder
offloaded between requests, and the pipeline's own sampling defaults.
```bash
fastvideo serve --config examples/serving/openai_pan2.yaml --server.host 127.0.0.1
```
Text-to-video uses the same request as any other served video model, with the
alias `pan2`:
```bash
curl --fail-with-body http://127.0.0.1:8000/v1/videos \
-H 'Content-Type: application/json' \
-d '{"model": "pan2", "prompt": "A red fox trots through fresh snow in a pine forest at dawn."}'
```
For image-to-video, add a first frame in `image_reference` (or the legacy
`input_reference`). The server then runs image-to-video; no second deployment or
workload is needed.
```bash
curl --fail-with-body http://127.0.0.1:8000/v1/videos \
-H 'Content-Type: application/json' \
-d '{"model": "pan2", "prompt": "The fox turns toward the camera.",
"image_reference": {"image_url": "https://example.com/first-frame.png"}}'
```
Local image paths must sit under `server.media_roots`, and private or loopback
URLs need `server.allow_private_media_urls`. Poll `/v1/videos/{id}` and download
`/v1/videos/{id}/content` as in the [serving guide](openai-api.md). Generation
time for the default 13-second clip is not measured, so raise the client's
polling deadline for long jobs instead of treating a slow job as failed.
The cookbook's generated serving clients send a text prompt only. The image
request above is the supported way to supply a first frame over HTTP, and no
recorded generation backs it yet.
## Evidence
Both recipes are **Source-backed**: the model ID, example and serving YAML are
checked against each other, and the serving defaults are tested against the
registered `pan2_t2v` preset. No GPU generation, memory measurement or HTTP
client run is recorded, so hardware fit and throughput are unknown. Numerical
parity with the reference implementation is checked on CPU with a public
random-weight checkpoint, which verifies the port and produces noise rather than
useful video.
+1
View File
@@ -24,6 +24,7 @@ deployments:
- generator.engine.parallelism.tp_size
- generator.engine.parallelism.sp_size
- generator.engine.offload.text_encoder
- generator.engine.quantization.transformer_quant
- generator.engine.offload.vae
- generator.pipeline.experimental.video_decode_backend
- generator.pipeline.experimental.h3_vae_nvfp4_decoder
+1
View File
@@ -21,6 +21,7 @@ deployments:
- generator.engine.parallelism.*
- "!generator.engine.parallelism.hsdp_*"
- generator.engine.offload.text_encoder
- generator.engine.quantization.transformer_quant
- generator.engine.offload.vae
- generator.pipeline.experimental.video_decode_backend
- generator.pipeline.experimental.h3_vae_nvfp4_decoder
+5 -1
View File
@@ -1,4 +1,4 @@
title: FastH3 OmniRef
title: FastH3 Omni v1
summary: Video and synchronized audio from a prompt plus ordered image, video, and audio references, in eight forwards.
guide: docs/inference/fasth3_omniref.md
deployments:
@@ -39,10 +39,12 @@ deployments:
directories in server.media_roots.
- Based on the maintained inference recipe; this serving adaptation has not been GPU-validated.
- "Optional NVFP4 decoder (Blackwell only): hf download FastVideo/FastH3-NVFP4-VAE-Decoder --include 'quality-36L/decoder-nvfp4.safetensors' 'fast-8L/decoder-nvfp4.safetensors' --local-dir fasth3-nvfp4-vae"
- "Optional NVFP4 text encoder (Blackwell only): hf download FastVideo/FastVideo-FastH3-8-Step-V2-NVFP4-Consumer --include 'text_encoder/*' --local-dir fasth3-nvfp4-encoder"
options: &omniref_options
- server.*
- generator.engine.offload.text_encoder
- generator.pipeline.experimental.h3_vae_nvfp4_decoder
- generator.pipeline.components.text_encoder_weights
- default_request.sampling.num_frames
- default_request.sampling.height
- default_request.sampling.width
@@ -92,6 +94,7 @@ deployments:
directories in server.media_roots.
- Based on the maintained inference recipe; this serving adaptation has not been GPU-validated.
- "Optional NVFP4 decoder (Blackwell only): hf download FastVideo/FastH3-NVFP4-VAE-Decoder --include 'quality-36L/decoder-nvfp4.safetensors' 'fast-8L/decoder-nvfp4.safetensors' --local-dir fasth3-nvfp4-vae"
- "Optional NVFP4 text encoder (Blackwell only): hf download FastVideo/FastVideo-FastH3-8-Step-V2-NVFP4-Consumer --include 'text_encoder/*' --local-dir fasth3-nvfp4-encoder"
options: *omniref_options
overrides:
default_request.sampling.num_frames:
@@ -142,5 +145,6 @@ deployments:
directories in server.media_roots.
- Based on the maintained inference recipe; this serving adaptation has not been GPU-validated.
- "Optional NVFP4 decoder (Blackwell only): hf download FastVideo/FastH3-NVFP4-VAE-Decoder --include 'quality-36L/decoder-nvfp4.safetensors' 'fast-8L/decoder-nvfp4.safetensors' --local-dir fasth3-nvfp4-vae"
- "Optional NVFP4 text encoder (Blackwell only): hf download FastVideo/FastVideo-FastH3-8-Step-V2-NVFP4-Consumer --include 'text_encoder/*' --local-dir fasth3-nvfp4-encoder"
options: *omniref_options
overrides: *omniref_overrides
+2
View File
@@ -21,6 +21,7 @@ deployments:
- Based on the released one-Spark inference recipe. This serving adaptation and edited hardware combinations have not been GPU-validated.
- The checkpoint includes NVFP4 weights and encoder, a lightweight video VAE, and its trained eight-forward schedule. No local CompactH3 assembly is needed.
- "Optional NVFP4 decoder (Blackwell only): hf download FastVideo/FastH3-NVFP4-VAE-Decoder --include 'quality-36L/decoder-nvfp4.safetensors' 'fast-8L/decoder-nvfp4.safetensors' --local-dir fasth3-nvfp4-vae"
- "Optional NVFP4 text encoder (Blackwell only): hf download FastVideo/FastVideo-FastH3-8-Step-V2-NVFP4-Consumer --include 'text_encoder/*' --local-dir fasth3-nvfp4-encoder"
options:
- server.*
- generator.engine.num_gpus
@@ -30,6 +31,7 @@ deployments:
- generator.engine.offload.vae
- generator.pipeline.experimental.video_decode_backend
- generator.pipeline.experimental.h3_vae_nvfp4_decoder
- generator.pipeline.components.text_encoder_weights
- default_request.prompt
- default_request.sampling.num_frames
- default_request.sampling.height
@@ -0,0 +1,18 @@
title: Open-H3-Regenerate-2K
summary: Regenerate a 768P MiniMax-H3 draft at 2560x1440 with three refine steps.
deployments:
cuda-rest:
runtime: fastvideo-cuda-rest
guide: docs/inference/open_h3_regenerate_2k.md
workload: v2v
defaults: examples/serving/openai_open_h3_regenerate_2k.yaml
requirements:
- 'UV_TORCH_BACKEND=cu130 uv pip install -e ".[fasth3]"'
- "One GPU with about 150 GB free (B200 or GB200). Send the draft as video_reference; local paths must sit under server.media_roots."
- "Optional NVFP4 decoder (Blackwell only): hf download FastVideo/FastH3-NVFP4-VAE-Decoder --include 'quality-36L/decoder-nvfp4.safetensors' --local-dir fasth3-nvfp4-vae"
options:
- server.*
- generator.engine.offload.text_encoder
- generator.pipeline.experimental.h3_vae_nvfp4_decoder
- default_request.sampling.seed
-38
View File
@@ -1,38 +0,0 @@
title: PAN 2
summary: Text-to-video and first-frame image-to-video from the 29B PAN 2 mixture-of-transformers model.
deployments:
cuda-rest:
runtime: fastvideo-cuda-rest
workload: t2v
defaults: examples/serving/openai_pan2.yaml
requirements:
- >-
Requests without a reference run text-to-video. Add image_reference or input_reference
to condition on a first frame; the same server and pipeline handle both.
- >-
To use more than one GPU, set sp_size equal to num_gpus (Ulysses sequence parallelism).
It must divide PAN 2's 32 attention heads, and each rank still holds the full DiT weights.
options:
- server.*
- generator.engine.num_gpus
- generator.engine.parallelism.*
- "!generator.engine.parallelism.hsdp_*"
- generator.engine.offload.text_encoder
- generator.engine.offload.vae
- default_request.sampling.num_frames
- default_request.sampling.height
- default_request.sampling.width
- default_request.sampling.fps
overrides:
default_request.sampling.num_frames:
default: 313
description: num_frames - 1 must be a multiple of 4.
default_request.sampling.height:
default: 704
minimum: 32
multipleOf: 32
default_request.sampling.width:
default: 1248
minimum: 32
multipleOf: 32
-1
View File
@@ -49,7 +49,6 @@ never deleted. See `examples/serving/README.md`.
| `openai_hunyuan15_i2v_480p.yaml` | HunyuanVideo 1.5 I2V 480p | `i2v` | CUDA REST | `hunyuan15-i2v-480p` | Source-backed; preset renders 720x1280 |
| `openai_kandinsky6_ti2va.yaml` | Kandinsky 6 Pro TI2VA | `t2v` | CUDA REST | `kandinsky6-ti2va` | Source-backed |
| `openai_kandinsky6_ti2va_distilled.yaml` | Kandinsky 6 Pro-distill TI2VA | `t2v` | CUDA REST | `kandinsky6-ti2va-distilled` | Source-backed |
| `openai_pan2.yaml` | PAN 2 | `t2v` (a reference switches to image-to-video) | CUDA REST | `pan2/cuda-rest` | Source-backed; checkpoint not public, no recorded run |
| `openai_ltx2_distilled.yaml` | LTX2 Distilled | `t2v` | CUDA REST | `ltx2-distilled/cuda-rest` | Source-backed |
| `streaming_demo.yaml` | LTX2 Distilled | `t2v` | CUDA streaming | `ltx2-distilled/cuda-streaming` | Source-backed |
| `mlx_fasth3.yaml` | FastH3 V1 converted | `t2v` | MLX REST | none (legacy catalog only) | Source runtime; T2VA only |
@@ -88,7 +88,7 @@ surfaces:
video_decode_backend: "MiniMax-H3 video decoder selection (full VAE vs TAEH3 preview); model-specific optimization not yet represented in the typed public schema."
taeh3_checkpoint: "Optional local TAEH3 safetensors path; model-specific optimization not yet represented in the typed public schema."
taeh3_chunk_size: "TAEH3 temporal chunk length; model-specific optimization not yet represented in the typed public schema."
h3_vae_nvfp4_decoder: "Exported NVFP4 MiniMax-H3 video decoder file that replaces the VAE decoder at load; model-specific optimization not yet represented in the typed public schema."
h3_vae_nvfp4_decoder: "Exported NVFP4 MiniMax-H3 video decoder file that replaces the VAE decoder at load; represented as ComponentConfig.h3_vae_nvfp4_decoder."
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."
attention_backend: "Process-wide default attention-backend request applied per component at load time; kernel-selection knob not yet represented in the typed public schema."
@@ -153,6 +153,18 @@ surfaces:
pipeline_config_extensions:
preset_owned:
draft_size:
sources:
- fastvideo.configs.pipelines.open_h3_regenerate_2k.OpenH3Regenerate2KPipelineConfig
refine_start_sigma:
sources:
- fastvideo.configs.pipelines.open_h3_regenerate_2k.OpenH3Regenerate2KPipelineConfig
refine_transformer_dir:
sources:
- fastvideo.configs.pipelines.open_h3_regenerate_2k.OpenH3Regenerate2KPipelineConfig
draft_latent_path:
sources:
- fastvideo.configs.pipelines.open_h3_regenerate_2k.OpenH3Regenerate2KPipelineConfig
audio_encoder_precision:
sources:
- fastvideo.configs.pipelines.wan.WanS2V14BConfig
@@ -283,18 +295,6 @@ surfaces:
- fastvideo.configs.pipelines.hunyuan15.Hunyuan15T2V720PConfig
- fastvideo.configs.pipelines.hyworld.HYWorldConfig
- fastvideo.configs.pipelines.hyworld.Hunyuan15T2V480PConfig
text_system_prompt:
sources:
- fastvideo.configs.pipelines.pan2.PAN2PipelineConfig
text_encoder_skip_layers:
sources:
- fastvideo.configs.pipelines.pan2.PAN2PipelineConfig
text_max_sequence_length:
sources:
- fastvideo.configs.pipelines.pan2.PAN2PipelineConfig
i2v_skip_first_latent_cfg:
sources:
- fastvideo.configs.pipelines.pan2.PAN2PipelineConfig
text_encoder_max_lengths:
sources:
- fastvideo.configs.pipelines.hunyuan15.Hunyuan15I2V480PStepDistilledConfig
@@ -464,6 +464,9 @@ surfaces:
kv_cache_device:
target: generator.pipeline.experimental.kv_cache_device
sources: [fastvideo.configs.pipelines.qwen_image21.QwenImage21PipelineConfig]
pika_resolution_tier:
target: generator.pipeline.experimental.pika_resolution_tier
sources: [fastvideo.configs.pipelines.pika_h3_flex.PikaH3FlexPipelineConfig]
compatibility_only:
batch_size: "Gen3C inference-only tuning field pending typed batching design."
gradient_checkpointing: "Gen3C inference-only compatibility field pending typed batching design."
@@ -477,6 +480,7 @@ surfaces:
frame_receptive_field: "MagiHuman internal data-proxy receptive-field setting."
image_conditioning: "MagiHuman preset variant marker for reference-image conditioning."
pdd_step_indices: "MiniMax-H3 PDD fine-grid partition; resolve_checkpoint_settings sets it from fastvideo_inference.json."
pika_denoising_steps: "Pika H3 Flex six-forward re-noise ladder fixed by the adapter contract; check_pipeline_config rejects any other value."
ref_audio_offset: "MagiHuman internal data-proxy audio alignment offset."
sr_local_attn_layers: "MagiHuman SR internal sparse-attention layer selection."
text_offset: "MagiHuman internal data-proxy text alignment offset."
+1 -1
View File
@@ -35,7 +35,7 @@ The preprocessing steps are identical to training. Run the appropriate preproces
```bash
# For Wan2.1 T2V
bash scripts/preprocess/v1_preprocess_wan_data_t2v
bash scripts/preprocess/v1_preprocess_wan_data_t2v.sh
# For Wan2.2 TI2V
bash examples/distill/Wan2.2-TI2V-5B-Diffusers/crush_smol/preprocess_wan_data_ti2v_5b.sh
+4 -4
View File
@@ -11,16 +11,16 @@ Attention. See [Attn-QAT Training](../training/attn_qat.md).
## 📊 Model Overview
We provide two distilled models:
We provide three distilled models:
- **[FastWan2.1-T2V-1.3B-Diffusers](https://huggingface.co/FastVideo/FastWan2.1-T2V-1.3B-Diffusers)**: 3-step inference, up to **16 FPS** on H100 GPU
- **[FastWan2.1-T2V-14B-480P-Diffusers](https://huggingface.co/FastVideo/FastWan2.1-T2V-14B-480P-Diffusers)**: 3-step inference, up to **60x speed up** at 480P, **90x speed up** at 720P for denoising loop
- **[FastWan2.2-TI2V-5B-FullAttn-Diffusers](https://huggingface.co/FastVideo/FastWan2.2-TI2V-5B-FullAttn-Diffusers)**: 3-step inference, up to **50x speed up** at 720P for denoising loop
Both models are trained on **61×448×832** resolution but support generating videos with **any resolution** (1.3B model mainly support 480P, 14B model support 480P and 720P, quality may degrade for different resolutions).
All three models are trained on 61 frames at 448×832 but support generating videos with **any resolution** (1.3B model mainly support 480P, 14B model support 480P and 720P, quality may degrade for different resolutions).
## ⚙️ Inference
First install [VSA](../attention/vsa/index.md). Set `MODEL_BASE` to your own model path and run:
First install [VSA](../attention/vsa/index.md), then run the 1.3B config. The config pins the checkpoint; edit `model_path` there to use a local copy.
```bash
FASTVIDEO_ATTENTION_BACKEND=VIDEO_SPARSE_ATTN \
@@ -45,7 +45,7 @@ python scripts/huggingface/download_hf.py \
### Wan2.1 1.3B Model Sparse-Distill
For the 1.3B model, we use **4 nodes with 32 H200 GPUs** (8 GPUs per node):
For the 1.3B model, we use **8 nodes with 64 H200 GPUs** (8 GPUs per node):
```bash
# Multi-node training (8 nodes, 64 GPUs total)
+1 -1
View File
@@ -35,11 +35,11 @@ COOKBOOK_FAMILIES = {
"cosmos",
"kandinsky5",
"kandinsky6",
"pan2",
"flux",
"qwen_image21",
"sd35",
"minimax_h3",
"pika_h3_flex",
"stable_audio",
"mmaudio",
"matrixgame",
@@ -6,9 +6,9 @@ load stands down when lazy owns deferral). Two boxes connected
by the QSFP ConnectX-7 cables can run **one clip faster** and can hold a
**longer clip** (up to the FastH3 15 s cap).
Copy-paste commands for both counts are on the
[MiniMax H3 cookbook](../../cookbook/minimax-h3.md): FastH3 V1 → NVIDIA DGX
Spark → 1 Spark or 2 Sparks.
The [MiniMax H3 cookbook](../../cookbook/minimax-h3.md) covers one Spark. For
two, run `examples/inference/basic/basic_fasth3_spark_pair.yaml`; it has no
serving baseline.
This is FastVideo sequence parallel (`sp_size=2`) over Ray, not a third-party
xDiT vendor. Do not install xDiT for this path.
@@ -156,7 +156,7 @@ lightweight video VAE. Use the environment above plus the release kernel
settings, then run one of these configs from the head:
```bash
export FASTVIDEO_MINIMAX_H3_FUSIONS=all FASTVIDEO_NVFP4_MM_BACKEND=cutlass
export FASTVIDEO_MINIMAX_H3_FUSIONS=all FASTVIDEO_NVFP4_MM_BACKEND=cudnn
export FASTVIDEO_H3_VAE_TILE_BATCH=1 FASTVIDEO_VSA_TRITON=1
export FASTVIDEO_VSA_PTX_BLACKWELL=0 FASTVIDEO_FA4=0
export FASTVIDEO_ATTENTION_BACKEND=VIDEO_SPARSE_ATTN_H3 FASTVIDEO_STAGE_LOGGING=1
+25 -9
View File
@@ -1,28 +1,44 @@
# 🚀 Quick Start
# Quick start
Get up and running with FastVideo in minutes!
Install FastVideo, generate one video, then start from a maintained recipe.
## Installation
## Install
First, install FastVideo:
Requires Python 3.10 to 3.12 and CUDA 12.6 or newer.
```bash
# If you previously used Conda, use uv instead for a faster, more stable setup
uv venv --python 3.12 --seed
source .venv/bin/activate
# Install FastVideo on NVIDIA CUDA 12
# CUDA 12. Use cu130 on CUDA 13.
UV_TORCH_BACKEND=cu126 uv pip install fastvideo
```
Use `UV_TORCH_BACKEND=cu130` instead on CUDA 13.
Also optionally install flash-attn:
For the fastest attention kernels, install flash-attn:
```bash
uv pip install flash-attn --no-build-isolation -v
```
## Generate a video
```python
from fastvideo import VideoGenerator
generator = VideoGenerator.from_pretrained(
"Wan-AI/Wan2.1-T2V-1.3B-Diffusers",
num_gpus=1,
text_encoder_cpu_offload=True,
)
generator.generate_video(
"A raccoon peers through a field of sunflowers.",
output_path="videos/",
save_video=True,
)
```
The first run downloads the Wan2.1 1.3B checkpoint. The call writes an MP4 under `videos/`. The full example is `examples/inference/basic/basic.py`.
## Choose a maintained recipe
The cookbook selects complete, checked-in recipes instead of mixing model,
+1 -1
View File
@@ -1,6 +1,6 @@
# Welcome to FastVideo
FastVideo is an inference and post-training framework for diffusion models. It features an end-to-end unified pipeline for accelerating diffusion models, starting from data preprocessing to model training, finetuning, distillation, and inference. FastVideo is designed to be modular and extensible, allowing users to easily add new optimizations and techniques. Whether it is training-free optimizations or post-training optimizations, FastVideo has you covered.
FastVideo is an inference and post-training framework for video diffusion models. One pipeline covers data preprocessing, fine-tuning, distillation, and inference. Attention backends, parallelism, and quantization are separate modules, so you can add one without changing model code.
## Key Features
+3 -3
View File
@@ -90,7 +90,7 @@ PipelineConfig (fastvideo/configs/pipelines/base.py)
│ ├── WanT2V720PConfig
│ └── WanI2V480PConfig
├── HunyuanConfig (fastvideo/configs/pipelines/hunyuan.py)
├── LTX2T2VConfig (fastvideo/configs/pipelines/ltx2.py)
├── LTX2T2VConfig (fastvideo/pipelines/basic/ltx2/pipeline_configs.py)
├── CosmosConfig (fastvideo/configs/pipelines/cosmos.py)
└── ... (15+ model families)
```
@@ -136,7 +136,7 @@ Concrete hierarchy: `DiTConfig` → `DiTArchConfig`, `VAEConfig` →
### SamplingParam (`fastvideo/api/sampling_param.py`)
Generation parameters separate from pipeline config. Each model family
provides defaults via a profile (see `fastvideo/pipelines/basic/<family>/profiles.py`):
provides defaults via a profile (see `fastvideo/pipelines/basic/<family>/presets.py`):
```python
sp = SamplingParam.from_pretrained("Wan-AI/Wan2.1-T2V-1.3B-Diffusers")
@@ -505,7 +505,7 @@ User: generator.generate_video(prompt, ...)
flow_shift, precision defaults.
2. **Sampling param profile** — Create a profile in
`fastvideo/pipelines/basic/<model>/profiles.py` with default height,
`fastvideo/pipelines/basic/<model>/presets.py` with default height,
width, num_frames, guidance_scale, num_inference_steps.
3. **Register configs** — In `fastvideo/registry.py`, add a
+1 -1
View File
@@ -72,7 +72,7 @@ def main():
sampling_param.guidance_scale = 7.5
# Random seed for reproducibility
sampling_param.seed = 42 # Optional, leave unset for random results
sampling_param.seed = 42 # Optional; the default is 1024
# Generate video with custom parameters
prompt = "A beautiful sunset over a calm ocean, with gentle waves."
+278
View File
@@ -0,0 +1,278 @@
# Open-H3-Regenerate-2K
`OpenH3Regenerate2KPipeline` regenerates a 768P [MiniMax-H3](https://huggingface.co/MiniMaxAI/MiniMax-H3) draft video
(1344x768, 121 frames, 24 fps) at **2560x1440** with three refine steps. It is video to video: the draft is the input,
and the output is a silent 2K MP4. FastVideo ports NVlabs'
[Open-H3-Regenerate-2K](https://huggingface.co/Efficient-Large-Model/Open-H3-Regenerate-2K)
([project page](https://nvlabs.github.io/Sana/Open-H3-Regenerate-2K/),
[reference code](https://github.com/NVlabs/Sana/tree/sol-engine/models/Open-H3-Regenerate-2K)); the dense BF16
`standard_fwd` path of that repo is the parity reference. The accelerated `sol_engine_fwd` path (Sol-Attn, MXFP8, CUDA
graphs) and the fast 24-layer VAE decoder are not ported.
```text
draft video -> H3 VAE encode -> latent upscaler (2x, 3D conv) -> 3 Euler refine steps -> H3 VAE decode -> 2560x1440
H3 DiT + merged LoRA + Sharp curve AdaLN branches
```
## What runs
| Step | Details |
|---|---|
| Draft encode | `ffmpeg` resamples to 24 fps, cover-scales (bilinear) and centre-crops to 1344x768 (`draft_size: 768p`, or 1024x576 for `576p`), holds the last frame to 121 frames plus 3 copies. The FP32 H3 VAE posterior is sampled under seed 42. |
| Latent upscale | The author's 3D-conv upscaler (`fastvideo/models/upsamplers/minimax_h3_latent_upscaler.py`), BF16, to the 160x90 latent grid. |
| Refine | Euler on sigmas `[0.909375, 0.857143, 0.666667, 0]`, CFG 1, model time `1 - sigma`, initial state `(1 - 0.909375) * upscaled + 0.909375 * noise`. `num_inference_steps: 2` uses `[0.7, 0.494118, 0]` (softer, about 30% faster). |
| Transformer | The base MiniMax-H3 DiT (the same module as the other H3 pipelines) with the released merged LoRA (FP32, rank 576 on 312 linears, scale 1) and 50 LMS Sharp curve AdaLN branches. |
| Decode | The released H3 VAE, or an NVFP4 decoder on Blackwell (see below); the output is trimmed to 121 frames. |
The merged LoRA loads through the standard H3 community-LoRA path as a constructor-time adapter: the pipeline sets
`lora_path` to `<model>/lora/open_h3_regenerate_2k_lora.safetensors` when you do not pass one. The Sharp branches take an
8-D timestep curve (`lora/adaln_t_table.bin`, 1025x8) instead of the layer input, so they cannot be folded into weights;
`sharp_curve.py` adds them to the AdaLN projection outputs with forward hooks.
**Audio.** The reference feeds an encoded-silence audio stream to the DiT as conditioning and writes the video with no
audio track (`-an`); it neither regenerates nor passes through the draft's audio. FastVideo does the same: the draft's
soundtrack is ignored and the output MP4 has no audio.
## Weights
The release repo keeps the unmodified MiniMax-H3 components (a copy of `MiniMaxAI/MiniMax-H3` at `6818f6c`) under
`base_model/` next to `lora/` and `latent_upsampler/`. Point `model_path` at the Hub id or at a local copy of that layout.
`adaln_t_table.bin` is base-derived and is not redistributed in the release repo: put the 32,800-byte file (SHA-256
`c02a6c11888297688c1e6278185ea1f947023acfc69f9003bbcdcec9a229a8e7`) at `lora/adaln_t_table.bin`. The reference
`download.py` fetches it by byte range from `Comfy-Org/MiniMax-H3` at revision
`e5eb578a89295337b8ff433a035929ce0279e0b6` (tensor `adaln_t_table` of
`diffusion_models/minimax_h3_ref2va_pruned_int8_convrot.safetensors`) and verifies the hash.
A GB200/B200-class GPU is needed (about 150 GB with the DiT, the 2K activations and the VAE).
## Usage
```bash
python examples/inference/basic/basic_open_h3_regenerate_2k.py \
--model-path Efficient-Large-Model/Open-H3-Regenerate-2K \
--video draft_768p.mp4 --prompt "A red fox trots through fresh snow" --output outputs/regen2k
```
The prompt conditions the refine steps (use the draft's own prompt). Drafts from other video models are better encoded at
576P (`--draft-size 576p`). `--steps 2` runs the two-step variant. The output is always 2560x1440; the first 121 frames of
the draft are used.
## Serving
```bash
fastvideo serve --config examples/serving/openai_open_h3_regenerate_2k.yaml
```
`examples/serving/openai_open_h3_regenerate_2k.yaml` loads the release on one GPU, sets `media_roots`, and pins the 2K
defaults. The draft arrives as the typed `video_reference` of `POST /v1/videos/sync` (the response body is the MP4) or of
`POST /v1/videos` (an async job; poll `GET /v1/videos/{id}` and download `GET /v1/videos/{id}/content`):
```bash
curl -sS -X POST http://localhost:8000/v1/videos/sync \
-H 'Content-Type: application/json' \
-d '{
"prompt": "A red fox trots through fresh snow",
"video_reference": {"video_url": "https://example.com/draft_768p.mp4"},
"seed": 1766580932
}' \
-o regen_2k.mp4 -D -
```
The response headers carry `X-Request-Id`, `X-Model`, `X-Inference-Time-S` and `X-Stage-Durations`. Rules:
- Exactly one `video_reference` (or the legacy `video_path` / `video_url`) and no image or audio references.
- `http(s)` URLs must resolve to public addresses unless `server.allow_private_media_urls` is set. A local path is accepted
only inside a directory listed in `server.media_roots`. `data:` URLs are refused for video references; this policy is
unchanged.
- `size`, `width` and `height` may be omitted or `2560x1440`; `num_frames` may be omitted or 124 (the frame count of the
VAE grid; the video has 121 frames); `fps` must be 24. `seconds` is not accepted.
- `num_inference_steps` is 3 (default) or 2; `seed` sets the refine noise.
- Serve with `attention_backend: TORCH_SDPA`: the refine stage selects cuDNN attention, which at about 133k tokens is
about 3x faster than PyTorch's default flash dispatch. The `FLASH_ATTN` backend bypasses that selection.
## NVFP4 video decoder
On Blackwell GPUs the released 36-block video decoder can be replaced by a pre-packed NVFP4 one; the latent space is
unchanged, so the draft encode, the upscale and the refine steps are the same and only the decode changes. Both tiers of
[FastVideo/FastH3-NVFP4-VAE-Decoder](https://huggingface.co/FastVideo/FastH3-NVFP4-VAE-Decoder) apply here, because the
release carries MiniMax-H3's full 36-block VAE (`base_model/vae`):
| Tier | Decoder blocks | File | Pick it when |
|---|---|---|---|
| `quality-36L` | 36 | `quality-36L/decoder-nvfp4.safetensors` | you want the released detail back; the default choice |
| `fast-8L` | 8 (evenly spaced; the load drops 28 blocks) | `fast-8L/decoder-nvfp4.safetensors` | decode time or memory dominate and some fine detail can go |
```bash
hf download FastVideo/FastH3-NVFP4-VAE-Decoder --include 'quality-36L/decoder-nvfp4.safetensors' \
'fast-8L/decoder-nvfp4.safetensors' --local-dir fasth3-nvfp4-vae
```
Select it through the surface you drive the pipeline with. The config field (also a dotted override of
`fastvideo generate`), the example's `--vae-decoder` and the environment variable all set
`fastvideo_args.h3_vae_nvfp4_decoder`, and the config field wins over the environment.
```bash
# example script
python examples/inference/basic/basic_open_h3_regenerate_2k.py --video draft.mp4 --prompt "..." \
--vae-decoder fasth3-nvfp4-vae/quality-36L/decoder-nvfp4.safetensors
# `fastvideo generate`: a run config, with the decoder as a dotted override
fastvideo generate --config regen2k_run.yaml \
--generator.pipeline.experimental.h3_vae_nvfp4_decoder fasth3-nvfp4-vae/quality-36L/decoder-nvfp4.safetensors
```
```yaml
generator:
pipeline:
experimental:
h3_vae_nvfp4_decoder: ./fasth3-nvfp4-vae/quality-36L/decoder-nvfp4.safetensors
```
`FASTVIDEO_H3_VAE_NVFP4_DECODER` stays as the fallback when the config does not set the field. The
swap happens in the VAE loader, so the dense decoder never reaches the GPU: it is replaced on the CPU by the exported
checkpoint (2.0 GiB of packed weights instead of 9.7 GiB of FP32), moved to the device, and decoded under bf16 autocast.
The decoder keeps the pipeline's tiling (at 2560x1440: 8x13 tiles of 256 px with 64 px overlap) and the fused Triton
block kernels, which are on by default (`FASTVIDEO_H3_VAE_NVFP4_FUSED`); `FASTVIDEO_H3_VAE_TILE_BATCH` decodes several
tiles per decoder call (4 is bit-identical and faster here), and `FASTVIDEO_H3_VAE_NVFP4_CUDA_GRAPH` replays the small
fused calls. The MP4 is unaffected: the same 121 frames are trimmed and written.
Warm decode of one refined latent at 2560x1440 (124 frames) on one GB200, median of 3, with the released decode as the
reference:
| Decoder | Decode (s) | Peak GPU memory (GiB) | PSNR (dB) | SSIM |
|---|---:|---:|---:|---:|
| released H3 VAE (dense, fp16 autocast) | 23.3 | 18.4 | - | - |
| `quality-36L` (fused) | 6.6 | 6.2 | 41.3 | 0.979 |
| `quality-36L` (fused, tile batch 4) | 5.0 | 6.3 | 41.3 | 0.979 |
| `quality-36L` (eager NVFP4 GEMM) | 67.0 | 6.2 | 41.3 | 0.979 |
| `fast-8L` (fused) | 1.7 | 5.3 | 32.8 | 0.892 |
| `fast-8L` (eager NVFP4 GEMM) | 16.1 | 5.2 | 32.8 | 0.892 |
The fused kernels are an exact speedup: the eager and fused rows decode bit-identical frames, and so does batching
tiles, so the PSNR/SSIM columns measure the tier and nothing else. The eager NVFP4 GEMM path loses to the dense fp16
decoder at these tile sizes (67 s against 23 s), so keep `FASTVIDEO_H3_VAE_NVFP4_FUSED` on. PSNR against the released
decode is bounded by the released decode itself: the refined latent already sits 45.2 dB from the NVlabs reference (see
below), so 41.3 dB is a decoder-only difference of the same order. `fast-8L` softens fine texture (fur, foliage) while
keeping structure; use it only when the decode budget is tight.
## Refining on a FastH3 DiT
`refine_transformer_dir` swaps the refine DiT for another MiniMax-H3 transformer while the rest of the release (VAEs,
encoders, upscaler, merged LoRA, Sharp branches) stays put. Point it at a transformer component directory, or at a
checkpoint whose `transformer/` subdirectory is one, such as a FastH3 export:
```python
PipelineSelection(components=ComponentConfig(),
experimental={"refine_transformer_dir": "/models/FastH3-QAD-v2"})
```
The released LoRA and Sharp branches are base-H3-derived adapters, so they keep loading over the chosen DiT. A packed
NVFP4 FastH3 export needs its quantization profile
(`EngineConfig(quantization=QuantizationConfig(transformer_quant="NVFP4", layer_profile="h3_dit_vsa"))`) and refuses a
dense attention backend, because its `to_gate_compress` linears are built only for VSA. Do not set
`FASTVIDEO_H3_ADALN_TABLE` for a different sigma grid than the one a checkpoint's `adaln_tables.pt` covers; the refine
grid is not FastH3's 8-step schedule, so load the real AdaLN projections.
This swap does not reproduce the release's refine. On the fox draft, FastH3 V2 BF16 walking its own DMD rungs from 500, the
best adapter subset reached 26 dB PSNR and 0.84 SSIM against the base-H3 refine (the base reproduces itself at 45 dB), with no
faster forwards at this sequence length. Draft with FastH3 and refine with the base DiT instead.
`draft_latent_path` skips the draft video: a `.pt` holding the normalized native H3 draft latent `[C, T, h, w]`
(float32) goes straight to the upscaler, so a draft generated by a FastH3 t2v run never needs decoding to pixels and
re-encoding. Starting from `vae.encode_pixels(...).latent_dist`, sample it and apply `vae.normalize_latents` before
saving, as the draft encoder does. A batched latent is accepted only with a leading dimension of 1.
```bash
python examples/inference/basic/basic_open_h3_regenerate_2k.py --video draft.mp4 --prompt "..." \
--refine-transformer /models/FastH3-8-Step-V2 --attention-backend VIDEO_SPARSE_ATTN_H3
```
## Regenerating a FastH3 draft
Only the draft model changes: a FastH3 checkpoint drafts at 768P (1344x768, 124 frames) and the regen stack stays as
released (base MiniMax-H3, merged LoRA, Sharp branches, three Euler steps). All H3-family drafts share the H3 VAE's latent
space (`[24, 37, 48, 84]` for a 124-frame 768P clip), so a FastH3 draft latent goes straight to the upscaler.
```bash
# FastH3 QAD (V2.1) NVFP4 draft, NVFP4 decode at 2K
python examples/inference/basic/basic_fasth3_to_regen2k.py \
--draft-model FastVideo/FastH3-QAD-v2 --draft-quant h3_dit_vsa \
--vae-decoder fasth3-nvfp4-vae/quality-36L/decoder-nvfp4.safetensors \
--prompt "A red fox trots through fresh snow" --output outputs/fasth3_regen2k
# FastH3 Trim NVFP4 draft: --draft-model FastVideo/FastVideo-FastH3-Trim-8-Step-NVFP4 --draft-quant h3_dit_vsa
# FastH3 V2 BF16 draft: --draft-model FastVideo/FastVideo-FastH3-8-Step-V2 (no --draft-quant)
```
Two handoffs are available:
- **Video:** decode the draft to an MP4 and pass it as `video_path`. The regen decodes it, resamples and VAE-encodes it (about
11 s).
- **Latent:** pass the draft's normalized native latent `[C, T, h, w]` as `draft_latent_path`, or per request as a `.pt`
`video_path` (through the OpenAI server it must be a local path under `server.media_roots`; not exercised in this run). The encode drops to 0.6 s and the
draft is never decoded to pixels.
The two give different 2K videos (24-27 dB PSNR against each other). On smooth content the MP4 route is much sharper (a
portrait: Laplacian variance 180-244 against 84-106 for the latent route). Writing the same draft as a lossless MP4 gives 85,
close to the latent route, so the extra energy comes from the H.264 artifacts of the draft MP4, which the refine turns into
texture. The latent route also leaves PSNR against the draft within 0.8 dB of the MP4 route. Prefer it: it is faster and does
not amplify codec noise.
Measured on one GB200 with nothing else on the node (regen at `TORCH_SDPA`, seed 1766580932, warm requests; fox, portrait
and harbor prompts, 5.2 s clips):
| Draft model | Draft | Regen: MP4 / latent / latent + NVFP4 decoder | Text to 2K (latent handoff) |
|---|---:|---:|---:|
| FastH3 QAD V2.1 NVFP4 | 36 s | 106 s / 96 s / 79 s | 132 s (115 s) |
| FastH3 Trim NVFP4 | 34 s | 106 s / 96 s / 79 s | 130 s (113 s) |
| FastH3 V2 BF16 | 35 s | 106 s / 96 s / 79 s | 131 s (114 s) |
| base H3 50-step | 316 s | 106 s / 96 s / 79 s | 412 s (395 s) |
The regen costs the same for every draft: about 70 s of refine (3 forwards at 133k tokens) and 24 s of decode. Setting
`h3_vae_nvfp4_decoder` to `quality-36L/decoder-nvfp4.safetensors` from `FastVideo/FastH3-NVFP4-VAE-Decoder` cuts the decode
to 7 s (82 s on the first call of a process) with no visible metric change. Against the draft, the 2K output keeps the scene
(PSNR 19-22 dB at 768P: the refine regenerates texture, so pixel agreement stays low for base and FastH3 drafts alike) and
raises Laplacian variance 1.7-3.3x with a 1.3-1.5x flicker ratio for every draft model. FastH3 drafts are sharper than base
drafts before the regen, so their absolute PSNR against the draft is about 2 dB lower.
## Parity with the reference
Measured on one GB200 (BF16, `TORCH_SDPA` with cuDNN attention) against the reference `standard_fwd` with the released
VAE, same draft, seed (1766580932), prompt and VAE posterior seed (42). The reference's noise is a CUDA generator draw
that FastVideo reproduces (the same `torch.Generator(device="cuda")` seed), so no injected noise was needed.
| Stage / quantity | Fox draft (FastVideo H3 t2v, 768P) | Dance draft (576P, project page) |
|---|---|---|
| Draft latent, upscaled latent, DiT input | bit-identical | bit-identical |
| One DiT forward (LoRA + Sharp), relative L2 | 1.1e-2 | 2.6e-2 |
| Refined latent, relative L2 | 1.3e-2 | 3.7e-2 |
| Decoded video PSNR / SSIM (21 frames) | 45.2 dB / 0.993 | 42.1 dB / 0.992 |
The differences come from text-encoder rounding (relative L2 1e-3 on the prompt embeddings) and BF16 kernel choices;
the reference reports the same order (1.4% relative L2 on the refined latent between its own runs).
Warm timings, one GB200 (seconds):
| | Upscale | Refine (3 steps) | VAE decode | Total |
|---|---:|---:|---:|---:|
| Reference `standard_fwd` | 0.87 | 71.6 | 15.0 | 87.5 |
| FastVideo (end to end, incl. draft encode 5-10 s and decode to host) | 5.9-10.7 (encode + upscale + silence) | 69.5 | 25.0 | 107.5 |
The first request builds nothing extra but loads weights (about 70 s once). The decode stage is slower than the
reference because it also copies 121 frames to host memory and the MP4 encode is outside both columns.
## Licences
The release is not covered by one licence; its `README.md` says so, and the runtime code's licence does not replace the
model licences. FastVideo only loads these weights and does not redistribute them.
- The refiner LoRA, the fast H3 VAE decoder and `base_model/` (a copy of `MiniMaxAI/MiniMax-H3` at `6818f6c`: DiT, VAEs,
text encoder) follow the
[MiniMax H3 Community License](https://huggingface.co/MiniMaxAI/MiniMax-H3/blob/6818f6c32d12b210915e44ad56a4228c2608f160/LICENSE),
including its territory, use and redistribution terms (`base_model/LICENSE`, `NOTICE`). The Qwen3-VL text encoder's
Apache-2.0 text ships as `LICENSE-Qwen.txt`.
- The latent upscaler follows its own licence:
[LBH-123-AI/Comfyui_Minimax_h3_latent_Upscaler](https://github.com/LBH-123-AI/Comfyui_Minimax_h3_latent_Upscaler)
(MIT, per the reference code) and its [weights](https://huggingface.co/LBH-123-AI/Minimax_h3_latent_Upscaler)
(`latent_upsampler/LICENSE`, Apache-2.0). `minimax_h3_latent_upscaler.py` is a port of that node's network.
- The merged LoRA includes [LightX2V FL2VA Turbo](https://huggingface.co/lightx2v/Minimax-h3-Turbo) and
[LMS Sharp](https://huggingface.co/Alissonerdx/Minimax-H3-ComfyUI), which keep their own licences; the Sharp
`adaln_t_table` is read from [Comfy-Org/MiniMax-H3](https://huggingface.co/Comfy-Org/MiniMax-H3).
+18
View File
@@ -114,6 +114,24 @@ 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.
### cuDNN SDPA for H3 dense attention (opt-in)
With the `FLASH_ATTN` backend, MiniMax-H3's dense, unmasked, inference-only DiT
self-attention can use cuDNN's fused SDPA engine instead of FlashAttention:
```bash
export FASTVIDEO_MINIMAX_H3_DENSE_ATTN=cudnn
```
It keeps the same precision contract (bf16 operands, fp32 softmax/accumulation,
bf16 output) but uses a different reduction order, so treat it as a
speed/quality option rather than an exact-parity mode. Training calls, compiled
regions, head sizes above 128, non-contiguous inputs, and the sparse VSA blocks
stay on their existing paths. Standalone kernel timings at 37.7k and 109k tokens
are within measurement noise of FA4; an in-model A/B on one GB200 measured a
2.2% faster DiT forward. Requires CUDA and a torch build with
`aten::_scaled_dot_product_cudnn_attention` (cuDNN 9.x).
### FP4 Flash Attention 4 (Blackwell only)
**`FLASH_ATTN`** with **`--nvfp4_fa4`**
-100
View File
@@ -1,100 +0,0 @@
# PAN 2 Text/Image to Video
PAN 2 is a 29B mixture-of-transformers video DiT with separate video and text streams, a Qwen3.5 text encoder and the
Wan2.2 VAE. One pipeline serves text-to-video and image-to-video: pass `image_path` to condition on a first frame and
leave it out for text only.
## Model
| Hub repo | Pipeline | Workloads | Example |
|---|---|---|---|
| `IFM/PAN2` | `PAN2ModularPipeline` | T2V, I2V | [`basic_pan2.py`](https://github.com/hao-ai-lab/FastVideo/blob/main/examples/inference/basic/basic_pan2.py) |
PAN2 checkpoints use the Modular Diffusers layout (`modular_model_index.json`). Any repo id or local directory whose
name contains `pan2` (or whose manifest declares `PAN2ModularPipeline`) resolves to this pipeline. The defaults come
from the `pan2_t2v` and `pan2_i2v` presets:
| Setting | Default |
|---|---|
| Resolution | 704x1248 (height and width must be multiples of 32) |
| Frames | 313, 13 s at 24 fps (`num_frames - 1` must be a multiple of 4) |
| Steps | 50, uniform sigmas from 1 to 0 with the checkpoint's flow shift (9 for `IFM/PAN2-TEST`, the reference sampler's value) |
| Guidance | 2.5, classifier-free guidance, the reference launcher's default (3 before 2026-10-23). Image-to-video keeps the first latent frame out of guidance (`i2v_skip_first_latent_cfg`, on by default, as in the reference sampler) |
| Negative prompt | None unless the request carries one. The reference sampler always sends the release's structured negative caption (`negative_prompt.json` of the PAN2 repository) with its three `time_range_sec` values set to the clip length, `0 to 20` for 481 frames at 24 fps |
PAN 2 is trained on the structured captions its prompt rewriter produces, serialized as one-line JSON. Short raw
prompts work, with weaker results.
## Usage
```bash
python examples/inference/basic/basic_pan2.py
```
```python
from fastvideo import VideoGenerator
generator = VideoGenerator.from_pretrained(
"IFM/PAN2",
num_gpus=1,
dit_cpu_offload=False,
dit_layerwise_offload=False,
text_encoder_cpu_offload=True,
)
generator.generate_video(
"A red fox trots through fresh snow in a pine forest at dawn.",
image_path=None, # or the path of a first frame for image-to-video
output_path="video_samples_pan2",
)
```
For image-to-video, the first frame is scaled to cover the requested resolution and center cropped, then encoded by the
VAE into the first latent frame of the conditioning input.
## Parallelism
With `num_gpus > 1`, FastVideo runs Ulysses sequence parallelism: the video tokens are sharded across ranks and the
text tokens are replicated. The number of GPUs must divide the 32 attention heads. Sequence-parallel padding of the
video tokens is trimmed before attention, so no attention mask is needed.
Padding is always excluded, without an opt-in setting. At 704x1248 with 313 frames, the 67,782 video tokens need
two padding tokens at SP=4. They are removed before joining the text tokens in every attention block. This provides
the same exclusion as vLLM-Omni's unconditional padding mask in
[revision `e0c4c731`](https://github.com/vllm-project/vllm-omni/commit/e0c4c73158a5e4f44eda8eaa8879f7fd9cedbd74).
The trimmed sequence can use either FlashAttention or Torch SDPA.
```bash
PAN2_NUM_GPUS=4 python examples/inference/basic/basic_pan2.py
```
## Performance switches
PAN2 sampling turns these on by default (`PAN2PipelineConfig`); pass any of them as `False` to
`VideoGenerator.from_pretrained` to turn it off:
| Switch | What it does |
|---|---|
| `vae_decode_kernel` | Decodes with fastvideo-kernel's Wan2.2 decoder on B200 / GB200 / B300 (704x1248, batch 1): bf16 with fp32 accumulation, 7x faster than the fp32 decode on a B300 at PSNR 61-63 dB and SSIM 0.998 against it, at a peak of about 20 GiB for a 5 s video. It builds on first use (minutes) into `WAN22_VAE_DECODE_BUILD_DIR`, against fastvideo-kernel's CUTLASS submodule (`git submodule update --init fastvideo-kernel/include/cutlass`, or set `CUTLASS_DIR`). Other GPUs and sizes keep the fp32 decode. |
| `vae_decode_kernel_parallel` | With 2, 4 or 8 GPUs, splits that decode over them instead of decoding the whole video on each: a 20 s video takes 3.0 s on one B300, 1.55 s on two, 0.79 s on four and 0.47 s on eight. |
| `dit_fused_kernels` | Runs the DiT's fused Triton kernels for Q/K RMSNorm + RoPE and LayerNorm + modulation. Measured with a distilled PAN2 model on eight B300s, they cut the denoising time by 21%. The rounding differs from the unfused path; record by record, fused and unfused videos were equally close to the 50-step reference. |
On GPUs with room for every component (B200, B300), keep the text encoder resident as well:
`text_encoder_cpu_offload=False`.
## Memory and offloading
The example keeps the DiT on the GPUs and offloads the text encoder between requests. Sequence parallelism shards
video tokens; each rank still holds the DiT weights. Increasing `num_gpus` alone does not shard those weights.
For devices that cannot hold the DiT, see [CPU offloading](offloading.md) for `dit_cpu_offload` and
`dit_layerwise_offload`. Hardware recipes and timings reported by vLLM-Omni use its own offload implementation;
they have not been validated for FastVideo.
The full `IFM/PAN2` checkpoint is not public yet. Local parity tests use the public random-weight
`wuqing157/tiny-pan2-modular-pipe` checkpoint; it verifies the integration and produces noise rather than useful video.
## Differences from the vLLM-Omni integration
- The optional prompt and video guardrails (`pan2-guardrail`) are not included.
- The Cache-DiT `quality` policy and CFG-parallel execution are vLLM-Omni serving features and are not ported.
- The text encoder is a FastVideo-native Qwen3.5 port. Its Gated DeltaNet layers use a PyTorch chunked delta rule
instead of the `fla` kernels.
+237
View File
@@ -0,0 +1,237 @@
# Pika H3 Flex
Pika H3 Flex is a post-trained MiniMax-H3 model that keeps H3's packed
video/audio transformer, Qwen3-VL conditioner, and video/audio VAEs. Pika ships
two rank-128 PEFT adapters instead of a second base checkpoint:
| Adapter | Trunk | Tasks |
|---|---|---|
| `fl2va/adapter.safetensors` | `transformer` | T2VA, I2VA, L2VA, FL2VA |
| `ref2va/adapter.safetensors` | `transformer_ref` | Ref2VA |
FastVideo detects `Pika-Labs/Pika-H3-Flex`, downloads only the selected task's
adapter, and applies it as an ordinary LoRA. The released adapter is selected
automatically; pass `lora_path` only when overriding it with a compatible
Pika adapter.
## Sampling contract
Pika's sampler is fixed, not a tunable step count:
* 6 transformer forwards over clean-time rungs
`1000, 750, 500, 250, 100, 40, 0`;
* video shift `12.0`, audio shift `3.0`;
* no CFG, `guidance_scale=1.0`, empty negative prompt;
* each step re-noises the predicted clean sample with fresh Gaussian noise from
a generator seeded `seed + 1`.
`num_inference_steps` must therefore be `6`; other values are rejected. This is
not a PDD checkpoint and it has no trained VSA gates, so PDD fused blocks and
VSA sparsity are not used.
## Tasks
The commands below assume a FlashAttention-4 host; see
[Attention backend](#attention-backend) before running them elsewhere.
```bash
export FASTVIDEO_ATTENTION_BACKEND=FLASH_ATTN FASTVIDEO_FA4=1
# text to video + audio
python examples/inference/basic/basic_pika_h3_flex.py --task t2va \
--prompt "a paper boat drifting down a rainy street"
# 480p portrait, ten seconds
python examples/inference/basic/basic_pika_h3_flex.py --task t2va \
--resolution 480p --aspect 9:16 --duration 10 \
--prompt "a lighthouse at dusk, waves breaking below"
# first frame, last frame, or both
python examples/inference/basic/basic_pika_h3_flex.py --task i2va \
--image first.png --prompt "the camera slowly pushes in"
python examples/inference/basic/basic_pika_h3_flex.py --task fl2va \
--image first.png --last-image last.png --prompt "the flower opens"
# ordered references, including reference video and audio
python examples/inference/basic/basic_pika_h3_flex.py --task ref2va \
--reference image:person.png --reference video:dance.mp4 \
--reference audio:voice.wav --prompt "the person dances to the music"
```
Ref2VA accepts up to 12 references: 9 images, 3 videos, and 3 audio. These
counts are enforced locally, as is the rule that audio-only requests are
invalid. A video reference needs at least 22 decoded frames at 24 fps. At least
one image or video is required, and the Ref2VA pipeline must be selected with
`PikaH3FlexRef2VAModularPipeline` (the example does this).
Pika's hosted API also documents a 2–15 s limit per video/audio reference and a
15 s total per modality. **The local path does not currently enforce those
duration caps**; it trims each reference to the requested output duration. Treat
the per-reference and per-modality limits as recommendations until the checks
are added.
## Canvas and frames
Pika uses an exact 32-pixel bucket lattice. A request that matches a bucket is
kept exactly; otherwise FastVideo picks the tier from the requested short edge
(`<= 480` → p480, else p768) and snaps to the nearest aspect bucket in that tier.
| Tier | Short edge | Max pixels | Reference-image short edge |
|---|---|---|---|
| p480 | 480 | 832×480 | 1024 |
| p768 | 768 | 1344×768 | 2048 |
Frame counts follow Pika's `17n + 5` grid at 24 fps (5–15 s, 120–362 aligned
frames). The default 16:9 p768 canvas is 1344×768 and 124 frames.
| Duration | 5 s | 10 s | 15 s |
|---|---|---|---|
| `num_frames` | 124 | 243 | 362 |
The example script picks the bucket with `--resolution {480p,768p}` and
`--aspect` (`21:9`, `16:9`, `3:2`, `4:3`, `5:4`, `1:1`, `4:5`, `3:4`, `2:3`,
`9:16`, `9:21`), and the frame count with `--duration` seconds. `--height`,
`--width`, and `--num-frames` override them.
Through the API, a request that omits `height` and `width` uses the 16:9
bucket of the configured tier:
```python
PipelineSelection(experimental={"pika_resolution_tier": "p480"}) # 832x480 when the size is omitted
```
An explicit `height`/`width` always wins and selects its own tier. "Omitted"
means absent from a request parsed from a mapping or config file, or from a
`generate_video(...)` call without `height`/`width`; a `GenerationRequest`
constructed in Python counts `SamplingConfig`'s 1280×720 default as set, which
snaps to the 768p 16:9 bucket. Pass the size explicitly in that case.
Pika center-crops both keyframes to the bucket. MiniMax-H3 stretches its first
frame instead; do not reuse that fit for Pika.
## Attention backend
Run Pika with FlashAttention-4 on Hopper or Blackwell:
```bash
FASTVIDEO_ATTENTION_BACKEND=FLASH_ATTN FASTVIDEO_FA4=1 \
python examples/inference/basic/basic_pika_h3_flex.py --task t2va --prompt "..."
```
FA4 is opt-in. Installing `flash_attn.cute` is not enough:
`fastvideo/attention/utils/flash_attn_default.py` only selects it when
`FASTVIDEO_FA4=1`, and neither the example's defaults nor `--lossless-accel`
set that variable. With `FASTVIDEO_FA4=1` and an unusable FA4 install the run
fails at import instead of silently falling back.
Without FA4, the `FLASH_ATTN` backend uses FA3 or FA2 if one is installed. On a
host with neither (common on Blackwell), backend selection falls back to
`TORCH_SDPA`. SDPA at 768p is much slower and can run out of memory, so do not
use it for 768p benchmarks or production runs.
## Performance and memory
All measurements: one GB200, 768p 16:9 (1344×768), 5 s (124 frames), six
forwards, warm requests, FA4, every component resident (`--offload resident`).
| Configuration (T2VA) | Generation time |
|---|---|
| Official `pika-h3-pro` CLI | 59.7 s |
| FastVideo, BF16 | 26.2 s |
| FastVideo, NVFP4 `quality-36L` decoder | 18.0 s |
| FastVideo, NVFP4 `fast-8L` decoder | 17.4 s |
The 36L decoder measured 40.5 dB PSNR against the BF16 decoder on the same
latents. The 8L decoder was timed but not scored here.
Task coverage, seed 42, NVFP4 text encoder, BF16 decoder, also with
`FASTVIDEO_MINIMAX_H3_FUSIONS=all`:
| Task | Wall | Denoise | Decode | Peak memory | Check |
|---|---|---|---|---|---|
| T2VA | 25.49 s | 16.70 s | 8.04 s | 104.6 GiB | on prompt; 21.7 dB PSNR vs the BF16-encoder output |
| I2VA | 27.37 s | 18.02 s | 8.23 s | 104.9 GiB | first frame vs input 26.5 dB, SSIM 0.715 |
| FL2VA | 28.95 s | 19.49 s | 8.12 s | 105.2 GiB | first frame 39.6 dB / 0.971, last frame 29.1 dB / 0.819 |
| Ref2VA, two image references | 51.17 s | 38.42 s | 7.74 s | 108.5 GiB | 64,241 packed tokens; both references used |
The NVFP4 text encoder's first request in a fresh process paid a one-time
first-use cost of about 183 s; later requests did not.
Peak resident memory for the BF16 FastVideo T2VA run was about 129 GiB. The
official CLI's sampling peak was 71.7 GiB; the two figures measure different
things (process-resident memory versus the sampling phase).
The example's default `--offload lazy` loads components on demand and offloads
the encoders and VAE. It fits smaller devices but reloads the DiT on every
request: the same T2VA request took 48.06 s (28.31 s denoise) on GB200 in that
mode. Use `--offload resident` when the device holds every component.
## Training-free acceleration
The 6 forwards already use FastVideo's normal inference path. Available
lossless switches:
* `FASTVIDEO_MINIMAX_H3_EXACT_KERNELS=all` — exact fused H3 kernels;
* `FASTVIDEO_H3_REF2VA_MEMO_ENTRIES=16` — cache reference conditioning
(Ref2VA);
* `FASTVIDEO_H3_VAE_TILE_PARALLEL=1` — parallel VAE tiles;
* `FASTVIDEO_ULYSSES_A2A=auto` — Ulysses all-to-all for multi-GPU sequence
parallelism;
* `OffloadConfig(lazy_module_load=True)` and `dit_layerwise=True` for smaller
GPUs;
* `CompileConfig(enabled=True)` for `torch.compile`;
* FlashAttention-4 through `FASTVIDEO_ATTENTION_BACKEND=FLASH_ATTN
FASTVIDEO_FA4=1` (see [Attention backend](#attention-backend)).
`--lossless-accel` in the example enables the first four before the generator
starts. It does not enable FA4; set the two attention variables at launch. Kernel work beyond the existing exact kernels is intentionally not part
of this integration.
## NVFP4 on Blackwell
Pika can reuse FastVideo's existing H3 NVFP4 VAE decoder and NVFP4 Qwen3-VL
text encoder. Both are weight formats, not new sampler paths; the six-forward
Pika schedule is unchanged.
```bash
# 36-block quality decoder: needs the full 36-block H3 VAE
python examples/inference/basic/basic_pika_h3_flex.py --task t2va \
--prompt "a paper boat drifting down a rainy street" \
--nvfp4-decoder ./FastH3-NVFP4-VAE-Decoder/quality-36L/decoder-nvfp4.safetensors
# 8-block fast decoder: depth-cuts any H3 VAE with at least 8 blocks
python examples/inference/basic/basic_pika_h3_flex.py --task ref2va \
--reference image:person.png --reference video:dance.mp4 \
--prompt "the person dances to the music" \
--nvfp4-decoder ./FastH3-NVFP4-VAE-Decoder/fast-8L/decoder-nvfp4.safetensors \
--nvfp4-text-encoder ./fasth3-nvfp4-encoder/text_encoder
```
* `quality-36L` keeps the full decoder depth and is the closest match to Pika's
dense decoder. `fast-8L` is much smaller and faster but is a distilled
student; expect a visible quality drop. Pika's six-step re-noise latents are
not the student's original training distribution, so validate 8L on your
prompts before using it for delivery.
* The NVFP4 decoder replaces only `vae.decoder`; keyframe and reference
encoding still use the FP32 VAE encoder.
* The NVFP4 text encoder quantizes the Qwen3-VL language stack while keeping
the vision tower in bf16, so Ref2VA reference images/videos still work.
* Do not select `taeh3` with an NVFP4 decoder; `h3-vae` is required.
* Both options require Blackwell FP4 hardware. The fused decoder kernels and
CUDA graphs are already enabled by default; see
`FASTVIDEO_H3_VAE_NVFP4_FUSED` and `FASTVIDEO_H3_VAE_NVFP4_CUDA_GRAPH`.
* There is no NVFP4 **VAE encoder** path. The H3 video VAE encoder is a causal
3D CNN and stays FP32; quantizing it would need a separate convolution
kernel/quantization effort.
If a process first loads the 8L decoder it depth-cuts the VAE to eight blocks;
loading 36L afterwards in the same process is not supported. Choose one decoder
tier per process.
## Hardware
The selected H3 trunk plus Qwen3-VL does not fit comfortably on a 24 GB card
without offload. A single 80 GB GPU can run with text-encoder offload; use
layerwise DiT offload or multiple GPUs for smaller devices. Ref2VA encodes
reference images at up to a 2048 short edge, so its conditioning memory is
higher than T2VA's.
+5 -1
View File
@@ -68,7 +68,8 @@ column links a runnable script in `examples/inference/basic/` where one exists.
| matrixgame | `FastVideo/Matrix-Game-2.0-Base-Distilled-Diffusers`<br />`FastVideo/Matrix-Game-2.0-GTA-Distilled-Diffusers`<br />`FastVideo/Matrix-Game-2.0-TempleRun-Distilled-Diffusers`<br />`FastVideo/Matrix-Game-2.0-Base-Diffusers`<br />`FastVideo/Matrix-Game-2.0-GTA-Diffusers`<br />`FastVideo/Matrix-Game-2.0-TempleRun-Diffusers`<br />`mignonjia/mg_longtuning_distilled_zelda`<br />`mignonjia/mg_sf_distilled_zelda_1k_steps`<br />`mignonjia/mg_sf_distilled_zelda`<br />`mignonjia/mg_causal_zelda`<br />`mignonjia/mg_bidirectional_zelda` | I2V | [basic_matrixgame2.py](https://github.com/hao-ai-lab/FastVideo/blob/main/examples/inference/basic/basic_matrixgame2.py) |
| matrixgame | `FastVideo/Matrix-Game-3.0-Base-Distilled-Diffusers` | I2V | [basic_matrixgame3.py](https://github.com/hao-ai-lab/FastVideo/blob/main/examples/inference/basic/basic_matrixgame3.py) |
| minimax_h3 | `MiniMaxAI/MiniMax-H3` | T2V, I2V | [T2VA](https://github.com/hao-ai-lab/FastVideo/blob/main/examples/inference/basic/basic_minimax_h3_t2v.py)<br />[FL2VA](https://github.com/hao-ai-lab/FastVideo/blob/main/examples/inference/basic/basic_minimax_h3_fl2va.py)<br />[Ref2VA](https://github.com/hao-ai-lab/FastVideo/blob/main/examples/inference/basic/basic_minimax_h3_ref2va.py) |
| pan2 | `IFM/PAN2` | T2V, I2V | [basic_pan2.py](https://github.com/hao-ai-lab/FastVideo/blob/main/examples/inference/basic/basic_pan2.py) |
| pika_h3_flex | `Pika-Labs/Pika-H3-Flex` | T2V, I2V | [T2VA/I2VA/L2VA/FL2VA/Ref2VA](https://github.com/hao-ai-lab/FastVideo/blob/main/examples/inference/basic/basic_pika_h3_flex.py) |
| open_h3_regenerate_2k | `Efficient-Large-Model/Open-H3-Regenerate-2K` | — | [basic_open_h3_regenerate_2k.py](https://github.com/hao-ai-lab/FastVideo/blob/main/examples/inference/basic/basic_open_h3_regenerate_2k.py) |
| sd35 | `stabilityai/stable-diffusion-3.5-medium` | T2I | [basic_sd35_t2i.py](https://github.com/hao-ai-lab/FastVideo/blob/main/examples/inference/basic/basic_sd35_t2i.py) |
| qwen_image21 | `Qwen/Qwen-Image-2.1` | T2I, I2I | [basic_qwen_image21.py](https://github.com/hao-ai-lab/FastVideo/blob/main/examples/inference/basic/basic_qwen_image21.py) |
| stable_audio | `FastVideo/stable-audio-open-1.0-Diffusers` | T2V | [basic_stable_audio.py](https://github.com/hao-ai-lab/FastVideo/blob/main/examples/inference/basic/basic_stable_audio.py) |
@@ -122,6 +123,9 @@ and distilled) generate video with audio from text, optionally plus an image (se
`kandinsky6_sr` IDs upscale an existing video (see the
[Video SR guide](kandinsky6_sr.md)).
**Note (Open-H3-Regenerate-2K)**: `open_h3_regenerate_2k` regenerates an existing 768P MiniMax-H3 draft video at
2560x1440 (video to video, silent output); see the [Open-H3-Regenerate-2K guide](open_h3_regenerate_2k.md).
**Note (LTX-2.5)**: T2V and I2V produce synchronized video and audio. The
official `Lightricks/LTX-2.5` repository uses gated, split component files, so
it must first be converted to FastVideo's component layout. See the
+8
View File
@@ -32,6 +32,14 @@ export const WORKLOADS = Object.freeze({
extension: "wav",
media_type: "audio/wav",
}),
v2v: Object.freeze({
input: "video",
output: "video",
endpoint: "/v1/videos/sync",
extension: "mp4",
media_type: "video/mp4",
reference: "video_reference",
}),
v2a: Object.freeze({
input: "video",
output: "audio",
-1
View File
@@ -1 +0,0 @@
/Users/aryank/os/FastVideo-review-int/docs/node_modules
@@ -12,6 +12,25 @@ function at(value, path) {
return path.split(".").reduce((current, key) => current?.[key], value);
}
test("every local NVFP4 encoder choice includes its download requirement", () => {
const path = "generator.pipeline.components.text_encoder_weights";
const catalogs = buildCatalogs().filter((catalog) => catalog.controls.some((control) => control.path === path));
assert.ok(catalogs.length > 0);
for (const catalog of catalogs) {
const option = catalog.controls.find((control) => control.path === path);
if (!option.schema.enum?.includes("./fasth3-nvfp4-encoder/text_encoder")) continue;
assert.ok(
catalog.requirements.some(
(requirement) =>
requirement.includes("hf download ") &&
requirement.includes("--include 'text_encoder/*'") &&
requirement.includes("--local-dir fasth3-nvfp4-encoder"),
),
`${catalog.id} must explain how to create the selected encoder directory`,
);
}
});
function fixture({ interfaceName = "rest", backend = "cuda", workload = "t2v" } = {}) {
return {
id: "example/deployment",
+1
View File
@@ -488,6 +488,7 @@ test("sample requests follow shared workload media", () => {
t2i: { endpoint: /\/v1\/images\/generations/, output: /--output output\.json/, reference: null },
t2a: { endpoint: /\/v1\/videos\/sync/, output: /--output output\.wav/, reference: null },
v2a: { endpoint: /\/v1\/videos\/sync/, output: /--output output\.wav/, reference: "video_reference" },
v2v: { endpoint: /\/v1\/videos\/sync/, output: /--output output\.mp4/, reference: "video_reference" },
};
for (const [workload, expected] of Object.entries(expectations)) {
const data = catalog();
+1 -1
View File
@@ -56,7 +56,7 @@ torchrun --nproc_per_node=$GPU_NUM \
| `--preprocess.dataset_path` | Path to dataset (HF repo ID or local folder) |
| `--preprocess.dataset_output_dir` | Output directory for Parquet files |
| `--preprocess.video_loader_type` | Video decoder: `torchcodec` or `torchvision` |
| `--preprocess.max_height` / `max_width` | Target resolution for videos |
| `--preprocess.max_height` / `--preprocess.max_width` | Target resolution for videos |
| `--preprocess.num_frames` | Number of frames to extract per video |
| `--preprocess.train_fps` | Target FPS for frame extraction |
+3 -3
View File
@@ -1,12 +1,12 @@
# 🧠 Finetuning
# Fine-tuning
This guide covers finetuning video diffusion models with FastVideo, including full finetuning and LoRA.
## Training Arguments
## Training arguments
FastVideo training scripts use several argument groups:
### Training Arguments
### Loop and model arguments
| Argument | Description |
|----------|-------------|
+23 -23
View File
@@ -12,7 +12,7 @@ parquet that step produces.
## What you need
| | |
| Requirement | Value |
|---|---|
| GPU | 48GB or more for the LoRA overfit; multi-GPU for full-parameter runs |
| Disk | ~40GB for the checkpoint (DiT 17GB + Qwen2.5-VL 16GB + ByT5/VAE) |
@@ -145,16 +145,10 @@ a real problem — stop and investigate rather than burning GPU hours.
## 4. Scale out
`examples/train/configs/fine_tuning/hunyuan15/t2v.yaml` runs
full-parameter training across 8 GPUs with FSDP. Full-parameter training
does not fit one 80GB card:
full-parameter training across 8 GPUs with FSDP. One 80 GB card cannot hold it:
It reads `data/hunyuan15_preprocessed` (and its `validation_prompts.json`),
not the overfit directory above — either preprocess into that path (set
`FASTVIDEO_TEST_HUNYUAN15_OVERFIT_OUTPUT_DIR=data/hunyuan15_preprocessed`) or
pass `--training.data.data_path` and `--callbacks.validation.dataset_file`.
| | bf16 |
|---|---|
| Item | Per rank (bf16) |
|---|---:|
| Weights | 16.7 GB |
| Gradients | 16.7 GB |
| AdamW `exp_avg` + `exp_avg_sq` | 33.4 GB |
@@ -163,21 +157,27 @@ pass `--training.data.data_path` and `--callbacks.validation.dataset_file`.
| **Total** | **~80 GB** |
That subtotal is independent of sequence length, so shortening the clip
(`--training.data.num_latent_t`) barely helps — measured peak memory moved
by 0.07GB going from 21 latent frames down to 9. Options that do move the
needle:
(`--training.data.num_latent_t`) barely helps: measured peak memory moved by
0.07 GB going from 21 latent frames to 9. Options that do move the needle:
- **LoRA** (what the overfit config uses) — gradients and optimizer state
shrink to the adapter parameters, ~30GB total.
- **FSDP sharding across GPUs** — the 66.8GB subtotal divides by the number
of shards.
- **A memory-light optimizer** — SGD with momentum keeps a single state
tensor instead of two. `torch.optim.Adafactor` is smaller still, but is
not DTensor-safe: its in-place `.square_()` fails under FSDP2 with
`aten.pow_.Scalar: in-place operations that require placement changes
are not supported`.
- **LoRA** (what the overfit config uses): gradients and optimizer state
shrink to the adapter parameters, about 30 GB total.
- **FSDP sharding across GPUs**: the 66.8 GB subtotal divides by the number of
shards.
- **A memory-light optimizer**: SGD with momentum keeps a single state tensor
instead of two. `torch.optim.Adafactor` is smaller still, but is not
DTensor-safe. Its in-place `.square_()` fails under FSDP2 with
`aten.pow_.Scalar: in-place operations that require placement changes are
not supported`.
Set `training.dit_precision: bf16` for any single-device run. The default
The config reads `data/hunyuan15_preprocessed` and its
`validation_prompts.json`, not the overfit directory above. Either preprocess
into that path (set
`FASTVIDEO_TEST_HUNYUAN15_OVERFIT_OUTPUT_DIR=data/hunyuan15_preprocessed`) or
pass `--training.data.data_path` and `--callbacks.validation.dataset_file`.
The shipped overfit config already sets `training.dit_precision: bf16`. If you
write your own config, set it for any single-device run. The default
is fp32, which doubles weights, gradients, and optimizer state.
## Troubleshooting
+2 -2
View File
@@ -18,7 +18,7 @@ should use the config-driven system documented here.
### Launch with the helper script
```bash
bash examples/train/run.sh examples/train/distill_wan2.1_t2v_1.3B_dmd2.yaml
bash examples/train/run.sh examples/train/configs/distribution_matching/wan/dmd2_t2v.yaml
```
The script auto-detects available GPUs and sets up `torchrun`. Override with
@@ -35,7 +35,7 @@ NUM_GPUS=4 NNODES=2 NODE_RANK=0 \
```bash
torchrun --nproc_per_node=8 \
fastvideo/train/entrypoint/train.py \
--config examples/train/distill_wan2.1_t2v_1.3B_dmd2.yaml
--config examples/train/configs/distribution_matching/wan/dmd2_t2v.yaml
```
### CLI flags
@@ -0,0 +1,140 @@
# SPDX-License-Identifier: Apache-2.0
"""Draft with a FastH3 checkpoint at 768P, then regenerate the draft at 2560x1440 with Open-H3-Regenerate-2K.
The draft's native latent goes straight to the regen's upscaler: the draft is never decoded to pixels and re-encoded.
The regen stack is unchanged (base MiniMax-H3, the released merged LoRA and Sharp branches, three Euler steps).
python examples/inference/basic/basic_fasth3_to_regen2k.py \
--draft-model FastVideo/FastVideo-FastH3-8-Step-V2 --regen-model Efficient-Large-Model/Open-H3-Regenerate-2K \
--prompt "A red fox trots through fresh snow" --output outputs/fasth3_regen2k
A packed NVFP4 draft checkpoint (FastH3 QAD, FastH3 Trim NVFP4) needs ``--draft-quant h3_dit_vsa``; ``--vae-decoder``
selects an NVFP4 decoder for the 2K decode.
"""
from __future__ import annotations
import argparse
import json
import os
from pathlib import Path
import torch
from fastvideo import VideoGenerator
from fastvideo.api import (
ComponentConfig,
EngineConfig,
GenerationRequest,
GeneratorConfig,
InputConfig,
OffloadConfig,
OutputConfig,
PipelineSelection,
QuantizationConfig,
SamplingConfig,
)
from fastvideo.utils import maybe_download_model
DRAFT_SIZE = (768, 1344)
NUM_FRAMES = 124
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument("--draft-model", required=True, help="FastH3 checkpoint id or local path")
parser.add_argument("--draft-quant", default=None, help="NVFP4 layer profile of a packed draft, e.g. h3_dit_vsa")
parser.add_argument("--regen-model", default="Efficient-Large-Model/Open-H3-Regenerate-2K")
parser.add_argument("--prompt", required=True)
parser.add_argument("--draft-steps", type=int, default=9, help="sigma-grid points of the draft (9 = 8 forwards)")
parser.add_argument("--draft-seed", type=int, default=1000)
parser.add_argument("--seed", type=int, default=1766580932, help="regen refine noise seed")
parser.add_argument("--output", type=Path, default=Path("outputs/fasth3_regen2k"))
parser.add_argument("--vae-decoder",
default=None,
help="NVFP4 H3 video decoder for the regen decode (Blackwell), e.g. "
"quality-36L/decoder-nvfp4.safetensors from FastVideo/FastH3-NVFP4-VAE-Decoder")
return parser.parse_args()
def normalize_h3_latents(latents: torch.Tensor, regen_model: str) -> torch.Tensor:
"""Re-apply the H3 VAE's latent statistics: a draft run returns denormalized latents, the regen takes normalized."""
vae_config = os.path.join(maybe_download_model(regen_model, allow_patterns=["base_model/vae/config.json"]),
"base_model", "vae", "config.json")
with open(vae_config, encoding="utf-8") as handle:
config = json.load(handle)
mean = torch.tensor(config["latents_mean"], dtype=torch.float32).view(-1, 1, 1, 1)
std = torch.tensor(config["latents_std"], dtype=torch.float32).view(-1, 1, 1, 1)
return (latents.float().cpu().reshape(-1, *latents.shape[-3:]) - mean) / std
def draft_latent(args: argparse.Namespace) -> torch.Tensor:
engine = EngineConfig(num_gpus=1, offload=OffloadConfig(dit=False, dit_layerwise=False, vae=False))
if args.draft_quant:
engine.quantization = QuantizationConfig(transformer_quant="NVFP4", layer_profile=args.draft_quant)
generator = VideoGenerator.from_config(
GeneratorConfig(model_path=args.draft_model,
engine=engine,
pipeline=PipelineSelection(components=ComponentConfig(),
experimental={
"attention_backend": "VIDEO_SPARSE_ATTN_H3",
"VSA_sparsity": 0.8,
"VSA_tile_size": 64,
"output_type": "latent",
})))
try:
result = generator.generate(
GenerationRequest(prompt=args.prompt,
sampling=SamplingConfig(height=DRAFT_SIZE[0],
width=DRAFT_SIZE[1],
num_frames=NUM_FRAMES,
fps=24,
num_inference_steps=args.draft_steps,
guidance_scale=1.0,
seed=args.draft_seed),
output=OutputConfig(save_video=False, return_frames=True)))
finally:
generator.shutdown()
return normalize_h3_latents(torch.as_tensor(result.samples), args.regen_model)
def main() -> None:
args = parse_args()
args.output.mkdir(parents=True, exist_ok=True)
latent_path = args.output / "draft_latent.pt"
torch.save(draft_latent(args), latent_path)
generator = VideoGenerator.from_config(
GeneratorConfig(model_path=args.regen_model,
engine=EngineConfig(num_gpus=1,
offload=OffloadConfig(dit=False, dit_layerwise=False, text_encoder=True,
vae=False)),
pipeline=PipelineSelection(components=ComponentConfig(),
experimental={
"attention_backend": "TORCH_SDPA",
"draft_size": "768p",
**({
"h3_vae_nvfp4_decoder": args.vae_decoder
} if args.vae_decoder else {}),
})))
try:
result = generator.generate(
GenerationRequest(prompt=args.prompt,
inputs=InputConfig(video_path=str(latent_path)),
sampling=SamplingConfig(height=1440,
width=2560,
num_frames=NUM_FRAMES,
fps=24,
num_inference_steps=3,
guidance_scale=1.0,
seed=args.seed),
output=OutputConfig(output_path=str(args.output / "regen2k.mp4"),
save_video=True,
return_frames=False)))
print(f"wrote {result.video_path}")
finally:
generator.shutdown()
if __name__ == "__main__":
main()
@@ -0,0 +1,108 @@
# SPDX-License-Identifier: Apache-2.0
"""Regenerate a 768P MiniMax-H3 draft video at 2560x1440 with Open-H3-Regenerate-2K.
The draft is VAE-encoded (the first 121 frames at 24 fps, 1344x768), its latent is upscaled, and three refine steps
of the H3 transformer with the released LoRA run at 2560x1440. The output video is silent. See
docs/inference/open_h3_regenerate_2k.md.
python examples/inference/basic/basic_open_h3_regenerate_2k.py --video draft.mp4 --prompt "..." --output out/
"""
from __future__ import annotations
import argparse
import time
from pathlib import Path
from fastvideo import VideoGenerator
from fastvideo.api import (
ComponentConfig,
EngineConfig,
GenerationRequest,
GeneratorConfig,
InputConfig,
OffloadConfig,
OutputConfig,
PipelineSelection,
QuantizationConfig,
SamplingConfig,
)
DEFAULT_MODEL = "Efficient-Large-Model/Open-H3-Regenerate-2K"
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument("--model-path", default=DEFAULT_MODEL, help="release repo id or a local copy of it")
parser.add_argument("--video", default=None, help="768P draft video (URL-free local path)")
parser.add_argument("--prompt", required=True, help="text the refine steps are conditioned on")
parser.add_argument("--output", type=Path, default=Path("outputs/open_h3_regenerate_2k"))
parser.add_argument("--steps", type=int, choices=(2, 3), default=3, help="refine steps (3 is the released setting)")
parser.add_argument("--seed", type=int, default=1766580932)
parser.add_argument("--attention-backend", default="TORCH_SDPA")
parser.add_argument("--draft-size", choices=("768p", "576p"), default="768p",
help="size the draft is encoded at; 576p for drafts from other video models")
parser.add_argument("--vae-decoder", default=None,
help="NVFP4 decoder-nvfp4.safetensors that replaces the VAE decoder at load "
"(Blackwell only, see docs/inference/open_h3_regenerate_2k.md)")
parser.add_argument("--refine-transformer", default=None,
help="refine on another MiniMax-H3 DiT (e.g. a FastH3 export) instead of the release's own")
parser.add_argument("--refine-transformer-quant", default=None,
help="NVFP4 layer profile for a packed refine DiT, e.g. h3_dit_vsa")
parser.add_argument("--draft-latent", default=None,
help="native draft latent .pt to refine instead of encoding --video")
parser.add_argument("--runs", type=int, default=1, help="repeat the request to measure warm timings")
return parser.parse_args()
def main() -> None:
args = parse_args()
if (args.video is None) == (args.draft_latent is None):
raise SystemExit("pass exactly one of --video or --draft-latent")
args.output.mkdir(parents=True, exist_ok=True)
experimental = {
"attention_backend": args.attention_backend,
"draft_size": args.draft_size,
}
if args.vae_decoder:
experimental["h3_vae_nvfp4_decoder"] = args.vae_decoder
experimental["refine_transformer_dir"] = args.refine_transformer
experimental["draft_latent_path"] = args.draft_latent
engine = EngineConfig(num_gpus=1,
offload=OffloadConfig(dit=False, dit_layerwise=False, text_encoder=True, vae=False))
if args.refine_transformer_quant:
engine.quantization = QuantizationConfig(transformer_quant="NVFP4", layer_profile=args.refine_transformer_quant)
generator = VideoGenerator.from_config(
GeneratorConfig(
model_path=args.model_path,
engine=engine,
pipeline=PipelineSelection(
components=ComponentConfig(),
experimental=experimental,
),
))
try:
for run in range(args.runs):
started = time.perf_counter()
result = generator.generate(
GenerationRequest(
prompt=args.prompt,
inputs=InputConfig(video_path=args.video),
sampling=SamplingConfig(height=1440,
width=2560,
num_frames=124,
fps=24,
num_inference_steps=args.steps,
guidance_scale=1.0,
seed=args.seed),
output=OutputConfig(output_path=str(args.output / f"regen2k_{run}.mp4"),
save_video=True,
return_frames=False),
))
print(f"run {run}: {time.perf_counter() - started:.1f}s -> {result.video_path}")
finally:
generator.shutdown()
if __name__ == "__main__":
main()
-40
View File
@@ -1,40 +0,0 @@
import os
from fastvideo import VideoGenerator
# PAN2 is trained on the structured captions its prompt rewriter produces (serialized as one-line JSON). Plain
# prompts like this one work, with weaker results.
PROMPT = ("A red fox trots through fresh snow in a pine forest at dawn, its breath visible in the cold air. "
"The camera tracks it from the side at ground level as low golden light filters through the trees.")
OUTPUT_PATH = "video_samples_pan2"
MODEL_PATH = os.environ.get("PAN2_MODEL_PATH", "IFM/PAN2")
# Set to a first frame (e.g. "assets/images/girl.png") for image-to-video; the same pipeline runs both modes.
IMAGE_PATH = None
def main():
# The 29B DiT runs in bf16; with more GPUs, num_gpus enables Ulysses sequence parallelism (the 32 attention
# heads must be divisible by it).
generator = VideoGenerator.from_pretrained(
MODEL_PATH,
num_gpus=int(os.environ.get("PAN2_NUM_GPUS", "1")),
use_fsdp_inference=False,
dit_cpu_offload=False,
dit_layerwise_offload=False,
vae_cpu_offload=False,
text_encoder_cpu_offload=True,
pin_cpu_memory=True,
)
generator.generate_video(
PROMPT,
image_path=IMAGE_PATH,
output_path=OUTPUT_PATH,
save_video=True,
)
if __name__ == "__main__":
main()
@@ -0,0 +1,257 @@
# SPDX-License-Identifier: Apache-2.0
"""Generate synchronized video/audio with Pika H3 Flex.
Five task shapes share one model release:
``t2va`` text only
``i2va`` first image
``l2va`` last image
``fl2va`` first and last images
``ref2va`` ordered image/video/audio references
The six-forward ladder is part of the adapter contract; this script deliberately
does not expose a step-count flag. ``--lossless-accel`` turns on the existing
training-free FastVideo switches (bit-identical kernels, reference memo,
parallel VAE tiles) before the generator starts. ``--nvfp4-decoder`` and
``--nvfp4-text-encoder`` opt into the existing Blackwell FP4 weight paths;
they change weights, not the six-forward sampler. Kernel/VSA work is separate.
``--resolution`` (480p/768p), ``--aspect`` (one of Pika's eleven buckets), and
``--duration`` (5-15 s) pick the bucket and the ``17n + 5`` frame count;
``--height``/``--width``/``--num-frames`` override them with explicit values.
Launch with ``FASTVIDEO_ATTENTION_BACKEND=FLASH_ATTN FASTVIDEO_FA4=1`` on a
FlashAttention-4 host (Hopper/Blackwell); without FA4 attention falls back to
FA3/FA2 or, if neither is installed, to SDPA.
``--offload resident`` keeps every component on the GPU, which is how the
GB200 timings in docs/inference/pika_h3_flex.md were measured; the default
``lazy`` mode loads components on demand for smaller devices and is slower.
Examples:
FASTVIDEO_ATTENTION_BACKEND=FLASH_ATTN FASTVIDEO_FA4=1 \
python examples/inference/basic/basic_pika_h3_flex.py --task t2va --offload resident \
--prompt "a paper boat drifting down a rainy street"
python examples/inference/basic/basic_pika_h3_flex.py --task t2va \
--resolution 480p --aspect 9:16 --duration 10 --prompt "a lighthouse at dusk"
python examples/inference/basic/basic_pika_h3_flex.py --task fl2va \
--image first.png --last-image last.png --prompt "the flower opens"
python examples/inference/basic/basic_pika_h3_flex.py --task ref2va \
--reference image:person.png --reference video:dance.mp4 \
--reference audio:voice.wav --prompt "the person dances to the music"
"""
from __future__ import annotations
import argparse
import os
from pathlib import Path
from typing import Any
from fastvideo import VideoGenerator
from fastvideo.api import (
CompileConfig,
ComponentConfig,
EngineConfig,
GenerationRequest,
GeneratorConfig,
InputConfig,
OffloadConfig,
OutputConfig,
ParallelismConfig,
PipelineSelection,
SamplingConfig,
)
from fastvideo.models.vision_utils import load_image
from fastvideo.pipelines.basic.minimax_h3 import MiniMaxH3Reference
from fastvideo.pipelines.basic.pika_h3_flex.buckets import (
PIKA_ASPECTS,
PIKA_FPS,
PIKA_MAX_DURATION,
PIKA_MIN_DURATION,
canvas_for_aspect,
resolve_frames,
)
# Training-free switches FastVideo already exposes. Workers inherit the
# environment, so they must be set before VideoGenerator starts.
LOSSLESS_ACCEL_ENV = {
"FASTVIDEO_MINIMAX_H3_EXACT_KERNELS": "all",
"FASTVIDEO_H3_REF2VA_MEMO_ENTRIES": "16",
"FASTVIDEO_H3_VAE_TILE_PARALLEL": "1",
"FASTVIDEO_ULYSSES_A2A": "auto",
}
TASKS = ("t2va", "i2va", "l2va", "fl2va", "ref2va")
RESOLUTIONS = {"480p": "p480", "768p": "p768"}
def _reference(value: str) -> MiniMaxH3Reference:
try:
media_type, source = value.split(":", 1)
except ValueError as error:
raise argparse.ArgumentTypeError("A reference must be image:PATH, video:PATH, or audio:PATH.") from error
if media_type not in ("image", "video", "audio"):
raise argparse.ArgumentTypeError(f"Unknown reference type {media_type!r}.")
return MiniMaxH3Reference(source=source, media_type=media_type)
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument("--model-path", default="Pika-Labs/Pika-H3-Flex")
parser.add_argument("--task", choices=TASKS, default="t2va")
parser.add_argument("--prompt", required=True)
parser.add_argument("--image", help="first frame (i2va/fl2va)")
parser.add_argument("--last-image", help="last frame (l2va/fl2va)")
parser.add_argument("--reference",
action="append",
type=_reference,
default=[],
help="ordered reference (repeatable; ref2va)")
parser.add_argument("--resolution", choices=tuple(RESOLUTIONS), default="768p", help="Pika resolution tier")
parser.add_argument("--aspect", choices=PIKA_ASPECTS, default="16:9", help="bucket aspect ratio (W:H)")
parser.add_argument("--duration",
type=float,
default=5.0,
help=f"seconds ({PIKA_MIN_DURATION:g}-{PIKA_MAX_DURATION:g}); rounded up to the 17n+5 grid")
parser.add_argument("--height", type=int, help="explicit canvas height (with --width; overrides the bucket)")
parser.add_argument("--width", type=int, help="explicit canvas width (with --height; overrides the bucket)")
parser.add_argument("--num-frames", type=int, help="explicit frame count (overrides --duration)")
parser.add_argument("--seed", type=int, default=0)
parser.add_argument("--num-gpus", type=int, default=1)
parser.add_argument("--compile", action="store_true", help="torch.compile the DiT")
parser.add_argument("--lossless-accel", action="store_true", help="enable bit-identical speed-ups")
parser.add_argument("--offload",
choices=("lazy", "resident"),
default="lazy",
help="lazy: load components on demand and offload the encoders/VAE (fits smaller devices); "
"resident: keep everything on the GPU (fastest, about 105-130 GiB on GB200)")
parser.add_argument("--low-vram", action="store_true", help="stream the DiT layerwise (lazy offload only)")
parser.add_argument("--nvfp4-decoder",
help="Blackwell-only NVFP4 H3 decoder (quality-36L or fast-8L decoder-nvfp4.safetensors)")
parser.add_argument("--nvfp4-text-encoder",
help="Blackwell-only serialized NVFP4 Qwen3-VL text_encoder directory")
parser.add_argument("--output", default="outputs/pika_h3_flex")
args = parser.parse_args()
if args.task in ("i2va", "fl2va") and not args.image:
parser.error(f"--task {args.task} needs --image")
if args.task in ("l2va", "fl2va") and not args.last_image:
parser.error(f"--task {args.task} needs --last-image")
if args.task == "ref2va" and not args.reference:
parser.error("--task ref2va needs at least one --reference")
if args.task != "ref2va" and args.reference:
parser.error("--reference is only valid with --task ref2va")
if (args.height is None) != (args.width is None):
parser.error("--height and --width must be passed together")
if args.low_vram and args.offload == "resident":
parser.error("--low-vram streams the DiT and cannot be combined with --offload resident")
return args
def _offload(args: argparse.Namespace) -> OffloadConfig:
if args.offload == "resident":
return OffloadConfig(dit=False,
dit_layerwise=False,
text_encoder=False,
vae=False,
pin_cpu_memory=False,
lazy_module_load=False)
return OffloadConfig(
dit=False,
dit_layerwise=args.low_vram,
text_encoder=True,
vae=True,
pin_cpu_memory=not args.low_vram,
lazy_module_load=True,
)
def _canvas(args: argparse.Namespace) -> tuple[int, int]:
if args.height is not None:
return args.height, args.width
return canvas_for_aspect(args.aspect, RESOLUTIONS[args.resolution])
def _num_frames(args: argparse.Namespace) -> int:
if args.num_frames is not None:
return args.num_frames
return resolve_frames(round(args.duration * PIKA_FPS))
def _inputs(args: argparse.Namespace) -> InputConfig:
if args.task == "ref2va":
return InputConfig(references=args.reference)
first = args.image if args.task in ("i2va", "fl2va") else None
last = args.last_image if args.task in ("l2va", "fl2va") else None
# The stage loads ``image_path`` itself but requires ``last_image`` as a PIL image.
return InputConfig(image_path=first, last_image=load_image(last) if last else None)
def _pipeline(args: argparse.Namespace) -> PipelineSelection:
components = ComponentConfig()
if args.task == "ref2va":
components.override_pipeline_cls_name = "PikaH3FlexRef2VAModularPipeline"
if args.nvfp4_decoder:
components.h3_vae_nvfp4_decoder = args.nvfp4_decoder
if args.nvfp4_text_encoder:
components.text_encoder_weights = args.nvfp4_text_encoder
return PipelineSelection(
workload_type="t2v" if args.task == "t2va" else "i2v",
components=components,
vae_tiling=True,
)
def main() -> None:
args = parse_args()
if args.lossless_accel:
for key, value in LOSSLESS_ACCEL_ENV.items():
os.environ.setdefault(key, value)
height, width = _canvas(args)
num_frames = _num_frames(args)
print(f"Pika H3 Flex {args.task}: {width}x{height}, {num_frames} frames ({num_frames / PIKA_FPS:.2f} s)")
output_path = Path(args.output) / f"pika_h3_flex_{args.task}.mp4"
output_path.parent.mkdir(parents=True, exist_ok=True)
offload = _offload(args)
generator = VideoGenerator.from_config(
GeneratorConfig(
model_path=args.model_path,
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=offload,
compile=CompileConfig(enabled=args.compile),
),
pipeline=_pipeline(args),
))
try:
result = generator.generate(
GenerationRequest(
prompt=args.prompt,
negative_prompt="",
inputs=_inputs(args),
sampling=SamplingConfig(
height=height,
width=width,
num_frames=num_frames,
fps=24,
num_inference_steps=6,
guidance_scale=1.0,
batch_cfg=False,
seed=args.seed,
),
output=OutputConfig(
output_path=str(output_path),
save_video=True,
return_frames=False,
),
))
print(f"Output written to: {result.video_path}")
finally:
generator.shutdown()
if __name__ == "__main__":
main()
-1
View File
@@ -90,7 +90,6 @@ uncovered registry families.
| `openai_hunyuan15_i2v_480p.yaml` | HunyuanVideo 1.5 I2V 480p | image-to-video | 1 |
| `openai_kandinsky6_ti2va.yaml` | Kandinsky 6 Pro TI2VA | text/image-to-video with audio | 1 |
| `openai_kandinsky6_ti2va_distilled.yaml` | Kandinsky 6 Pro-distill TI2VA | text/image-to-video with audio | 1 |
| `openai_pan2.yaml` | PAN 2 | text/image-to-video | 1 |
| `openai_ltx2_distilled.yaml` | LTX2 Distilled | text-to-video with audio | 4 |
| `openai_flux1_dev.yaml` | FLUX.1-dev | text-to-image | 1 |
| `openai_flux2_dev.yaml` | FLUX.2-dev | text-to-image | 1 |
@@ -0,0 +1,46 @@
# OpenAI-compatible Open-H3-Regenerate-2K server (one GPU with about 150 GB free, e.g. B200 / GB200).
# POST /v1/videos/sync (or /v1/videos) with a 768P H3 draft as video_reference; the response is the 2560x1440 MP4.
# Local draft paths are accepted only inside server.media_roots; http(s) URLs must resolve to public addresses unless
# server.allow_private_media_urls is set. data: URLs are always refused. See docs/inference/open_h3_regenerate_2k.md.
generator:
model_path: Efficient-Large-Model/Open-H3-Regenerate-2K
engine:
num_gpus: 1
offload:
dit: false
dit_layerwise: false
text_encoder: true
vae: false
pin_cpu_memory: true
compile:
enabled: false
vae_enabled: false
pipeline:
experimental:
attention_backend: TORCH_SDPA
# 768p for MiniMax-H3 drafts; 576p encodes drafts from other video models at 1024x576.
draft_size: 768p
# NVFP4 video decoder (Blackwell only). Download one tier from FastVideo/FastH3-NVFP4-VAE-Decoder and point
# this at it; the release VAE has the full 36 blocks, so both tiers apply. `fast-8L` is the cheap option.
# h3_vae_nvfp4_decoder: ./fasth3-nvfp4-vae/quality-36L/decoder-nvfp4.safetensors
server:
host: 0.0.0.0
port: 8000
output_dir: outputs/openai_open_h3_regenerate_2k
served_model_name: open-h3-regenerate-2k
media_roots:
- /data/drafts
media_reference_max_bytes: 536870912
default_request:
sampling:
height: 1440
width: 2560
num_frames: 124
fps: 24
num_inference_steps: 3
guidance_scale: 1.0
seed: 1766580932
output:
return_frames: false
-36
View File
@@ -1,36 +0,0 @@
# OpenAI-compatible server for PAN 2 text-to-video and first-frame image-to-video.
# fastvideo serve --config examples/serving/openai_pan2.yaml
# Omit image_reference for text-to-video; send it (or input_reference) for image-to-video.
generator:
model_path: IFM/PAN2
engine:
num_gpus: 1
use_fsdp_inference: false
parallelism:
tp_size: 1
sp_size: 1
offload:
dit: false
dit_layerwise: false
vae: false
text_encoder: true
pin_cpu_memory: true
pipeline:
workload_type: t2v
server:
host: 0.0.0.0
port: 8000
output_dir: outputs/openai_pan2
served_model_name: pan2
default_request:
sampling:
height: 704
width: 1248
num_frames: 313
fps: 24
num_inference_steps: 50
guidance_scale: 3.0
output:
return_frames: false
-1
View File
@@ -21,7 +21,6 @@ Runtime-JIT kernels (no build step, ship in every wheel/image):
| Triton: STA, VSA block-sparse, SLA, fused compress+topk, FP4 QAT training, quant/norm utils | `python/fastvideo_kernel/triton_kernels/` | automatic fallback when the matching C++ op is absent (`ops.py`, `turbodiffusion_ops.py`) |
| FA4 CuTe-DSL block-sparse forward/backward (VSA-128/256 fastpath on `sm_100`) | `block_sparse_attn_cute_fwd.py` | optional `flash_attn.cute` dependency, see below |
| VMoBA `moba_attn_varlen` | `vmoba.py` | wraps flash-attn varlen |
| Wan2.2 VAE decoder (TI2V-5B, 48 latent channels) at 704 x 1248 on `sm_100a` / `sm_103a`, on 1-8 GPUs of a node | `wan22_vae_decode/`, built from `wan22_vae_decode/solution/` on first use against `include/cutlass` | `wan22_vae_decode.WanVAEDecoder`; FastVideo's `WanVAEConfig.decode_kernel`, on for PAN2 sampling (see `wan22_vae_decode/README.md`) |
## What gets built where, and when
@@ -5,7 +5,7 @@ Benchmark the SM100 fused VSA64 tile layout against the legacy scatter/transpose
Times Wan VSA inference preprocessing through the backend
(``VideoSparseAttentionImpl.preprocess_qkv``), toggling
``FASTVIDEO_DISABLE_VSA64_FUSED_LAYOUT``:
- legacy: tile scatter into BSHD + four BSHD->BHSD transposes
- legacy: tile scatter into BSHD + three contiguous Q/K/V transposes + a gate view
- fused: one Triton kernel writing tiled BHSD directly
With ``--include_attn`` the timed region also covers ``forward`` (the
@@ -59,20 +59,20 @@ def parse_arguments() -> argparse.Namespace:
def make_step(impl: VideoSparseAttentionImpl, qkvg: torch.Tensor, builder: VideoSparseAttentionMetadataBuilder,
build_kwargs: dict, fused: bool, include_attn: bool) -> Callable[[], torch.Tensor]:
build_kwargs: dict, fused: bool, include_attn: bool) -> Callable[[], tuple[torch.Tensor, ...]]:
metadata = builder.build(**build_kwargs)
def step() -> torch.Tensor:
def step() -> tuple[torch.Tensor, ...]:
# The routing gate reads the switch on every call.
with envs.FASTVIDEO_DISABLE_VSA64_FUSED_LAYOUT.override(not fused):
tiled = impl.preprocess_qkv(qkvg, metadata)
q, k, v, gate = tiled.chunk(4)
if include_attn:
return impl.forward(q, k, v, gate, metadata)
return (impl.forward(q, k, v, gate, metadata), )
if metadata.fused_layout_active:
return tiled
# Legacy route: the transposes forward() applies before video_sparse_attn.
return torch.cat([t.transpose(1, 2).contiguous() for t in (q, k, v, gate)])
return q, k, v, gate
return (q.transpose(1, 2).contiguous(), k.transpose(1, 2).contiguous(), v.transpose(1, 2).contiguous(),
gate.transpose(1, 2))
with torch.no_grad():
step()
@@ -100,7 +100,7 @@ def main() -> None:
legacy = make_step(impl, qkvg, builder, build_kwargs, fused=False, include_attn=args.include_attn)
fused = make_step(impl, qkvg, builder, build_kwargs, fused=True, include_attn=args.include_attn)
with torch.no_grad():
if not torch.equal(legacy(), fused()):
if not all(torch.equal(old, new) for old, new in zip(legacy(), fused(), strict=True)):
raise RuntimeError("Fused and legacy outputs differ.")
legacy_ms = do_bench(legacy, warmup=args.warmup, rep=args.rep, return_mode="median")
fused_ms = do_bench(fused, warmup=args.warmup, rep=args.rep, return_mode="median")
+61 -1
View File
@@ -5,6 +5,9 @@ Production only routes SM100 here, but the kernel is plain Triton, so parity is
checked on any CUDA device.
"""
import importlib.util
from pathlib import Path
import pytest
import torch
@@ -40,6 +43,63 @@ def test_tile_to_bhsd_matches_scatter_and_four_transposes(dit_shape):
source_index[metadata.non_pad_index] = metadata.tile_partition_indices.to(torch.int32)
out = torch.empty((4, 2, padded_sequence, 128), device="cuda", dtype=torch.bfloat16)
actual = tile_to_bhsd(qkvg, source_index, out, sequence, padded_sequence, 2, 128).chunk(4)
for name, old, new in zip(("q", "k", "v", "gate"), expected, actual):
for name, old, new in zip(("q", "k", "v", "gate"), expected, actual, strict=True):
assert old.shape == new.shape, name
assert torch.equal(old, new), name
@pytest.mark.parametrize("fused", [False, True])
def test_layout_benchmark_matches_forward_preprocessing(monkeypatch, fused):
if not torch.cuda.is_available():
pytest.skip("CUDA device required")
from fastvideo.attention.backends import video_sparse_attn as vsa
path = Path(__file__).resolve().parents[1] / "benchmarks/bench_vsa_tile_layout.py"
spec = importlib.util.spec_from_file_location("bench_vsa_tile_layout", path)
benchmark = importlib.util.module_from_spec(spec)
spec.loader.exec_module(benchmark)
original_gate = vsa._can_fuse_vsa64_layout
def allow_test_device(x, metadata):
with monkeypatch.context() as gate_patch:
gate_patch.setattr(torch.cuda, "get_device_capability", lambda *args: (10, 0))
return original_gate(x, metadata)
monkeypatch.setattr(vsa, "_can_fuse_vsa64_layout", allow_test_device)
impl = object.__new__(vsa.VideoSparseAttentionImpl)
builder = vsa.VideoSparseAttentionMetadataBuilder()
kwargs = dict(current_timestep=0,
raw_latent_shape=(5, 5, 6),
patch_size=(1, 1, 1),
VSA_sparsity=0.8,
device=torch.device("cuda"),
cache_tile_buf=True)
qkvg = torch.randn((4, 157, 2, 128), device="cuda", dtype=torch.bfloat16)[:, :150]
captured = []
def capture_attention_inputs(q, k, v, *args, **kwargs):
captured[:] = [q, k, v, kwargs["compress_attn_weight"]]
return q
monkeypatch.setattr(vsa, "video_sparse_attn", capture_attention_inputs)
with torch.no_grad():
layout_step = benchmark.make_step(impl, qkvg, builder, kwargs, fused=fused, include_attn=False)
attention_step = benchmark.make_step(impl, qkvg, builder, kwargs, fused=fused, include_attn=True)
def reject_cat(*args, **kwargs):
pytest.fail("The timed layout step must not concatenate its outputs")
with monkeypatch.context() as timed_patch:
timed_patch.setattr(torch, "cat", reject_cat)
layout = layout_step()
attention = attention_step()
assert isinstance(layout, tuple) and len(layout) == 4
for actual, expected in zip(layout, captured, strict=True):
assert torch.equal(actual, expected)
assert actual.stride() == expected.stride()
assert all(t.is_contiguous() for t in layout[:3])
assert layout[3].is_contiguous() is fused
assert isinstance(attention, tuple) and len(attention) == 1
assert torch.equal(attention[0], captured[0].transpose(1, 2))
@@ -1,97 +0,0 @@
"""The Wan2.2 VAE decoder (fastvideo-kernel/wan22_vae_decode) through its wrapper, against the reference.
Needs an sm_100a or sm_103a GPU (B200 / GB200, B300) and the VAE's weights: WAN22_VAE_WEIGHTS names either a diffusers AutoencoderKLWan
directory (its diffusion_pytorch_model.safetensors) or a .pt file holding the 110 tensors in weight_names() order. The
first test builds the extension (about a minute). The kernel's own checks are wan22_vae_decode/tools/bench.py (nine
timed workloads and six short clips at 1, 2 and 4 GPUs, on real latents); these tests cover the wrapper on one GPU:
the weights it reads from a state dict, and decodes against the fp32 reference, each within three times what the bf16
reference errs by on the same latents (the bench's rule, calibrated per input instead of per clip).
"""
import functools
import os
import sys
import pytest
import torch
PACKAGE = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "wan22_vae_decode")
sys.path.insert(0, PACKAGE)
sys.path.insert(0, os.path.join(PACKAGE, "tools"))
WEIGHTS = os.environ.get("WAN22_VAE_WEIGHTS", "")
pytestmark = [
pytest.mark.skipif(not torch.cuda.is_available() or torch.cuda.get_device_capability() not in ((10, 0), (10, 3)),
reason="the Wan2.2 VAE decoder is built for sm_100a / sm_103a (B200 / GB200, B300)"),
pytest.mark.skipif(not WEIGHTS or not os.path.exists(WEIGHTS),
reason="set WAN22_VAE_WEIGHTS to the VAE's diffusers directory or a weights .pt file"),
]
@functools.lru_cache(maxsize=None)
def _state_dict():
"""name -> tensor of the VAE's parameters, under diffusers' names."""
from wan22_vae_decode import weight_names
if os.path.isdir(WEIGHTS):
from safetensors.torch import load_file
return load_file(os.path.join(WEIGHTS, "diffusion_pytorch_model.safetensors"))
tensors = torch.load(WEIGHTS, map_location="cpu", weights_only=True)
return tensors if isinstance(tensors, dict) else dict(zip(weight_names(), tensors))
@functools.lru_cache(maxsize=None)
def _decoder():
from wan22_vae_decode import WanVAEDecoder
return WanVAEDecoder(_state_dict(), device="cuda")
def test_weights_follow_diffusers_names():
from wan22_vae_decode import decoder_weights, weight_names
names = weight_names()
assert len(names) == 110 and names[0] == "post_quant_conv.weight" and names[-1] == "decoder.conv_out.bias"
weights = decoder_weights(_state_dict(), "cpu")
assert all(w.dtype == torch.bfloat16 and w.is_contiguous() for w in weights)
assert all(torch.equal(w, _state_dict()[n].to(torch.bfloat16)) for n, w in zip(names, weights))
@pytest.mark.parametrize("latent_frames", [1, 2, 5])
def test_decode_against_reference(latent_frames):
import reference
from wan22_vae_decode import weight_names
generator = torch.Generator().manual_seed(latent_frames)
z = torch.randn((1, 48, latent_frames, 44, 78), generator=generator).to("cuda", torch.bfloat16)
video = _decoder().decode(z)
assert video.shape == (1, 3, 4 * latent_frames - 3, 704, 1248) and video.dtype == torch.bfloat16
weights = {n: _state_dict()[n].to("cuda", torch.bfloat16).float() for n in weight_names()}
ref = reference.decode(z.float(), weights, torch.float32)
release = reference.decode(z, weights, torch.bfloat16).float()
d, d_release = video.float() - ref, release - ref
assert bool(torch.isfinite(video).all()) and float(video.abs().max()) <= 1.0
assert float(d.abs().max()) <= 3 * float(d_release.abs().max())
assert float(d.pow(2).mean().sqrt()) <= 3 * float(d_release.pow(2).mean().sqrt())
def test_decodes_are_independent():
"""No state carries from one call to the next: the same latents decode to the same video after another clip."""
z = torch.randn((1, 48, 2, 44, 78), generator=torch.Generator().manual_seed(7)).to("cuda", torch.bfloat16)
first = _decoder().decode(z)
_decoder().decode(torch.randn((1, 48, 3, 44, 78), generator=torch.Generator().manual_seed(8)).to("cuda",
torch.bfloat16))
assert torch.equal(_decoder().decode(z), first)
def test_pass_size_does_not_change_the_video():
"""The causal history crosses passes, so any number of latent frames per pass decodes the same video."""
from wan22_vae_decode import WanVAEDecoder
z = torch.randn((1, 48, 6, 44, 78), generator=torch.Generator().manual_seed(9)).to("cuda", torch.bfloat16)
reference_pass = _decoder().decode(z)
for frames in (1, 16):
assert torch.equal(WanVAEDecoder(_state_dict(), device="cuda", frames_per_pass=frames).decode(z), reference_pass)
@@ -1,98 +0,0 @@
# Wan2.2 VAE decoder (sm_100a, sm_103a)
The decoder of the Wan2.2 VAE (the VAE of Wan2.2-TI2V-5B: 48 latent channels, 16 x 16 spatial and 4x temporal
compression) at 704 x 1248, on one, two or four B200 / GB200 GPUs of a node. It decodes latents [1, 48, T, 44, 78]
into the video [1, 3, 4T - 3, 704, 1248] for any T >= 1, as diffusers' `AutoencoderKLWan.decode(z).sample` does;
`reference.py` defines the result exactly, a plain-PyTorch decoder bit-exact with diffusers in fp32 and in bf16.
## Use
```python
import sys
sys.path.insert(0, "fastvideo-kernel/wan22_vae_decode")
from wan22_vae_decode import WanVAEDecoder
decoder = WanVAEDecoder(vae.state_dict(), device="cuda") # diffusers' AutoencoderKLWan, or its state dict
video = decoder.decode(latents) # [1, 48, T, 44, 78], as the VAE's decode takes them -> [1, 3, 4T-3, 704, 1248] bf16
```
- **Over several GPUs**, every rank builds a decoder with the same process group (`WanVAEDecoder(sd, device, pg)`,
default: torch.distributed's default group) and calls `decode` with the full latents; rank 0 gets the video and the
other ranks `None`. On one GPU without torch.distributed, the decoder initializes a single-rank NCCL group.
- The first call builds the extension from `solution/` (about a minute) into `$WAN22_VAE_DECODE_BUILD_DIR` (default
`~/.cache/fastvideo/wan22_vae_decode`, one directory per `LOCAL_RANK`), and later processes reuse the build.
- `WanVAEDecoder(..., frames_per_pass=4)` sets the latent frames each GPU carries through one pass (see below).
- `WanVAEDecoder` prepares the weights once (layout, phase and Winograd transforms of the weights). The raw entry
points are `prepare(weights, rank, world, pg)`, `run(z, weights, rank, world, pg, out)` (4 frames per pass) and
`run_with(..., out, frames_per_pass)` in `solution/binding.cpp`, destination-passing, on the current stream;
`weights` are the 110 tensors of `weight_names()` in bf16.
## Performance and memory
At PAN2's default 704 x 1248, 24 fps, on GB200 GPUs, against PAN2's decode today (diffusers' `AutoencoderKLWan` in bf16
under autocast, one GPU, one latent frame at a time). Memory is the peak allocated and, after it, the peak reserved by
PyTorch's caching allocator (what nvidia-smi shows), per GPU; the default is 4 latent frames per GPU per pass:
| Decoder | 8 s (193 frames) | 12 s (289 frames) | 20 s (481 frames) |
|---|---|---|---|
| Release, 1 GPU | 5.88 s, 12.9 / 17.1 GiB | 8.84 s, 13.9 / 19.4 GiB | 14.76 s, 15.8 / 91.5 GiB |
| **This decoder, 1 GPU** | **1.17 s** (x5.0), 20.7 / 36.3 GiB | **1.74 s** (x5.1), 21.2 / 36.8 GiB | **2.89 s** (x5.1), 22.2 / 37.8 GiB |
| **This decoder, 2 GPUs** | **619 ms**, 18.7 / 33.0 GiB | **929 ms**, 19.2 / 33.5 GiB | **1.54 s**, 20.2 / 34.5 GiB |
| **This decoder, 4 GPUs** | **328 ms**, 17.8 / 41.4 GiB | **475 ms**, 18.3 / 34.1 GiB | **787 ms** (x18.8), 19.2 / 42.9 GiB |
`frames_per_pass` trades memory for speed and leaves the video unchanged. On one GPU:
| Latent frames per pass | 8 s | 12 s | 20 s |
|---|---|---|---|
| 2 | 1.25 s, 14.4 / 24.4 GiB | 1.88 s, 14.9 / 24.9 GiB | 3.13 s, 15.9 / 25.8 GiB |
| 4 (default) | 1.17 s, 20.7 / 36.3 GiB | 1.74 s, 21.2 / 36.8 GiB | 2.89 s, 22.2 / 37.8 GiB |
| 8 | 1.10 s, 33.7 / 62.1 GiB | 1.64 s, 34.2 / 62.6 GiB | 2.72 s, 35.2 / 63.6 GiB |
| 16 | 1.06 s, 59.8 / 121.0 GiB | 1.58 s, 60.2 / 121.7 GiB | 2.67 s, 61.2 / 122.5 GiB |
- With `PYTORCH_CUDA_ALLOC_CONF=expandable_segments:True`, the default reserves 30.6-32.0 GiB on one GPU and 2 frames
per pass 19.6-21.1 GiB, at the same speed.
- On the optimization run's nine workloads (`tools/bench.py`: 121, 241 and 481 frames at 1, 2 and 4 GPUs, SM clocks
1.6-1.95 GHz), the default's geomean is 758 ms, against 1043 ms for the run's first solution (x1.38) and 698 ms at
16 frames per pass.
- The CUTLASS Winograd component products take 60 % of the GPU time, the transforms and epilogues 30 %
(`notes/optimization.md`).
## Correctness
Against the fp32 decode of real latents, with `d = video - reference` per clip: every element finite and in [-1, 1],
max |d| at most three times what the release's bf16 decode errs by, and the RMS of d likewise. On this tree, every
clip at worlds 1, 2 and 4 and the short clips `f1`, `f5`, `f17` at worlds 1 and 4 pass, using 0.30-0.66 of the max
allowance and 0.35-0.42 of the RMS one (PSNR 56-60 dB against fp32; the release's bf16 decode has 58-61 dB).
```bash
cd fastvideo-kernel/wan22_vae_decode
DATA=/path/to/data python tools/bench.py --solution solution # every workload this job has the GPUs for
DATA=/path/to/data python tools/bench.py --workload f5x1 f17x1 --no-time # correctness of the short clips
cd .. && WAN22_VAE_WEIGHTS=/path/to/vae python -m pytest tests/test_wan22_vae_decode.py # the wrapper, one GPU
```
`tools/bench.py` reads `$DATA`: the latents (`latents_<source>.pt`), the weights in `weight_names()` order
(`decoder_weights_bf16.pt`, `weight_names.json`), the fp32 decodes (`ref_<clip>.pt`) and the release decode's errors
(`calibration.json`); `tools/spec.py` documents each. The tests need only the VAE's weights.
## Limits
- sm_100a and sm_103a (B200 / GB200, B300): `tcgen05` with tensor memory, TMA and 2-CTA clusters; the loader builds
for the GPU it runs on. CUDA 13, torch 2.12 tested; the tests pass on B300 too, built for sm_103a.
- 704 x 1248 video (latents 44 x 78) and batch 1; any T >= 1.
- 1, 2 or 4 GPUs of one node were checked; the code accepts up to 8 ranks.
- bf16 weights and activations, fp32 accumulation and fp32 RMS-norm statistics.
- In FastVideo, `WanVAEConfig.decode_kernel` routes `AutoencoderKLWan.decode` here where it applies (on one GPU per
rank, `WanVAEDecoder(..., local=True)`), and PAN2 sampling turns it on (`PAN2PipelineConfig.vae_decode_kernel`). On
a B300 with PAN2 latents it decodes 7x faster than the fp32 decode, at PSNR 61-63 dB and SSIM 0.998 against it.
## Layout
- `solution/`: the kernels and their binding; `solution.json` holds the build flags. The CUTLASS and CuTe headers
come from fastvideo-kernel's CUTLASS submodule (`include/cutlass`, or `$CUTLASS_DIR`); the decoder's structure follows
diffusers' (`WAN_LICENSE.txt`).
- `wan22_vae_decode.py`: `WanVAEDecoder`, `decoder_weights` and `weight_names`.
- `reference.py`: the definition of the result.
- `tools/`: `bench.py` (check, audit and time), `loader.py` (the build), `spec.py` (workloads, weights, data),
`audit.py`, `bounds.py`.
- `notes/optimization.md`: the design, what each merge bought, what did not pay, and where to go next.
@@ -1,99 +0,0 @@
# How the decoder got here
The solution is the main of a multi-agent optimization run (bifrost, 2026-10-25/26): 32 DeepSeek V4.1 Flash peers
and two GPT-6 Astra architects on GB200, starting from a first solution written before the run. Every merge passed the
run's CI (correct on every workload and short clip at worlds 1, 2 and 4, and at least 1 % faster than main by the
geomean in the same job, unless waived) and an independent review. The comments in `solution/` cite the run's pull
requests (PRn), issues (In) and jobs (Jn).
## The design
The decoder runs layer by layer, as kernels of its own and CUTLASS kernels instantiated in `solution/` (the
headers are fastvideo-kernel's CUTLASS submodule). There is no megakernel: see "What did not pay" below.
- **Frames in passes.** A pass carries several latent frames per GPU instead of the reference's one (4 by default,
`frames_per_pass`; the run measured its speed at 16, see "Memory"), so its GEMMs are large, and the causal history
(the last two frames of each 3 x 3 x 3 convolution's input) is carried from pass to pass as an offset, never by
concatenation.
- **The 18 convolutions of up blocks 1-3** (most of the FLOPs) are F(4x4, 3x3) Winograd in space, keeping the causal
3-tap convolution in time, in three steps:
1. `wino_in.cu`: the input transform, two blocks per tile;
2. 36 component products, each a CUTLASS SM100 implicit-GEMM 3 x 1 x 1 convolution (`conv.cu`: 2-CTA 256 x 256 x 64
tiles, TMA im2col, `tcgen05` MMA, fp32 accumulation);
3. `wino_epilogue.cu`: the inverse transform with the bias and the residual add, and the *next* convolution's RMS
norm, SiLU and causal padding, so that convolution reads its input ready-made.
- **The other convolutions** at 44 x 78 are direct CUTLASS implicit GEMMs; a few small ones still go through cuDNN
and cuBLAS, under 1 % of the GPU time.
- **The upsamplers** never materialize the upsampled tensor: nearest x2 followed by a 3 x 3 convolution is four 2 x 2
phase convolutions at the low resolution, and `resample.cu` interleaves them with the bias and the DupUp3D shortcut
in one pass. The temporal upsampler (`timeconv.cu`) writes its two frames straight into place.
- **Pointwise and the rest:** `norm.cu` (RMS norm + SiLU + causal pad in one pass, where the epilogue does not already
apply it), `attn.cu` (the mid block's single-head attention over each frame's 3,432 positions), and `head.cu` (the
output head: `conv_out` as a 3 x 1 x 1 convolution to 112 channels, then a 9-tap stencil with the bias, the clamp
and the unpatchify write).
- **Over GPUs** (`comm.cu`, `prefix.cu`): the decoder's prefix (post_quant_conv, conv_in, the mid block's first
residual block and its attention) is split by time, each rank computing its frames with exact causal context from
the full latents, then transposed to bands of rows (`prefix.cu`). From there each rank owns 44 / world latent rows;
every 3 x 3 convolution exchanges one halo row with the neighbours only (NCCL send/recv in one group), and rank 0
receives the other ranks' pixels per chunk.
## The merges
| Merge | Change | CI speedup | Geomean after |
|---|---|---|---|
| kernel#0 | the first solution: Winograd up blocks, phase upsamplers, fused pointwise, row bands | | 1043 ms |
| PR3 | 1 x 1 channel shortcuts as own kernels, their bias folded into the residual | x1.017 | 1025 ms |
| PR24 | mid-block attention as own kernels instead of ATen SDPA (8.5 -> 2.6 ms of f121x1) | x1.013 | 1012 ms |
| PR15 | RMS norm + SiLU + pad in one pass | x1.110 | 913 ms |
| PR204 | halo exchange with the neighbours only, one coalesced group | x1.014 | 901 ms |
| PR392 | the fused Winograd input transform and the fused inverse epilogue (with the next norm) | x1.189 | 757 ms |
| PR90 | output head as a temporal convolution and a 9-tap stencil (waived at parity) | x1.003 | 755 ms |
| PR438 | the prefix split by time before the row bands | x1.019 | 740 ms |
| PR452 | world 8 uses all-gather, not the ring (worlds 1/2/4 unchanged; waived) | x1.001 | 740 ms |
| PR488 | the upsampler's phases interleaved in one pass, and the temporal upsampler in place | x1.017 | 728 ms |
| PR513 | chunks of min(16 x world, 61) latent frames instead of 8 x world | x1.023 | 711 ms |
| PR490 | a check that no fused input leaks across chunks (waived) | x0.999 | 712 ms |
| PR592 | no dead store of y at each residual block's first convolution | x1.010 | 704 ms |
| PR517 | the input transform at two blocks per tile (waived at x1.007) | x1.007 | 699 ms |
## Memory
The run optimized time alone, and its main decoded 16 latent frames per GPU per pass at a peak of 100-102 GiB on one
GPU, against 13-16 GiB for the release's decode, which carries one latent frame. After the run:
- **Tensors are freed at their last use.** At the peak, up block 3's first convolution (512 -> 256 channels at
352 x 624) held its padded input after the Winograd transform had read it, the transform (2.25x the input) after
every product was enqueued, and the block's input after its shortcut and first norm had read it. Releasing each of
them then takes the peak from 101 to 60 GiB at 16 frames per pass, at the same speed, with a bit-identical video.
- **The pass is 4 latent frames per GPU by default** (`frames_per_pass`), since memory grows with it at 3.5-4.2 GiB per
latent frame at 704 x 1248: 21-22 GiB on one GPU and 18-20 GiB per GPU at 2 and 4 GPUs, for 8-9 % more time than 16
frames per pass. The time a smaller pass costs is in the low resolutions (44 x 78 and 88 x 156), whose GEMMs do not
fill the GPU with few frames, while the memory is in the two high ones (176 x 312 and 352 x 624). Passes of 16 frames
through up block 1 and of 2-4 through up blocks 2 and 3 should keep most of the speed at this memory; it is not built.
## What did not pay
- **Megakernels.** Before the run, a persistent kernel per residual block measured 4.1-7.5x slower than two fused
per-convolution kernels. During the run, a cooperative kernel for one whole Winograd convolution (forward transform,
products and inverse with the next norm, in one launch of 152 CTAs in 2-CTA clusters) was correct but took
6.2-7.2 ms at T 8, 352 x 624, 256 channels, against 3.3 ms for the same work in three launches (0.46-0.53x). The gaps
between the kernels of a decode are about 5 us each, so fewer launches buy little; what a single kernel loses is
the occupancy and the tiling each stage picks for itself.
- **FP8.** e4m3 operands in the Winograd component products cut the products from 262 to 162 ms at f121x1 (10-11 %
of the decode), but fail the error budget on all three clips (RMS at 2.3-3.3x the allowance, even with a power-of-two
scale per component): the transformed operands need about one more mantissa bit. Before the run, FP8 in the direct
convolutions passed in up blocks 1-3 but bought only 1 % of the decode, since quantization was a pass of its own.
- **Splitting by time throughout.** A chunk needs 38 latent frames of causal context, so recomputing it is a poor fit;
only the prefix is split by time (PR438).
## Where to go next
- **The products** are about 60 % of the GPU time (59.6 % at f121x1: 1,383 CUTLASS launches, 384 ms of 644), at
65-68 % of the tensor cores' peak, bound by operand fetch. The transforms and the epilogues are another 30 %. A grouped products kernel (all 36 components of a convolution, persistent, in
2-CTA clusters) passed correctness during the run; the step after it is to compute the input transform inside the
products' operand producer, which removes a write and a read of the transformed tensor (2.25x the activation) per
convolution.
- **Eight GPUs across two trays:** the code accepts up to 8 ranks (PR452 fixed world 8's all-gather), but world 8
was never in CI. A frame pipeline that passes each
layer's two-frame context beat row bands at eight GPUs before the run (1.40-1.55 s against 2.12 s on f241, with
library convolutions).
@@ -1,251 +0,0 @@
"""Wan2.2 VAE decoder: a standalone PyTorch reference of diffusers' AutoencoderKLWan._decode (Wan2.2-TI2V-5B VAE).
decode(z, weights, dtype) maps latents z [B, 48, T, H/16, W/16] to a video [B, 3, 4T-3, H, W] with values in [-1, 1],
as AutoencoderKLWan.decode(z).sample does with tiling and slicing off (its defaults). It needs torch and nothing else:
`weights` is a plain dict name -> tensor of the decoder's and post_quant_conv's parameters under diffusers' names
(the 110 of wan22_vae_decode.weight_names()), such as the VAE's state dict:
weights = {k: v.cuda().float() for k, v in vae.state_dict().items()}
video = decode(latents.cuda(), weights) # fp32
video = decode(latents.cuda(), weights, torch.bfloat16) # a pipeline's bf16 decode
What is computed
----------------
The decoder of the TI2V-5B VAE (z_dim 48, decoder_base_dim 256, dim_mult [1, 2, 4, 4], num_res_blocks 2,
is_residual, patch_size 2, temporal upsampling in the first two up blocks, as the VAE's config.json says):
1. post_quant_conv: a 1x1x1 conv 48 -> 48, over all T latent frames at once.
2. The decoder, run on one latent frame at a time, in order (chunk i is latent frame i). A chunk sees the earlier
ones only through the causal caches (below). With h x w = H/16 x W/16:
conv_in causal 3x3x3 conv 48 -> 1024 at h x w
mid block residual block 1024, attention 1024, residual block 1024 at h x w
up block 0 3 residual blocks 1024; upsample3d 1024 (t x2, h w x2); + DupUp3D 1024 -> 1024 to 2h x 2w
up block 1 3 residual blocks 1024; upsample3d 1024 (t x2, h w x2); + DupUp3D 1024 -> 1024 to 4h x 4w
up block 2 residual blocks 1024 -> 512, 512, 512; upsample2d 512 (h w x2); + DupUp3D 1024 -> 512 to 8h x 8w
up block 3 residual blocks 512 -> 256, 256, 256; no resampler, no shortcut at 8h x 8w
norm_out RMS norm 256, then SiLU
conv_out causal 3x3x3 conv 256 -> 12 at 8h x 8w
Up blocks 0 to 2 add their shortcut, DupUp3D of the block's input, to the output of their resampler.
The chunks' outputs are concatenated along time.
3. unpatchify, [B, 12, F, 8h, 8w] -> [B, 3, F, H, W]: the 12 channels are (colour k, column offset j, row offset i)
of 2 x 2 pixel patches, video[b, k, f, 2y + i, 2x + j] = out[b, 4k + 2j + i, f, y, x].
4. clamp to [-1, 1].
The parts, with diffusers' class names:
- causal conv (WanCausalConv3d), kernel kt x kh x kw, stride 1: zero padding of kh//2 and kw//2 on both sides in
space; in time, 2 * (kt//2) frames before the chunk and none after: the frames of the conv's cache, and zeros for
those the cache lacks.
- RMS norm (WanRMS_norm) over channels: F.normalize, x / max(||x||_2, 1e-12), times sqrt(C), times gamma.
- residual block (WanResidualBlock), in -> out channels: x1 = conv1(SiLU(norm1(x))), x2 = conv2(SiLU(norm2(x1))),
causal 3x3x3 convs; it returns x2 + shortcut, the shortcut being x, or a 1x1x1 conv in -> out of x when in != out.
(Dropout is 0, and diffusers decodes in eval mode.)
- attention (WanAttentionBlock), per frame: n = RMS norm of x; q, k and v are the channels [0, C), [C, 2C) and
[2C, 3C) of a 1x1 conv C -> 3C of n; single-head attention softmax(q k^T / sqrt(C)) v over the h*w positions of the
frame (head dim 1024, no mask: diffusers' docstring calls it causal, it is not); a 1x1 conv C -> C; plus x.
- upsample3d (WanResample "upsample3d"): the time conv, a causal 3x1x1 conv C -> 2C whose output channel c becomes
frame 2t and channel C + c frame 2t + 1, then upsample2d. upsample2d (WanResample "upsample2d"): per frame, nearest
x2 in h and w (mode nearest-exact, WanUpsample) and a 3x3 conv2d C -> C with zero padding 1.
- DupUp3D, in -> out channels, factor ft in time (2 in up blocks 0 and 1, 1 in up block 2) and 2 in h and w: each
input channel repeated r = out * ft * 4 / in times, the copies laid out over the (ft, 2, 2) sub-positions:
dup[c, ft*t + a, 2y + i, 2x + j] = x[(4*ft*c + 4a + 2i + j) // r, t, y, x]. In up blocks 0 and 1 (r = 8) that is
nearest x2 in t, h and w; in up block 2 (r = 2) it is dup[c, t, 2y + i, 2x + j] = x[2c + i, t, y, x]: channel pairs
become row pairs, each value repeated in two columns.
The first latent frame yields one frame
---------------------------------------
On the first chunk (diffusers' first_chunk):
- upsample3d skips the time conv: the chunk's frame passes through as one frame (diffusers marks the conv's cache
"Rep"). The time conv's input sequence therefore begins at the second chunk, after zeros: it never sees latent
frame 0. (Diffusers runs the second chunk's time conv without history and caches a zero frame in front of that
chunk's frame; giving it no history, as here, and zero padding yields the same frames.)
- DupUp3D keeps only the last of its ft copies in time (x[:, :, ft - 1:]): one frame, as on the main path.
So chunk 0 gives one video frame and every later chunk four (each upsample3d doubles them): 1 + 4(T - 1) = 4T - 3.
Causal caches
-------------
Each causal conv with a 3-frame kernel (conv_in, conv1 and conv2 of every residual block, the two time convs,
conv_out) keeps the last two frames of its input, the activations it convolves; after a one-frame chunk, the previous
chunk's last frame and that one. The next chunk's conv sees them before its own frames, zero padding standing in for
what is missing (the first chunk has no history, a one-frame history lacks a frame). The loop thus computes, chunk by
chunk, what one causal conv over the whole sequence would, with zeros before the first frame. The 1x1x1 convs
(post_quant_conv, the residual shortcuts) need no cache.
Over the whole sequence at once, then, the decoder runs every causal conv over all the frames of its level, two zero
frames before the first; upsample3d's time conv over the frames after the first only, the first passing through; and
DupUp3D doubling every frame but the first. That form equals the frame loop up to rounding, not bit for bit, its convs
having other shapes (fp32: max |diff| 1.7e-5, mean 6e-7 on scripts/check_reference.py's latents).
Precision
---------
dtype=torch.float32 computes everything in fp32, as diffusers does in fp32 (both under the same torch.backends
settings; scripts/check_reference.py turns TF32 off).
dtype=torch.bfloat16 computes what diffusers does with the VAE in bf16 under torch.autocast("cuda", dtype=bfloat16),
which is how a video pipeline decodes with this VAE in bf16: the weights and the latents are rounded to bf16 (autocast
casts the latents at post_quant_conv); every conv and the attention take bf16 operands and return bf16 (autocast's
lower-precision ops; the kernels accumulate in fp32; for head dim 1024 PyTorch runs SDPA's memory-efficient kernel);
the RMS norm normalizes in fp32 and rounds to bf16 before the products with sqrt(C) and gamma, each of which rounds
again (diffusers' upcast for 16-bit inputs); the nearest upsampling runs in fp32 (WanUpsample's upcast; it only copies
values); SiLU, the sums, DupUp3D, unpatchify and clamp run in bf16. The output is bf16. decode turns autocast off
itself: the casts are explicit.
The operations and their order are diffusers', so that the same kernels see the same operands: on a GB200 with torch
2.12 and cuDNN 9.20, scripts/check_reference.py finds the reference bit-exact against diffusers in both modes. (The
bf16 decode itself is max 0.07, mean 0.003 from the fp32 one there.)
Credits
-------
Derived from the decoder of diffusers' AutoencoderKLWan (src/diffusers/models/autoencoders/autoencoder_kl_wan.py,
diffusers 0.39.0), Copyright 2025 The Wan Team and The HuggingFace Team, licensed under the Apache License, Version 2.0
(http://www.apache.org/licenses/LICENSE-2.0); diffusers' port of the Wan2.2 VAE of Wan-AI. Its structure, parameter
names and caching rules are theirs. Changes: the modules are rewritten as functions of a weight dict, the cache is
keyed by parameter name instead of a running index, the "Rep" marker is expressed as the time conv starting at the
second chunk, and the encoder, tiling and slicing are left out.
"""
import torch
import torch.nn.functional as F
CACHE_T = 2 # frames of history kept by a causal conv with a 3-frame kernel
PATCH_SIZE = 2
RESNETS_PER_UP_BLOCK = 3 # num_res_blocks + 1
# decoder.up_blocks.{i}: (in channels, out channels, temporal upsampling, spatial upsampling)
UP_BLOCKS = ((1024, 1024, True, True), (1024, 1024, True, True), (1024, 512, False, True), (512, 256, False, False))
PREFIXES = ("decoder.", "post_quant_conv.")
@torch.no_grad()
def decode(z, weights, dtype=torch.float32):
"""Decodes latents z [B, 48, T, H/16, W/16] into a video [B, 3, 4T-3, H, W] in [-1, 1], of `dtype`.
`weights` maps diffusers' parameter names to tensors; those of the decoder and post_quant_conv are cast to `dtype`
on z's device (no copy when they already are). dtype is torch.float32 or torch.bfloat16 (see the module docstring).
"""
if dtype not in (torch.float32, torch.bfloat16):
raise ValueError(f"dtype must be torch.float32 or torch.bfloat16, not {dtype}")
weights = {name: t.to(z.device, dtype) for name, t in weights.items() if name.startswith(PREFIXES)}
with torch.autocast(z.device.type, enabled=False):
x = causal_conv(z.to(dtype), weights, "post_quant_conv")
cache = {}
chunks = [decode_chunk(x[:, :, i : i + 1], weights, cache, first=i == 0) for i in range(x.shape[2])]
return unpatchify(torch.cat(chunks, dim=2), PATCH_SIZE).clamp(-1.0, 1.0)
def decode_chunk(x, weights, cache, first):
"""The decoder on one latent frame x [B, 48, 1, h, w]: [B, 12, 1, 8h, 8w] on the first chunk, [B, 12, 4, 8h, 8w]
on the others. `cache` holds the causal convs' history, by conv name, and is updated."""
x = cached_conv(x, weights, "decoder.conv_in", cache)
x = residual_block(x, weights, "decoder.mid_block.resnets.0", cache)
x = attention(x, weights, "decoder.mid_block.attentions.0")
x = residual_block(x, weights, "decoder.mid_block.resnets.1", cache)
for i in range(len(UP_BLOCKS)):
x = up_block(x, weights, i, cache, first)
x = F.silu(rms_norm(x, weights["decoder.norm_out.gamma"]))
return cached_conv(x, weights, "decoder.conv_out", cache)
def up_block(x, weights, i, cache, first):
"""WanResidualUpBlock i: the residual blocks, then (up blocks 0 to 2) the resampler plus DupUp3D of x."""
_, out_channels, temporal, spatial = UP_BLOCKS[i]
name = f"decoder.up_blocks.{i}"
block_input = x
for j in range(RESNETS_PER_UP_BLOCK):
x = residual_block(x, weights, f"{name}.resnets.{j}", cache)
if not spatial:
return x
# The first chunk skips the time conv, whose history therefore starts empty at the second chunk.
if temporal and not first:
x = time_upsample(x, weights, f"{name}.upsampler.time_conv", cache)
x = spatial_upsample(x, weights, f"{name}.upsampler.resample.1")
return x + dup_up(block_input, out_channels, 2 if temporal else 1, 2, first)
def residual_block(x, weights, name, cache):
"""WanResidualBlock: conv2(SiLU(norm2(conv1(SiLU(norm1(x)))))) plus x, or plus a 1x1x1 conv of x."""
shortcut = causal_conv(x, weights, f"{name}.conv_shortcut") if f"{name}.conv_shortcut.weight" in weights else x
x = cached_conv(F.silu(rms_norm(x, weights[f"{name}.norm1.gamma"])), weights, f"{name}.conv1", cache)
x = cached_conv(F.silu(rms_norm(x, weights[f"{name}.norm2.gamma"])), weights, f"{name}.conv2", cache)
return x + shortcut
def attention(x, weights, name):
"""WanAttentionBlock: single-head attention over the h*w positions of each frame, plus x."""
identity = x
b, c, t, h, w = x.shape
x = x.permute(0, 2, 1, 3, 4).reshape(b * t, c, h, w)
x = rms_norm(x, weights[f"{name}.norm.gamma"])
qkv = F.conv2d(x, weights[f"{name}.to_qkv.weight"], weights[f"{name}.to_qkv.bias"])
qkv = qkv.reshape(b * t, 1, c * 3, -1).permute(0, 1, 3, 2).contiguous() # [b*t, 1 head, h*w, 3C]
q, k, v = qkv.chunk(3, dim=-1)
x = F.scaled_dot_product_attention(q, k, v)
x = x.squeeze(1).permute(0, 2, 1).reshape(b * t, c, h, w)
x = F.conv2d(x, weights[f"{name}.proj.weight"], weights[f"{name}.proj.bias"])
return x.view(b, t, c, h, w).permute(0, 2, 1, 3, 4) + identity
def time_upsample(x, weights, name, cache):
"""upsample3d's time conv: a causal 3x1x1 conv C -> 2C; channel c becomes frame 2t, channel C + c frame 2t + 1."""
b, c, t, h, w = x.shape
x = cached_conv(x, weights, name, cache)
x = x.reshape(b, 2, c, t, h, w)
x = torch.stack((x[:, 0], x[:, 1]), 3)
return x.reshape(b, c, t * 2, h, w)
def spatial_upsample(x, weights, name):
"""Per frame: nearest x2 in h and w (in fp32, as WanUpsample does; it only copies values), then a 3x3 conv2d."""
b, c, t, h, w = x.shape
x = x.permute(0, 2, 1, 3, 4).reshape(b * t, c, h, w)
x = F.interpolate(x.float(), scale_factor=(2.0, 2.0), mode="nearest-exact").type_as(x)
x = F.conv2d(x, weights[f"{name}.weight"], weights[f"{name}.bias"], padding=1)
return x.view(b, t, x.size(1), x.size(2), x.size(3)).permute(0, 2, 1, 3, 4)
def dup_up(x, out_channels, factor_t, factor_s, first):
"""DupUp3D: each channel repeated r = out * ft * fs * fs / in times, the copies spread over the (ft, fs, fs)
sub-positions of time, rows and columns; on the first chunk only the last of the ft copies in time is kept."""
b, c, t, h, w = x.shape
x = x.repeat_interleave(out_channels * factor_t * factor_s * factor_s // c, dim=1)
x = x.view(b, out_channels, factor_t, factor_s, factor_s, t, h, w)
x = x.permute(0, 1, 5, 2, 6, 3, 7, 4).contiguous()
x = x.view(b, out_channels, t * factor_t, h * factor_s, w * factor_s)
return x[:, :, factor_t - 1 :] if first else x
def rms_norm(x, gamma):
"""WanRMS_norm over channels (dim 1): F.normalize, in fp32 for a 16-bit x and rounded back, times sqrt(C), times
gamma."""
x_hat = F.normalize(x.float() if x.dtype in (torch.float16, torch.bfloat16) else x, dim=1).to(x.dtype)
return x_hat * x.shape[1] ** 0.5 * gamma
def cached_conv(x, weights, name, cache):
"""causal_conv of one chunk with the history in cache[name], which it replaces by the last CACHE_T frames of its
input: after a one-frame chunk, the previous chunk's last frame and this one."""
history = cache.get(name)
keep = x[:, :, -CACHE_T:].clone()
if keep.shape[2] < CACHE_T and history is not None:
keep = torch.cat([history[:, :, -1:], keep], dim=2)
cache[name] = keep
return causal_conv(x, weights, name, history)
def causal_conv(x, weights, name, history=None):
"""WanCausalConv3d `name` on x [B, C, t, h, w]: zero padding of kh//2 and kw//2 on both sides in space and, in
time, 2 * (kt//2) frames before x: `history` (the frames before x, at most that many), then zeros."""
weight, bias = weights[f"{name}.weight"], weights[f"{name}.bias"]
kt, kh, kw = weight.shape[2:]
pad = [kw // 2, kw // 2, kh // 2, kh // 2, 2 * (kt // 2), 0]
if history is not None and pad[4] > 0:
x = torch.cat([history, x], dim=2)
pad[4] -= history.shape[2]
return F.conv3d(F.pad(x, pad), weight, bias)
def unpatchify(x, p):
"""[B, 3*p*p, F, h, w] -> [B, 3, F, p*h, p*w]: channel (k*p + j)*p + i is colour k at row offset i, column j."""
b, cp, f, h, w = x.shape
x = x.view(b, cp // (p * p), p, p, f, h, w)
x = x.permute(0, 1, 4, 5, 3, 6, 2).contiguous()
return x.view(b, cp // (p * p), f, h * p, w * p)
@@ -1,217 +0,0 @@
// Mid-block self-attention with our own kernels, replacing ATen's
// scaled_dot_product_attention (fmha_cutlassF, 8.5 ms of f121x1's 954 ms).
//
// The reference computes, per latent frame, one head of softmax(q k^T/32) v over
// the H*W=3432 positions with C=1024 channels and no mask. ATen's path cannot use
// flash attention here (head dim 1024 > 256) and falls back to the xformers
// cutlass kernel, which reads and writes far more per frame than the products
// need: the decode's whole attention is 1.5 TFLOP (0.63 ms at 16-bit peak for
// f121x1) against 8.5 ms measured, ~13x off.
//
// Three stages, each at its own bound:
// 1. pack: q,k into contiguous [HW,C] per frame and v transposed into the
// [C,PAD] weight layout the convolution GEMM wants (~21 MB/frame).
// 2. S = q k^T per frame (fp32 out) and O = p v per frame (bf16 out), both as
// 1x1x1 implicit GEMMs through the vendored CUTLASS SM100 convolution
// collective (2-CTA tcgen05 MMA, TMA, fp32 accumulation) -- the same
// machinery conv.cu uses, so no new GEMM path is introduced.
// 3. row softmax: S row -> exp(S/sqrt(C) - max)/sum in bf16, one CTA per row,
// the row held in shared memory (one read of S, one write of P).
// P's rows are padded to a multiple of 64 columns and v's transpose is zero
// there, so the GEMM's tail contributes nothing.
#include <ATen/ATen.h>
#include <ATen/cuda/CUDAContext.h>
#include <cuda_bf16.h>
#include <cuda_runtime.h>
#include "attn.h"
#include "cutlass/cutlass.h"
#include "cute/tensor.hpp"
#include "cutlass/kernel_hardware_info.hpp"
#include "cutlass/conv/convolution.h"
#include "cutlass/conv/convnd_problem_shape.hpp"
#include "cutlass/tensor_ref.h"
#include "cutlass/conv/dispatch_policy.hpp"
#include "cutlass/conv/collective/collective_builder.hpp"
#include "cutlass/epilogue/collective/collective_builder.hpp"
#include "cutlass/conv/device/conv_universal_adapter.hpp"
#include "cutlass/conv/kernel/conv_universal.hpp"
using namespace cute;
using ElementBF16 = cutlass::bfloat16_t;
using LayoutNDHWC = cutlass::layout::TensorNDHWC;
using ArchTag = cutlass::arch::Sm100;
using OperatorClass = cutlass::arch::OpClassTensorOp;
constexpr auto Fprop = cutlass::conv::Operator::kFprop;
// One 1x1x1 implicit GEMM, y = x (per position) times w, fp32 accumulation and
// ElementD out. x: channels-last3d [1,C,D,1,1] (may be a strided view); w:
// [K,C,1,1,1] with explicit strides; y: packed channels-last3d [1,K,D,1,1].
template <class ElementD, int M, int N, int CL>
static void gemm1x1(const at::Tensor& x, const at::Tensor& w, const at::Tensor& y) {
using Tile = Shape<Int<M>, Int<N>, Shape<_64>>;
using Cluster = Shape<Int<CL>, _1, _1>;
using Epilogue = typename cutlass::epilogue::collective::CollectiveBuilder<
ArchTag, OperatorClass, Tile, Cluster, cutlass::epilogue::collective::EpilogueTileAuto,
float, float, ElementD, LayoutNDHWC, 8, ElementD, LayoutNDHWC, 8,
cutlass::epilogue::collective::EpilogueScheduleAuto>::CollectiveOp;
using Mainloop = typename cutlass::conv::collective::CollectiveBuilder<
ArchTag, OperatorClass, Fprop, ElementBF16, LayoutNDHWC, 8, ElementBF16, LayoutNDHWC, 8, float,
Tile, Cluster,
cutlass::conv::collective::StageCountAutoCarveout<static_cast<int>(sizeof(typename Epilogue::SharedStorage))>,
cutlass::conv::collective::KernelScheduleAuto>::CollectiveOp;
using Problem = cutlass::conv::ConvProblemShape<Fprop, 3>;
using Kernel = cutlass::conv::kernel::ConvUniversal<Problem, Mainloop, Epilogue>;
using Conv = cutlass::conv::device::ConvUniversalAdapter<Kernel>;
int C = x.size(1), D = x.size(2);
int K = w.size(0);
TORCH_CHECK(y.size(1) == K && y.size(2) == D, "attn gemm: output shape mismatch");
Problem problem(cutlass::conv::Mode::kCrossCorrelation,
{1, D, 1, 1, C}, {x.stride(0), x.stride(2), x.stride(3), x.stride(4), x.stride(1)},
{K, 1, 1, 1, C}, {w.stride(0), w.stride(2), w.stride(3), w.stride(4), w.stride(1)},
{0, 0, 0}, {0, 0, 0}, {1, 1, 1}, {1, 1, 1}, 1);
typename Kernel::StrideC sc{};
typename Kernel::StrideD sd{};
cute::for_each(cute::make_seq<cute::rank<0>(sc)>{}, [&](auto i) { cute::get<0, i>(sc) = problem.stride_C[Problem::RankT - 2 - i]; });
cute::for_each(cute::make_seq<cute::rank<0>(sd)>{}, [&](auto i) { cute::get<0, i>(sd) = problem.stride_C[Problem::RankT - 2 - i]; });
typename Conv::Arguments args{problem, {(const ElementBF16*)x.data_ptr(), (const ElementBF16*)w.data_ptr()},
{{1.f, 0.f}, (const ElementD*)y.data_ptr(), sc, (ElementD*)y.data_ptr(), sd}};
Conv conv;
TORCH_CHECK(conv.can_implement(args) == cutlass::Status::kSuccess,
"attn gemm: CUTLASS cannot implement D=", D, " N=", K, " C=", C);
size_t size = Conv::get_workspace_size(args);
auto workspace = at::empty({(int64_t)size}, x.options().dtype(at::kByte));
auto stream = at::cuda::getCurrentCUDAStream();
TORCH_CHECK(conv.initialize(args, workspace.data_ptr(), stream) == cutlass::Status::kSuccess,
"attn gemm: initialization failed");
TORCH_CHECK(conv.run(stream) == cutlass::Status::kSuccess, "attn gemm: run failed");
}
// Channels-last-3d views with explicit strides: a size-1 dim built by unsqueeze
// carries stride 1, which the im2col TMA descriptor rejects.
static at::Tensor nhwc_in(const at::Tensor& m, int64_t C, int64_t D) { // [D,C] -> [1,C,D,1,1]
return m.as_strided({1, C, D, 1, 1}, {C * D, 1, C, C, C});
}
static at::Tensor nhwc_w(const at::Tensor& m, int64_t K, int64_t C) { // [K,C] -> [K,C,1,1,1]
return m.as_strided({K, C, 1, 1, 1}, {C, 1, C, C, C});
}
static at::Tensor nhwc_out(const at::Tensor& m, int64_t K, int64_t D) { // [D,K] -> [1,K,D,1,1]
return m.as_strided({1, K, D, 1, 1}, {K * D, 1, K, K, K});
}
// v [T,1,HW,3C] at offset 2C -> vt [T,C,PAD] (transposed, [T,C,PAD] zeroed first).
static __global__ void transpose_v_kernel(const __nv_bfloat16* __restrict__ qkv, int T, int HW, int C, int PAD,
__nv_bfloat16* __restrict__ vt) {
__shared__ __nv_bfloat16 tile[32][33];
int i0 = blockIdx.x * 32, c0 = blockIdx.y * 32, t = blockIdx.z;
{
int i = i0 + threadIdx.y, c = c0 + threadIdx.x;
if (i < HW && c < C) tile[threadIdx.y][threadIdx.x] = qkv[((size_t)t * HW + i) * (3 * C) + 2 * C + c];
}
__syncthreads();
{
int i = i0 + threadIdx.x, c = c0 + threadIdx.y;
if (i < HW && c < C) vt[((size_t)t * C + c) * PAD + i] = tile[threadIdx.x][threadIdx.y];
}
}
// One CTA per row: p = exp(s*scale - max)/sum in bf16, then zero the padded tail.
static __global__ void row_softmax_kernel(const float* __restrict__ s, int cols, int PAD, float scale,
__nv_bfloat16* __restrict__ p) {
extern __shared__ float row[];
const int n4 = cols / 4;
const float4* src = (const float4*)(s + (size_t)blockIdx.x * cols);
__nv_bfloat16* dst = p + (size_t)blockIdx.x * PAD;
float4* r4 = (float4*)row;
for (int j = threadIdx.x; j < n4; j += blockDim.x) {
float4 v = src[j];
v.x *= scale;
v.y *= scale;
v.z *= scale;
v.w *= scale;
r4[j] = v;
}
__syncthreads();
float m = -INFINITY;
for (int j = threadIdx.x; j < n4; j += blockDim.x) {
float4 v = r4[j];
m = fmaxf(m, fmaxf(fmaxf(v.x, v.y), fmaxf(v.z, v.w)));
}
for (int off = 16; off > 0; off >>= 1) m = fmaxf(m, __shfl_xor_sync(0xffffffff, m, off));
__shared__ float red[32];
if ((threadIdx.x & 31) == 0) red[threadIdx.x >> 5] = m;
__syncthreads();
if (threadIdx.x < 32) {
m = threadIdx.x < blockDim.x / 32 ? red[threadIdx.x] : -INFINITY;
for (int off = 16; off > 0; off >>= 1) m = fmaxf(m, __shfl_xor_sync(0xffffffff, m, off));
if (threadIdx.x == 0) red[0] = m;
}
__syncthreads();
m = red[0];
__syncthreads();
float sum = 0.f;
for (int j = threadIdx.x; j < n4; j += blockDim.x) {
float4 v = r4[j];
v.x = __expf(v.x - m);
v.y = __expf(v.y - m);
v.z = __expf(v.z - m);
v.w = __expf(v.w - m);
r4[j] = v;
sum += (v.x + v.y) + (v.z + v.w);
}
for (int off = 16; off > 0; off >>= 1) sum += __shfl_xor_sync(0xffffffff, sum, off);
if ((threadIdx.x & 31) == 0) red[threadIdx.x >> 5] = sum;
__syncthreads();
if (threadIdx.x < 32) {
sum = threadIdx.x < blockDim.x / 32 ? red[threadIdx.x] : 0.f;
for (int off = 16; off > 0; off >>= 1) sum += __shfl_xor_sync(0xffffffff, sum, off);
if (threadIdx.x == 0) red[0] = sum;
}
__syncthreads();
const float inv = 1.f / red[0];
const int npair = cols / 2;
for (int j = threadIdx.x; j < npair; j += blockDim.x)
*(__nv_bfloat162*)(dst + 2 * j) = __floats2bfloat162_rn(row[2 * j] * inv, row[2 * j + 1] * inv);
for (int j = cols + threadIdx.x; j < PAD; j += blockDim.x) dst[j] = __float2bfloat16(0.f);
}
at::Tensor attn_forward(const at::Tensor& qkv) {
TORCH_CHECK(qkv.dim() == 4 && qkv.size(1) == 1 && qkv.size(3) % 3 == 0, "attn: expected [T,1,HW,3C]");
int T = qkv.size(0), HW = qkv.size(2), C = qkv.size(3) / 3;
TORCH_CHECK(C % 64 == 0, "attn: channels must be a multiple of 64");
int PAD = (HW + 63) / 64 * 64;
auto stream = at::cuda::getCurrentCUDAStream();
auto bf = qkv.options();
auto vt = at::zeros({T, C, PAD}, bf);
auto s = at::empty({T, HW, HW}, bf.dtype(at::kFloat));
auto p = at::empty({T, HW, PAD}, bf);
auto o = at::empty({T, 1, HW, C}, bf);
const __nv_bfloat16* qkv_p = (const __nv_bfloat16*)qkv.data_ptr();
transpose_v_kernel<<<dim3((HW + 31) / 32, (C + 31) / 32, T), dim3(32, 32), 0, stream>>>(
qkv_p, T, HW, C, PAD, (__nv_bfloat16*)vt.data_ptr());
// S = q k^T : M = positions, N = keys, K = channels. q and k are read in place:
// their channel stride is 1 and their position stride 3C, which TMA accepts.
for (int t = 0; t < T; ++t) {
auto qv = qkv.select(0, t).select(0, 0); // [HW, 3C]
auto x = qv.narrow(1, 0, C).as_strided({1, C, HW, 1, 1}, {(int64_t)3 * C * HW, 1, 3 * C, 3 * C, 3 * C});
auto w = qv.narrow(1, C, C).as_strided({HW, C, 1, 1, 1}, {3 * C, 1, 3 * C, 3 * C, 3 * C});
auto y = nhwc_out(s.select(0, t), HW, HW);
gemm1x1<float, 256, 256, 2>(x, w, y);
}
float* s_p = (float*)s.data_ptr();
float scale = 1.f / sqrtf((float)C);
row_softmax_kernel<<<T * HW, 256, HW * sizeof(float), stream>>>(
s_p, HW, PAD, scale, (__nv_bfloat16*)p.data_ptr());
// O = p v : M = positions, N = channels, K = keys (padded).
for (int t = 0; t < T; ++t) {
auto x = nhwc_in(p.select(0, t), PAD, HW);
auto w = nhwc_w(vt.select(0, t), C, PAD);
auto y = nhwc_out(o.select(0, t).select(0, 0), C, HW);
gemm1x1<ElementBF16, 256, 256, 2>(x, w, y);
}
return o;
}
@@ -1,6 +0,0 @@
#pragma once
#include <ATen/ATen.h>
// One head of self-attention over the H*W positions of every latent frame of
// one decoder chunk: qkv is [T,1,HW,3C] bf16 (q, k, v concatenated on the last
// dim, as binding.cpp's attention() builds it); the result is [T,1,HW,C] bf16.
at::Tensor attn_forward(const at::Tensor& qkv);
@@ -1,459 +0,0 @@
// Wan2.2 VAE decoder seed. Derived from the reference architecture and
// indexing in diffusers' AutoencoderKLWan (Wan Team / Hugging Face Team,
// Apache-2.0; see WAN_LICENSE.txt). Native CUDA execution is implemented here.
// All invocation state, including temporal history, belongs to Decoder below.
#include <torch/extension.h>
#include <ATen/ops/cudnn_convolution.h>
#include <ATen/ops/scaled_dot_product_attention.h>
#include <ATen/record_function.h>
#include <c10/cuda/CUDAGuard.h>
#include <torch/csrc/distributed/c10d/ProcessGroup.hpp>
#include "kernels.h"
#include "prefix.h"
#include "comm.h"
#include "resample.h"
#include "attn.h"
#include "head.h"
#include <array>
#include <algorithm>
#include <cstdio>
#include <cstdlib>
at::Tensor custom_conv(const at::Tensor&,const at::Tensor&,int64_t,int64_t,int64_t);
// The temporal upsampler, writing the frame-interleaved video directly (solution/timeconv.cu).
at::Tensor time_upsample(const at::Tensor&,const at::Tensor&,const at::Tensor&,const at::Tensor&,int64_t,int64_t,bool);
using PG = c10::intrusive_ptr<c10d::ProcessGroup>;
using at::Tensor;
using Weights = std::vector<Tensor>;
Weights prepare(const Weights& weights,int64_t rank,int64_t world,const PG& pg) {
TORCH_CHECK(weights.size()==110,"Expected the 110 decoder tensors");
at::NoGradGuard guard;
Weights result;
result.reserve(weights.size());
for(const auto& w:weights) {
// A 1x1x1 tensor is "trivially channels-last", so contiguous() leaves it with unit strides on
// its size-1 spatial dims; CUTLASS's im2col TMA needs those strides to be 16-byte multiples.
// The same bytes with dense strides: as_strided keeps the NDHWC order but gives every spatial
// dim the channel-count stride.
if(w.dim()==5 && w.size(2)==1 && w.size(3)==1 && w.size(4)==1) {
auto wc=w.contiguous();int64_t C=wc.size(1);
result.push_back(wc.as_strided({wc.size(0),C,1,1,1},{C,1,C,C,C}));
}
else if(w.dim()==5) result.push_back(w.contiguous(at::MemoryFormat::ChannelsLast3d));
else if(w.dim()==4 && w.size(2)>0 && w.size(1)>1)
result.push_back(w.unsqueeze(2).contiguous(at::MemoryFormat::ChannelsLast3d));
else result.push_back(w.contiguous());
}
// The output head: conv_out's nine spatial taps become the output channels of a 3x1x1
// (temporal-only) convolution, so its 112 outputs are (tap i,j) x (channel c) and head.cu's
// epilogue sums the nine shifted copies. W'[(i*3+j)*12+c, ch, t] = W[c, ch, t, i, j]; the four
// channels past 108 are zero and are never read. N=112 rather than 108 keeps the epilogue's
// channel count a multiple of 16.
{
auto orig=result[108];
auto t=orig.permute({3,4,0,1,2}).contiguous(); // [i,j,c,ch,t] = [3,3,12,256,3]
auto filled=at::zeros({112,256,3},orig.options()); // [n,ch,t], n=(i*3+j)*12+c
filled.narrow(0,0,108).copy_(t.reshape({108,256,3}));
auto kt=filled.transpose(1,2).contiguous(); // [n,t,ch] contiguous
// The layout a [K,C,3,3,3] weight has under ChannelsLast3d: C innermost with stride 1, every
// other stride a multiple of 16 bytes. torch normalises a size-1 H/W away, hence as_strided.
result[108]=kt.transpose(1,2).as_strided({112,256,3,1,1},{768,1,256,256,256});
}
// Nearest-x2 + 3x3 convolution equals four 2x2 low-resolution phases.
// Form the sums in fp32 and store the transformed weights in bf16.
for(int spatial : {39,61,85}) {
auto orig=result[spatial].to(at::kFloat);
int C=orig.size(0);
for(int a=0;a<2;++a)for(int b=0;b<2;++b) {
auto transformed=at::zeros({C,C,1,2,2},orig.options());
for(int u=0;u<2;++u)for(int v=0;v<2;++v) {
auto sum=at::zeros({C,C},orig.options());
for(int r=0;r<3;++r)for(int c=0;c<3;++c) {
int dy=a+r-1,dx=b+c-1;
int iy=dy<0?-1:dy/2,ix=dx<0?-1:dx/2;
if(iy==u+a-1&&ix==v+b-1)sum.add_(orig.select(2,0).select(2,r).select(2,c));
}
transformed.select(2,0).select(2,u).select(2,v).copy_(sum);
}
result.push_back(transformed.to(at::kBFloat16).contiguous(at::MemoryFormat::ChannelsLast3d));
}
}
// F(4x4,3x3) Winograd weights for up blocks 1, 2, and 3.
// The 36 components each retain the causal 3-tap temporal convolution.
const float G[6][3]={{.25f,0,0},{-1.f/6,-1.f/6,-1.f/6},{-1.f/6,1.f/6,-1.f/6},{1.f/24,1.f/12,1.f/6},{1.f/24,-1.f/12,1.f/6},{0,0,1}};
for(int idx : {44,47,50,53,56,59,66,69,74,77,80,83,88,91,96,99,102,105}) {
auto orig=result[idx].to(at::kFloat);
for(int a=0;a<6;a++)for(int b=0;b<6;b++) {
auto sum=at::zeros({orig.size(0),orig.size(1),3},orig.options());
for(int r=0;r<3;r++)for(int c=0;c<3;c++)if(G[a][r]*G[b][c]!=0)
sum.add_(orig.select(3,r).select(3,c),G[a][r]*G[b][c]);
result.push_back(sum.unsqueeze(3).unsqueeze(4).to(at::kBFloat16).contiguous(at::MemoryFormat::ChannelsLast3d));
}
}
// The 1x1x1 shortcut of a channel-changing resnet is added to the second convolution's
// bias + output, out = round(acc2 + b2) + round(acc1 + b1). Fold b1 into b2 instead (one more
// bf16 rounding of the bias sum and none of the operand): the shortcut tensor then needs no
// separate bias pass, which was a full read+write of it.
for(auto pair : {std::pair<int,int>{72,70},std::pair<int,int>{94,92}}) {
auto sum=result[pair.second].to(at::kFloat)+result[pair.first].to(at::kFloat);
result[pair.second]=sum.to(at::kBFloat16).contiguous();
}
return result;
}
struct Decoder {
// This must not implicitly bind to a fused-epilogue weight index when
// independently extended conv() interfaces are merged.
struct TemporalTrim {
int frames;
explicit constexpr TemporalTrim(int n) : frames(n) {}
};
const Weights& w;
int rank,world;
PG pg;
std::array<Tensor,110> cache;
bool sharded=false;
// A convolution whose input was already normalized, activated and padded by the previous
// Winograd convolution's fused epilogue: (tensor, the conv index it is prepared for, and the
// sharding it was prepared under — a mismatch there would be silently wrong, not a crash).
Tensor prepared; int prepared_for=-1; bool prepared_sharded=false;
// The conv index whose y store was suppressed because this conv reserved it for `prepared`.
// It is the RUNTIME half of the y_dead contract: the producer states an intent and this records
// it, so a later edit that stops the consumer taking the `pre` edge aborts instead of handing a
// never-written tensor to norm_pad. Cleared when the edge is taken, and asserted closed at the
// end of every chunk beside prepared_for, because a conv index restarts at the same values.
int y_suppressed_for=-1;
Decoder(const Weights& weights,int r,int n,const PG& p):w(weights),rank(r),world(n),pg(p){}
Tensor conv(Tensor x,int idx,int gamma=-1,bool activate=true,bool add_bias=true,const Tensor& residual={},TemporalTrim trim=TemporalTrim{0},int fg=-1,int fnext=-1,bool y_dead=false) {
RECORD_FUNCTION("wan.conv."+std::to_string(idx), std::vector<c10::IValue>());
const int temporal_trim=trim.frames;
int kt=w[idx].size(2),kh=w[idx].size(3),kw=w[idx].size(4);
TORCH_CHECK(temporal_trim>=0 && temporal_trim<kt,"Invalid causal prefix trim");
int pt=kt-1-temporal_trim,ph=(sharded&&world>1&&kh==3)?1:0;
Tensor p=x;
bool pre=(prepared_for==idx);
// The consumer asserts the producer's y_dead contract AT RUNTIME, not in prose. If a conv had
// its y store suppressed for this idx and this conv does not take the fused `prepared` edge,
// it is about to read a tensor that was never written -- so abort instead of producing a wrong
// video. The existing metadata checks at `pre` cannot catch this, because they live inside the
// branch that is not being taken.
TORCH_CHECK(pre || y_suppressed_for!=idx,
"conv ",idx,": its y store was suppressed FOR THIS CONV, which then did not take "
"the prepared edge; the tensor it is about to read is unwritten");
// Prefix pruning never uses the fused, full-length prepared-input edge.
// A future caller must implement a trimmed prepared layout before mixing them.
TORCH_CHECK(temporal_trim==0 || (!pre && fg<0 && fnext<0),
"Causal prefix trim cannot use or produce a fused prepared input");
if(pre) {
TORCH_CHECK(prepared.defined(),"conv ",idx," is marked prepared but no tensor was stored");
TORCH_CHECK(prepared_sharded==sharded,"prepared tensor crosses a change of sharding");
// The graph edge checks itself. pt/ph here are the consumer's own, read from this conv's
// weight, and the producer computed the same two from w[fnext] with fnext==idx -- so these
// equalities are true by construction today and are a proof obligation on any later edit:
// a wrong fnext would otherwise hand a wrong-shaped tensor to the convolution, and a
// wrong-but-same-shaped one would pass every check the kernel makes.
TORCH_CHECK(prepared.size(1)==x.size(1) && prepared.size(2)==x.size(2)+pt &&
prepared.size(3)==x.size(3)+2*ph && prepared.size(4)==x.size(4),
"conv ",idx,": prepared ",prepared.sizes()," does not fit input ",x.sizes(),
" (pt ",pt,", ph ",ph,", sharded ",(int)sharded,")");
p=prepared; prepared=Tensor(); prepared_for=-1; prepared_sharded=false;
y_suppressed_for=-1; // the suppressed y was reserved for this edge and this edge took it
}
else if(pt||ph||gamma>=0) p=norm_pad(x,gamma>=0?w[gamma]:Tensor(),cache[idx],pt,ph,activate);
// Memory: the unpadded input is dead once the padded copy exists (p holds it when no copy was made).
x=Tensor();
// The causal cache carries this convolution's input history to the next chunk. `p` may be the
// tensor the previous fused epilogue produced and this conv has just consumed, so the copy is
// ordered after that kernel by the stream they share -- a side stream here would race it.
if(pt) cache[idx]=last_frames(p,pt,ph);
if(ph) {
RECORD_FUNCTION("wan.halo",std::vector<c10::IValue>());
auto recv=halo_exchange(halo_pack(p),pg,rank,world);
halo_unpack(p,recv,rank,world);
}
const int Ho=p.size(3)-2*ph,Wo=p.size(4);
std::vector<int64_t> padding={0,ph?0:kh/2,kw/2};
bool use_custom=(p.size(4)==78 && kt==3 && kh==3 && w[idx].size(0)==1024 && w[idx].size(1)==1024) || idx==61 || idx==85
|| idx==71 || idx==93;
// Only the expensive spatial 3x3 convolutions use Winograd. Low-resolution
// convolutions remain direct implicit GEMMs; 1x1 and temporal-only layers
// use cuDNN. Each transformed GEMM accumulates bf16 operands in fp32.
int wi=-1;
const int wids[18]={44,47,50,53,56,59,66,69,74,77,80,83,88,91,96,99,102,105};
for(int i=0;i<18;i++)if(idx==wids[i])wi=i;
Tensor y;
if(wi>=0) {
auto transformed=winograd_input_fast(p,ph);
// Memory: the padded input is dead once transformed (its history went to the cache above), and the transform
// (2.25x the input) once every component product is enqueued; the caching allocator reuses both in stream order.
p=Tensor();
std::vector<Tensor> products;
for(int a=0;a<36;a++) {
auto input=transformed.select(0,a).unsqueeze(0).permute({0,4,1,2,3});
products.push_back(custom_conv(input,w[122+36*wi+a],0,0,3));
}
transformed=Tensor();
// The SiLU this epilogue applies is the CONSUMER's norm activation, not this conv's
// `activate`: fusing is valid only when the consumer would have activated. Every fused
// consumer in the decoder (the two convs of a residual block) has activate=true, so the
// test below holds by construction; a non-activating consumer would have to pass its own.
bool fuse=(fg>=0&&fnext>=0&&activate);
// write_y is false exactly where the caller asserts nobody reads this launch's y AND the
// fused `prepared` edge exists to carry the activation instead. It is recorded so the
// consumer can ASSERT the edge rather than trust it; see y_suppressed_for above.
const bool write_y=!(y_dead && fuse);
int pt2=pt,ph2=ph;
if(fuse) { pt2=w[fnext].size(2)-1; ph2=(sharded&&world>1&&w[fnext].size(3)==3)?1:0; }
auto out=wino_epilogue(products,Ho,Wo,add_bias?w[idx+1]:Tensor(),residual,
fuse?w[fg]:Tensor(),fuse?cache[fnext]:Tensor(),pt2,ph2,write_y);
y=out.y;
if(fuse) { prepared=out.n; prepared_for=fnext; prepared_sharded=sharded;
if(!write_y) y_suppressed_for=fnext; }
} else y=use_custom?custom_conv(p,w[idx],padding[1],padding[2],3):at::cudnn_convolution(p,w[idx],padding,{1,1,1},{1,1,1},1,false,false,false);
if(add_bias && wi<0) finish(y,w[idx+1],residual);
return y;
}
Tensor resnet(Tensor x,int base,bool change=false,int nx=-1,int nxg=-1) {
RECORD_FUNCTION("wan.resnet."+std::to_string(base),std::vector<c10::IValue>());
Tensor skip=change?conv(x,base+6,-1,true,false):x;
// Memory: moved into the first convolution, the block's input is released there once its norm has read it
// (skip still holds it when it is the residual).
auto y=conv(std::move(x),base+1,base,true,true,Tensor(),TemporalTrim{0},base+3,base+4, /*y_dead=*/true);
return conv(y,base+4,base+3,true,true,skip,TemporalTrim{0},nxg,nx);
}
Tensor attention(const Tensor& x) {
RECORD_FUNCTION("wan.attention",std::vector<c10::IValue>());
int T=x.size(2),H=x.size(3),W=x.size(4),C=x.size(1);
Tensor qkv=conv(x,5,4,false);
auto p=qkv.permute({0,2,3,4,1}).view({T,1,H*W,3*C});
Tensor y=attn_forward(p);
y=y.view({1,T,H,W,C}).permute({0,4,1,2,3});
return conv(y,7,-1,true,true,x);
}
// The output head: RMS norm + SiLU + the causal padding, the 3x1x1 convolution to 112 channels
// (108 real) and the 3x3 stencil with the bias and clamp (head.cu). Replaces conv(x,108,107),
// whose 12 output channels waste 244 of a 256-wide tile's columns.
Tensor output_head(const Tensor& x) {
RECORD_FUNCTION("wan.head",std::vector<c10::IValue>());
const int pt=2;
int ph=world>1?1:0;
Tensor p=norm_pad(x,w[107],cache[108],pt,ph,true);
if(ph) {
RECORD_FUNCTION("wan.halo",std::vector<c10::IValue>());
// The third halo site. PR204 deleted pack_edges/unpack_edges, and the resolution that
// compiles-and-passes while silently dropping the vectorised pack is to re-add them:
// this is the same two pad rows through comm.cu's halo_pack/halo_unpack, which is what
// the other two sites (conv/resnet) already use. Census: post_edges == wait_edges == 3.
auto recv=halo_exchange(halo_pack(p),pg,rank,world);
halo_unpack(p,recv,rank,world);
}
cache[108]=last_frames(p,pt,ph);
Tensor g=conv_n128(p,w[108]);
int rows=x.size(3);
auto out=at::empty({1,12,g.size(2),rows,g.size(4)},x.options().memory_format(at::MemoryFormat::ChannelsLast3d));
// 352 is the pixel-plane height (44 latent rows x 8) and lo this rank's first pixel-plane row;
// both are the fixed 704x1248 geometry, and lo must stay 8*(44*rank/world) because run()
// passes lo*16 - the same row on the 704-tall video grid - to write_video.
head_stencil(g,w[109],out,352,ph,8*(44*rank/world));
return out;
}
Tensor up(Tensor input,int block,bool first) {
RECORD_FUNCTION("wan.up_block."+std::to_string(block),std::vector<c10::IValue>());
const int bases[4]={21,43,65,87};
int base=bases[block];
bool change=block>=2;
int b2=base+(change?8:6),b3=base+(change?14:12);
// Memory: only the resampler's DupUp3D shortcut reads the block's input again, and block 3 has none.
auto x=resnet(block==3?std::move(input):input,base,change,b2+1,b2);
x=resnet(x,b2,false,b3+1,b3);
x=resnet(x,b3,false);
if(block==3) return x;
int spatial=base+(change?20:18);
if(block<2) {
// The 3x1x1 causal time convolution writes the frame-interleaved tensor directly (a stride-2 D epilogue
// in solution/timeconv.cu), so `up_time` and `finish` are gone from this seam. `activate=false` is
// INERT: with no gain the dispatcher takes norm.cu's copy-only mode and never reads the flag.
int start=first?1:0;
Tensor head=first?x.narrow(2,0,1):Tensor();
int nf=x.size(2)-start;
Tensor p;
if(nf>0) {
p=norm_pad(x.narrow(2,start,nf),Tensor(),cache[spatial+2],2,0,false);
cache[spatial+2]=last_frames(p,2,0);
}
x=time_upsample(p,head,w[spatial+2],w[spatial+3],x.size(2),nf,first);
}
if(world>1) {
x=norm_pad(x,Tensor(),Tensor(),0,1,false);
auto recv=halo_exchange(halo_pack(x),pg,rank,world);
halo_unpack(x,recv,rank,world);
}
// Four-phase spatial upsampler: the four 2x2 phase convolutions, then one bandwidth-bound pass that
// interleaves them with the bias and the DupUp3D shortcut (resample.cu).
std::vector<Tensor> pw;
for(int a=0;a<2;++a)for(int b=0;b<2;++b) pw.push_back(w[110+4*block+2*a+b]);
return resample_upsample(x,pw,w[spatial+1],input,world>1,block<2,first);
}
// Stateless exact prefix interval. Three causal convolutions require six
// preceding latent frames, but the support is pruned by two after each conv.
Tensor prefix_interval(const Tensor& z,int begin,int count) {
sharded=false;
const int end=begin+count;
const int lo6=std::max(0,begin-6),lo4=std::max(0,begin-4),lo2=std::max(0,begin-2);
auto raw=z.narrow(2,lo6,end-lo6).contiguous(at::MemoryFormat::ChannelsLast3d);
auto x=conv(raw,0);
// Reducing left temporal padding prunes the invalid outputs without ever
// computing them. Near global frame zero, the remaining padding is real.
x=conv(x,2,-1,true,true,Tensor(),TemporalTrim{lo4-lo6});
auto y=conv(x,10,9,true,true,Tensor(),TemporalTrim{lo2-lo4});
// Batch is one and T is the outer physical NDHWC dimension, so this
// frame slice remains dense for finish()'s linear residual indexing.
auto skip=x.narrow(2,begin-lo4,count);
TORCH_CHECK(skip.is_contiguous(at::MemoryFormat::ChannelsLast3d),"Prefix residual must be dense NDHWC");
y=conv(y,13,12,true,true,skip,TemporalTrim{begin-lo2});
return attention(y);
}
Tensor distributed_prefix(const Tensor& z,int start,int count) {
// No persistent prefix history: temporal ownership changes from batch to
// batch. The full latents on each rank provide exact predecessor context.
Decoder prefix(w,rank,world,pg);
Tensor x;
if(count>=2*world) {
const int lo=count*rank/world,hi=count*(rank+1)/world;
x=prefix.prefix_interval(z,start+lo,hi-lo);
x=prefix_temporal_to_rows(x,count,rank,world,pg);
} else {
// Tiny clips/tails do not amortize the transpose, and may have empty
// temporal owners. A stateless replicated prefix remains exact.
x=prefix.prefix_interval(z,start,count);
const int lo=44*rank/world,hi=44*(rank+1)/world;
x=x.narrow(3,lo,hi-lo).contiguous(at::MemoryFormat::ChannelsLast3d);
}
return x;
}
Tensor tail(Tensor x,bool first) {
// Every prepared edge is consumed within its residual/up-block chain.
// Tail history survives chunks, but no prepared current-chunk tensor may.
TORCH_CHECK(prepared_for<0 && !prepared.defined() && y_suppressed_for<0,
"Prepared input crossed a tail-chunk boundary");
sharded=true;
x=resnet(x,15);
for(int i=0;i<4;++i) x=up(std::move(x),i,first);
return output_head(x);
}
Tensor chunk(const Tensor& z,bool first) {
sharded=false;
// The invariant tail() checks must be CHECKED here too, not cleared. Clearing it first makes
// tail()'s check vacuous on the only path world 1 takes, and a leaked fused tensor is not
// merely retained: prepared_for is a conv INDEX and every chunk restarts at the same indices,
// so the next chunk's conv of that index CONSUMES it (`pre`) and the video is silently wrong
// at world 1 while world 4 errors out.
//
// The reset was dead on this tree, so the check costs one compare per chunk and nothing else,
// but the closure argument is NOT "every fused pair lives inside one resnet()" -- that is
// false. resnet(b)'s second conv is called with (nxg=b2, nx=b2+1) and produces for
// fnext=nx=b2+1, which is the FIRST conv of the next resnet; the same edge appears at every
// up block (47->50, 53->56 at block 1; 69->74, 77->80 at block 2). The chain closes at the
// END OF THE UP BLOCK instead: up() runs resnet(base, nxg=b2, nx=b2+1), then
// resnet(b2, nxg=b3, nx=b3+1), then resnet(b3) with the DEFAULT nx=-1, so the third resnet's
// last conv has fg=fnext=-1, produces nothing, and no fused tensor leaves up(). up() is the
// only Winograd caller and nothing after it is Winograd (output_head goes through conv_n128),
// so the triple is clear at every chunk entry.
// The predicate is tail()'s, EXACTLY, and that identity is the point rather than a
// coincidence: chunk() must accept everything tail() accepts, or the subset property this
// change exists to establish is not established. A third conjunct would also be unreachable --
// all three of prepared / prepared_for / prepared_sharded are written together at the single
// producer site (conv()'s `if(fuse)`) and cleared together at the single consumption site, so
// `prepared_sharded` can never differ from `prepared_for` and cannot fail on its own.
TORCH_CHECK(prepared_for<0 && !prepared.defined(),
"Prepared input crossed a chunk boundary");
auto x=conv(z,0);
x=conv(x,2);
x=resnet(x,9);
x=attention(x);
if(world>1) {
int lo=44*rank/world,hi=44*(rank+1)/world;
x=x.narrow(3,lo,hi-lo).contiguous(at::MemoryFormat::ChannelsLast3d);
}
return tail(x,first);
}
};
void decode_video(const Tensor& z,const Weights& weights,int64_t rank,int64_t world,const PG& pg,Tensor out,
int64_t frames_per_pass) {
at::NoGradGuard no_grad;
c10::cuda::CUDAGuard device_guard(z.device());
TORCH_CHECK(z.scalar_type()==at::kBFloat16&&z.dim()==5&&z.size(0)==1&&z.size(1)==48&&z.size(2)>=1&&z.size(3)==44&&z.size(4)==78,"Expected bf16 [1,48,T,44,78]");
TORCH_CHECK(world>=1&&world<=8&&rank>=0&&rank<world,"Invalid rank/world");
TORCH_CHECK(weights.size()==770,"Call prepare() once before run()");
TORCH_CHECK(frames_per_pass>=1,"frames_per_pass must be at least 1, not ",frames_per_pass);
halo_stats()=HaloStats{}; // per call: the debug tail below prints THIS run's exchange, not the process's
Decoder decoder(weights,rank,world,pg);
// A pass carries frames_per_pass latent frames per GPU, frames_per_pass * world in all. The working memory of a GPU
// grows with it (3.5-4.2 GiB per latent frame at 704 x 1248), and so does the speed: a pass's GEMMs at 44 x 78 and
// 88 x 156 fill the GPU only with enough frames, and above world 1 every pass also pays a fixed cost (its prefix
// and its halo exchanges). The causal history crosses passes in `cache`, so the video does not depend on the size.
// At most 61 latent frames: the largest pass this tree was measured with (wino_epilogue.cu's indices are 64-bit).
static const int64_t kMaxChunk=61;
const int chunk_size=(int)std::min<int64_t>(frames_per_pass*world,kMaxChunk);
int T=z.size(2);
for(int start=0;start<T;start+=chunk_size) {
int count=std::min((int)chunk_size,T-start);
Tensor pixels;
if(world>1) pixels=decoder.tail(decoder.distributed_prefix(z,start,count),start==0);
else {
auto input=z.narrow(2,start,count).contiguous(at::MemoryFormat::ChannelsLast3d);
pixels=decoder.chunk(input,start==0);
}
int frame_offset=start==0?0:4*start-3;
if(world==1) write_video(pixels,out,frame_offset,0);
else if(rank==0) {
write_video(pixels,out,frame_offset,0);
for(int peer=1;peer<world;++peer) {
int lo=44*peer/world,hi=44*(peer+1)/world;
auto part=nhwc_empty(12,pixels.size(2),(hi-lo)*8,624,pixels);
std::vector<Tensor> buffers={part};
pg->recv(buffers,peer,0)->wait();
write_video(part,out,frame_offset,lo*16);
}
} else {
std::vector<Tensor> buffers={pixels};
pg->send(buffers,0,0)->wait();
}
}
// Debug only, and never set by CI: cached once per process so the measured span runs no getenv.
static const bool show_halo_stats=std::getenv("WAN_HALO_STATS")!=nullptr;
if(show_halo_stats) {
const HaloStats& s=halo_stats();
printf("[halo rank %d/%d] exchanges %lld sent %.1f MB received %.1f MB\n",(int)rank,(int)world,
(long long)s.exchanges,1e-6*s.bytes_sent,1e-6*s.bytes_received);
fflush(stdout);
}
}
// The default pass: 4 latent frames per GPU. On one GB200 at 704 x 1248 it peaks at 21-22 GiB allocated and takes
// 1.09x the time of 16 frames per GPU, which peaks at 60-61 GiB (8 to 20 s of video).
constexpr int64_t kFramesPerPass=4;
void run(const Tensor& z,const Weights& weights,int64_t rank,int64_t world,const PG& pg,Tensor out) {
decode_video(z,weights,rank,world,pg,out,kFramesPerPass);
}
PYBIND11_MODULE(TORCH_EXTENSION_NAME,m) {
m.def("prepare",&prepare);
m.def("run",&run,py::call_guard<py::gil_scoped_release>());
m.def("run_with",&decode_video,py::call_guard<py::gil_scoped_release>());
}
@@ -1,213 +0,0 @@
// Halo edge transport: neighbour-only point to point and the seed's ring all-gather, over an
// eight-channels-per-thread edge pack/scatter. Which arm runs is use_allgather's world rule below --
// the ring only at world 2, neighbour-only at world 4 and at every world above it -- which WAN_HALO
// can force either way for a measurement.
// THIS POLICY IS STATED IN THREE PLACES AND THEY MUST AGREE: this file header, the block comment on
// use_allgather, and the WAN_HALO contract in comm.h. An earlier revision of this change corrected one
// of the three and was rightly blocked on the other two, so the cross-reference is the fix for the class
// and not for the instance.
//
// Why the point to point ops go in one coalesced group: ProcessGroupNCCL launches an unbatched p2p op as a
// group of its own on the group's stream, so a rank's p2p ops are stream-ordered against each other, and two
// ranks each waiting to send into the other's not-yet-issued receive is a cycle. (NCCL documents that
// serialization; w24's M1387 reaches the same deadlock from a direct all-to-all of unbatched p2p ops.)
// ProcessGroup::startCoalescing/endCoalescing puts all of this rank's neighbour ops into one NCCL group, which
// removes that ordering: the exchange is one hop, posted and closed with no host block.
#include "comm.h"
#include <ATen/cuda/CUDAContext.h>
#include <c10/cuda/CUDAGuard.h>
#include <c10/cuda/CUDAException.h>
#include <cstdlib>
#include <cstring>
namespace {
cudaStream_t stream() { return at::cuda::getCurrentCUDAStream(); }
// One uint4 is eight bf16 channels; C is a multiple of 8 for every layer that sharded space (256/512/1024).
__global__ void pack_edge8(const uint4* __restrict__ x, uint4* __restrict__ y, int64_t n8, int CV, int T, int H,
int W) {
for (int64_t i = (int64_t)blockIdx.x * blockDim.x + threadIdx.x; i < n8; i += (int64_t)gridDim.x * blockDim.x) {
int64_t row = i / (W * CV), rv = i % (W * CV);
int t = (int)(row % T), edge = (int)(row / T);
y[i] = x[((int64_t)t * H + (edge ? H - 2 : 1)) * W * CV + rv];
}
}
// Scatters the two received edges into the pad rows, eight channels per thread.
__global__ void unpack_edge8(uint4* __restrict__ x, const uint4* __restrict__ e, int64_t n8, int CV, int T, int H,
int W, int rank, int world) {
for (int64_t i = (int64_t)blockIdx.x * blockDim.x + threadIdx.x; i < n8; i += (int64_t)gridDim.x * blockDim.x) {
int64_t row = i / (W * CV), rv = i % (W * CV);
int t = (int)(row % T), edge = (int)(row / T);
int peer = rank + (edge ? 1 : -1); // pad row 0 comes from rank-1, pad row H-1 from rank+1
uint4 v = make_uint4(0u, 0u, 0u, 0u);
if (peer >= 0 && peer < world) v = e[((int64_t)peer * 2 * T + (int64_t)(1 - edge) * T + t) * W * CV + rv];
x[((int64_t)t * H + (edge ? H - 1 : 0)) * W * CV + rv] = v;
}
}
int grid_for(int64_t work) {
int64_t blocks = (work + 255) / 256;
return (int)(blocks > 2048 ? 2048 : (blocks < 1 ? 1 : blocks));
}
// Where each transport is used, when WAN_HALO does not force one. With P the bytes of one edge (half the packed
// buffer): the ring sends (world-1)*2P and receives world*2P, of which this exchange reads 2P (one edge from each
// neighbour); the neighbour path sends and receives one edge per neighbour -- 2P in and out for an interior rank,
// P at the ends. So it is 6P -> 2P each way at world 4 and 2P/4P -> P/P at world 2, i.e. the received bytes fall
// by 3x and 4x. So the policy is the ring only where it has been measured to cost nothing, and the
// neighbour path everywhere else:
// world 2 the two arms measured the same, and the copy counts are equal there too. J817, three
// alternating rounds in one job at f121x2, minima 698.6 ms (ring) against 696.5 ms
// (neighbour), 0.3% apart, J570's 2.3% the other way sitting inside its own run-to-run
// spread; and J3057 ran none / ag / p2p at f481x2 in one job, 2305.6 / 2309.7 / 2293.2.
// world 4 neighbour. J2211's own `for A in ag p2p ag p2p` at f481x4: ag 2462.9 / 2244.5 against
// p2p 1010.6 / 1017.5, the p2p arm's A/A 0.68 %. The ring moves (world-1) copies each
// way, the neighbour path one.
// above 4 neighbour, and this rung is chosen on COPY COUNTS rather than on a measurement: the
// ring moves (world-1) copies each way against the neighbour path's 1, so the ratio grows
// with the world size, and no world above 4 has been measured on either arm here -- a
// cross-tray one has never run. The world-4 gap (2.32x) is the only scaled evidence and
// it is the smallest world where the two differ. Whoever first gets eight GPUs should
// treat this rung as the thing to measure, not as settled.
// What that leaves: every world the harness runs is unchanged -- 1 by the early return, 2 true, 4 false
// -- and only world 3 and world 5 and up change.
// WAN_HALO forces either arm for a measurement.
bool use_allgather(int world) {
if (world < 2) return true; // one rank has no neighbour to talk to
const int forced = halo_transport();
if (forced == kAllGather) return true;
if (forced == kP2P) return false;
return world == 2;
}
} // namespace
HaloStats& halo_stats() {
static HaloStats stats;
return stats;
}
int halo_transport() {
static const int mode = [] {
const char* s = std::getenv("WAN_HALO");
if (!s) return (int)kPolicy;
if (std::strcmp(s, "ag") == 0 || std::strcmp(s, "allgather") == 0) return (int)kAllGather;
if (std::strcmp(s, "p2p") == 0 || std::strcmp(s, "neighbour") == 0) return (int)kP2P;
return (int)kPolicy;
}();
return mode;
}
at::Tensor halo_pack(const at::Tensor& padded) {
const int C = (int)padded.size(1), T = (int)padded.size(2), H = (int)padded.size(3), W = (int)padded.size(4);
TORCH_CHECK(C % 8 == 0, "halo_pack needs C a multiple of 8");
at::Tensor e = at::empty({2, T, W, C}, padded.options());
const int CV = C / 8;
int64_t n8 = (int64_t)2 * T * W * CV;
pack_edge8<<<grid_for(n8), 256, 0, stream()>>>((const uint4*)padded.data_ptr(), (uint4*)e.data_ptr(), n8, CV, T, H,
W);
C10_CUDA_KERNEL_LAUNCH_CHECK();
return e;
}
void halo_unpack(at::Tensor& padded, const at::Tensor& edges, int rank, int world) {
const int C = (int)padded.size(1), T = (int)padded.size(2), H = (int)padded.size(3), W = (int)padded.size(4);
TORCH_CHECK(C % 8 == 0, "halo_unpack needs C a multiple of 8");
const int CV = C / 8;
int64_t n8 = (int64_t)2 * T * W * CV;
unpack_edge8<<<grid_for(n8), 256, 0, stream()>>>((uint4*)padded.data_ptr(), (const uint4*)edges.data_ptr(), n8, CV,
T, H, W, rank, world);
C10_CUDA_KERNEL_LAUNCH_CHECK();
}
HaloHandle post_edges(const PG& pg, const at::Tensor& send, at::Tensor& recv, int rank, int world,
cudaStream_t comm) {
HaloHandle h;
h.started = true;
// Both buffers are flat here: the p2p slices are element offsets (narrowing the packed [2,T,W,C] on dim 0 would
// index a dimension of size 2, which is what round 1 of this PR did).
TORCH_CHECK(send.is_contiguous(), "halo edges must be contiguous");
TORCH_CHECK(recv.is_contiguous(), "the halo receive buffer must be contiguous");
h.send = send.reshape({-1});
h.recv = recv.reshape({-1});
HaloStats& s = halo_stats();
++s.exchanges;
const int64_t bytes = send.numel() * (int64_t)send.element_size();
// The transport is ordered on `comm`: ProcessGroupNCCL makes its own stream wait for the current stream and
// the current stream wait for its end event, so this guard is what puts the collective after the pack.
c10::cuda::CUDAStreamGuard guard(c10::cuda::getStreamFromExternal(comm, c10::cuda::current_device()));
if (use_allgather(world)) {
// A ring all-gather: each rank's buffer goes round the ring, (world-1) copies each way.
at::Tensor in = send;
h.keep.push_back(in);
h.keep.push_back(recv);
h.works.push_back(pg->_allgather_base(recv, in));
s.bytes_sent += bytes * (world - 1);
s.bytes_received += bytes * (world - 1);
return h;
}
// The packed edges are [2,T,W,C]; the p2p slices below are element offsets, so they are taken flat (above).
h.keep.push_back(send);
h.keep.push_back(h.send);
h.keep.push_back(recv);
// Split on rank 0 = rows [0, H/world), so rank+1 is the band BELOW me and rank-1 the band above. Each rank
// sends its first real row up to rank-1 and its last real row down to rank+1 -- one edge per neighbour, not
// both to each -- and reads back only the two neighbour slots of `recv`; its other world-2 slots are never
// written and stay uninitialised, which halo_unpack is careful not to read (it touches slot rank-1's edge 1
// and slot rank+1's edge 0 and nothing else). On the all-gather path every slot is filled. Both leave the
// same two edges in the same places.
const int64_t half = send.numel() / 2; // one edge: [T,W,C]; edge 0 first, edge 1 second
const int neighbours = (rank > 0 ? 1 : 0) + (rank + 1 < world ? 1 : 0);
s.bytes_sent += (int64_t)neighbours * half * send.element_size();
s.bytes_received += (int64_t)neighbours * half * send.element_size();
// Every tensor operation happens before the group opens: a throw between startCoalescing and endCoalescing
// would leave the NCCL group open and hang every rank instead of failing this one.
const bool hasLower = rank + 1 < world, hasUpper = rank > 0;
std::vector<at::Tensor> recvLower, sendUpper, recvUpper, sendLower;
if (hasLower) {
at::Tensor v = h.recv.narrow(0, (int64_t)(rank + 1) * 2 * half, half); // below: its edge 0
h.keep.push_back(v);
recvLower.push_back(v);
}
if (hasUpper) {
at::Tensor v = h.send.narrow(0, 0, half); // above: my first real row
h.keep.push_back(v);
sendUpper.push_back(v);
}
if (hasUpper) {
at::Tensor v = h.recv.narrow(0, (int64_t)(rank - 1) * 2 * half + half, half); // above: its edge 1
h.keep.push_back(v);
recvUpper.push_back(v);
}
if (hasLower) {
at::Tensor v = h.send.narrow(0, half, half); // below: my last real row
h.keep.push_back(v);
sendLower.push_back(v);
}
pg->startCoalescing(c10::DeviceType::CUDA);
if (hasLower) pg->recv(recvLower, rank + 1, 0);
if (hasUpper) pg->send(sendUpper, rank - 1, 0);
if (hasUpper) pg->recv(recvUpper, rank - 1, 0);
if (hasLower) pg->send(sendLower, rank + 1, 0);
// The ops inside a coalesced group carry no work of their own: the group's work is the one to wait on.
h.works.push_back(pg->endCoalescing(c10::DeviceType::CUDA));
return h;
}
void wait_edges(HaloHandle& h) {
if (!h.started) return;
// Each wait installs the collective's completion as an event wait on the current stream and does not block
// the host, so the caller's next kernel on that stream is ordered after the transfer.
for (auto& w : h.works) w->wait();
h.works.clear();
h.started = false;
}
at::Tensor halo_exchange(const at::Tensor& send, const PG& pg, int rank, int world) {
at::Tensor recv = at::empty({world * send.numel()}, send.options());
HaloHandle h = post_edges(pg, send, recv, rank, world, at::cuda::getCurrentCUDAStream());
wait_edges(h);
return recv;
}
@@ -1,83 +0,0 @@
// Halo edge transport for the row-band split: the two boundary rows a 3x3 convolution needs from its
// neighbours, moved with the cheapest mechanism available. Representation-agnostic by design: it carries
// whatever `halo_pack` packs (today the normalized NDHWC bf16 edge rows; the fusion work may change that),
// and it leaves the packed layout and `halo_unpack` untouched, so the decoded video is bit-identical to the
// all-gather it replaces.
//
// Two ways in:
// halo_exchange(...) one call: allocates the recv buffer, transports and closes. What
// Decoder::conv uses today.
// post_edges(...) / wait_edges(...) the split seam for overlapping the transport with interior compute:
// post enqueues the exchange and returns a handle without a host block,
// wait closes it. `halo_pack` may run on one stream and the transport on
// another: post_edges takes the stream the transport is ordered on.
#pragma once
#include <ATen/ATen.h>
#include <c10/cuda/CUDAStream.h>
#include <torch/csrc/distributed/c10d/ProcessGroup.hpp>
#include <torch/csrc/distributed/c10d/Work.hpp>
#include <c10/util/intrusive_ptr.h>
#include <cuda_runtime.h>
#include <vector>
using PG = c10::intrusive_ptr<c10d::ProcessGroup>;
// Which transport the halo exchange uses, from WAN_HALO once per process. With nothing set the world-size rule
// in comm.cu decides; the variable is for forcing an arm in a measurement (both must be the same on every rank
// of a world: one rank in a collective and one in a p2p group would never meet).
// unset: the rule -- the ring all-gather only at world 2, where both arms have been measured equal, and the
// neighbour path at world 4 and at every world above it. (World 1 has no exchange at all: ph is 0
// there, so nothing calls this.)
// "ag" or "allgather": the ring collective, whatever the world size.
// "p2p" or "neighbour": the neighbour path, whatever the world size. This is what the rule selects above
// world 4, and the neighbour path across trays has never been measured: nothing at that world size
// has, on either arm.
enum HaloTransport { kPolicy = 0, kAllGather = 1, kP2P = 2 };
int halo_transport();
// The exchange sites and the bytes they moved. Debug instrumentation only: nothing on the run path reads them,
// and with WAN_HALO_STATS unset they cost one increment per exchange.
struct HaloStats {
int64_t exchanges = 0, bytes_sent = 0, bytes_received = 0;
};
HaloStats& halo_stats();
// Gather the two edges a rank must send: the first and the last real row of the padded NDHWC tensor
// [1,C,T,H,W] (H includes exactly one pad row per side), as [2,T,W,C] contiguous, edge-major: edge 0 is the
// first real row (H index 1), edge 1 the last (H index H-2). Same layout as the seed's pack_edges, eight
// channels per thread.
at::Tensor halo_pack(const at::Tensor& padded);
// Scatter the received edges into the pad rows: row 0 takes rank-1's edge 1, row H-1 takes rank+1's edge 0,
// and a rank with no neighbour on that side gets zeros. `edges` is the transport's `recv` buffer, and rank+1
// is the band *below* (higher rows), since the split gives rank 0 the rows [0, H/world).
void halo_unpack(at::Tensor& padded, const at::Tensor& edges, int rank, int world);
// The transport, split in two. `send` is halo_pack's [2,T,W,C]; `recv` is the caller's buffer of
// world*2*T*W*C elements in the all-gather's layout (slot r is rank r's two edges), which is what
// halo_unpack reads. The handle owns the works and keeps every send and recv view alive until it is waited.
struct HaloHandle {
std::vector<c10::intrusive_ptr<c10d::Work>> works;
std::vector<at::Tensor> keep;
at::Tensor send, recv; // flat views the p2p slices index
bool started = false;
HaloHandle() = default;
HaloHandle(HaloHandle&&) = default;
HaloHandle& operator=(HaloHandle&&) = default;
HaloHandle(const HaloHandle&) = delete;
HaloHandle& operator=(const HaloHandle&) = delete;
};
// Enqueue only: no host block, and nothing added to the compute stream. `comm` is the stream the transport is
// ordered on; it must already carry whatever ordering the caller needs (a wait on the pack, say), and the
// unpack must not run on a different stream before wait_edges has been called on it.
HaloHandle post_edges(const PG& pg, const at::Tensor& send, at::Tensor& recv, int rank, int world,
cudaStream_t comm);
// Close it: this orders the CALLER'S CURRENT STREAM after the transfer and returns at once. WorkNCCL::wait is
// an event wait on the stream that is current at the call, not a CPU block. So the next kernel enqueued on
// that stream sees the edges -- and a kernel enqueued on any other stream does not.
void wait_edges(HaloHandle& h);
// Allocate the recv buffer, post, close and return the edges in halo_unpack's layout.
at::Tensor halo_exchange(const at::Tensor& send, const PG& pg, int rank, int world);
@@ -1,78 +0,0 @@
// CUTLASS 4.2 Blackwell implicit convolution: 2-CTA 256x256x64 tiles,
// TMA im2col loads, tcgen05 MMA, fp32 accumulation, bf16 epilogue.
// Every included CUTLASS/CuTe dependency is vendored beside this source.
#include <ATen/ATen.h>
#include <ATen/cuda/CUDAContext.h>
#include "cutlass/cutlass.h"
#include "cute/tensor.hpp"
#include "cutlass/kernel_hardware_info.hpp"
#include "cutlass/conv/convolution.h"
#include "cutlass/conv/convnd_problem_shape.hpp"
#include "cutlass/tensor_ref.h"
#include "cutlass/conv/dispatch_policy.hpp"
#include "cutlass/conv/collective/collective_builder.hpp"
#include "cutlass/epilogue/collective/collective_builder.hpp"
#include "cutlass/conv/device/conv_universal_adapter.hpp"
#include "cutlass/conv/kernel/conv_universal.hpp"
using namespace cute;
using ElementBF16=cutlass::bfloat16_t;
using LayoutNDHWC=cutlass::layout::TensorNDHWC;
using ArchTag=cutlass::arch::Sm100;
using OperatorClass=cutlass::arch::OpClassTensorOp;
constexpr auto Fprop=cutlass::conv::Operator::kFprop;
template<int M,int N,int CL>
at::Tensor launch(const at::Tensor& x,const at::Tensor& w,int ph,int pw,int phu=-1,int pwu=-1) {
if(phu<0)phu=ph;if(pwu<0)pwu=pw;
using Tile=Shape<Int<M>,Int<N>,Shape<_64>>;
using Cluster=Shape<Int<CL>,_1,_1>;
using Epilogue=typename cutlass::epilogue::collective::CollectiveBuilder<
ArchTag,OperatorClass,Tile,Cluster,cutlass::epilogue::collective::EpilogueTileAuto,
float,float,ElementBF16,LayoutNDHWC,8,ElementBF16,LayoutNDHWC,8,cutlass::epilogue::collective::EpilogueScheduleAuto>::CollectiveOp;
using Mainloop=typename cutlass::conv::collective::CollectiveBuilder<
ArchTag,OperatorClass,Fprop,ElementBF16,LayoutNDHWC,8,ElementBF16,LayoutNDHWC,8,float,Tile,Cluster,
cutlass::conv::collective::StageCountAutoCarveout<static_cast<int>(sizeof(typename Epilogue::SharedStorage))>,
cutlass::conv::collective::KernelScheduleAuto>::CollectiveOp;
using Problem=cutlass::conv::ConvProblemShape<Fprop,3>;
using Kernel=cutlass::conv::kernel::ConvUniversal<Problem,Mainloop,Epilogue>;
using Conv=cutlass::conv::device::ConvUniversalAdapter<Kernel>;
int C=x.size(1),D=x.size(2),H=x.size(3),W=x.size(4);
int K=w.size(0),KT=w.size(2),KH=w.size(3),KW=w.size(4);
auto y=at::empty({1,K,D-KT+1,H+ph+phu-KH+1,W+pw+pwu-KW+1},x.options().memory_format(at::MemoryFormat::ChannelsLast3d));
Problem problem(cutlass::conv::Mode::kCrossCorrelation,
{1,D,H,W,C},{x.stride(0),x.stride(2),x.stride(3),x.stride(4),x.stride(1)},
{K,KT,KH,KW,C},{w.stride(0),w.stride(2),w.stride(3),w.stride(4),w.stride(1)},
{0,ph,pw},{0,phu,pwu},{1,1,1},{1,1,1},1);
typename Kernel::StrideC sc{};
typename Kernel::StrideD sd{};
cute::for_each(cute::make_seq<cute::rank<0>(sc)>{},[&](auto i){cute::get<0,i>(sc)=problem.stride_C[Problem::RankT-2-i];});
cute::for_each(cute::make_seq<cute::rank<0>(sd)>{},[&](auto i){cute::get<0,i>(sd)=problem.stride_C[Problem::RankT-2-i];});
typename Conv::Arguments args{problem,{(const ElementBF16*)x.data_ptr(),(const ElementBF16*)w.data_ptr()},
{{1.f,0.f},(const ElementBF16*)y.data_ptr(),sc,(ElementBF16*)y.data_ptr(),sd}};
Conv conv;
TORCH_CHECK(conv.can_implement(args)==cutlass::Status::kSuccess,"CUTLASS cannot implement shape");
size_t size=Conv::get_workspace_size(args);
auto workspace=at::empty({(int64_t)size},x.options().dtype(at::kByte));
auto stream=at::cuda::getCurrentCUDAStream();
auto status=conv.initialize(args,workspace.data_ptr(),stream);
TORCH_CHECK(status==cutlass::Status::kSuccess,"CUTLASS initialization failed ",int(status));
status=conv.run(stream);
TORCH_CHECK(status==cutlass::Status::kSuccess,"CUTLASS convolution failed ",int(status));
return y;
}
at::Tensor custom_conv(const at::Tensor& x,const at::Tensor& w,int64_t ph,int64_t pw,int64_t variant) {
return launch<256,256,2>(x,w,ph,pw);
}
// The output head's 3x1x1 convolution: N=128 because the head has 112 output channels, and a
// 256-wide tile would spend 144 of its 256 columns on nothing. Measured at one clock (1951-2022
// MHz, both arms in J1044 and J1343): N=128 is 0.83 PFLOP/s = 34% of peak, N=256 is 0.69 = 28%.
// The time is what decides it and it is not close: 6.30 ms against 15.2 ms.
at::Tensor conv_n128(const at::Tensor& x,const at::Tensor& w) {
return launch<256,128,2>(x,w,0,0);
}
at::Tensor phase_conv(const at::Tensor& x,const at::Tensor& w,int a,int b,bool halo) {
at::Tensor v=halo?x.narrow(3,a,x.size(3)-1):x;
return launch<256,256,2>(v,w,halo?0:1-a,1-b,halo?0:a,b);
}
@@ -1,82 +0,0 @@
// The output head, epilogue half: conv_out's 3x3 spatial stencil, its bias and the clamp to
// [-1,1], from the 112-channel plane conv.cu's `conv_n128` (a 3x1x1 convolution) writes to the
// 12-channel plane write_video consumes.
//
// Why the convolution writes 108 channels and this pass sums 9 of them. conv_out is 256x27 MACs
// and its per-position INPUT WINDOW is 6912*2 B (the weight is 12x that and stays resident), i.e.
// 12 FLOP/byte against the machine's ~375, so any form that reads the 27 taps per position is
// operand-bound. Moving the nine spatial taps into the output channel dimension of a 3x1x1
// (temporal-only) convolution makes the operand 768*2 B for 108 outputs instead of 13.8 KB for
// 12, and the nine shifted copies are summed here.
//
// The value path, so that the rounding is on the record: the three temporal taps are accumulated
// in the convolution's fp32 accumulator and rounded to bf16 in g; the nine spatial taps are then
// summed here in fp32 from nine bf16 g values, the bias is added in fp32, and the result is
// rounded once to bf16 on the store. Main's path is also two roundings (cuDNN's bf16 conv_out,
// then finish_scalar's bf16 of x+bias); the count is the same and what differs is WHERE the first
// one sits - after three taps instead of after twenty-seven - which is why max|d| moves and rms
// does not. The reference accumulates all 27 taps in one fp32 accumulator.
#include <ATen/ATen.h>
#include <ATen/cuda/CUDAContext.h>
#include <c10/cuda/CUDAGuard.h>
#include <cuda_bf16.h>
#include "head.h"
// One thread per output position. Its 12 accumulators take the 12 channels of the same (i,j)
// tap from nine neighbouring positions; the nine shifted reads of each 216-byte channel vector
// are served from L1, so the pass moves each element of g about once.
static __global__ void head_stencil_kernel(const __nv_bfloat16* __restrict__ g,
const __nv_bfloat16* __restrict__ bias,
__nv_bfloat16* __restrict__ out,
int D,int rows,int W,int H,int g_off,int lo) {
int idx=blockIdx.x*blockDim.x+threadIdx.x;
if(idx>=D*rows*W)return;
int w=idx%W, t=idx/W, r=t%rows, d=t/rows;
int gh0=lo+r;
const int gH=rows+2*g_off;
float acc[12];
#pragma unroll
for(int c=0;c<12;++c)acc[c]=__bfloat162float(bias[c]);
for(int i=0;i<3;++i) {
int gh=gh0+i-1;
if(gh<0||gh>=H)continue; // conv3d's zero padding, in the plane
int gr=r+i-1+g_off;
if(gr<0||gr>=gH)continue;
for(int j=0;j<3;++j) {
int ww=w+j-1;
if(ww<0||ww>=W)continue; // conv3d's zero padding, across the row
const __nv_bfloat162* p=(const __nv_bfloat162*)(g+((((size_t)d*gH+gr)*W)+ww)*112+(i*3+j)*12);
#pragma unroll
for(int k=0;k<6;++k) {
float2 f=__bfloat1622float2(p[k]);
acc[2*k]+=f.x;
acc[2*k+1]+=f.y;
}
}
}
__nv_bfloat162* o=(__nv_bfloat162*)(out+((((size_t)d*rows+r)*W)+w)*12);
#pragma unroll
for(int k=0;k<6;++k)
o[k]=__floats2bfloat162_rn(fminf(1.f,fmaxf(-1.f,acc[2*k])),fminf(1.f,fmaxf(-1.f,acc[2*k+1])));
}
void head_stencil(const at::Tensor& g,const at::Tensor& bias,const at::Tensor& out,
int64_t H_global,int64_t g_off,int64_t lo) {
int D=out.size(2),rows=out.size(3),W=out.size(4);
TORCH_CHECK(g.is_contiguous(at::MemoryFormat::ChannelsLast3d)&&g.size(1)==112,
"head_stencil: g must be [1,112,D,rows+2*g_off,W] channels-last");
TORCH_CHECK(out.is_contiguous(at::MemoryFormat::ChannelsLast3d)&&out.size(1)==12,
"head_stencil: out must be [1,12,D,rows,W] channels-last");
// The kernel strides g by gH = rows+2*g_off taken from `out` and reads D*rows*W positions of it,
// so a g of the wrong depth or height reads out of bounds rather than failing here.
TORCH_CHECK(g.size(2)==out.size(2)&&g.size(3)==out.size(3)+2*g_off&&g.size(4)==out.size(4),
"head_stencil: g must be [1,112,D,rows+2*g_off,W] of out's [1,12,D,rows,W]");
c10::cuda::CUDAGuard guard(g.device());
int64_t total=(int64_t)D*rows*W;
int threads=256;
int blocks=(int)((total+threads-1)/threads);
head_stencil_kernel<<<blocks,threads,0,at::cuda::getCurrentCUDAStream()>>>(
(const __nv_bfloat16*)g.data_ptr(),(const __nv_bfloat16*)bias.data_ptr(),
(__nv_bfloat16*)out.data_ptr(),D,rows,W,(int)H_global,(int)g_off,(int)lo);
C10_CUDA_KERNEL_LAUNCH_CHECK();
}
@@ -1,19 +0,0 @@
#pragma once
#include <ATen/ATen.h>
// The output head, epilogue half: conv_out's 3x3 spatial stencil, its bias and the clamp to
// [-1,1], from the 112-channel plane the 3x1x1 convolution writes (conv.cu's `conv_n128`) to the
// 12-channel plane write_video consumes.
// g: [1,112,D,rows+2*g_off,W] channels-last-3d bf16, n = (i*3+j)*12 + c for the tap (i,j);
// channels 108..111 are the zero-weight lanes and are never read.
// out: [1,12,D,rows,W] channels-last-3d bf16, written whole.
// H_global is the height of the plane this stencil filters (352 = 44 latent rows x 8; the pixel
// plane is 704 tall and run()'s video row is twice this one). lo is the first row of this rank's
// band IN THAT PLANE, 8*(44*rank/world), and it must stay that: run() passes lo*16 to write_video,
// the same row on the 704-tall video grid, and the two derivations have to agree.
void head_stencil(const at::Tensor& g,const at::Tensor& bias,const at::Tensor& out,
int64_t H_global,int64_t g_off,int64_t lo);
// conv.cu: the head's 3x1x1 convolution, at the tile its 112 output channels want. Declared here
// so the two-sided declaration of a CUTLASS tile cannot drift from its definition in conv.cu.
at::Tensor conv_n128(const at::Tensor& x,const at::Tensor& w);
@@ -1,99 +0,0 @@
#include "kernels.h"
#include <ATen/cuda/CUDAContext.h>
#include <c10/cuda/CUDAException.h>
#include <cuda_bf16.h>
#include <algorithm>
using B = __nv_bfloat16;
union Pack { uint4 v; B b[8]; __nv_bfloat162 h[4]; };
static cudaStream_t stream() { return at::cuda::getCurrentCUDAStream(); }
static const B* ptr(const Tensor& t) { return t.defined() ? (const B*)t.data_ptr<at::BFloat16>() : nullptr; }
static B* ptr(Tensor& t) { return (B*)t.data_ptr<at::BFloat16>(); }
Tensor nhwc_empty(int64_t c,int64_t t,int64_t h,int64_t w,const Tensor& like) {
return at::empty({1,c,t,h,w},like.options().memory_format(at::MemoryFormat::ChannelsLast3d));
}
Tensor last_frames(const Tensor& x,int n,int ph) {
int C=x.size(1),T=x.size(2),H=x.size(3)-2*ph,W=x.size(4);
Tensor y=nhwc_empty(C,n,H,W,x);
size_t row=(size_t)H*W*C*sizeof(B),src_pitch=(size_t)(H+2*ph)*W*C*sizeof(B);
const B* source=ptr(x)+((int64_t)(T-n)*(H+2*ph)+ph)*W*C;
C10_CUDA_CHECK(cudaMemcpy2DAsync(ptr(y),row,source,src_pitch,row,n,cudaMemcpyDeviceToDevice,stream()));
return y;
}
// The halo edge pack/scatter lives in comm.cu (halo_pack/halo_unpack, vectorised), with the transport.
__global__ void finish_vector(B* x,const B* b,const B* s,int64_t n,int C) {
for(int64_t i=(int64_t)blockIdx.x*blockDim.x+threadIdx.x;i<n/8;i+=(int64_t)gridDim.x*blockDim.x) {
Pack v, bias, skip;
v.v=((uint4*)x)[i];bias.v=((const uint4*)b)[i%(C/8)];
if(s) skip.v=((const uint4*)s)[i];
#pragma unroll
for(int j=0;j<4;j++) { v.h[j]=__hadd2(v.h[j],bias.h[j]); if(s) v.h[j]=__hadd2(v.h[j],skip.h[j]); }
((uint4*)x)[i]=v.v;
}
}
__global__ void finish_scalar(B* x,const B* b,const B* s,int64_t n,int C) {
for(int64_t i=(int64_t)blockIdx.x*blockDim.x+threadIdx.x;i<n;i+=(int64_t)gridDim.x*blockDim.x) {
B v=__float2bfloat16(__bfloat162float(x[i])+__bfloat162float(b[i%C]));
if(s) v=__float2bfloat16(__bfloat162float(v)+__bfloat162float(s[i])); x[i]=v;
}
}
void finish(Tensor& x,const Tensor& b,const Tensor& s) {
int C=x.size(1);int64_t n=x.numel();
if(C%8==0) finish_vector<<<std::min((int64_t)2048,(n+2047)/2048),256,0,stream()>>>(ptr(x),ptr(b),ptr(s),n,C);
else finish_scalar<<<std::min((int64_t)2048,(n+255)/256),256,0,stream()>>>(ptr(x),ptr(b),ptr(s),n,C);
C10_CUDA_KERNEL_LAUNCH_CHECK();
}
__global__ void write_kernel(const B* x,B* out,int64_t n,int T,int H,int W,int fullT,int fullH,int offsetT,int offsetH) {
int64_t i=(int64_t)blockIdx.x*blockDim.x+threadIdx.x;
if(i<n) {
int c=i%12;int64_t p=i/12;int w=p%W,h=(p/W)%H,t=p/((int64_t)W*H);
int color=c/4,dy=c%2,dx=(c%4)/2;
int64_t o=(((int64_t)color*fullT+t+offsetT)*fullH+offsetH+2*h+dy)*(2*W)+2*w+dx;
out[o]=__float2bfloat16(fminf(1.f,fmaxf(-1.f,__bfloat162float(x[i]))));
}
}
void write_video(const Tensor& x,Tensor& out,int frame_offset,int row_offset) {
write_kernel<<<(x.numel()+255)/256,256,0,stream()>>>(ptr(x),ptr(out),x.numel(),x.size(2),x.size(3),x.size(4),out.size(2),out.size(3),frame_offset,row_offset);
C10_CUDA_KERNEL_LAUNCH_CHECK();
}
// phases_kernel used to sit here: PR17 replaces it, together with finish_phases below, with one
// bandwidth-bound pass in resample.cu that writes the interleaved tensor directly.
// F(4x4,3x3), with fp32 spatial transforms and bf16 transformed operands.
__device__ __forceinline__ void bt6(float v0,float v1,float v2,float v3,float v4,float v5,float* y) {
float a=v4-4*v2,b=v3-4*v1,c=v4-v2,d=2*(v3-v1);
y[0]=4*v0-5*v2+v4;y[1]=a+b;y[2]=a-b;y[3]=c+d;y[4]=c-d;y[5]=4*v1-5*v3+v5;
}
__global__ void winograd_input_kernel(const B* x,B* v,int C,int D,int H,int W,int PH,int TH,int TW) {
int HP=H+2*PH,CV=C/2;int64_t n=(int64_t)D*TH*TW*CV;
for(int64_t i=(int64_t)blockIdx.x*blockDim.x+threadIdx.x;i<n;i+=(int64_t)gridDim.x*blockDim.x) {
int c=i%CV,p=i/CV,tx=p%TW,ty=(p/TW)%TH,t=p/(TW*TH);
float d[2][6][6];
#pragma unroll
for(int a=0;a<6;a++) {
int y=4*ty-1+a+PH;
#pragma unroll
for(int b=0;b<6;b++) {int xx=4*tx-1+b;float2 f=(y>=0&&y<HP&&xx>=0&&xx<W)?__bfloat1622float2(((const __nv_bfloat162*)x)[(((int64_t)t*HP+y)*W+xx)*CV+c]):make_float2(0,0);d[0][a][b]=f.x;d[1][a][b]=f.y;}
}
#pragma unroll
for(int lane=0;lane<2;lane++) {
#pragma unroll
for(int b=0;b<6;b++) {float q[6];bt6(d[lane][0][b],d[lane][1][b],d[lane][2][b],d[lane][3][b],d[lane][4][b],d[lane][5][b],q);
#pragma unroll
for(int a=0;a<6;a++)d[lane][a][b]=q[a];}
}
#pragma unroll
for(int a=0;a<6;a++) {float q0[6],q1[6];bt6(d[0][a][0],d[0][a][1],d[0][a][2],d[0][a][3],d[0][a][4],d[0][a][5],q0);bt6(d[1][a][0],d[1][a][1],d[1][a][2],d[1][a][3],d[1][a][4],d[1][a][5],q1);
#pragma unroll
for(int b=0;b<6;b++)((__nv_bfloat162*)v)[(6*a+b)*n+i]=__floats2bfloat162_rn(q0[b],q1[b]);}
}
}
Tensor winograd_input(const Tensor& x,int ph) {
int C=x.size(1),D=x.size(2),H=x.size(3)-2*ph,W=x.size(4),TH=(H+3)/4,TW=(W+3)/4;
auto v=at::empty({36,D,TH,TW,C},x.options());int64_t n=(int64_t)D*TH*TW*C;
winograd_input_kernel<<<std::min((int64_t)2048,(n+255)/256),256,0,stream()>>>(ptr(x),ptr(v),C,D,H,W,ph,TH,TW);
C10_CUDA_KERNEL_LAUNCH_CHECK();return v;
}
// (WinoPtrs, at6, winograd_output_kernel and winograd_output are deleted: with the fused inverse
// in wino_epilogue.cu nothing calls them. That file's a6v reads the same A^T out of shared memory
// instead of 36 separate global pointers, and it is the only copy of the transform left there.)
@@ -1,21 +0,0 @@
#pragma once
#include <ATen/ATen.h>
using at::Tensor;
Tensor nhwc_empty(int64_t c,int64_t t,int64_t h,int64_t w,const Tensor& like);
Tensor norm_pad(const Tensor& x, const Tensor& gamma, const Tensor& history, int pt, int ph, bool silu);
Tensor last_frames(const Tensor& padded, int n, int ph);
void finish(Tensor& x, const Tensor& bias, const Tensor& residual);
void write_video(const Tensor& x, Tensor& out, int frame_offset, int row_offset);
Tensor winograd_input(const Tensor& x,int ph);
Tensor winograd_input_fast(const Tensor& x,int ph);
// winograd_output / winograd_output_kernel are deleted: the fused inverse in wino_epilogue.cu
// replaced their only caller, and they had no live user left (PR172 round-1 review).
// Fused inverse transform + bias/residual + RMS norm/SiLU. y = A^T M A + bias + residual;
// n (when gamma is given) = silu(rmsnorm(y)*sqrt(C)*gain) in the (D+pt, H+2ph, W, C) padded
// layout the next convolution consumes, with its first pt frames taken from `hist`.
struct WinoOut { Tensor y; Tensor n; };
WinoOut wino_epilogue(const std::vector<Tensor>& m,int H,int W,const Tensor& bias,
const Tensor& residual,const Tensor& gamma,const Tensor& hist,int pt,int ph,
bool write_y=true);
@@ -1,198 +0,0 @@
// norm.cu — RMS norm + SiLU + pad for the Wan2.2 VAE decoder, one warp per position.
//
// This replaces the seed's norm_vector<C> / pad_vector pair in kernels.cu with the same
// call contract (norm_pad(x, gamma, hist, pt, ph, silu)) and the same layout (NDHWC,
// [T+pt][H+2ph][W][C], history frames copied through, pad rows zeroed).
//
// Why it is faster (measured in probe/norm_probe2.cu and norm_probe3.cu, one GB200):
// - the seed is XU-pipe bound (w08's and w14's ncu: 78% SM busy, 16% DRAM, XU 75%):
// per element it pays cvt bf16->f32 for the sum, five cvts for the reference's three
// bf16 roundings, __expf and an IEEE divide. This does
// * bf16 -> f32 by an integer shift (ALU pipe, exact, no XU op),
// * one rounding for the whole normalize*sqrt(C)*gain product instead of three,
// which is also closer to the fp32 reference the decode is graded against,
// * SiLU as u + u*tanh(u) with u = 0.5*pre-activation (one tanh.approx.f32),
// * bf16 stores two elements at a time (cvt.rn.bf16x2.f32),
// i.e. ~1.5 XU ops per element instead of ~11.
// - rows are blocked so the per-position integer divisions of the seed's flat-pixel loop
// happen once per row, not once per position (at C=256 a position is only 8 elements).
// - P positions per warp in flight, the number that measured best per channel count.
//
// Numerics: one bf16 rounding for the whole normalize*gain product instead of three, and
// SiLU in fp32 through tanh.approx.f32. That approximation's documented worst case is an
// absolute error of about 2^-11 (5e-4) in tanh, which is harmless here because it enters
// the result as u*2^-11 and the stored value is bf16, so the perturbation stays below the
// bf16 quantum of u + u*tanh(u) over the range these pre-activations take. Measured, and
// the place to look if that ever changes (bench.py, f121x1 vs the fp32 reference):
// seed max 0.1709 (0.34x budget) rms 0.002242 (0.38x budget)
// this max 0.1992 (0.40x budget) rms 0.002187 (0.37x budget)
// i.e. the max moves up a little, the rms moves down, both far inside the budget.
#include <ATen/ATen.h>
#include <ATen/cuda/CUDAContext.h>
#include <c10/cuda/CUDAException.h>
#include <cuda_bf16.h>
#include <algorithm>
#include <cstdlib>
#include <type_traits>
using B = __nv_bfloat16;
namespace {
__device__ __forceinline__ void split2(uint32_t w, float& lo, float& hi) {
lo = __uint_as_float(w << 16); // exact bf16 -> f32, ALU pipe
hi = __uint_as_float(w & 0xffff0000u);
}
__device__ __forceinline__ float tanh_approx(float x) {
float r; asm("tanh.approx.f32 %0, %1;" : "=f"(r) : "f"(x)); return r;
}
__device__ __forceinline__ uint32_t pack2(float a, float b) {
uint32_t r; asm("cvt.rn.bf16x2.f32 %0, %1, %2;" : "=r"(r) : "f"(b), "f"(a)); return r;
}
// MODE 0: RMS norm * sqrt(C) * gain, then SiLU. MODE 1: the same, no SiLU. MODE 2: copy only.
template <int C, int P, int MODE>
__global__ void norm_pad_kernel(const B* __restrict__ x, const B* __restrict__ gain,
const B* __restrict__ hist, B* __restrict__ y,
int T, int H, int W, int PT, int PH) {
constexpr int CV = C / 8; // uint4 per position
constexpr int NP = C / 256; // uint4 per lane per position
const int lane = threadIdx.x & 31, warp = threadIdx.x >> 5, warps = blockDim.x >> 5;
const int HP = H + 2 * PH;
const int64_t rows = (int64_t)(T + PT) * HP;
const float sqrtC = sqrtf((float)C);
for (int64_t row = blockIdx.x; row < rows; row += gridDim.x) {
const int h = (int)(row % HP), t = (int)(row / HP);
const bool interior = (h >= PH && h < H + PH);
const bool current = (t >= PT); // a history frame is copied through, never re-normalized
const int ph = h - PH;
const uint4* src = nullptr;
if (interior) {
if (current) src = (const uint4*)x + ((int64_t)(t - PT) * H + ph) * W * CV;
else if (hist) src = (const uint4*)hist + ((int64_t)t * H + ph) * W * CV;
}
for (int w0 = warp * P; w0 < W; w0 += warps * P) {
uint4 v[P][NP];
#pragma unroll
for (int q = 0; q < P; q++) {
const int w = w0 + q;
#pragma unroll
for (int j = 0; j < NP; j++) {
v[q][j] = make_uint4(0, 0, 0, 0);
if (src && w < W) v[q][j] = src[(int64_t)w * CV + lane + j * 32];
}
}
if (MODE < 2 && interior && current) {
float s[P][2];
#pragma unroll
for (int q = 0; q < P; q++) { s[q][0] = 0.f; s[q][1] = 0.f; }
#pragma unroll
for (int q = 0; q < P; q++) {
#pragma unroll
for (int j = 0; j < NP; j++) {
float f[8];
split2(v[q][j].x, f[0], f[1]); split2(v[q][j].y, f[2], f[3]);
split2(v[q][j].z, f[4], f[5]); split2(v[q][j].w, f[6], f[7]);
#pragma unroll
for (int k = 0; k < 8; k += 2) {
s[q][0] = fmaf(f[k], f[k], s[q][0]);
s[q][1] = fmaf(f[k + 1], f[k + 1], s[q][1]);
}
}
}
#pragma unroll
for (int q = 0; q < P; q++) {
float t0 = s[q][0] + s[q][1];
#pragma unroll
for (int d = 16; d; d >>= 1) t0 += __shfl_xor_sync(0xffffffff, t0, d);
// 0.5 * sqrt(C) / max(sqrt(sum), 1e-12): one multiply per element, u = 0.5*pre-activation
const float hs = 0.5f * sqrtC * rsqrtf(fmaxf(t0, 1e-24f));
#pragma unroll
for (int j = 0; j < NP; j++) {
const uint4 gn = ((const uint4*)gain)[lane + j * 32];
float g[8], xf[8], yf[8];
split2(gn.x, g[0], g[1]); split2(gn.y, g[2], g[3]);
split2(gn.z, g[4], g[5]); split2(gn.w, g[6], g[7]);
split2(v[q][j].x, xf[0], xf[1]); split2(v[q][j].y, xf[2], xf[3]);
split2(v[q][j].z, xf[4], xf[5]); split2(v[q][j].w, xf[6], xf[7]);
#pragma unroll
for (int k = 0; k < 8; k++) {
const float u = xf[k] * hs * g[k];
yf[k] = (MODE == 0) ? fmaf(u, tanh_approx(u), u) : (2.f * u);
}
v[q][j].x = pack2(yf[0], yf[1]); v[q][j].y = pack2(yf[2], yf[3]);
v[q][j].z = pack2(yf[4], yf[5]); v[q][j].w = pack2(yf[6], yf[7]);
}
}
}
#pragma unroll
for (int q = 0; q < P; q++) {
const int w = w0 + q;
if (w < W) {
uint4* dst = (uint4*)y + row * W * CV + (int64_t)w * CV + lane;
#pragma unroll
for (int j = 0; j < NP; j++) dst[j * 32] = v[q][j];
}
}
}
}
}
// Copy + pad for any C divisible by 8: the calls with no gain (post_quant_conv's input, the temporal convs).
__global__ void copy_row_kernel(const uint4* __restrict__ x, const uint4* __restrict__ hist,
uint4* __restrict__ y, int T, int H, int W, int PT, int PH, int CV) {
const int HP = H + 2 * PH;
const int64_t rows = (int64_t)(T + PT) * HP;
const int64_t nrow = (int64_t)W * CV;
for (int64_t row = blockIdx.x; row < rows; row += gridDim.x) {
const int h = (int)(row % HP), t = (int)(row / HP);
const bool interior = (h >= PH && h < H + PH);
const uint4* src = nullptr;
if (interior) {
const int tt = t - PT, ph = h - PH;
if (tt >= 0) src = x + ((int64_t)tt * H + ph) * nrow;
else if (hist) src = hist + ((int64_t)t * H + ph) * nrow;
}
uint4* dst = y + row * nrow;
const uint4 zz = make_uint4(0, 0, 0, 0);
for (int64_t i = threadIdx.x; i < nrow; i += blockDim.x) dst[i] = src ? src[i] : zz;
}
}
} // namespace
at::Tensor norm_pad(const at::Tensor& x, const at::Tensor& gamma, const at::Tensor& hist,
int pt, int ph, bool silu) {
const int C = (int)x.size(1), T = (int)x.size(2), H = (int)x.size(3), W = (int)x.size(4);
auto y = at::empty({1, C, T + pt, H + ph * 2, W},
x.options().memory_format(at::MemoryFormat::ChannelsLast3d));
const B* xp = (const B*)x.data_ptr<at::BFloat16>();
const B* gp = gamma.defined() ? (const B*)gamma.data_ptr<at::BFloat16>() : nullptr;
const B* hp = hist.defined() ? (const B*)hist.data_ptr<at::BFloat16>() : nullptr;
B* yp = (B*)y.data_ptr<at::BFloat16>();
const int64_t rows = (int64_t)(T + pt) * (H + ph * 2);
auto stream = at::cuda::getCurrentCUDAStream();
const int blocks = (int)std::min<int64_t>(rows, 1024);
const int threads = 256;
auto go = [&](auto tag) {
constexpr int CC = decltype(tag)::value;
// P positions in flight per warp: measured best at 4 for C<=512, 2 at C=1024
constexpr int P = (CC == 1024) ? 2 : 4;
if (!gp) norm_pad_kernel<CC, P, 2><<<blocks, threads, 0, stream>>>(xp, gp, hp, yp, T, H, W, pt, ph);
else if (silu) norm_pad_kernel<CC, P, 0><<<blocks, threads, 0, stream>>>(xp, gp, hp, yp, T, H, W, pt, ph);
else norm_pad_kernel<CC, P, 1><<<blocks, threads, 0, stream>>>(xp, gp, hp, yp, T, H, W, pt, ph);
};
const bool fast = (C == 1024 || C == 512 || C == 256);
TORCH_CHECK(!gp || fast, "Unsupported RMS width ", C);
if (fast) {
if (C == 1024) go(std::integral_constant<int, 1024>{});
else if (C == 512) go(std::integral_constant<int, 512>{});
else go(std::integral_constant<int, 256>{});
} else {
TORCH_CHECK(C % 8 == 0, "norm_pad copy needs C divisible by 8, got ", C);
copy_row_kernel<<<blocks, threads, 0, stream>>>((const uint4*)xp, (const uint4*)hp, (uint4*)yp, T, H, W,
pt, ph, C / 8);
}
C10_CUDA_KERNEL_LAUNCH_CHECK();
return y;
}
@@ -1,56 +0,0 @@
// Prefix ownership transpose: no arithmetic, only bf16 NDHWC packets.
#include "prefix.h"
#include "kernels.h"
#include <ATen/cuda/CUDAContext.h>
#include <c10/cuda/CUDAException.h>
#include <cuda_runtime.h>
namespace {
// Blocks for destination bands are disjoint; each packet is [T,H_peer,W,C].
__global__ void pack_prefix_rows(const uint4* x,uint4* send,int T,int H,int WC8,int world) {
const int peer=blockIdx.y;
const int lo=H*peer/world, hi=H*(peer+1)/world;
const int64_t n=(int64_t)T*(hi-lo)*WC8;
const int64_t offset=(int64_t)T*lo*WC8;
for(int64_t i=(int64_t)blockIdx.x*blockDim.x+threadIdx.x;i<n;i+=(int64_t)gridDim.x*blockDim.x) {
const int64_t t=i/((int64_t)(hi-lo)*WC8);
const int64_t rowcol=i%((int64_t)(hi-lo)*WC8);
send[offset+i]=x[(t*H+lo)*WC8+rowcol];
}
}
}
at::Tensor prefix_temporal_to_rows(const at::Tensor& local,int64_t total_frames,
int rank,int world,const c10::intrusive_ptr<c10d::ProcessGroup>& pg) {
const int C=local.size(1),T=local.size(2),H=local.size(3),W=local.size(4);
TORCH_CHECK(C%8==0 && local.is_contiguous(at::MemoryFormat::ChannelsLast3d),"Prefix needs aligned NDHWC");
TORCH_CHECK(T==total_frames*(rank+1)/world-total_frames*rank/world,"Prefix ownership mismatch");
auto send=at::empty({local.numel()},local.options());
const int band_height=H*(rank+1)/world-H*rank/world;
auto output=nhwc_empty(C,total_frames,band_height,W,local);
// ProcessGroup splits count bf16 elements, not bytes. Receive into the
// physical NDHWC order: the source ranks own chronological frame intervals.
auto physical=output.permute({0,2,3,4,1});
TORCH_CHECK(physical.is_contiguous(),"Prefix receive must be dense NDHWC");
auto recv=physical.view({-1});
if(T) {
dim3 grid(256,world);
pack_prefix_rows<<<grid,256,0,at::cuda::getCurrentCUDAStream()>>>(
(const uint4*)local.data_ptr(),(uint4*)send.data_ptr(),T,H,W*(C/8),world);
C10_CUDA_KERNEL_LAUNCH_CHECK();
}
std::vector<int64_t> in_split(world),out_split(world);
int64_t sent=0,received=0;
for(int p=0;p<world;++p) {
in_split[p]=(int64_t)T*(H*(p+1)/world-H*p/world)*W*C;
out_split[p]=(total_frames*(p+1)/world-total_frames*p/world)*band_height*W*C;
sent+=in_split[p]; received+=out_split[p];
}
TORCH_CHECK(sent==send.numel() && received==recv.numel(),"Prefix packet size mismatch");
// WorkNCCL::wait orders the caller's stream after the collective; it need
// not block the host. ProcessGroup's tensor/allocator stream tracking keeps
// send/recv storage live through that dependency. Do not drop this wait
// when tail kernels consume output on the caller's current stream.
pg->alltoall_base(recv,send,out_split,in_split)->wait();
return output;
}
@@ -1,7 +0,0 @@
#pragma once
#include <ATen/ATen.h>
#include <torch/csrc/distributed/c10d/ProcessGroup.hpp>
// Exchange chronological full-frame prefix slices for chronological row bands.
// Source rank r owns [floor(T*r/world), floor(T*(r+1)/world)).
at::Tensor prefix_temporal_to_rows(const at::Tensor& local,int64_t total_frames,
int rank,int world,const c10::intrusive_ptr<c10d::ProcessGroup>& pg);
@@ -1,96 +0,0 @@
// Resampler stage: the four-phase spatial upsamplers of up_blocks 0,1,2 of the Wan2.2 VAE decoder.
// See resample.h for why the phase convolutions stay direct and what this pass replaces.
#include "resample.h"
#include <ATen/cuda/CUDAContext.h>
#include <c10/cuda/CUDAException.h>
#include <cuda_bf16.h>
using B = __nv_bfloat16;
// conv.cu: a 2x2 low-resolution convolution of one output parity, padded per phase; `halo` is set when
// the input carries a one-row halo from the neighbour rank, so the input is narrowed instead of padded.
at::Tensor phase_conv(const at::Tensor&, const at::Tensor&, int, int, bool);
static cudaStream_t stream() { return at::cuda::getCurrentCUDAStream(); }
static const B* pc(const Tensor& t) { return (const B*)t.data_ptr<at::BFloat16>(); }
static B* pm(Tensor& t) { return (B*)t.data_ptr<at::BFloat16>(); }
union P8 { uint4 v; B b[8]; __nv_bfloat162 h[4]; };
// One thread: eight channels of one 2x2 low-resolution tile, all four phases. The grid carries the
// coordinates (tx, t, ty) so there is no integer division on the hot path, and every access is a
// 16-byte vector: four phase outputs and the DupUp3D source in, four interleaved positions out.
__global__ void upsampler_kernel(const B* __restrict__ p0, const B* __restrict__ p1,
const B* __restrict__ p2, const B* __restrict__ p3,
B* __restrict__ y, const B* __restrict__ bias,
const B* __restrict__ skip, int C, int HL, int WL, int TC,
bool temporal, bool first) {
const int cg = threadIdx.x; // 8-channel group
const int tx = blockIdx.x, t = blockIdx.y, ty = blockIdx.z;
const int CP = C / 8, SP = TC / 8;
const P8 b = {((const uint4*)bias)[cg]};
const int st = temporal ? (first ? (t + 1) / 2 : t / 2) : t;
const int64_t wC = 2 * (int64_t)WL * C; // one row of the interleaved output
const int64_t tC = 2 * (int64_t)HL * wC; // one frame of it
#pragma unroll
for (int i = 0; i < 2; ++i) {
const int yy = 2 * ty + i;
if (yy >= HL) continue;
#pragma unroll
for (int j = 0; j < 2; ++j) {
const int xx = 2 * tx + j;
if (xx >= WL) continue;
const int64_t q0 = ((int64_t)t * HL + yy) * WL + xx;
P8 ph[4];
ph[0].v = ((const uint4*)p0)[q0 * CP + cg];
ph[1].v = ((const uint4*)p1)[q0 * CP + cg];
ph[2].v = ((const uint4*)p2)[q0 * CP + cg];
ph[3].v = ((const uint4*)p3)[q0 * CP + cg];
const int64_t s = ((int64_t)st * HL + yy) * WL + xx;
P8 s0, s1;
if (temporal) {
s0.v = ((const uint4*)skip)[s * SP + cg];
s1.v = s0.v; // nearest x2 in time: both row parities alike
} else {
// up block 2: output channel J takes source channel 2*J + (row parity), so read both parities.
s0.v = ((const uint4*)skip)[s * SP + 2 * cg];
s1.v = ((const uint4*)skip)[s * SP + 2 * cg + 1];
}
B* out = y + (int64_t)t * tC + (int64_t)(2 * yy) * wC + (int64_t)(2 * xx) * C + 8 * cg;
if (!temporal) {
// channel J of the output takes source channel 2J + a: even channels from s0, odd from s1.
P8 e, o;
#pragma unroll
for (int k = 0; k < 4; ++k) {
e.b[k] = s0.b[2 * k];
e.b[k + 4] = s1.b[2 * k];
o.b[k] = s0.b[2 * k + 1];
o.b[k + 4] = s1.b[2 * k + 1];
}
s0.v = e.v;
s1.v = o.v;
}
P8 r;
#pragma unroll
for (int p = 0; p < 4; ++p) {
const P8& sv = (p & 2) ? s1 : s0; // row parity of this phase
#pragma unroll
for (int k = 0; k < 4; ++k) r.h[k] = __hadd2(__hadd2(ph[p].h[k], b.h[k]), sv.h[k]);
const int a = p >> 1, bcol = p & 1;
((uint4*)(out + (int64_t)a * wC + (int64_t)bcol * C))[0] = r.v;
}
}
}
}
Tensor resample_upsample(const Tensor& x, const std::vector<Tensor>& pw, const Tensor& bias,
const Tensor& skip, bool halo, bool temporal, bool first) {
TORCH_CHECK(pw.size() == 4, "resample_upsample needs the four phase kernels");
std::vector<Tensor> q(4);
for (int a = 0; a < 2; ++a) for (int b = 0; b < 2; ++b) q[2 * a + b] = phase_conv(x, pw[2 * a + b], a, b, halo);
const int C = q[0].size(1), D = q[0].size(2), HL = q[0].size(3), WL = q[0].size(4);
Tensor y = at::empty({1, C, D, 2 * HL, 2 * WL}, x.options().memory_format(at::MemoryFormat::ChannelsLast3d));
dim3 block(C / 8, 1), grid((WL + 1) / 2, D, (HL + 1) / 2);
upsampler_kernel<<<grid, block, 0, stream()>>>(pc(q[0]), pc(q[1]), pc(q[2]), pc(q[3]), pm(y), pc(bias),
pc(skip), C, HL, WL, (int)skip.size(1), temporal, first);
C10_CUDA_KERNEL_LAUNCH_CHECK();
return y;
}
@@ -1,51 +0,0 @@
#pragma once
// Resampler stage of the Wan2.2 VAE decoder: the four-phase spatial upsamplers of up_blocks 0,1,2.
//
// A resampler is "nearest x2 in space, then a 3x3x3 convolution", which equals four 2x2 low-resolution
// convolutions, one per output parity (their repeated taps already summed into per-phase kernels by
// prepare()). Those convolutions stay direct (4 taps, no transform: F(2x2,2x2) would cut the MACs 1.78x
// but materialise 4 x 9/4 = 9 x the low-resolution tensor of transformed input and 2.25x the output of
// products, which prices out as a wash - measured and posted).
//
// What is fixed here is the pass after them: the seed's interleave kernel walked the four phase tensors
// and the DupUp3D source with four 64-bit division/modulo chains per 16 bytes and ran at 3.9 TB/s, half
// of HBM. This one walks tiles and channel groups with the grid carrying the coordinates, so the pass
// is bandwidth bound: it reads the four phase outputs and the DupUp3D source once and writes the
// interleaved high-resolution tensor once, with bias and the shortcut added in bf16 at the same
// rounding points the seed used.
#include <ATen/ATen.h>
#include <vector>
using at::Tensor;
// One resampler over one latent chunk. x is the low-resolution input, already padded by one row top
// and bottom ([1,C,D,H+2,W] channels-last) with the halo installed at world > 1 (`halo`), and by the two
// causal history frames in time; pw are the four per-phase 2x2 kernels ([C_out,C_in,1,2,2], phase =
// 2*row_parity + col_parity), bias [C_out], and skip the DupUp3D source ([1,TC,Ds,HL,WL]). `temporal`
// is true for up_blocks 0 and 1 (the shortcut and the phase kernels are nearest-x2 in time as well),
// false for up block 2, whose shortcut is the channel-pair-to-row rearrangement. `first` marks the
// chunk that holds latent frame 0.
// Returns the interleaved high-resolution tensor [1,C_out,D,2*HL,2*WL] channels-last.
//
// The pass moves 100.5 GB of logical traffic for f121x1 (the four phase tensors read 42.5, the DupUp3D
// source 15.5, the output written 42.5) in 14.8 ms = 6.8 TB/s.
//
// THAT 6.8 IS NOT A BOUND AND MUST NOT BE TRACED TO ONE. It is solved from 100.5 / 14.8, so quoting it
// against a ceiling is an identity - w06 M18769/M18811, w22 M18463. The bounds on this board that could
// apply are: the MIX ceiling for a read+write stage, 5.07-5.59 TB/s at this stage's own f_r (w08's
// f_r-form, J2654/J2944); w14's per-thread store-loop write arm, 4.86-5.15 (J3082); and w12's 4-byte-
// per-lane burst arm, 7.35-7.50 (J3029), which is BURST-FLATTERED at 2 KB against the tree's 128 B and
// which w12 withdrew. 6.9 TB/s was a deflation of that withdrawn arm, so it inherits the arm's defect:
// it has no f_r, it belongs to a limb with no read, and this is a read+write stage.
//
// So the honest statement is that the stage moves 100.5 GB at 6.8 TB/s and that NO CEILING FOR ITS OWN
// STREAM HAS BEEN MEASURED. The counters on this pass would supply one; that run has not been made.
// Do not write "at its byte bound", and do not write "the model is conservative" (106.7 GB / 14.8 ms is
// 7.21 TB/s, which is ABOVE the mixed ceiling - the same fact as "the model is 6 % low", stated with the
// sign that does not flatter it).
//
// J2574 also counts 1.37x the sectors of its logical read. That is sector waste on the access pattern
// and not work the algorithm needs: this kernel reads each phase tensor and the shortcut at the
// position's own address, with no neighbour re-read and no stencil anywhere in it.
Tensor resample_upsample(const Tensor& x, const std::vector<Tensor>& pw, const Tensor& bias,
const Tensor& skip, bool halo, bool temporal, bool first);
@@ -1 +0,0 @@
{"languages":["cuda_cpp"],"entry_point":"binding.cpp::run","destination_passing_style":true,"compile_options":{"cflags":["-O3","-std=c++17"],"cuda_cflags":["-O3","-std=c++17","-lineinfo"],"ld_flags":["-lcuda"]},"dependencies":[]}
@@ -1,122 +0,0 @@
// w11: a CUTLASS implicit convolution whose epilogue stores into a caller-given destination with arbitrary
// (N,D,H,W) strides, and can accumulate on what the destination already holds (beta=1, source == destination).
// One user: the temporal upsampler, which writes the frame-interleaved video straight out (stride 2 in D).
// The resampler's phase tensors come from conv.cu's phase_conv, not from this file.
// Nothing else changes: same tile (256x256x64), same 2-CTA cluster, same TMA im2col, same bf16 epilogue.
#include <ATen/ATen.h>
#include <ATen/cuda/CUDAContext.h>
#include <c10/cuda/CUDAException.h>
#include <cuda_bf16.h>
#include "cutlass/cutlass.h"
#include "cute/tensor.hpp"
#include "cutlass/kernel_hardware_info.hpp"
#include "cutlass/conv/convolution.h"
#include "cutlass/conv/convnd_problem_shape.hpp"
#include "cutlass/tensor_ref.h"
#include "cutlass/conv/dispatch_policy.hpp"
#include "cutlass/conv/collective/collective_builder.hpp"
#include "cutlass/epilogue/collective/collective_builder.hpp"
#include "cutlass/conv/device/conv_universal_adapter.hpp"
#include "cutlass/conv/kernel/conv_universal.hpp"
at::Tensor nhwc_empty(int64_t c, int64_t t, int64_t h, int64_t w, const at::Tensor& like);
using namespace cute;
using B = __nv_bfloat16;
using ElementBF16 = cutlass::bfloat16_t;
using LayoutNDHWC = cutlass::layout::TensorNDHWC;
using ArchTag = cutlass::arch::Sm100;
using OperatorClass = cutlass::arch::OpClassTensorOp;
constexpr auto Fprop = cutlass::conv::Operator::kFprop;
// x: activation [1,C,D,H,W] channels-last; w: [K,C,KT,KH,KW] channels-last; y: the destination, with its own strides.
template <int M, int N, int CL>
static void launch_into(const at::Tensor& x, const at::Tensor& w, at::Tensor& y, int ph, int pw, float beta,
int phu = -1, int pwu = -1) {
if (phu < 0) phu = ph;
if (pwu < 0) pwu = pw;
using Tile = Shape<Int<M>, Int<N>, Shape<_64>>;
using Cluster = Shape<Int<CL>, _1, _1>;
using Epilogue = typename cutlass::epilogue::collective::CollectiveBuilder<
ArchTag, OperatorClass, Tile, Cluster, cutlass::epilogue::collective::EpilogueTileAuto, float, float,
ElementBF16, LayoutNDHWC, 8, ElementBF16, LayoutNDHWC, 8,
cutlass::epilogue::collective::EpilogueScheduleAuto>::CollectiveOp;
using Mainloop = typename cutlass::conv::collective::CollectiveBuilder<
ArchTag, OperatorClass, Fprop, ElementBF16, LayoutNDHWC, 8, ElementBF16, LayoutNDHWC, 8, float, Tile, Cluster,
cutlass::conv::collective::StageCountAutoCarveout<static_cast<int>(sizeof(typename Epilogue::SharedStorage))>,
cutlass::conv::collective::KernelScheduleAuto>::CollectiveOp;
using Problem = cutlass::conv::ConvProblemShape<Fprop, 3>;
using Kernel = cutlass::conv::kernel::ConvUniversal<Problem, Mainloop, Epilogue>;
using Conv = cutlass::conv::device::ConvUniversalAdapter<Kernel>;
int C = x.size(1), D = x.size(2), H = x.size(3), W = x.size(4);
int K = w.size(0), KT = w.size(2), KH = w.size(3), KW = w.size(4);
int OD = y.size(2), OH = y.size(3), OW = y.size(4);
TORCH_CHECK(OD == D - KT + 1 && OH == H + ph + phu - KH + 1 && OW == W + pw + pwu - KW + 1,
"launch_into: destination shape does not match the convolution");
TORCH_CHECK(y.size(1) == K && y.stride(1) == 1, "launch_into: destination must be channel-innermost");
Problem problem(cutlass::conv::Mode::kCrossCorrelation,
{1, D, H, W, C}, {x.stride(0), x.stride(2), x.stride(3), x.stride(4), x.stride(1)},
{K, KT, KH, KW, C}, {w.stride(0), w.stride(2), w.stride(3), w.stride(4), w.stride(1)},
{0, ph, pw}, {0, phu, pwu}, {1, 1, 1}, {1, 1, 1}, 1);
// The output's own strides: index 0 N, 1 D, 2 H, 3 W, 4 C (the layout the C strides already use).
problem.stride_C[0] = y.stride(0);
problem.stride_C[1] = y.stride(2);
problem.stride_C[2] = y.stride(3);
problem.stride_C[3] = y.stride(4);
problem.stride_C[4] = 1;
typename Kernel::StrideC sc{};
typename Kernel::StrideD sd{};
cute::for_each(cute::make_seq<cute::rank<0>(sc)>{},
[&](auto i) { cute::get<0, i>(sc) = problem.stride_C[Problem::RankT - 2 - i]; });
cute::for_each(cute::make_seq<cute::rank<0>(sd)>{},
[&](auto i) { cute::get<0, i>(sd) = problem.stride_C[Problem::RankT - 2 - i]; });
const ElementBF16* dst = (const ElementBF16*)y.data_ptr();
typename Conv::Arguments args{problem, {(const ElementBF16*)x.data_ptr(), (const ElementBF16*)w.data_ptr()},
{{1.f, beta}, dst, sc, (ElementBF16*)y.data_ptr(), sd}};
Conv conv;
TORCH_CHECK(conv.can_implement(args) == cutlass::Status::kSuccess, "CUTLASS cannot implement shape");
size_t size = Conv::get_workspace_size(args);
auto workspace = at::empty({(int64_t)size}, x.options().dtype(at::kByte));
auto stream = at::cuda::getCurrentCUDAStream();
auto status = conv.initialize(args, workspace.data_ptr(), stream);
TORCH_CHECK(status == cutlass::Status::kSuccess, "CUTLASS initialization failed ", int(status));
status = conv.run(stream);
TORCH_CHECK(status == cutlass::Status::kSuccess, "CUTLASS convolution failed ", int(status));
}
// The temporal upsampler: y[t] = w[:,:,2] x[t-2] + w[:,:,1] x[t-1] + w[:,:,0] x[t] + bias, with frame 0 passed
// through unchanged when `first`, the two temporal halves of the 2C channels going to consecutive frames.
// p is the activation already padded with its two causal history frames ([1,C,nf+2,H,W], channels-last).
// INVARIANT: every element of y is written by head_bias_kernel, and the two parity arms below cover the
// non-head frames disjointly, each exactly once -- which is what makes beta=1 onto an at::empty safe.
static __global__ void head_bias_kernel(const B* head, const B* bias, B* y, int C, int T2, int64_t HW, int off) {
int64_t n = (int64_t)T2 * HW * (C / 8);
for (int64_t i = (int64_t)blockIdx.x * blockDim.x + threadIdx.x; i < n; i += (int64_t)gridDim.x * blockDim.x) {
int cg = (int)(i % (C / 8)); int64_t r = i / (C / 8);
int64_t pos = r % HW; int t = (int)(r / HW);
uint4 v;
if (t < off) v = ((const uint4*)head)[pos * (C / 8) + cg];
else v = ((const uint4*)(bias + ((t - off) & 1) * C))[cg];
((uint4*)y)[i] = v;
}
}
at::Tensor time_upsample(const at::Tensor& p, const at::Tensor& head, const at::Tensor& w, const at::Tensor& bias,
int64_t T, int64_t nf, bool first) {
const at::Tensor& ref = p.defined() ? p : head;
int C = bias.size(0) / 2, H = ref.size(3), W = ref.size(4);
int T2 = (int)(2 * T - (first ? 1 : 0));
at::Tensor y = nhwc_empty(C, T2, H, W, ref);
int off = first ? 1 : 0;
head_bias_kernel<<<1024, 256, 0, at::cuda::getCurrentCUDAStream()>>>(
first ? (const B*)head.data_ptr() : nullptr, (const B*)bias.data_ptr(), (B*)y.data_ptr(), C, T2,
(int64_t)H * W, off);
C10_CUDA_KERNEL_LAUNCH_CHECK();
if (nf > 0) {
int64_t sN = y.stride(0), sD = 2 * y.stride(2), sH = y.stride(3), sW = y.stride(4);
for (int par = 0; par < 2; par++) {
auto yv = y.as_strided({1, (int64_t)C, nf, (int64_t)H, (int64_t)W}, {sN, 1, sD, sH, sW},
(int64_t)(off + par) * y.stride(2));
launch_into<256, 256, 2>(p, w.narrow(0, (int64_t)par * C, C), yv, 0, 0, 1.f);
}
}
return y;
}
@@ -1,400 +0,0 @@
// Fused F(4x4,3x3) inverse transform for the causal 3x3x3 Winograd convolutions.
// One pass over the 36 component tensors M produces BOTH
// y = A^T M A + bias + residual (raw conv output: the resnet's residual operand)
// n = silu(rmsnorm(y) * sqrt(C) * gain) (the next convolution's normalized, activated,
// temporal+spatial padded input)
// so the normalized tensor is never written and read again by a separate norm pass.
// The RMS statistic needs every output channel of a position, so one block holds all of C
// for one 4x4 output tile: blockDim.x == C/2, one channel pair per thread.
//
// M layout: 36 separate allocations, each [D][TH][TW][C] bf16, C innermost, so a plane's
// position stride is 1 in C and its plane offset is (((t*TH+ty)*TW+tx)*C + ch).
// y is [D][H][W][C]; n is [D+PT][H+2PH][W][C].
//
// Two lessons from the guild, both measured elsewhere and both applied here:
// - w01's J576 (input transform): the head carries no divisions, no predicates and no 64-bit
// address chain. 3-D grid (tx,ty,t), a separate EXACT instantiation with the bounds test
// removed, and a 32-bit plane byte offset. EXACT is dispatched per call: it holds at
// 88x156, 176x312 and 352x624, whose widths are multiples of 4 -- NOT at 44x78, where
// W=78 leaves the last tile two columns short and the checked path is taken.
// - w05's J627 (inverse transform, 5.5 TB/s at 64 registers): no thread loads 4 B of one plane.
// The block stages the 36 components of its tile into shared memory cooperatively -- one
// plane's C channels is C*2 contiguous bytes, so a warp pass is 16 B per lane and one fully
// coalesced 512 B request -- and the consumer reads smem[comp][2c] as a 4-byte pair, which is
// 128 contiguous bytes per warp and therefore conflict-free. No transpose on either side.
//
// Per-element work follows w02's recipe (M137, PR15): bf16->f32 by an integer shift so the
// conversions leave the XU pipe, one bf16 rounding for the whole normalize*sqrt(C)*gain product
// instead of the reference's three, SiLU as u + u*tanh(u) with u = pre-activation/2 and
// tanh.approx.f32, and 0.5*sqrt(C)*rsqrt(sum) as one per-position scalar.
#include "kernels.h"
#include <ATen/cuda/CUDAContext.h>
#include <c10/cuda/CUDAException.h>
#include <cuda_bf16.h>
#include <algorithm>
using B=__nv_bfloat16;
static cudaStream_t e_stream(){return at::cuda::getCurrentCUDAStream();}
static const B* e_ptr(const Tensor& t){return t.defined()?(const B*)t.data_ptr<at::BFloat16>():nullptr;}
static B* e_mut(Tensor& t){return t.defined()?(B*)t.data_ptr<at::BFloat16>():nullptr;}
struct EpiPtrs{const B* p[36];};
// A of F(4x4,3x3) applied along one axis: out[i] = sum_v A[i][v] v[v], two fp32 lanes per
// instruction (a6v below is the only form this file uses). add/sub/fma.f32x2 need sm_100+; this
// tree is built for sm_100a only (solution.json), so no scalar fallback is kept beside it -- a
// scalar copy would be dead source here, which is why the scalar `a6` was removed rather than left.
// f32x2: PTX 8.6 gives add/sub/mul/fma on a .b64 operand holding two f32 lanes (sm_100a).
// This kernel already carries two independent fp32 lanes per thread -- the channel pair a thread
// owns -- so `ch` IS the SIMD lane and nothing crosses a lane. Each instruction performs exactly
// the arithmetic the scalar instruction performs, in the same association.
struct V2{
float x,y;
__device__ V2(){} __device__ V2(float a,float b):x(a),y(b){}
};
__device__ __forceinline__ unsigned long long pk(V2 a){
return (unsigned long long)__float_as_uint(a.y)<<32|__float_as_uint(a.x);
}
__device__ __forceinline__ V2 upk(unsigned long long u){
V2 r; r.x=__uint_as_float((unsigned)u); r.y=__uint_as_float((unsigned)(u>>32)); return r;
}
__device__ __forceinline__ V2 vadd(V2 a,V2 b){
unsigned long long r; asm("add.rn.f32x2 %0,%1,%2;":"=l"(r):"l"(pk(a)),"l"(pk(b))); return upk(r);
}
__device__ __forceinline__ V2 vsub(V2 a,V2 b){
unsigned long long r; asm("sub.rn.f32x2 %0,%1,%2;":"=l"(r):"l"(pk(a)),"l"(pk(b))); return upk(r);
}
__device__ __forceinline__ V2 vfmas(V2 a,float c,V2 acc){
V2 k; k.x=k.y=c; unsigned long long r;
asm("fma.rn.f32x2 %0,%1,%2,%3;":"=l"(r):"l"(pk(a)),"l"(pk(k)),"l"(pk(acc))); return upk(r);
}
__device__ __forceinline__ V2 b2v(uint32_t w){
V2 r; r.x=__uint_as_float(w<<16); r.y=__uint_as_float(w&0xffff0000u); return r;
}
// The transform of A above, two lanes per instruction.
__device__ __forceinline__ void a6v(V2 v0,V2 v1,V2 v2,V2 v3,V2 v4,V2 v5,V2* y){
V2 a=vadd(v1,v2), b=vsub(v1,v2), c=vadd(v3,v4), d=vsub(v3,v4);
y[0]=vadd(vadd(v0,a),c); y[1]=vfmas(d,2.f,b); y[2]=vfmas(c,4.f,a); y[3]=vadd(vfmas(d,8.f,b),v5);
}
// Exact bf16 pair -> two floats without the XU pipe: a bf16 is the top half of an fp32.
__device__ __forceinline__ float2 b2f(uint32_t w){
return make_float2(__uint_as_float(w<<16),__uint_as_float(w&0xffff0000u));
}
__device__ __forceinline__ float tanh_ap(float x){float r;asm("tanh.approx.f32 %0, %1;":"=f"(r):"f"(x));return r;}
// silu(x) with u = x/2: x*sigmoid(x) = u + u*tanh(u).
__device__ __forceinline__ float silu2(float u){return __fmaf_rn(u,tanh_ap(u),u);}
__device__ __forceinline__ uint32_t add2(uint32_t a,uint32_t b){
__nv_bfloat162 x=*(const __nv_bfloat162*)&a,y=*(const __nv_bfloat162*)&b;
__nv_bfloat162 r=__hadd2(x,y); return *(const uint32_t*)&r;
}
__device__ __forceinline__ uint32_t pack2(float x,float y){
__nv_bfloat162 r=__floats2bfloat162_rn(x,y); return *(const uint32_t*)&r;
}
__device__ __forceinline__ float sq8(const uint4& v){
float2 a=b2f(v.x),b=b2f(v.y),c=b2f(v.z),d=b2f(v.w);
return (__fmaf_rn(a.x,a.x,a.y*a.y)+__fmaf_rn(b.x,b.x,b.y*b.y))
+(__fmaf_rn(c.x,c.x,c.y*c.y)+__fmaf_rn(d.x,d.x,d.y*d.y));
}
// Stage the 36 components of one 4x4 tile into shared memory: 36*C/8 tasks of 16 bytes,
// 9 per thread at any C. Each warp's 32 lanes take 32 consecutive 16-byte slots, i.e. 512
// contiguous bytes of one plane.
//
// The copy is `cp.async.cg` (16 B, global->shared, L2-cached, NO register round trip) rather than
// the `uint4` load-then-store it replaces. Measured reason (J3871, wino_epi_kernel<1024,1>, ncu,
// 3 passes): the binding FIXABLE stall is `mio_throttle 2.11` cycles per issued instruction --
// second only to `not_selected 2.73`, which is not a defect (it means the SM already has eligible
// warps to pass over) -- and `l1tex__throughput` is the highest pipe at 62.7-64.7 %. The old form
// is a global load AND a shared store per 16 B, so it spends two MIO slots and a writeback
// register per slot; this form spends one slot and no register. Per block at C=1024 that is
// 9*512 = 4,608 pairs, i.e. 9,216 MIO instructions becoming 4,608.
//
// Bytes moved, alignment and the resulting V are IDENTICAL: same comp/off decomposition, same
// 16-byte slot, same plane offset inside smem, so the transform reads what it read before and the
// output is bit-for-bit the old output. This is a scheduling change, not a numerical one.
//
// The commit/wait pair sits here rather than at the call site because the transform consumes the
// staging immediately -- there is no independent work to overlap the transfer with inside one
// tile. Overlap across tiles needs a persistent loop and is a separate change, not this one.
// The form above is measured, not preferred: `cp.async.cg` beats the `uint4` load+store it
// replaced by 12.7 ms at f121x1 (J4745, one job, one clock, A/A 0.2 ms).
template<int C>
__device__ __forceinline__ void stage36(B* sm,const EpiPtrs& mp,unsigned src_tile){
constexpr int CV=C/2;
constexpr int PER=C/8; // 16-byte slots per plane
const unsigned smbase=(unsigned)__cvta_generic_to_shared(sm);
#pragma unroll
for(int i=0;i<9;i++){
const int task=threadIdx.x+i*CV;
const int comp=task/PER, off=task%PER;
const unsigned dst=smbase+(unsigned)((comp*C+off*8)*2);
const char* src=(const char*)mp.p[comp]+src_tile+off*16;
asm volatile("cp.async.cg.shared.global [%0], [%1], 16;\n"::"r"(dst),"l"(src));
}
asm volatile("cp.async.commit_group;\n"::);
asm volatile("cp.async.wait_group 0;\n"::);
}
template<int C,bool EXACT>
__global__ __launch_bounds__(C/2) void wino_epi_kernel(
EpiPtrs mp,B* __restrict__ y,B* __restrict__ n,
const B* __restrict__ bias,const B* __restrict__ residual,const B* __restrict__ gain,
int H,int W,int TH,int TW,int PT,int PH,bool wy){
// `wy` is the CALLER's assertion that nobody reads this launch's y. It is only ever false where
// the very next conv has idx == fnext and therefore takes the fused `prepared` edge (see
// binding.cpp: resnet's first conv, whose consumer is its own second conv). The n tensor is
// written either way, so this can never be false without a n to carry the activation.
constexpr int CV=C/2; // channel pairs per position
constexpr int WP=CV/32; // warps per block
const int c=threadIdx.x;
const int tx=blockIdx.x, ty=blockIdx.y, t=blockIdx.z;
extern __shared__ __align__(16) B smem[];
B* sm=smem; // 36 planes x C, this tile
// 32-bit plane byte offset: D*TH*TW*C*2 bytes, where D is the PIXEL frame count at this level
// (4*chunk-3 at up block 1, not the latent chunk size -- the earlier "34" was the latent count).
// chunk_size is NOW min(16*world, 61) -- this PR is what moved the rule off `8*world`, so chunk
// 32 at world 4 no longer exists and the worst case moves with it. Over the shipping configs the
// largest src_tile is world 2 / chunk 32 = 4.39e8 bytes and world 1 / chunk 16 = 4.29e8, both
// ~10x inside UINT32_MAX and both enforced at run time by the guard further down this file.
// The `8.8e8` that stood here was 4x too large and it described a configuration this PR retires.
// A chunk size that more than doubles again, or a wider clip, must widen this to unsigned long long.
const unsigned src_tile=(unsigned)((((t*TH+ty)*TW+tx)*C)*2u);
stage36<C>(sm,mp,src_tile);
__syncthreads();
V2 acc[4][4];
#pragma unroll
for(int i=0;i<4;i++)
#pragma unroll
for(int b=0;b<4;b++) acc[i][b]=V2(0.f,0.f);
const B* q0=sm+2*c; // this thread's channel pair, in each plane
#pragma unroll
for(int u=0;u<6;u++){
V2 f[6];
#pragma unroll
for(int v=0;v<6;v++) f[v]=b2v(*(const uint32_t*)(q0+(6*u+v)*C));
V2 q[4];
a6v(f[0],f[1],f[2],f[3],f[4],f[5],q);
#pragma unroll
for(int b=0;b<4;b++){
const V2 v=q[b];
if(u==0) acc[0][b]=vadd(acc[0][b],v);
else if(u==1){acc[0][b]=vadd(acc[0][b],v);acc[1][b]=vadd(acc[1][b],v);acc[2][b]=vadd(acc[2][b],v);acc[3][b]=vadd(acc[3][b],v);}
else if(u==2){acc[0][b]=vadd(acc[0][b],v);acc[1][b]=vsub(acc[1][b],v);acc[2][b]=vadd(acc[2][b],v);acc[3][b]=vsub(acc[3][b],v);}
else if(u==3){acc[0][b]=vadd(acc[0][b],v);acc[1][b]=vfmas(v,2.f,acc[1][b]);acc[2][b]=vfmas(v,4.f,acc[2][b]);acc[3][b]=vfmas(v,8.f,acc[3][b]);}
else if(u==4){acc[0][b]=vadd(acc[0][b],v);acc[1][b]=vfmas(v,-2.f,acc[1][b]);acc[2][b]=vfmas(v,4.f,acc[2][b]);acc[3][b]=vfmas(v,-8.f,acc[3][b]);}
else {acc[3][b]=vadd(acc[3][b],v);}
}
}
const int Hp=H+2*PH;
float2 biasv=make_float2(0.f,0.f),gainv=make_float2(0.f,0.f);
if(bias) biasv=b2f(*(const uint32_t*)&((const __nv_bfloat162*)bias)[c]);
if(gain) gainv=b2f(*(const uint32_t*)&((const __nv_bfloat162*)gain)[c]);
const long yb=(((long)t*H+4*ty)*W+4*tx)*CV+c;
const __nv_bfloat162 bv=__floats2bfloat162_rn(biasv.x,biasv.y);
if(!n){
// No prepared tensor: write the raw conv output in the natural per-thread layout.
#pragma unroll
for(int i=0;i<4;i++){
const int yy=4*ty+i;
#pragma unroll
for(int b=0;b<4;b++){
const int xx=4*tx+b;
const long off=(long)(i*W+b)*CV;
const bool ok=EXACT?true:(yy<H&&xx<W);
__nv_bfloat162 v=__floats2bfloat162_rn(acc[i][b].x,acc[i][b].y);
if(bias) v=__hadd2(v,bv);
if(residual&&ok) v=__hadd2(v,((const __nv_bfloat162*)residual)[yb+off]);
if(ok) ((__nv_bfloat162*)y)[yb+off]=v;
}
}
}
if(n){
// Remapped epilogue (w01's M655, measured 1.58x on the standalone inverse): the thread that
// computes the transform owns a channel pair, so it would write 4 bytes per output, which
// caps the pass at the 4-byte row of the stream table. Instead the 16 outputs go to shared
// memory with the bias already applied, one barrier, and the epilogue is re-laid so a warp
// owns one position and each lane an octet of channels: the residual read and the y write are
// one 512-byte transaction per warp instruction, and the sum of squares becomes a single-warp
// butterfly because a warp now holds every channel of its position.
__syncthreads(); // the components are dead; their buffer is reused
B* so=smem; // so[p*C + ch], bias applied
#pragma unroll
for(int i=0;i<4;i++)
#pragma unroll
for(int b=0;b<4;b++){
__nv_bfloat162 v=__floats2bfloat162_rn(acc[i][b].x,acc[i][b].y);
if(bias) v=__hadd2(v,bv);
((__nv_bfloat162*)so)[(i*4+b)*CV+c]=v;
}
__syncthreads();
constexpr int NPW=16/WP; // positions per warp (4, 2 or 1)
constexpr int CW=C/32; // channels per lane (8, 16 or 32)
constexpr int NU=CW/8; // 16-byte chunks per lane (1, 2 or 4)
const int wp=c>>5, ln=c&31;
const float hs=0.5f*sqrtf((float)C);
uint4 gv[NU];
// Chunk map: lane ln takes chunk (k*32+ln), i.e. channel (k*32+ln)*8, so the 32 lanes of one
// warp instruction cover 32 consecutive chunks = 512 contiguous bytes. The previous map gave
// lane ln the block [ln*CW, ln*CW+CW), which puts neighbours CW*2 bytes apart: contiguous at
// C=256 (16 B), half a line at C=512 (32 B), a quarter at C=1024 (64 B) -- and at C=1024 that
// is a 4x sector over-fetch on the residual read and on both stores. w21's J1096 gives it in
// counters (L1 hit 5.32%, L2 hit 0.20%, DRAM 65.6%): no reuse, so every over-fetched sector
// is paid at DRAM. Measured at D=8, min of three rounds: C=256 1.000x (identical expression at
// NU=1), C=512 1.244x, C=1024 1.431x. It permutes which channel each k reads, so the 4-term
// sum of squares accumulates in a different order and s rounds differently in its last bits --
// f121x1 max|d| 0.1605 -> 0.147, rms unchanged to five digits.
#pragma unroll
for(int k=0;k<NU;k++)
gv[k]=gain?*(const uint4*)((const char*)gain+((long)(k*32+ln)*8)*2)
:make_uint4(0x3f803f80u,0x3f803f80u,0x3f803f80u,0x3f803f80u);
#pragma unroll
for(int j=0;j<NPW;j++){
const int p=wp*NPW+j, yy=4*ty+(p>>2), xx=4*tx+(p&3);
const bool ok=EXACT?true:(yy<H&&xx<W);
uint4 yv4[NU]; float sq=0.f;
#pragma unroll
for(int k=0;k<NU;k++){
const int off=(k*32+ln)*8;
uint4 v=*(const uint4*)((const char*)so+((long)p*C+off)*2);
if(residual&&ok){
// (long)t first: the whole offset must be 64-bit, not the last factor. `H` here is this
// RANK'S BAND height, not the full 352 -- chunk() narrows dim 3 of the latent grid (44
// rows) to [44r/W,44(r+1)/W) before resnet15 and every up block, so block 3 carries 88
// rows at world 4 and 44 at world 8. The band shrinks as the temporal chunk grows, which
// is why a rank's working set is near-constant -- BUT THE CENSUS THAT STOOD HERE WAS
// TRUE ONLY OF `chunk_size = 8*world`, AND THIS PR MOVES THE RULE TO `min(16*world, 61)`.
// Post-change THREE cells pass INT_MAX and THE LARGEST IS WORLD 2, so it is listed
// first: narrowing against world 4 because "chunk 61 looks biggest" is exactly the
// mislabelled-maximum this paragraph exists to prevent.
// world 2 / chunk 32 D=125, rows 176, 624x256 = 3.51e9 <-- THE MAXIMUM
// world 1 / chunk 16 D=61, rows 352 = 3.43e9
// world 4 / chunk 61 D=241, rows 88 = 3.39e9
// World 2's chunk 32 is THIS PR's OWN value at world 2 (min(16*2, 61) = 32), not a
// foreign or historical form. ALL THREE ARE PAST INT_MAX. This site is
// correct because it chains `(long)` here and again on `nbase` below, so the 64-bit chain
// is now LOAD-BEARING at those three cells rather than merely belt-and-braces. Do not
// narrow it back on the strength of an `inside INT_MAX` reading from the old rule.
const uint4 r=*(const uint4*)((const char*)residual
+((long)((((long)t*H+yy)*W+xx)*C+off))*2);
v.x=add2(v.x,r.x); v.y=add2(v.y,r.y); v.z=add2(v.z,r.z); v.w=add2(v.w,r.w);
}
yv4[k]=v; sq+=sq8(v);
}
#pragma unroll
for(int d=16;d;d>>=1) sq+=__shfl_xor_sync(0xffffffffu,sq,d);
if(ok){
const float s=hs*rsqrtf(fmaxf(sq,1e-24f));
const long ybase=((((long)t*H+yy)*W+xx)*C)*2;
const long nbase=(((((long)(t+PT)*Hp+PH+yy)*W+xx)*C))*2;
#pragma unroll
for(int k=0;k<NU;k++){
const long cof=(long)((k*32+ln)*8)*2;
if(wy) *(uint4*)((char*)y+ybase+cof)=yv4[k];
uint4 u=yv4[k], g=gv[k], o;
float2 f0=b2f(u.x), f1=b2f(u.y), f2=b2f(u.z), f3=b2f(u.w);
float2 g0=b2f(g.x), g1=b2f(g.y), g2=b2f(g.z), g3=b2f(g.w);
o.x=pack2(silu2(f0.x*s*g0.x),silu2(f0.y*s*g0.y));
o.y=pack2(silu2(f1.x*s*g1.x),silu2(f1.y*s*g1.y));
o.z=pack2(silu2(f2.x*s*g2.x),silu2(f2.y*s*g2.y));
o.w=pack2(silu2(f3.x*s*g3.x),silu2(f3.y*s*g3.y));
*(uint4*)((char*)n+nbase+cof)=o;
}
}
}
}
}
// Zero the spatial padding rows of every frame of the prepared tensor.
__global__ void epi_border(B* n,int C,int T,int Hp,int W){
const int CV=C/2; const long per=(long)2*W*CV;
const long total=(long)T*per;
for(long i=(long)blockIdx.x*blockDim.x+threadIdx.x;i<total;i+=(long)gridDim.x*blockDim.x){
const int t=(int)(i/per); const long r=i%per; const int edge=(int)(r/((long)W*CV));
const long r2=r%((long)W*CV); const int w=(int)(r2/CV),cc=(int)(r2%CV);
const long idx=(((long)t*Hp+(edge?Hp-1:0))*W+w)*CV+cc;
((__nv_bfloat162*)n)[idx]=__floats2bfloat162_rn(0.f,0.f);
}
}
__global__ void epi_zero(B* n,long pairs){
const __nv_bfloat162 z=__floats2bfloat162_rn(0.f,0.f);
for(long i=(long)blockIdx.x*blockDim.x+threadIdx.x;i<pairs;i+=(long)gridDim.x*blockDim.x)
((__nv_bfloat162*)n)[i]=z;
}
template<int C,bool EXACT>
static void launch_epi(const dim3& grid,const dim3& block,size_t smem,
const EpiPtrs& p,B* y,B* n,
const B* b,const B* r,const B* g,
int H,int W,int TH,int TW,int PT,int PH,bool wy,cudaStream_t s){
auto k=wino_epi_kernel<C,EXACT>;
if(smem>48*1024){
static bool done[2]={false,false};
if(!done[EXACT?1:0]){
C10_CUDA_CHECK(cudaFuncSetAttribute(k,cudaFuncAttributeMaxDynamicSharedMemorySize,36*C*2));
done[EXACT?1:0]=true;
}
}
k<<<grid,block,smem,s>>>(p,y,n,b,r,g,H,W,TH,TW,PT,PH,wy);
}
WinoOut wino_epilogue(const std::vector<Tensor>& m,int H,int W,const Tensor& bias,
const Tensor& residual,const Tensor& gamma,const Tensor& hist,int pt,int ph,
bool write_y){
// `write_y=false` is admissible ONLY when the caller's next conv has idx == the `fnext` this call
// prepares for, so that next conv takes the `prepared` edge and never reads the returned y. Out.y
// is still a distinct allocation: its unread contents are undefined, not zero.
TORCH_CHECK(m.size()==36&&m[0].dim()==5,"wino_epilogue: M list ",m.size()," rank ",m[0].dim());
const int C=m[0].size(1),D=m[0].size(2),TH=m[0].size(3),TW=m[0].size(4);
TORCH_CHECK(C==256||C==512||C==1024,"wino_epilogue: unsupported channel count");
auto s=e_stream();
EpiPtrs p; for(int i=0;i<36;i++) p.p[i]=(const B*)m[i].data_ptr<at::BFloat16>();
WinoOut out;
out.y=nhwc_empty(C,D,H,W,m[0]);
const int Hp=H+2*ph;
if(gamma.defined()){
out.n=nhwc_empty(C,D+pt,Hp,W,m[0]);
B* np=e_mut(out.n);
const long pairs=(long)(D+pt)*Hp*W*(C/2);
if(pt>0&&!hist.defined())
epi_zero<<<(int)std::min((long)2048,((long)pt*Hp*W*(C/2)+4095)/4096),256,0,s>>>(np,(long)pt*Hp*W*(C/2));
if(ph>0)
epi_border<<<(int)std::min((long)2048,(pairs+4095)/4096),256,0,s>>>(np,C,D+pt,Hp,W);
if(pt>0&&hist.defined()){
// `hist` is a `last_frames` output: a COMPACT [C,pt,H,W] channels-last-3d tensor, so its
// per-frame SOURCE pitch is H*W*C -- unpadded. Only the DESTINATION is padded: out.n is
// [C,D+pt,Hp,W] with pitch Hp*W*C, and these frames land in its interior rows [ph,ph+H).
// The two pitches are one of each and neither is a typo: passing Hp*W*C as the source pitch
// would read pt frames of garbage, and passing H*W*C as the destination pitch would write
// the pad rows away. If `last_frames` changes its shape or its pitch, this copy changes.
const size_t row=(size_t)H*W*C*sizeof(B);
C10_CUDA_CHECK(cudaMemcpy2DAsync(np+(long)ph*W*C,(size_t)Hp*W*C*sizeof(B),
e_ptr(hist),row,row,pt,cudaMemcpyDeviceToDevice,s));
}
}
// src_tile in the kernel is the only 32-bit index in this file, so assert here what it relies on
// rather than trusting the kernel's comment. (The y and n offsets in the kernel body are formed in
// 64-bit and are not bounded by this.) Checked against UINT32_MAX, not INT32_MAX: the index is
// unsigned, and a check against the signed bound would refuse shapes that are in fact safe.
{ const unsigned long long off=(((unsigned long long)(D-1)*TH+(TH-1))*TW+(TW-1))*(unsigned long long)C*2ull;
TORCH_CHECK(off<=0xFFFFFFFFull,"wino_epilogue: 32-bit tile offset ",off,
" exceeds UINT32_MAX; widen src_tile in wino_epilogue.cu"); }
const bool exact=(TH*4==H&&TW*4==W);
const dim3 grid(TW,TH,D);
const dim3 block(C/2);
const size_t shmem=(size_t)36*C*2;
B* yp=e_mut(out.y); B* np=e_mut(out.n);
const B* bp=e_ptr(bias); const B* rp=e_ptr(residual); const B* gp=e_ptr(gamma);
if(C==256){ if(exact) launch_epi<256,true>(grid,block,shmem,p,yp,np,bp,rp,gp,H,W,TH,TW,pt,ph,write_y,s);
else launch_epi<256,false>(grid,block,shmem,p,yp,np,bp,rp,gp,H,W,TH,TW,pt,ph,write_y,s); }
else if(C==512){ if(exact) launch_epi<512,true>(grid,block,shmem,p,yp,np,bp,rp,gp,H,W,TH,TW,pt,ph,write_y,s);
else launch_epi<512,false>(grid,block,shmem,p,yp,np,bp,rp,gp,H,W,TH,TW,pt,ph,write_y,s); }
else { if(exact) launch_epi<1024,true>(grid,block,shmem,p,yp,np,bp,rp,gp,H,W,TH,TW,pt,ph,write_y,s);
else launch_epi<1024,false>(grid,block,shmem,p,yp,np,bp,rp,gp,H,W,TH,TW,pt,ph,write_y,s); }
C10_CUDA_KERNEL_LAUNCH_CHECK();
return out;
}
@@ -1,247 +0,0 @@
// F(4x4,3x3) spatial input transform of the Winograd path of the Wan2.2 VAE decoder.
//
// ONE PASS: `winograd_input_fast` replaces `kernels.cu`'s `winograd_input` for the 18 Winograd
// convolutions (1024/512/256 channels; any other channel count keeps the general kernel), and
// its output V is BIT-IDENTICAL to it -- same patch origin, same block-uniform fast-path
// condition, same B^T-then-B order with the in-place column update, same 6a+b plane index, same
// single bf16 rounding at the store, same plane stride. Nothing else in the tree moves but the
// one call site in binding.cpp and one declaration in kernels.h.
//
// Why it is faster, from the profile of the kernel it replaces (w01's J549): 581.8 M
// warp-instructions in 1.031 ms at 59.5% issue-active, of which 43.5% are ALU/integer against
// 47.6% FMA and 4.9% LSU; DRAM 23.6%, FMA pipe 32.4%. It sits at 82% of its own instruction
// issue bound and the integer address math is the only large removable block:
// 1. a 3-D grid, blockIdx = (tx, ty, t) and threadIdx = the channel pair, so nothing divides;
// 2. one block-uniform fast path for the interior tiles, where the 36 loads carry no
// predicate at all -- only the first and last tile of a row and the first and last tile
// row can touch the time pad or the frame edge -- and the patch row pointer advances by
// the row stride, so the 36 addresses are 36 LSU instructions with folded offsets;
// 3. the 36 plane stores walk one pointer by V's plane stride instead of doing an int64
// multiply per store.
//
// MORE BLOCKS PER TILE INSTEAD OF ONE: SPLIT = 4 AT C = 1024, 2 AT C = 512 AND 256.
// A tile's C/2 channel pairs used to be covered by one block of C/2 threads, so `blockDim` and
// `C` were the same variable and the grid held exactly one block per tile. A block is now the
// tile's width divided by SPLIT, and SPLIT blocks of the grid cover one tile. At C = 1024 that
// moves the level from one block of 16 warps (32.08 ms) to five blocks of 4 warps (22.42 ms,
// 20 warps resident); at C = 512 and C = 256 the same move is worth 1.05 % and -0.4 % -- i.e.
// nothing -- so those two stay at SPLIT = 2. See the table below for why.
//
// `__launch_bounds__(CP, 512 / CP)` IS LOAD-BEARING AND IS HALF THE MEASUREMENT. It pins the
// register budget at 65536/512 = 128 at EVERY width -- exactly what main's
// `__launch_bounds__(512, 1)` grants at its one wide instantiation. Without it ptxas may spend
// up to the architectural cap of 255 registers per thread (65536/CP is itself 512 at CP = 128,
// so it is the 255 that binds there), floor(65536/(CP * REG)) falls, the resident warps follow,
// and the same arm runs 2.25x SLOWER. The counterfactual is measured at ONE instantiation:
// J3705 ran `wino_in_kernel_st<256,2>` both ways -- `__launch_bounds__(256, 1)` at 59.11 ms
// against `(256, 2)` at 26.26 ms, one integer and one body. At the shipped CP = 128 the no-pin
// case gives floor(65536/(128*255)) = 2 blocks of 4 warps against the pinned 5, i.e. 20 resident
// warps down to 8 -- a 2.5x cut, not a halving, and the reason C = 1024 needs the pin most.
// The declaration and the launch are one change; neither is the arm without the other.
//
// Measured (J3868, one build, one process, A B B B B A over SPLIT = 1 2 4 8 2 1, f121x1
// --kernels, 1132 MHz on every leg, exit 0). Both control legs are shown so the drift is visible:
// SPLIT=1 SPLIT=2 SPLIT=4 SPLIT=8 2 -> 4
// C = 1024 (shipped) 32.08/32.08 26.26/26.53 22.42 22.27 1.1775
// C = 512 (2 kept) 64.06/63.92 54.60/55.44 54.45 55.45 1.0105
// C = 256 (2 kept) 60.47/60.66 59.56/60.05 60.04 (n/a) 0.9962
// CALL 864.7 847.6 844.2 845.2
// A/A 0.3 %, B/B 1.0-1.5 %; max |d| 0.1543 of 0.4984, rms 0.002093 of 0.005847, PSNR 59.6 dB,
// IDENTICAL to every printed digit on all six legs.
// The C = 256 SPLIT = 8 cell is `n/a`, not a decline: J3868's probe only instantiated the CP
// values it needed and SPLIT = 8 at C = 256 would ask for CP = 16, which that build did not
// contain. The head's launcher is an exhaustive switch with no fall-back path of any kind.
//
// ONLY THE 2 -> 4 MOVE IS A C = 1024-ONLY GAIN, and that is why the other two keep SPLIT = 2:
// the shipped SPLIT = 2 already takes the 1 -> 2 step on BOTH of them -- 64.06 -> 54.60 at
// C = 512 is 1.1733x and 60.47 -> 59.56 at C = 256 is 1.0183x, the +15.5 % and +1.5 % below --
// so neither is a rung that gains nothing. What is declined on them is the FOURTH block:
// C = 512's 1.0105 is inside the B/B drift (its own legs read 54.60 and 55.44, a 1.5 % spread);
// C = 256's 0.9962 REVERSES -- its legs read 59.56, 60.05 and 60.66, a 1.8 % spread, and 60.04
// sits inside it, so the option is declined rather than merely unpriced;
// SPLIT = 8 at C = 1024 is 22.27 against 22.42 -- 0.7 % at a 1.0 % drift, a tie, not a win.
//
// WHY C = 1024 AND NOT THE OTHERS. With REG 96 the realized resident warps are
// floor(65536/(96*blockDim)) * blockDim/32, i.e. 16 at blockDim 512 or 256, 20 at 128 or 64, and
// 21 at 32. Main's C = 1024 case used blockDim 512, which rounds to ONE block and never reaches
// past 16 warps although 2048/96 = 21.3 is its ceiling; `__launch_bounds__` cannot fix that,
// because 2 * 512 * 96 = 98,304 > 65,536. Only a narrower block can, and narrowing it is SPLIT.
// C = 512 and C = 256 already reach 20 warps at SPLIT = 2, so there is nothing there to buy.
// The three levels' rates were 2.609 / 3.340 / 3.760 TB/s (J3195's counted bytes). DERIVED, not
// re-measured: at fixed byte counts rate_after/rate_before = t_before/t_after, so from J3868's own
// legs the after-rates are 2.609*32.08/22.42 = 3.733, 3.340*64.06/55.44 = 3.859 and
// 3.760*60.47/59.56 = 3.817 TB/s -- a 44.1 % spread closed to 3.4 %. The largest move is the
// slow level's own (+43 %), and the fast two barely move (+15.5 % and +1.5 %), so what happened is
// that the slow level was walked up to where the other two already sat, not that a ceiling moved.
// An earlier draft of this comment stated 3.952 for C = 256, which needs 60.47 -> 57.5 ms and no
// leg of J3868 is below 59.56; it was a number carried over from a withdrawn row and it is wrong.
//
// `J3761`'s rebuild of the round-1 source: BUILD_RC=0, REG:96 STACK:0 SHARED:0 LOCAL:0.
// `J3889`'s build of this source: the same, with `wino_in_kernel<128,4>` at REG:95 (one register
// lower, which changes no block count: 65536/(128*95) = 5.39 -> 5, the same as at 96).
// Correctness at worlds 1 and 4 on all three short clips: J3889 f1x1/f5x1/f17x1 ok at
// x0.41/x0.41/x0.65 and J3908 f1x4/f5x4/f17x4 ok at x0.45/x0.45/x0.66, every leg inside main's
// own margins.
//
// The share of the call is flat in clip length (17.6 % at f121x1, 17.9 % at f481x1) and this
// function takes no rank, no world and no process group, so a world-1 cell is the geomean cell.
//
// Measured by w04 on main's own tree (kernel#1, this change alone, f121x1 at 1849 MHz): the
// three instantiations total 114.4 ms against 221.8 ms of `winograd_input_kernel`, and the call
// 969.9 -> 862.5 ms (-11.1%), with V bit-identical -- f1/f5/f17 keep the seed's max |d| and rms
// to the digit -- and f481x1 ok at x0.38 of the max and x0.41 of the rms allowance. w21 wrote
// the kernel; w08 owns this file.
#include "kernels.h"
#include <ATen/cuda/CUDAContext.h>
#include <c10/cuda/CUDAException.h>
#include <cuda_bf16.h>
#include <algorithm>
using B = __nv_bfloat16;
static cudaStream_t stream() { return at::cuda::getCurrentCUDAStream(); }
__device__ __forceinline__ void wbt6(float v0, float v1, float v2, float v3, float v4, float v5, float* y) {
float a = v4 - 4 * v2, b = v3 - 4 * v1, c = v4 - v2, d = 2 * (v3 - v1);
y[0] = 4 * v0 - 5 * v2 + v4; y[1] = a + b; y[2] = a - b; y[3] = c + d; y[4] = c - d; y[5] = 4 * v1 - 5 * v3 + v5;
}
// One block per (part-tile, frame); threadIdx.x = the channel pair within this block's share.
// CP = C/2/SPLIT is the block size. The shipped dispatch is SPLIT = 4 at C = 1024 and SPLIT = 2
// at C = 512 and 256, so blockDim is 128 / 128 / 64 at C = 1024 / 512 / 256 -- but SPLIT is NOT
// uniform and no comment here may claim it is. The invariant that actually guarantees coverage is
// CP * SPLIT == C / 2 (128*4 = 128*2 = 64*2 = C/2)
// enforced by the `static_assert` in `winograd_input_fast` below, together with `grid.x == SPLIT*TW`
// from the same `constexpr`. `(blockIdx.x % SPLIT) * CP + threadIdx.x` then walks [0, SPLIT*CP) =
// [0, C/2) exactly once -- every channel pair of a tile is written once and no pair twice -- and
// `blockIdx.x / SPLIT` over a grid of SPLIT*TW hits each tile exactly SPLIT times, for any SPLIT:
// the remainder cycles 0..SPLIT-1 within each group of SPLIT consecutive blockIdx, so TW need not be
// a multiple of SPLIT.
//
// SPLIT = 1 with CP = C/2 gives back main's one-block-per-tile BODY, but not its attribute (this
// one is `__launch_bounds__(CP, 512/CP)`, which equals main's `(CP, 1)` only at CP = 512) and not
// its grid shape, so do not use it as an exact revert; revert by checking out the parent commit.
//
// The launched blockDim must equal CP. It does at all three call sites below, from the same
// template value, so the two cannot drift apart.
template <int CP, int SPLIT>
__global__ void __launch_bounds__(CP, 512 / CP)
wino_in_kernel(const B* __restrict__ x, B* __restrict__ v, int C, int D, int H, int W, int PH, int TH, int TW) {
const int cp = (blockIdx.x % SPLIT) * CP + threadIdx.x;
const int tx = blockIdx.x / SPLIT;
const int ty = blockIdx.y, t = blockIdx.z;
const int HP = H + 2 * PH;
const int y0 = 4 * ty - 1 + PH, x0 = 4 * tx - 1;
const int64_t rowStep = (int64_t)W * C;
const B* base = x + (int64_t)t * HP * rowStep + (int64_t)y0 * rowStep + (int64_t)x0 * C + 2 * cp;
float d[2][6][6];
// Uniform across the block, so it is one branch and not 36 predicates.
if ((y0 >= 0) && (y0 + 5 < HP) && (x0 >= 0) && (x0 + 5 < W)) {
const B* prow = base;
#pragma unroll
for (int a = 0; a < 6; a++) {
#pragma unroll
for (int b = 0; b < 6; b++) {
float2 f = __bfloat1622float2(*(const __nv_bfloat162*)(prow + b * C));
d[0][a][b] = f.x; d[1][a][b] = f.y;
}
prow += rowStep;
}
} else {
const B* prow = base;
#pragma unroll
for (int a = 0; a < 6; a++) {
const int y = y0 + a;
const bool ok = (y >= 0) && (y < HP);
#pragma unroll
for (int b = 0; b < 6; b++) {
const int xx = x0 + b;
if (ok && xx >= 0 && xx < W) {
float2 f = __bfloat1622float2(*(const __nv_bfloat162*)(prow + b * C));
d[0][a][b] = f.x; d[1][a][b] = f.y;
} else {
d[0][a][b] = 0.f; d[1][a][b] = 0.f;
}
}
prow += rowStep;
}
}
// B^T d down the patch's 6 columns, then B along its 6 rows, one bf16 rounding at the end.
#pragma unroll
for (int k = 0; k < 2; k++)
#pragma unroll
for (int b = 0; b < 6; b++) {
float q[6];
wbt6(d[k][0][b], d[k][1][b], d[k][2][b], d[k][3][b], d[k][4][b], d[k][5][b], q);
#pragma unroll
for (int a = 0; a < 6; a++) d[k][a][b] = q[a];
}
B* dst = v + (int64_t)(((t * TH + ty) * TW + tx) * C + 2 * cp);
const int64_t plane = (int64_t)D * TH * TW * C;
#pragma unroll
for (int a = 0; a < 6; a++) {
float q[2][6];
#pragma unroll
for (int k = 0; k < 2; k++)
wbt6(d[k][a][0], d[k][a][1], d[k][a][2], d[k][a][3], d[k][a][4], d[k][a][5], q[k]);
#pragma unroll
for (int b = 0; b < 6; b++, dst += plane) *(__nv_bfloat162*)dst = __floats2bfloat162_rn(q[0][b], q[1][b]);
}
}
Tensor winograd_input_fast(const Tensor& x, int ph) {
const int C = x.size(1), D = x.size(2), H = x.size(3) - 2 * ph, W = x.size(4);
const int TH = (H + 3) / 4, TW = (W + 3) / 4;
// The 18 Winograd convolutions are the 1024-, 512- and 256-channel ones; anything else
// keeps the general kernel.
if (C != 1024 && C != 512 && C != 256) return winograd_input(x, ph);
auto v = at::empty({36, D, TH, TW, C}, x.options());
// SPLIT = 4 at C = 1024, 2 at C = 512 and 256. CP * SPLIT = C/2 at every case, so a tile's
// channel pairs are covered exactly once; grid.x is SPLIT * TW, a block count and not a tile
// count. blockDim is CP, taken from the same template value, so the two cannot drift.
//
// Why 4 only at C = 1024 (J3868: 2 -> 4 on the rung at each C, one build, one process):
// C=1024 <256,2> 26.26/26.53 -> <128,4> 22.42 1.1775 warps 16 -> 20
// C= 512 <128,2> 54.60/55.44 -> < 64,4> 54.45 1.0105 warps 20 -> 20 inside the 1.5 % drift
// C= 256 < 64,2> 59.56/60.05 -> < 32,4> 60.04 0.9962 warps 20 -> 21 it REVERSES
// So the resolved gain is C = 1024 alone, and it is the only rung whose split buys warps:
// with REG 96 the realized resident warps are floor(65536/(96*blockDim))*blockDim/32, i.e.
// 16 at blockDim 512 or 256, 20 at 128 or 64, 21 at 32. The step off 16 is the whole of it.
// SPLIT = 8 at C = 1024 measured 22.27 against 22.42 -- 0.7 % at a 1.0 % B/B drift, not
// resolvable, so it is not taken. A tie is not a win.
//
// ONE `constexpr` PER CASE BINDS THE TEMPLATE ARGUMENT, THE BLOCK WIDTH, THE GRID MULTIPLIER AND
// THE COVERAGE INVARIANT. Three literals in three places is a desync waiting to become a bug:
// raise SPLIT without raising grid.x and `tx = blockIdx.x / SPLIT` stays inside TW, the kernel
// silently computes one block's share of every tile and the rest of V is never written; raise
// grid.x without SPLIT and `tx` runs past TW, computing x0 = 4*tx-1 for tiles that do not exist,
// which is an out-of-bounds read AND write rather than a slowdown. The `static_assert` makes the
// first a compile error and the single `constexpr` makes the second unrepresentable.
const B* xp = (const B*)x.data_ptr<at::BFloat16>();
B* vp = (B*)v.data_ptr<at::BFloat16>();
auto st = stream();
switch (C) {
case 1024: {
constexpr int SPLIT = 4, CP = 1024 / 2 / SPLIT;
static_assert(CP * SPLIT == 1024 / 2, "CP * SPLIT must equal C/2 or a tile is not covered");
const dim3 g((unsigned)(SPLIT * TW), (unsigned)TH, (unsigned)D);
wino_in_kernel<CP, SPLIT><<<g, CP, 0, st>>>(xp, vp, C, D, H, W, ph, TH, TW);
break;
}
case 512: {
constexpr int SPLIT = 2, CP = 512 / 2 / SPLIT;
static_assert(CP * SPLIT == 512 / 2, "CP * SPLIT must equal C/2 or a tile is not covered");
const dim3 g((unsigned)(SPLIT * TW), (unsigned)TH, (unsigned)D);
wino_in_kernel<CP, SPLIT><<<g, CP, 0, st>>>(xp, vp, C, D, H, W, ph, TH, TW);
break;
}
case 256: {
constexpr int SPLIT = 2, CP = 256 / 2 / SPLIT;
static_assert(CP * SPLIT == 256 / 2, "CP * SPLIT must equal C/2 or a tile is not covered");
const dim3 g((unsigned)(SPLIT * TW), (unsigned)TH, (unsigned)D);
wino_in_kernel<CP, SPLIT><<<g, CP, 0, st>>>(xp, vp, C, D, H, W, ph, TH, TW);
break;
}
}
C10_CUDA_KERNEL_LAUNCH_CHECK();
return v;
}
@@ -1,359 +0,0 @@
"""The stack rules of a solution, checked on its sources; and who launched each kernel of a call, found at run time.
The stack is CUDA C++ (with inline PTX), compiled ahead of time into one extension module (see loader.py), with
communication through the ProcessGroup it is given (NCCL) or CUDA peer access and IPC between the ranks.
Static rules (check): solution.json declares the languages in LANGUAGES, and RULES maps a rule's name to what it
looks for in the C/C++/CUDA sources and why. Kinds of match:
include the target of an #include
text the source with comments removed
flag solution.json: a compile option
vendored an #include target that must resolve to a file of the solution itself
A task's spec.py may extend the table: RULES = {name: rule, or None to drop a default rule}.
Run time (trace): one call runs under CUPTI. Every kernel is attributed to the library that launched it, from the
native call stack of its launch: kernels launched from inside cuBLAS/cuBLASLt, cuDNN or ATen (libtorch) are library
kernels, and so are Triton's (a stack the rules exclude); NCCL's are communication, which the rules allow, counted
apart; kernels launched from the solution's own module or by driver calls are the solution's, whatever their names,
so kernels built from CUTLASS/CuTe templates copied into the solution count as its own. Kernels whose launch cannot be
traced (graph replays, device-side launches) are classified by name. Library kernels are allowed, as placeholders:
their count and their share of the call's GPU time show where the solution still depends on them. Only kernel
launches call back into this module (enabling the whole driver API would call back on NCCL's watchdog thread, which
queries events while it holds a lock the launching thread needs: a deadlock under the GIL).
python tools/audit.py [solution] the static rules
"""
import argparse
import collections
import ctypes
import json
import os
import re
import sys
CPP_FILES = (".cu", ".cpp", ".cc", ".cxx", ".c", ".h", ".hpp", ".cuh", ".hh", ".hxx", ".inl", ".inc")
LANGUAGES = ["cuda_cpp"]
RULES = {
"vendored-cutlass": {
"vendored": r"(cutlass|cute)/.*",
"why": "CUTLASS/CuTe headers are copied into the solution and maintained there (cutlass/..., cute/... next to "
"the entry file), not taken from the installed CUTLASS"},
"runtime-build": {
"include": r"(.*/)?(nvrtc|nvJitLink|nvPTXCompiler)\.h",
"text": r"\b(nvrtc[A-Z]\w*|nvJitLink[A-Z]\w*|nvPTXCompiler\w*|cuModuleLoad\w*|"
r"cuLink(Create|AddData|AddFile|Complete)\w*|cuLibraryLoad\w*|cudaLibraryLoad\w*)\s*\(",
"flag": r"-l(nvrtc|nvJitLink|nvptxcompiler)\w*",
"why": "nothing is compiled or loaded at run time (NVRTC, nvJitLink, PTX or cubins loaded through the driver): "
"device code, inline PTX included, is compiled by nvcc with the solution"},
"python-from-cpp": {
"text": r"\b(py(bind11)?::module_?::import|py(bind11)?::(exec|eval)(_file)?|PyImport_\w+|PyRun_\w+)\b",
"why": "run() stays in C++ and runs no Python: Triton, torch.compile, torch.utils.cpp_extension and other "
"kernel toolkits would come in that way"},
}
def rule_table(extra=None):
"""The rule table in force: the defaults, changed by a task's RULES (None drops a rule)."""
table = dict(RULES)
for name, rule in (extra or {}).items():
if rule is None:
table.pop(name, None)
else:
table[name] = rule
return table
def _match(pattern, s):
return re.fullmatch(pattern, s) is not None
def _strip_cpp_comments(text):
"""Removes // and /* */ comments (string literals are not parsed; good enough for these rules)."""
text = re.sub(r"/\*.*?\*/", " ", text, flags=re.S)
return re.sub(r"//[^\n]*", "", text)
def _includes(text):
return [(m.group(1), text.count("\n", 0, m.start()) + 1)
for m in re.finditer(r'^\s*#\s*include\s*[<"]([^>"]+)[>"]', text, flags=re.M)]
def check(solution_dir, rules, languages=None):
"""Violations of `rules` by a solution's sources: a list of "file:line: what (rule: why)" strings.
languages: the languages the solution may declare in solution.json (default LANGUAGES)."""
languages = LANGUAGES if languages is None else languages
out = []
try:
with open(os.path.join(solution_dir, "solution.json")) as f:
spec = json.load(f)
except (OSError, ValueError) as e:
return [f"solution.json: unreadable ({e})"]
if not isinstance(spec, dict):
return ["solution.json: must hold an object"]
declared = spec.get("languages")
if not isinstance(declared, list) or not declared or any(lang not in languages for lang in declared):
out.append(f"solution.json: languages {declared} are not allowed here (allowed: {languages})")
options = spec.get("compile_options") if isinstance(spec.get("compile_options"), dict) else {}
flags = [f for v in options.values() if isinstance(v, list) for f in v]
for name, rule in rules.items():
for flag in flags:
if "flag" in rule and _match(rule["flag"], str(flag)):
out.append(f"solution.json: compile option {flag!r} ({name}: {rule['why']})")
for root, dirs, files in os.walk(solution_dir):
dirs[:] = sorted(d for d in dirs if d != "__pycache__")
for file in sorted(files):
if not file.endswith(CPP_FILES):
continue
path = os.path.join(root, file)
try:
with open(path, encoding="utf-8") as f:
text = f.read()
except (OSError, UnicodeDecodeError):
continue
out += _check_cpp(solution_dir, os.path.relpath(path, solution_dir), text, rules)
return out
def _check_cpp(solution_dir, rel, text, rules):
out = []
code = _strip_cpp_comments(text)
includes = _includes(code)
for name, rule in rules.items():
for target, line in includes:
if "include" in rule and _match(rule["include"], target):
out.append(f"{rel}:{line}: #include <{target}> ({name}: {rule['why']})")
if "vendored" in rule and _match(rule["vendored"], target):
here = os.path.dirname(os.path.join(solution_dir, rel))
if not any(os.path.isfile(os.path.join(d, target)) for d in (here, solution_dir)):
out.append(f"{rel}:{line}: #include <{target}> is not in the solution ({name}: {rule['why']})")
if "text" in rule:
for m in re.finditer(rule["text"], code):
line = code.count("\n", 0, m.start()) + 1
out.append(f"{rel}:{line}: {m.group(0)!r} ({name}: {rule['why']})")
break
return out
# Run-time audit.
# The library a kernel was launched from, by the path of the innermost library on the launch's native stack.
LAUNCHERS = [
(r".*/libcublas(Lt)?[^/]*", "cuBLAS"),
(r".*/libcudnn[^/]*", "cuDNN"),
(r".*/lib(torch|c10)[^/]*", "ATen"),
(r".*triton.*", "Triton"),
(r".*/libnccl[^/]*", "NCCL"),
(r".*/lib(cufft|curand|cusparse|cusolver|cutensor|nvshmem|npp)[^/]*", "CUDA library"),
]
# Frames skipped on the way out: CUPTI and this module's callback, the Python interpreter, the CUDA runtime/driver.
SKIP = r"(.*/)?(libffi[^/]*|_ctypes[^/]*|python[^/]*|libpython[^/]*|callback_wrappers[^/]*|cupti[^/]*|libcupti[^/]*|" \
r"libcuda\.so[^/]*|libcudart[^/]*|libc\.so[^/]*|ld-linux[^/]*|\?)"
# Fallback by kernel name, for launches that could not be traced.
NAMES = [
(r"(void )?at::.*|.*at::native::.*|_ZN2at.*|.*at_cuda_detail.*|_ZN3c10.*", "ATen"),
(r".*(nvjet|cublas|gemv2?[NT]?_|gemmSN|splitKreduce|xmma|sgemm|hgemm|dgemm).*|cutlass3x_.*|cutlass_\d.*", "cuBLAS"),
(r".*cudnn.*", "cuDNN"),
(r"triton_.*", "Triton"),
(r"(void )?nccl.*|_Z\d+nccl.*", "NCCL"),
]
# Libraries whose kernels are communication: allowed outright, and counted apart from the placeholders.
COMMUNICATION = ("NCCL",)
# The CUPTI activity buffers: few and large, since a decode launches tens of thousands of kernels.
BUFFER_BYTES = 32 << 20
_libc = None
_demangler = None
class _DlInfo(ctypes.Structure):
_fields_ = [("fname", ctypes.c_char_p), ("fbase", ctypes.c_void_p), ("sname", ctypes.c_char_p),
("saddr", ctypes.c_void_p)]
def _launcher():
"""Path of the innermost library on the current native stack that is not CUPTI, Python or the CUDA runtime."""
global _libc
if _libc is None:
_libc = ctypes.CDLL("libc.so.6")
_libc.backtrace.argtypes = [ctypes.POINTER(ctypes.c_void_p), ctypes.c_int]
_libc.dladdr.argtypes = [ctypes.c_void_p, ctypes.POINTER(_DlInfo)]
buf = (ctypes.c_void_p * 96)()
n = _libc.backtrace(buf, 96)
for i in range(n):
info = _DlInfo()
path = info.fname.decode() if buf[i] and _libc.dladdr(buf[i], ctypes.byref(info)) and info.fname else "?"
if not re.fullmatch(SKIP, path):
return path
return ""
def demangle(name):
global _demangler
if _demangler is None:
try:
lib = ctypes.CDLL("libstdc++.so.6")
lib.__cxa_demangle.argtypes = [ctypes.c_char_p, ctypes.c_char_p, ctypes.POINTER(ctypes.c_size_t),
ctypes.POINTER(ctypes.c_int)]
lib.__cxa_demangle.restype = ctypes.c_char_p
_demangler = lib
except OSError:
_demangler = False
if not _demangler or not name.startswith("_Z"):
return name
status = ctypes.c_int()
out = _demangler.__cxa_demangle(name.encode(), None, None, ctypes.byref(status))
return out.decode() if status.value == 0 and out else name
def short_name(name, limit=110):
"""A kernel's demangled name without its return type, parameter list and the at::native/anonymous namespaces."""
text = demangle(name).replace("(anonymous namespace)::", "")
depth, start, end = 0, 0, len(text)
for i, c in enumerate(text):
if c in "<(":
if c == "(" and depth == 0 and i > 0:
end = i
break
depth += 1
elif c in ">)":
depth -= 1
elif c == " " and depth == 0:
start = i + 1
text = text[start:end] or text
for noise in ("at::native::", "at::", "std::"):
text = text.replace(noise, "")
return text if len(text) <= limit else text[:limit - 3] + "..."
def classify(name, launcher, traced):
"""The library a kernel belongs to (None: the solution's own)."""
if traced:
for pattern, label in LAUNCHERS:
if re.fullmatch(pattern, launcher):
return label
return None
for pattern, label in NAMES:
if re.fullmatch(pattern, name) or re.fullmatch(pattern, demangle(name)):
return label
return None
Op = collections.namedtuple("Op", "kind name bytes launcher library ns")
def launch_callbacks(cupti):
"""The driver API callbacks of kernel launches (cuLaunchKernel, cuLaunchKernelEx, cuGraphLaunch, ...)."""
return [int(getattr(cbid, "value", cbid)) for name, cbid in cupti.driver_api_trace_cbid.__members__.items()
if "Launch" in name and "HostFunc" not in name]
def trace(fn, *args):
"""Calls fn(*args) once under CUPTI; returns its GPU operations in start order, as Op(kind, name, bytes,
launcher, library, ns): kind is kernel, memcpy or memset; library is None for the solution's own kernels; ns is
the operation's GPU time."""
import torch
from cupti import cupti
launches, records, errors = {}, [], []
def on_api(userdata, domain, cbid, data):
try:
if data.callback_site == cupti.ApiCallbackSite.API_ENTER:
graph = "Graph" in data.function_name
launches[data.correlation_id] = "" if graph else _launcher()
except Exception as e: # never let an exception cross into the driver
errors.append(repr(e))
def requested():
return BUFFER_BYTES, 0
def completed(activities):
for a in activities:
if a.kind == cupti.ActivityKind.CONCURRENT_KERNEL:
records.append((a.start, a.end, "kernel", a.name, 0, a.correlation_id,
bool(a.graph_id or a.is_device_launched)))
elif a.kind == cupti.ActivityKind.MEMCPY:
records.append((a.start, a.end, "memcpy", "", a.bytes, a.correlation_id, False))
elif a.kind == cupti.ActivityKind.MEMSET:
records.append((a.start, a.end, "memset", "", a.bytes, a.correlation_id, False))
kinds = (cupti.ActivityKind.CONCURRENT_KERNEL, cupti.ActivityKind.MEMCPY, cupti.ActivityKind.MEMSET)
torch.cuda.synchronize()
sub = cupti.subscribe(on_api, 0)
try:
for cbid in launch_callbacks(cupti):
cupti.enable_callback(1, sub, cupti.CallbackDomain.DRIVER_API, cbid)
for k in kinds:
cupti.activity_enable(k)
cupti.activity_register_callbacks(requested, completed)
fn(*args)
torch.cuda.synchronize()
cupti.activity_flush_all(1)
finally:
for k in kinds:
cupti.activity_disable(k)
cupti.unsubscribe(sub)
cupti.finalize()
if errors:
raise RuntimeError(f"the launch trace failed: {errors[0]}")
ops = []
for start, end, kind, name, nbytes, corr, untraceable in sorted(records):
launcher = launches.get(corr)
traced = kind == "kernel" and not untraceable and bool(launcher)
library = classify(name, launcher or "", traced) if kind == "kernel" else None
ops.append(Op(kind, name, nbytes, os.path.basename(launcher or ""), library, end - start))
return ops
def placeholder(library):
"""Whether a library's kernels are placeholders (cuBLAS, cuDNN, ATen, ...), as opposed to the solution's own or
communication."""
return bool(library) and library not in COMMUNICATION
def kernels(ops):
"""What a call launched, by kernel: [[library or "", name, launches, ns]] by GPU time, the largest first; copies
and sets as the kernels "(memcpy)" and "(memset)"."""
table = collections.defaultdict(lambda: [0, 0])
for op in ops:
key = (op.library or "", op.name if op.kind == "kernel" else f"({op.kind})")
table[key][0] += 1
table[key][1] += op.ns
return sorted(([lib, name, n, ns] for (lib, name), (n, ns) in table.items()), key=lambda r: -r[3])
def library_share(ops):
"""The share of a call's GPU time (the summed time of its operations) spent in placeholder library kernels."""
total = sum(op.ns for op in ops)
return sum(op.ns for op in ops if placeholder(op.library)) / total if total else 0.0
def comm_share(ops):
"""The share of a call's GPU time spent in communication kernels (NCCL)."""
total = sum(op.ns for op in ops)
return sum(op.ns for op in ops if op.library in COMMUNICATION) / total if total else 0.0
def main():
here = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, here)
import spec # noqa: E402
p = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
p.add_argument("solution", nargs="?", default="solution")
a = p.parse_args()
table = rule_table(getattr(spec, "RULES", None))
found = check(a.solution, table)
for v in found:
print(v)
print(f"stack rules: {'ok' if not found else f'{len(found)} violations'} ({len(table)} rules)")
sys.exit(1 if found else 0)
if __name__ == "__main__":
main()

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