Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6ffac3a46d | ||
|
|
8c11f4eb0d | ||
|
|
1bd620570d | ||
|
|
910ee7d6bb | ||
|
|
7490986ec8 | ||
|
|
b466e3cde4 | ||
|
|
cf3ea82e74 | ||
|
|
8304915b8f |
@@ -49,6 +49,7 @@ export default function HardwarePicker({
|
||||
value,
|
||||
onChange,
|
||||
gpuCount = 1,
|
||||
purpose = "target",
|
||||
reload,
|
||||
}: {
|
||||
backend: string;
|
||||
@@ -58,6 +59,7 @@ export default function HardwarePicker({
|
||||
value: HardwareSelection;
|
||||
onChange: (selection: HardwareSelection) => void;
|
||||
gpuCount?: number;
|
||||
purpose?: "target" | "filter";
|
||||
reload?: () => void;
|
||||
}) {
|
||||
const id = useId();
|
||||
@@ -69,9 +71,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 = 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 unknown = !!value.id && value.id !== "custom" && !record;
|
||||
const candidates =
|
||||
purpose === "filter" ? catalog : 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);
|
||||
@@ -142,7 +144,8 @@ export default function HardwarePicker({
|
||||
</span>
|
||||
{record.usable_budget_gb ? (
|
||||
<span className="hardware-selected-meta">
|
||||
Usable memory: {record.usable_budget_gb} {record.memory_unit}
|
||||
{value.id === "custom" ? "Your budget" : "Usable budget"}: {record.usable_budget_gb}{" "}
|
||||
{record.memory_unit}
|
||||
</span>
|
||||
) : null}
|
||||
{record.host_ram_gb && record.memory_type !== "unified" ? (
|
||||
@@ -152,24 +155,20 @@ export default function HardwarePicker({
|
||||
) : null}
|
||||
</div>
|
||||
<button type="button" className="hardware-clear" onClick={() => onChange({ id: "" })}>
|
||||
Clear hardware
|
||||
Clear hardware {purpose}
|
||||
</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">
|
||||
The saved device ({value.id}) is not in this catalog.
|
||||
Saved hardware target ({value.id}) is not in this catalog.
|
||||
</p>
|
||||
) : (
|
||||
<p className="hardware-current" role="status">
|
||||
No hardware selected.
|
||||
No hardware {purpose} selected.
|
||||
</p>
|
||||
)}
|
||||
{record ? (
|
||||
@@ -185,7 +184,7 @@ export default function HardwarePicker({
|
||||
<div className="hardware-picker-actions">
|
||||
{!record && value.id ? (
|
||||
<button type="button" className="hardware-clear" onClick={() => onChange({ id: "" })}>
|
||||
Clear hardware
|
||||
Clear hardware {purpose}
|
||||
</button>
|
||||
) : null}
|
||||
<button
|
||||
@@ -199,11 +198,21 @@ export default function HardwarePicker({
|
||||
setAdding(false);
|
||||
}}
|
||||
>
|
||||
{record ? "Change hardware" : backend === "mlx" ? "+ Find or add a Mac" : "+ Find or add a GPU"}
|
||||
{record
|
||||
? "Change hardware"
|
||||
: purpose === "filter"
|
||||
? "Choose hardware"
|
||||
: backend === "mlx"
|
||||
? "+ Find or add a Mac"
|
||||
: "+ Find or add a GPU"}
|
||||
</button>
|
||||
</div>
|
||||
{record || open ? (
|
||||
<p className="hardware-picker-caption">Used for the setup link and, on single-unit systems, the GPU count.</p>
|
||||
<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>
|
||||
) : null}
|
||||
{open ? (
|
||||
<div className="hardware-lookup" id={panelId} onKeyDown={(event) => event.key === "Escape" && close()}>
|
||||
@@ -287,13 +296,13 @@ export default function HardwarePicker({
|
||||
value={draft.memory_type}
|
||||
onChange={(event) => setDraft({ ...draft, memory_type: event.target.value as MemoryType })}
|
||||
>
|
||||
<option value="dedicated">GPU memory (VRAM)</option>
|
||||
<option value="unified">Unified memory</option>
|
||||
<option value="system">System memory (RAM)</option>
|
||||
<option value="dedicated">Dedicated GPU memory (VRAM)</option>
|
||||
<option value="unified">Unified memory (CPU + GPU)</option>
|
||||
<option value="system">System memory (host RAM)</option>
|
||||
</select>
|
||||
</label>
|
||||
<label className="hardware-form-field">
|
||||
<span>Total memory</span>
|
||||
<span>Nominal memory</span>
|
||||
<Input
|
||||
type="number"
|
||||
min="1"
|
||||
@@ -314,7 +323,7 @@ export default function HardwarePicker({
|
||||
</select>
|
||||
</label>
|
||||
<label className="hardware-form-field">
|
||||
<span>Usable memory (optional)</span>
|
||||
<span>Usable budget (optional)</span>
|
||||
<Input
|
||||
type="number"
|
||||
min="1"
|
||||
@@ -339,14 +348,16 @@ export default function HardwarePicker({
|
||||
</label>
|
||||
) : null}
|
||||
</div>
|
||||
{budgetTooLarge ? <p className="hardware-form-error">Usable memory cannot exceed total memory.</p> : null}
|
||||
{budgetTooLarge ? (
|
||||
<p className="hardware-form-error">The usable budget cannot exceed the nominal memory.</p>
|
||||
) : null}
|
||||
<p className="hardware-form-note">
|
||||
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.
|
||||
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.
|
||||
</p>
|
||||
<div className="hardware-form-actions">
|
||||
<button type="submit" disabled={!validDraft}>
|
||||
Save device
|
||||
Use this device
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { useId, useState } from "react";
|
||||
import type { MemoryAssessment } from "@/lib/capacity";
|
||||
import { useState } from "react";
|
||||
|
||||
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,51 +12,48 @@ const validityLabel: Record<Validity, string> = {
|
||||
const fitLabel: Record<Fit, string> = {
|
||||
fits: "Fits estimate",
|
||||
tight: "Tight fit",
|
||||
exceeds: "Exceeds memory",
|
||||
unsupported: "Unsupported",
|
||||
unknown: "Memory not estimated",
|
||||
exceeds: "Likely exceeds memory",
|
||||
unsupported: "Unsupported combination",
|
||||
unknown: "Fit unknown",
|
||||
};
|
||||
const weightsLabel: Record<Fit, string> = {
|
||||
fits: "Weights fit",
|
||||
tight: "Weights fit, little headroom",
|
||||
exceeds: "Weights exceed memory",
|
||||
unsupported: "Weights unsupported",
|
||||
unknown: "Weights not estimated",
|
||||
const evidenceLabel: Record<Evidence, string> = {
|
||||
recorded: "Run evidence available",
|
||||
none: "No recorded run",
|
||||
stale: "Run evidence does not match these settings",
|
||||
};
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* 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.
|
||||
*/
|
||||
export default function RecipeFacts({
|
||||
validity,
|
||||
memory,
|
||||
fit = "unknown",
|
||||
evidence = "none",
|
||||
subject = "configuration",
|
||||
detail,
|
||||
validityText,
|
||||
validityDetail,
|
||||
detail,
|
||||
}: {
|
||||
validity: Validity;
|
||||
memory?: MemoryAssessment;
|
||||
fit?: Fit;
|
||||
evidence?: Evidence;
|
||||
subject?: "configuration" | "example";
|
||||
detail?: string;
|
||||
validityText?: string;
|
||||
validityDetail?: string;
|
||||
detail?: string;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
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];
|
||||
const id = `recipe-facts-${validity}-${fit}-${evidence}`;
|
||||
return (
|
||||
<div className="recipe-facts">
|
||||
<div className="recipe-fact-list" aria-label="Recipe checks">
|
||||
<div className="recipe-fact-list" aria-label="Recipe facts">
|
||||
<span data-state={validity}>
|
||||
{validity === "valid" ? (validityText ?? `Valid ${subject}`) : validityLabel[validity]}
|
||||
</span>
|
||||
<span data-state={fit}>{label}</span>
|
||||
<span data-state={fit}>{fitLabel[fit]}</span>
|
||||
<span data-state={evidence}>{evidenceLabel[evidence]}</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
@@ -73,19 +70,24 @@ export default function RecipeFacts({
|
||||
<strong>{validityLabel[validity]}.</strong>{" "}
|
||||
{validityDetail ??
|
||||
(validity === "valid"
|
||||
? "These values pass the recipe schema and runtime topology checks."
|
||||
? "Selected values pass the recipe schema and runtime topology checks."
|
||||
: validity === "invalid"
|
||||
? "Output is hidden until the highlighted fields are fixed."
|
||||
? "Output is withheld until the highlighted fields are corrected."
|
||||
: "Values are still being checked.")}
|
||||
</p>
|
||||
<p>
|
||||
<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.")}
|
||||
<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."}
|
||||
</p>
|
||||
{detail && <p>{detail}</p>}
|
||||
</div>
|
||||
|
||||
@@ -4,17 +4,12 @@ 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,14 +1,7 @@
|
||||
---
|
||||
title: FastH3 Omni v1
|
||||
description: Configure FastH3 Omni v1 reference-to-video serving and copy inference or serving commands.
|
||||
title: FastH3 OmniRef
|
||||
description: Configure FastH3 OmniRef reference-to-video serving on an RTX 5090, RTX PRO 6000, or DGX Spark.
|
||||
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: PAN 2
|
||||
description: Configure text-to-video and first-frame image-to-video generation with PAN 2.
|
||||
mode: frame
|
||||
---
|
||||
|
||||
<Cookbook modelId="pan2" />
|
||||
@@ -1,112 +0,0 @@
|
||||
---
|
||||
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.
|
||||
+67
-120
@@ -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, recentlyAdded, sitePath, type DirectoryModel } from "@/lib/cookbook-pages";
|
||||
import { modelDirectory, recipePages, 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,7 +19,6 @@ 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,
|
||||
@@ -131,7 +130,7 @@ function FieldControl({
|
||||
</div>
|
||||
{choices ? (
|
||||
<div role="group" aria-label={label} className="option-chips">
|
||||
{value === undefined && !choices.some((choice) => choice === null) && (
|
||||
{value === undefined && (
|
||||
<button type="button" aria-pressed={!changed} onClick={() => update(field.path, undefined, field)}>
|
||||
Default
|
||||
</button>
|
||||
@@ -143,9 +142,18 @@ function FieldControl({
|
||||
aria-pressed={value === choice}
|
||||
onClick={() => update(field.path, JSON.stringify(choice), field)}
|
||||
>
|
||||
{choice === null ? "Default" : choiceLabel(field.path, choice)}
|
||||
{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
|
||||
@@ -305,41 +313,6 @@ 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}`}
|
||||
@@ -587,7 +560,7 @@ function Editor({
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<RecipeFacts validity={failure ? "invalid" : result ? "valid" : "loading"} memory={memory} />
|
||||
<RecipeFacts validity={failure ? "invalid" : result ? "valid" : "loading"} fit="unknown" evidence="none" />
|
||||
</MatrixRow>
|
||||
{catalog.workload === "i2v" && runMode !== "serving" && (
|
||||
<MatrixRow title="Inputs">
|
||||
@@ -608,7 +581,7 @@ function Editor({
|
||||
}}
|
||||
/>
|
||||
<p id={`${sourceImageId}-hint`} className="matrix-note">
|
||||
Local path to the first frame. FastVideo reads it on the machine that runs the command.
|
||||
Path to an image on the machine running FastVideo. Included as request.inputs.image_path.
|
||||
</p>
|
||||
</div>
|
||||
</MatrixRow>
|
||||
@@ -698,11 +671,6 @@ 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), []);
|
||||
@@ -725,7 +693,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 = deploymentFromUrl();
|
||||
const requested = new URL(window.location.href).searchParams.get("deployment");
|
||||
const requestedModel = data.models.find((model) =>
|
||||
model.deployments.some((deployment) => deployment.id === requested),
|
||||
);
|
||||
@@ -771,7 +739,7 @@ export default function Cookbook({ modelId }: { modelId?: string }) {
|
||||
}
|
||||
useEffect(() => {
|
||||
const restore = () => {
|
||||
const requested = deploymentFromUrl();
|
||||
const requested = new URL(window.location.href).searchParams.get("deployment");
|
||||
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;
|
||||
@@ -838,8 +806,11 @@ export default function Cookbook({ modelId }: { modelId?: string }) {
|
||||
}
|
||||
|
||||
const GPU_COUNT_PATH = "generator.engine.num_gpus";
|
||||
function singleGpuDevice(record: { form_factor?: string; kind?: string }) {
|
||||
return record.kind === "system" || record.form_factor === "soc" || record.form_factor === "laptop";
|
||||
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 ?? "");
|
||||
}
|
||||
|
||||
/** Returns new edits where every fixed parallel size that no longer divides `gpus` becomes automatic. */
|
||||
@@ -862,13 +833,6 @@ 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;
|
||||
@@ -883,20 +847,8 @@ 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,
|
||||
badge,
|
||||
}: {
|
||||
model: DirectoryModel;
|
||||
href: string;
|
||||
modes?: string[];
|
||||
badge?: string;
|
||||
}) {
|
||||
function RecipeCard({ model, href, modes }: { model: DirectoryModel; href: string; modes?: string[] }) {
|
||||
const [focused, setFocused] = useState(false);
|
||||
const details = useRef<HTMLDivElement>(null);
|
||||
const [restOffset, setRestOffset] = useState(72);
|
||||
@@ -917,7 +869,6 @@ function RecipeCard({
|
||||
<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"}
|
||||
@@ -981,7 +932,6 @@ 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) &&
|
||||
@@ -997,10 +947,9 @@ function RecipeDirectory({ index, error, retry }: { index?: Index; error: string
|
||||
setRuntime("all");
|
||||
setTask("All tasks");
|
||||
}
|
||||
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 configurable = visible.filter((model) => model.configurable);
|
||||
const examples = visible.filter((model) => !model.configurable);
|
||||
const exampleFamilies = [...new Set(examples.map((model) => model.family))];
|
||||
const filtered =
|
||||
!!query.trim() ||
|
||||
category !== "All models" ||
|
||||
@@ -1008,7 +957,7 @@ function RecipeDirectory({ index, error, retry }: { index?: Index; error: string
|
||||
runtime !== "all" ||
|
||||
task !== "All tasks";
|
||||
const categoryCount = (value: string) =>
|
||||
modelDirectory.filter((model) => !model.hidden && (value === "All models" || model.category === value)).length;
|
||||
modelDirectory.filter((model) => value === "All models" || model.category === value).length;
|
||||
return (
|
||||
<MotionConfig reducedMotion="user">
|
||||
<div className="cookbook not-prose recipe-directory">
|
||||
@@ -1042,6 +991,7 @@ 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 ? (
|
||||
@@ -1093,62 +1043,59 @@ function RecipeDirectory({ index, error, retry }: { index?: Index; error: string
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
{fastH3.length ? (
|
||||
<section className="recipe-section" aria-labelledby="recipe-fasth3-heading">
|
||||
{configurable.length ? (
|
||||
<section className="recipe-section" aria-labelledby="recipe-configurable-heading">
|
||||
<header className="recipe-section-head">
|
||||
<h2 id="recipe-fasth3-heading">FastVideo FastH3</h2>
|
||||
<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>
|
||||
</header>
|
||||
<div className="recipe-card-grid recipe-card-grid-configurable">
|
||||
{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) => (
|
||||
{configurable.map((model) => (
|
||||
<RecipeCard
|
||||
key={model.id}
|
||||
model={model}
|
||||
href={sitePath(model.path)}
|
||||
modes={modelModes(index, model)}
|
||||
badge="New"
|
||||
modes={configurableModes(index, model.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
{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>
|
||||
{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>
|
||||
</div>
|
||||
<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>
|
||||
);
|
||||
})}
|
||||
);
|
||||
})}
|
||||
</section>
|
||||
) : null}
|
||||
{!visible.length ? (
|
||||
<div className="recipe-empty">
|
||||
<h2>No matching recipes</h2>
|
||||
|
||||
@@ -14,7 +14,7 @@ export default function FastModels() {
|
||||
models: group.models.filter(
|
||||
(model) =>
|
||||
(family === "All models" || family === group.name) &&
|
||||
`${group.name} ${model.id}`.toLowerCase().includes(query.trim().toLowerCase()),
|
||||
`${group.name} ${group.summary} ${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>Pick the checkpoint that matches your runtime.</p>
|
||||
<p>Find the export your runtime needs.</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, family, or precision…"
|
||||
placeholder="Name, precision, or runtime…"
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
/>
|
||||
|
||||
@@ -4,18 +4,17 @@ import { modelDirectory, sitePath } from "@/lib/cookbook-pages";
|
||||
export default function RecipeFamily({ family }: { family: string }) {
|
||||
const models = modelDirectory.filter(
|
||||
(model) =>
|
||||
!model.hidden &&
|
||||
(model.recipes.some((recipe) => recipe.family === family) ||
|
||||
(family === "minimax_h3" && model.family === "FastH3")),
|
||||
model.recipes.some((recipe) => recipe.family === family) ||
|
||||
(family === "minimax_h3" && model.family === "FastH3"),
|
||||
);
|
||||
return (
|
||||
<div className="recipe-directory not-prose">
|
||||
<a className="recipe-back" href={sitePath("/cookbook")}>
|
||||
← All recipes
|
||||
← All models
|
||||
</a>
|
||||
<div className="recipe-card-grid">
|
||||
{models.map((model) => (
|
||||
<a className="recipe-card recipe-card-static" key={model.id} href={sitePath(model.path)}>
|
||||
<a className="recipe-card" 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>
|
||||
|
||||
@@ -484,7 +484,9 @@ export default function SourceRecipe({ model }: { model: DirectoryModel }) {
|
||||
<RecipeFacts
|
||||
validity={failure ? "invalid" : example ? "valid" : "loading"}
|
||||
validityText={yamlSource ? "YAML parses" : "Example settings applied"}
|
||||
validityDetail="Checks that the YAML parses and that edited numbers are valid. It does not run the recipe."
|
||||
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"
|
||||
subject={yamlSource ? "configuration" : "example"}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,226 +0,0 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -2,15 +2,6 @@ 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",
|
||||
@@ -29,6 +20,15 @@ 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,7 +41,7 @@ export const recipePages = [
|
||||
{
|
||||
id: "fasth3-omniref",
|
||||
family: "MiniMax H3",
|
||||
title: "FastH3 Omni v1",
|
||||
title: "FastH3 OmniRef",
|
||||
mark: "H3",
|
||||
path: "/cookbook/minimax-h3/fasth3-omniref",
|
||||
description: "Video and audio from a prompt plus ordered image, video, and audio references.",
|
||||
@@ -66,6 +66,15 @@ 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",
|
||||
@@ -110,6 +119,7 @@ const familyInfo: Record<string, { title: string; path: string; logo: string; ca
|
||||
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" },
|
||||
@@ -127,17 +137,8 @@ const backendModels: Record<string, string> = {
|
||||
"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"],
|
||||
@@ -157,69 +158,58 @@ export function taskLabel(value: string): string {
|
||||
return TASK_GROUPS.find(([pattern]) => pattern.test(normalized))?.[1] ?? "Text to video";
|
||||
}
|
||||
|
||||
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",
|
||||
"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 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),
|
||||
);
|
||||
export type DirectoryModel = (typeof modelDirectory)[number];
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
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
@@ -127,6 +127,10 @@
|
||||
],
|
||||
"display": "group"
|
||||
},
|
||||
{
|
||||
"label": "PAN 2 text/image to video",
|
||||
"href": "/inference/pan2"
|
||||
},
|
||||
{
|
||||
"label": "Examples",
|
||||
"href": "/inference/examples/examples_inference_index"
|
||||
@@ -141,17 +145,21 @@
|
||||
"label": "All recipes",
|
||||
"href": "/cookbook"
|
||||
},
|
||||
{
|
||||
"label": "H3 server and playground",
|
||||
"href": "/cookbook/openai-api"
|
||||
},
|
||||
{
|
||||
"label": "MiniMax H3",
|
||||
"display": "group",
|
||||
"collapsed": true,
|
||||
"collapsed": false,
|
||||
"items": [
|
||||
{
|
||||
"label": "MiniMax H3",
|
||||
"href": "/cookbook/minimax-h3/models/minimax-h3-t2v"
|
||||
},
|
||||
{
|
||||
"label": "FastH3 Omni v1",
|
||||
"label": "FastH3 OmniRef",
|
||||
"href": "/cookbook/minimax-h3/fasth3-omniref"
|
||||
},
|
||||
{
|
||||
@@ -162,6 +170,10 @@
|
||||
"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"
|
||||
@@ -179,7 +191,7 @@
|
||||
{
|
||||
"label": "Wan",
|
||||
"display": "group",
|
||||
"collapsed": true,
|
||||
"collapsed": false,
|
||||
"items": [
|
||||
{
|
||||
"label": "FastWan2.1 1.3B",
|
||||
@@ -241,6 +253,25 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"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"
|
||||
|
||||
@@ -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 tests/readme-sections.test.mjs"
|
||||
"test:content": "node --test tests/content.test.mjs tests/commands.test.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"blume": "2.2.3",
|
||||
|
||||
@@ -6,7 +6,6 @@ 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, "..");
|
||||
@@ -177,11 +176,12 @@ 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.replace(/\{#([^}]+)\}/g, "[#$1]"));
|
||||
await writeFile(file.replace(/\.md$/, ".mdx"), text);
|
||||
await rm(file);
|
||||
}
|
||||
}
|
||||
@@ -251,15 +251,10 @@ 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, 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 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 links = [...text.matchAll(/\[([^\]]+)\]\(([^)]+)\)/g)];
|
||||
const headline = text
|
||||
.replace(/\[([^\]]+)\]\([^)]+\)/g, "$1")
|
||||
|
||||
@@ -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 video diffusion models."));
|
||||
assert.ok(home.includes("FastVideo is an inference and post-training framework for diffusion models."));
|
||||
assert.ok(!home.includes("About FastVideo and performance overview"));
|
||||
await readFile(new URL("dist/assets/logos/logo.svg", site));
|
||||
});
|
||||
|
||||
@@ -223,18 +223,18 @@ test("inherited values, explicit false, zero and null retain their meanings", as
|
||||
test("catalog discovery opens dedicated model recipes and filters by family and task", async ({ page }) => {
|
||||
await page.goto("/FastVideo/cookbook");
|
||||
const cards = page.locator(".recipe-card");
|
||||
await expect(cards).toHaveCount(36);
|
||||
await expect(cards.first()).toContainText("FastH3 Omni v1");
|
||||
await expect(cards).toHaveCount(38);
|
||||
await expect(cards.first()).toContainText("FastH3 OmniRef");
|
||||
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,7 +253,8 @@ 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("Weights fit", { exact: true })).toBeVisible();
|
||||
await expect(page.getByText("Fit unknown", { exact: true })).toBeVisible();
|
||||
await expect(page.getByText("No recorded run", { 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");
|
||||
@@ -276,7 +277,7 @@ test("output tabs support keyboard navigation and precision chips use the backen
|
||||
await page.getByLabel("Search recipes").fill("does not exist");
|
||||
await expect(page.getByRole("heading", { name: "No matching recipes" })).toBeVisible();
|
||||
await page.getByRole("button", { name: "Clear filters" }).click();
|
||||
await expect(page.locator(".recipe-card")).toHaveCount(36);
|
||||
await expect(page.locator(".recipe-card")).toHaveCount(38);
|
||||
});
|
||||
|
||||
test("cookbook cards and controls remain readable in dark mode and reflow at 320px", async ({ page }) => {
|
||||
@@ -442,6 +443,26 @@ 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));
|
||||
@@ -461,16 +482,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: "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: "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: "Host RAM (optional)", exact: true }).fill("64");
|
||||
await page.getByRole("button", { name: "Save device", exact: true }).click();
|
||||
await page.getByRole("button", { name: "Use this 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("Usable memory: 22 GB");
|
||||
await expect(page.locator(".hardware-selected")).toContainText("Your budget: 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/);
|
||||
@@ -479,26 +500,28 @@ 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", exact: true }).click();
|
||||
await page.getByRole("button", { name: "Clear hardware target", exact: true }).click();
|
||||
await expect(page.getByText("RTX 4090", { exact: true })).toHaveCount(0);
|
||||
await expect(page.getByText("No hardware selected.", { exact: true })).toBeVisible();
|
||||
await expect(page.getByText("No hardware target selected.", { exact: true })).toBeVisible();
|
||||
});
|
||||
|
||||
test("hardware is never preselected and a desktop card keeps the recipe GPU count", async ({ page }) => {
|
||||
test("hardware is never preselected; a single-GPU device sets one GPU with valid parallelism", async ({ page }) => {
|
||||
await page.goto(route);
|
||||
await expect(page.locator(".hardware-picker")).toBeVisible({ timeout: 30000 });
|
||||
await expect(page.getByText("No hardware selected.", { exact: true })).toBeVisible();
|
||||
await expect(page.getByText("No hardware target 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(yaml).toContainText('"num_gpus": 4');
|
||||
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).not.toContainText('"hardware"');
|
||||
await expect(page.getByText("Valid configuration", { exact: true })).toBeVisible();
|
||||
await page.getByRole("button", { name: "Clear hardware", exact: true }).click();
|
||||
await expect(page.getByText("No hardware selected.", { 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();
|
||||
});
|
||||
|
||||
test("lowering the GPU count resets parallelism that no longer fits", async ({ page }) => {
|
||||
@@ -519,7 +542,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 selected.", { exact: true })).toBeVisible();
|
||||
await expect(page.getByText("No hardware target selected.", { exact: true })).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "Download YAML", exact: true })).toBeEnabled();
|
||||
await expect(page.getByText("Loading deployment settings…")).toHaveCount(0);
|
||||
});
|
||||
@@ -532,135 +555,15 @@ 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 state validity and why memory has no estimate", async ({ page }) => {
|
||||
test("recipe facts separate validity, fit, and run evidence with inspectable assumptions", async ({ page }) => {
|
||||
await page.goto(route);
|
||||
const facts = page.locator(".recipe-facts");
|
||||
const facts = page.getByLabel("Recipe facts");
|
||||
await expect(facts.getByText("Valid configuration", { exact: true })).toBeVisible();
|
||||
await expect(facts.getByText("Memory not estimated", { exact: true })).toBeVisible();
|
||||
await expect(facts.getByText("Fit unknown", { exact: true })).toBeVisible();
|
||||
await expect(facts.getByText("No recorded run", { exact: true })).toBeVisible();
|
||||
await page.getByRole("button", { name: "Details", exact: true }).click();
|
||||
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();
|
||||
await expect(page.getByText(/This is not a fit claim/)).toBeVisible();
|
||||
await expect(page.getByText(/do not establish this configuration/)).toBeVisible();
|
||||
});
|
||||
|
||||
test("catalog lookup resolves aliases, variants, and unified memory without pooling", async ({ page }) => {
|
||||
@@ -669,7 +572,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", exact: true }).click();
|
||||
await page.getByRole("button", { name: "Clear hardware target", 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();
|
||||
@@ -689,9 +592,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 memory: 96 GB");
|
||||
await expect(page.locator(".hardware-selected")).toContainText("Usable budget: 96 GB");
|
||||
await expect(page.locator(".hardware-address-note")).toContainText("not dedicated VRAM");
|
||||
await expect(page.getByRole("button", { name: "Clear hardware", exact: true })).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "Clear hardware target", exact: true })).toBeVisible();
|
||||
});
|
||||
|
||||
test("the directory filters by runtime without a hardware picker", async ({ page }) => {
|
||||
@@ -705,33 +608,6 @@ 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(36);
|
||||
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");
|
||||
@@ -757,10 +633,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(/The saved device \(retired-gpu\) is not in this catalog/)).toBeVisible();
|
||||
await page.getByRole("button", { name: "Clear hardware", exact: true }).click();
|
||||
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).not.toHaveURL(/hardware=/);
|
||||
await expect(page.getByText("No hardware selected.", { exact: true })).toBeVisible();
|
||||
await expect(page.getByText("No hardware target selected.", { exact: true })).toBeVisible();
|
||||
});
|
||||
|
||||
test("catalog search renders one plain no-results message", async ({ page }) => {
|
||||
|
||||
@@ -1,19 +1,5 @@
|
||||
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;
|
||||
@@ -173,12 +159,6 @@ 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();
|
||||
@@ -198,7 +178,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 recipes", exact: true })).toHaveAttribute(
|
||||
await expect(page.getByRole("link", { name: "← All models", exact: true })).toHaveAttribute(
|
||||
"href",
|
||||
/\/FastVideo\/cookbook$/,
|
||||
);
|
||||
|
||||
@@ -1,64 +0,0 @@
|
||||
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 });
|
||||
});
|
||||
@@ -798,39 +798,6 @@
|
||||
.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;
|
||||
@@ -1655,25 +1622,3 @@
|
||||
.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;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"version": 19,
|
||||
"version": 20,
|
||||
"recipes": [
|
||||
{
|
||||
"id": "fastwan21-t2v",
|
||||
@@ -381,6 +381,33 @@
|
||||
"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",
|
||||
|
||||
@@ -1,92 +0,0 @@
|
||||
{
|
||||
"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"]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,7 @@ 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 |
|
||||
| `stabilityai.webp` | [Official Stability AI Hugging Face organization avatar](https://huggingface.co/stabilityai) | Stable Diffusion and Stable Audio cards and page headers |
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 4.2 KiB |
@@ -157,22 +157,15 @@ __all__ = [
|
||||
|
||||
## 4. Register the Backend
|
||||
|
||||
Registration is two edits:
|
||||
Update `fastvideo/attention/backends/__init__.py` to export your new class.
|
||||
|
||||
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.
|
||||
```python
|
||||
from .mynew_attn import MyNewAttnBackend
|
||||
```
|
||||
|
||||
`fastvideo/attention/backends/__init__.py` is empty and does not register
|
||||
anything.
|
||||
## 5. Platform Integration
|
||||
|
||||
## 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).
|
||||
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__`.
|
||||
|
||||
## 6. Add Documentation
|
||||
|
||||
|
||||
@@ -85,10 +85,10 @@ from fastvideo_kernel import video_sparse_attn
|
||||
# topk: Number of blocks to attend
|
||||
|
||||
output = video_sparse_attn(
|
||||
q, k, v,
|
||||
variable_block_sizes,
|
||||
q_variable_block_sizes,
|
||||
topk=32,
|
||||
q, k, v,
|
||||
block_sizes,
|
||||
block_sizes,
|
||||
topk=32
|
||||
)
|
||||
```
|
||||
|
||||
|
||||
@@ -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 52 hardware variant(s).`
|
||||
The check currently prints `Validated 1 capacity model(s) and 50 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,41 +404,6 @@ 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,
|
||||
|
||||
@@ -259,6 +259,7 @@ 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). |
|
||||
@@ -328,6 +329,7 @@ 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`. |
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ 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)
|
||||
|
||||
@@ -59,7 +59,7 @@
|
||||
</tr>
|
||||
<tr>
|
||||
<td>DGX Spark</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>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>Not wired</td>
|
||||
</tr>
|
||||
<tr>
|
||||
@@ -100,7 +100,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. 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>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>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 +111,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. 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>
|
||||
<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>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
+20
-17
@@ -1,27 +1,30 @@
|
||||
# Run H3 with a server and playground
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
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 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.
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
This guide covers FastH3 V1 (four forwards) and FastH3 V2 (eight forwards). For
|
||||
direct Python generation, see the [H3 cookbook](minimax-h3.md).
|
||||
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.
|
||||
|
||||
## Install and start the server
|
||||
|
||||
All runtimes need a FastVideo clone and an activated Python environment.
|
||||
|
||||
### CUDA
|
||||
|
||||
Complete the [CUDA installation requirements](../getting_started/installation/gpu.md)
|
||||
Use a FastVideo clone and an activated Python environment. Complete the
|
||||
[CUDA installation requirements](../getting_started/installation/gpu.md)
|
||||
before running these commands:
|
||||
|
||||
```bash
|
||||
@@ -30,9 +33,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 and does not record a GPU
|
||||
model or VRAM requirement, so treat it as a starting point, not a measured
|
||||
profile. Compilation is disabled.
|
||||
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.
|
||||
|
||||
For FastH3 V2, use the same install and the 8-step config (nine sigma
|
||||
points, eight DiT forwards, VSA sparsity 0.8):
|
||||
|
||||
@@ -97,14 +97,6 @@ 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
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
# 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, guidance 2.5 and PAN 2's default negative prompt. 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`. PAN 2 runs
|
||||
with its prompt and video guardrails by default, so install them first (see
|
||||
[Guardrails](../inference/pan2.md#guardrails)):
|
||||
|
||||
```bash
|
||||
pip install git+https://github.com/MBZUAI-IFM/PAN2-guardrail
|
||||
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.
|
||||
@@ -24,7 +24,6 @@ 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
|
||||
|
||||
@@ -21,7 +21,6 @@ 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
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
title: FastH3 Omni v1
|
||||
title: FastH3 OmniRef
|
||||
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,12 +39,10 @@ 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
|
||||
@@ -94,7 +92,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:
|
||||
default_request.sampling.num_frames:
|
||||
@@ -145,6 +142,5 @@ 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
|
||||
|
||||
@@ -21,7 +21,6 @@ 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
|
||||
@@ -31,7 +30,6 @@ 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,38 @@
|
||||
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
|
||||
@@ -49,6 +49,7 @@ 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 |
|
||||
|
||||
@@ -283,6 +283,18 @@ 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
|
||||
|
||||
@@ -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.sh
|
||||
bash scripts/preprocess/v1_preprocess_wan_data_t2v
|
||||
|
||||
# For Wan2.2 TI2V
|
||||
bash examples/distill/Wan2.2-TI2V-5B-Diffusers/crush_smol/preprocess_wan_data_ti2v_5b.sh
|
||||
|
||||
@@ -11,16 +11,16 @@ Attention. See [Attn-QAT Training](../training/attn_qat.md).
|
||||
|
||||
## 📊 Model Overview
|
||||
|
||||
We provide three distilled models:
|
||||
We provide two 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
|
||||
|
||||
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).
|
||||
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).
|
||||
|
||||
## ⚙️ Inference
|
||||
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.
|
||||
First install [VSA](../attention/vsa/index.md). Set `MODEL_BASE` to your own model path and run:
|
||||
|
||||
```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 **8 nodes with 64 H200 GPUs** (8 GPUs per node):
|
||||
For the 1.3B model, we use **4 nodes with 32 H200 GPUs** (8 GPUs per node):
|
||||
|
||||
```bash
|
||||
# Multi-node training (8 nodes, 64 GPUs total)
|
||||
|
||||
@@ -35,6 +35,7 @@ COOKBOOK_FAMILIES = {
|
||||
"cosmos",
|
||||
"kandinsky5",
|
||||
"kandinsky6",
|
||||
"pan2",
|
||||
"flux",
|
||||
"qwen_image21",
|
||||
"sd35",
|
||||
|
||||
@@ -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).
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
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=cudnn
|
||||
export FASTVIDEO_MINIMAX_H3_FUSIONS=all FASTVIDEO_NVFP4_MM_BACKEND=cutlass
|
||||
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
|
||||
|
||||
@@ -1,44 +1,28 @@
|
||||
# Quick start
|
||||
# 🚀 Quick Start
|
||||
|
||||
Install FastVideo, generate one video, then start from a maintained recipe.
|
||||
Get up and running with FastVideo in minutes!
|
||||
|
||||
## Install
|
||||
## Installation
|
||||
|
||||
Requires Python 3.10 to 3.12 and CUDA 12.6 or newer.
|
||||
First, install FastVideo:
|
||||
|
||||
```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
|
||||
|
||||
# CUDA 12. Use cu130 on CUDA 13.
|
||||
# Install FastVideo on NVIDIA CUDA 12
|
||||
UV_TORCH_BACKEND=cu126 uv pip install fastvideo
|
||||
```
|
||||
|
||||
For the fastest attention kernels, install flash-attn:
|
||||
Use `UV_TORCH_BACKEND=cu130` instead on CUDA 13.
|
||||
|
||||
Also optionally 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
@@ -1,6 +1,6 @@
|
||||
# Welcome to FastVideo
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
## Key Features
|
||||
|
||||
|
||||
@@ -90,7 +90,7 @@ PipelineConfig (fastvideo/configs/pipelines/base.py)
|
||||
│ ├── WanT2V720PConfig
|
||||
│ └── WanI2V480PConfig
|
||||
├── HunyuanConfig (fastvideo/configs/pipelines/hunyuan.py)
|
||||
├── LTX2T2VConfig (fastvideo/pipelines/basic/ltx2/pipeline_configs.py)
|
||||
├── LTX2T2VConfig (fastvideo/configs/pipelines/ltx2.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>/presets.py`):
|
||||
provides defaults via a profile (see `fastvideo/pipelines/basic/<family>/profiles.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>/presets.py` with default height,
|
||||
`fastvideo/pipelines/basic/<model>/profiles.py` with default height,
|
||||
width, num_frames, guidance_scale, num_inference_steps.
|
||||
|
||||
3. **Register configs** — In `fastvideo/registry.py`, add a
|
||||
|
||||
@@ -72,7 +72,7 @@ def main():
|
||||
sampling_param.guidance_scale = 7.5
|
||||
|
||||
# Random seed for reproducibility
|
||||
sampling_param.seed = 42 # Optional; the default is 1024
|
||||
sampling_param.seed = 42 # Optional, leave unset for random results
|
||||
|
||||
# Generate video with custom parameters
|
||||
prompt = "A beautiful sunset over a calm ocean, with gentle waves."
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
# 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 | 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, as in the reference sampler. It is used whenever the request's negative prompt is empty; a single space gives the empty unconditional prompt PAN 2 trained with |
|
||||
|
||||
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. As in the reference sampler, an `image_path` is read as its
|
||||
stored pixels: an EXIF orientation tag does not rotate it.
|
||||
|
||||
## Guardrails
|
||||
|
||||
PAN 2 runs with guardrails by default, as in its vLLM-Omni and Diffusers integrations. The
|
||||
[`pan2-guardrail`](https://github.com/MBZUAI-IFM/PAN2-guardrail) package checks every prompt before any compute and every
|
||||
generated video after decoding. A blocked prompt or video raises `PAN2GuardrailViolationError`. Install the package
|
||||
before loading the pipeline:
|
||||
|
||||
```bash
|
||||
pip install git+https://github.com/MBZUAI-IFM/PAN2-guardrail
|
||||
```
|
||||
|
||||
| Setting | Default | What it does |
|
||||
|---|---|---|
|
||||
| `enable_guardrails` | `True` | Pass `False` to `VideoGenerator.from_pretrained` to turn both checks off, at your own risk. Without the package, loading fails unless the guardrails are off. |
|
||||
| `offload_guardrail_models` | `False` | Keeps the guard models on CPU and moves each one to the GPU only for its check. |
|
||||
|
||||
With sequence parallelism, the first GPU of the group loads the guard models and runs the checks, and every rank
|
||||
raises on its verdict. The video check reads the decoded pixels, so `output_type="latent"` is refused while the
|
||||
guardrails are on.
|
||||
|
||||
## 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) against fastvideo-kernel's CUTLASS submodule (`git submodule update --init fastvideo-kernel/include/cutlass`, or set `CUTLASS_DIR`); each process builds in a directory of its own under `WAN22_VAE_DECODE_BUILD_DIR`, and later processes copy the build from the cache there. 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. Their rounding differs slightly from the unfused path. |
|
||||
|
||||
On Blackwell GPUs (sm_100 and newer) with FlashAttention-4 (`flash_attn.cute`) installed, PAN2 also attends with FA4 unless `FASTVIDEO_FA4` is set: 288 s instead of 670 s with the Torch SDPA fallback for 241 frames on one B300. Set `FASTVIDEO_FA4=0` to keep the backend FastVideo picks otherwise.
|
||||
|
||||
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 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.
|
||||
@@ -68,6 +68,7 @@ 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) |
|
||||
| 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) |
|
||||
|
||||
Symlink
+1
@@ -0,0 +1 @@
|
||||
/Users/aryank/os/FastVideo-review-int/docs/node_modules
|
||||
@@ -12,25 +12,6 @@ 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",
|
||||
|
||||
@@ -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` / `--preprocess.max_width` | Target resolution for videos |
|
||||
| `--preprocess.max_height` / `max_width` | Target resolution for videos |
|
||||
| `--preprocess.num_frames` | Number of frames to extract per video |
|
||||
| `--preprocess.train_fps` | Target FPS for frame extraction |
|
||||
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
# Fine-tuning
|
||||
# 🧠 Finetuning
|
||||
|
||||
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:
|
||||
|
||||
### Loop and model arguments
|
||||
### Training Arguments
|
||||
|
||||
| Argument | Description |
|
||||
|----------|-------------|
|
||||
|
||||
@@ -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,10 +145,16 @@ 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. One 80 GB card cannot hold it:
|
||||
full-parameter training across 8 GPUs with FSDP. Full-parameter training
|
||||
does not fit one 80GB card:
|
||||
|
||||
| Item | Per rank (bf16) |
|
||||
|---|---:|
|
||||
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 |
|
||||
|---|---|
|
||||
| Weights | 16.7 GB |
|
||||
| Gradients | 16.7 GB |
|
||||
| AdamW `exp_avg` + `exp_avg_sq` | 33.4 GB |
|
||||
@@ -157,27 +163,21 @@ full-parameter training across 8 GPUs with FSDP. One 80 GB card cannot hold it:
|
||||
| **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.07 GB going from 21 latent frames to 9. Options that do move the needle:
|
||||
(`--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:
|
||||
|
||||
- **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`.
|
||||
- **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`.
|
||||
|
||||
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
|
||||
Set `training.dit_precision: bf16` for any single-device run. The default
|
||||
is fp32, which doubles weights, gradients, and optimizer state.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
@@ -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/configs/distribution_matching/wan/dmd2_t2v.yaml
|
||||
bash examples/train/run.sh examples/train/distill_wan2.1_t2v_1.3B_dmd2.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/configs/distribution_matching/wan/dmd2_t2v.yaml
|
||||
--config examples/train/distill_wan2.1_t2v_1.3B_dmd2.yaml
|
||||
```
|
||||
|
||||
### CLI flags
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
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()
|
||||
@@ -90,6 +90,7 @@ 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,38 @@
|
||||
# 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:
|
||||
# Empty: PAN 2's default negative prompt, its time ranges set to the clip's duration.
|
||||
negative_prompt: ""
|
||||
sampling:
|
||||
height: 704
|
||||
width: 1248
|
||||
num_frames: 313
|
||||
fps: 24
|
||||
num_inference_steps: 50
|
||||
guidance_scale: 2.5
|
||||
output:
|
||||
return_frames: false
|
||||
@@ -21,6 +21,7 @@ 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 + three contiguous Q/K/V transposes + a gate view
|
||||
- legacy: tile scatter into BSHD + four BSHD->BHSD transposes
|
||||
- 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[[], tuple[torch.Tensor, ...]]:
|
||||
build_kwargs: dict, fused: bool, include_attn: bool) -> Callable[[], torch.Tensor]:
|
||||
metadata = builder.build(**build_kwargs)
|
||||
|
||||
def step() -> tuple[torch.Tensor, ...]:
|
||||
def step() -> 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 q, k, v, gate
|
||||
return (q.transpose(1, 2).contiguous(), k.transpose(1, 2).contiguous(), v.transpose(1, 2).contiguous(),
|
||||
gate.transpose(1, 2))
|
||||
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)])
|
||||
|
||||
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 all(torch.equal(old, new) for old, new in zip(legacy(), fused(), strict=True)):
|
||||
if not torch.equal(legacy(), fused()):
|
||||
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")
|
||||
|
||||
@@ -5,9 +5,6 @@ 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
|
||||
|
||||
@@ -43,63 +40,6 @@ 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, strict=True):
|
||||
for name, old, new in zip(("q", "k", "v", "gate"), expected, actual):
|
||||
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))
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
"""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)
|
||||
@@ -0,0 +1,99 @@
|
||||
# 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`). Each process builds in a directory of its own, so concurrent processes do
|
||||
not collide, and puts the finished build in the `cache/` beside it, from which later processes copy it.
|
||||
- `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.
|
||||
@@ -0,0 +1,99 @@
|
||||
# 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).
|
||||
@@ -0,0 +1,251 @@
|
||||
"""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)
|
||||
@@ -0,0 +1,217 @@
|
||||
// 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;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
#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);
|
||||
@@ -0,0 +1,459 @@
|
||||
// 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>());
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
// 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;
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
// 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);
|
||||
@@ -0,0 +1,78 @@
|
||||
// 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);
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
// 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();
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
#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);
|
||||
@@ -0,0 +1,99 @@
|
||||
#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.)
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
#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);
|
||||
@@ -0,0 +1,198 @@
|
||||
// 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;
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
// 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;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
#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);
|
||||
@@ -0,0 +1,96 @@
|
||||
// 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;
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
#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);
|
||||
@@ -0,0 +1 @@
|
||||
{"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":[]}
|
||||
@@ -0,0 +1,122 @@
|
||||
// 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;
|
||||
}
|
||||
@@ -0,0 +1,400 @@
|
||||
// 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;
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
// 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;
|
||||
}
|
||||
@@ -0,0 +1,359 @@
|
||||
"""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()
|
||||
@@ -0,0 +1,771 @@
|
||||
"""Correctness, timing and library-kernel audit of a solution: the Wan2.2 VAE decode of latents into video, on one, two
|
||||
or four GB200 GPUs of one node. The task's CI runs this same file (see ci.py).
|
||||
|
||||
The task (spec.py has the details)
|
||||
--------
|
||||
A solution decodes latents z [1, 48, T, 44, 78] (bf16) into the video [1, 3, 4T-3, 704, 1248] (bf16, in [-1, 1]) with
|
||||
the Wan2.2 VAE's decoder, as diffusers' AutoencoderKLWan decodes them (reference.py in the kernel repo). It is a
|
||||
directory (default solution/, see loader.py) compiled into one extension module that exports
|
||||
|
||||
run(z, weights, rank, world, pg, out) positional, destination passing
|
||||
prepare(weights, rank, world, pg) -> list[Tensor] optional
|
||||
|
||||
called in one process per GPU: z the full latents on every rank; weights the decoder's 110 bf16 tensors on the rank's
|
||||
GPU, in the order of spec.WEIGHTS (or what prepare returned); pg torch.distributed's NCCL ProcessGroup over the ranks;
|
||||
out the video on rank 0, from torch.empty and never zeroed (every element must be written), and an empty tensor on the
|
||||
other ranks. When run has returned on every rank, rank 0's out holds the video. prepare, if exported, runs once per
|
||||
process before any timed call and is not timed; its result replaces the weights in every run.
|
||||
|
||||
The workloads (spec.WORKLOADS) are clips decoded at a world size, named <clip>x<world>. A clip is the first T latent
|
||||
frames of one of three real 20 s clips, named by its video frames: f1, f5, f17 (T = 1, 2, 5) and f121, f241, f481
|
||||
(T = 31, 61, 121). The long clips at 1, 2 and 4 GPUs are the nine timed workloads (f121x1 ... f481x4), whose geomean
|
||||
is the objective; the short clips at 1 and 4 GPUs (f1x1 ... f17x4) are checked and never timed.
|
||||
|
||||
Running it
|
||||
----------
|
||||
In a job with as many GPUs as the largest world size it runs, with the task's data at $DATA (default /data), from the
|
||||
repo root:
|
||||
|
||||
python tools/bench.py every workload this job has the GPUs for: check, audit, time
|
||||
python tools/bench.py --workload f5x1 f17x1 a subset; the short clips take seconds
|
||||
python tools/bench.py --workload f121x1 --no-time correctness only
|
||||
python tools/bench.py --kernels also list the audited calls' kernels by GPU time
|
||||
python tools/bench.py --solution other another solution directory
|
||||
|
||||
A run
|
||||
-----
|
||||
The solution is built once, by this process, exactly as it is deployed (loader.py; a development copy, tools/, takes a
|
||||
build its node already made of the same sources, flags and toolchain from the node's cache, unless --no-cache). Then,
|
||||
per world size in turn (1, 2, 4), torch.multiprocessing spawns one process per rank, rank r on GPU r of the job's
|
||||
CUDA_VISIBLE_DEVICES. Each initializes NCCL over 127.0.0.1 with its device (the default group, the solution's pg) and
|
||||
a gloo group for the harness's own barriers, imports the build (in its working directory, as deployed), moves the
|
||||
weights ($DATA/decoder_weights_bf16.pt, checked against spec.WEIGHTS) to its GPU and calls prepare, if the module
|
||||
exports it. The ranks then run that world size's workloads in spec.WORKLOADS' order. A rank that raises, dies or
|
||||
hangs (WORKLOAD_SEC per workload, SESSION_START_SEC before the first) fails the workload it was at: all the ranks'
|
||||
processes are stopped, and the workloads after it run in new ones. This process runs no solution code.
|
||||
|
||||
Every call gets z at a fresh address (a copy of the clip's latents) and out at a fresh address, 256-byte aligned and
|
||||
filled with NaN on rank 0, so that an element run leaves unwritten fails the check. run must not write z, nor the
|
||||
weights (compared after each workload with what prepare left), nor start threads: each fails the workload.
|
||||
|
||||
Correctness
|
||||
-----------
|
||||
The first call of a workload, its warm-up, is checked on rank 0 against the clip's fp32 decode ($DATA/ref_<clip>.pt):
|
||||
every element finite and within [-1, 1]; and with d = out - ref in fp32, max |d| <= min(2, 3 x bf16_max_abs) and
|
||||
rms(d) <= 3 x bf16_rms, the error of the release's bf16 decode of that clip ($DATA/calibration.json; spec.py,
|
||||
Correctness). The line shows max |d| and rms(d), each with its allowance and the share of it used, and the PSNR,
|
||||
10 log10(4 / mse). Every later call's output (the audited call's, the trials') must agree with the checked one:
|
||||
lose none of the elements it wrote (none left NaN or overflowed), and be within the same allowance of it everywhere
|
||||
else. A race, or a state carried from call to call, fails the workload.
|
||||
|
||||
Timing (the timed workloads)
|
||||
------
|
||||
After the warm-up come the trials: 3 when the warm-up took under 1.5 s, else 2. A trial is one call timed by wall
|
||||
clock between barriers on all ranks: synchronize the device, barrier, start, run, synchronize, barrier, stop. The
|
||||
time of record is rank 0's; the latency of record the mean of the trials, and the fastest trial is shown too. While
|
||||
the trials run, this process reads the SM clock of the world's GPUs through NVML every CLOCK_PERIOD_S (a rank inside
|
||||
run may hold its GIL for the whole call, so the ranks do not read it themselves); the mean of rank 0's GPU is shown
|
||||
with the latency, and the range over the world's GPUs where they differ by 2% or more: the clocks are not locked
|
||||
alike on every GPU, and this says at what clock the times were taken. Which processes hold the world's GPUs is
|
||||
asked (nvidia-smi) before every trial and after the last: a workload timed while another process held one of them is
|
||||
marked SHARED, since its times do not compare with those of another job.
|
||||
|
||||
Audit
|
||||
-----
|
||||
At world 1, one more call per timed clip runs under CUPTI, and each kernel is attributed to the library that launched
|
||||
it (see audit.py). Library kernels (cuBLAS/cuBLASLt, cuDNN, ATen) are allowed as placeholders: their count per call
|
||||
and their share of the call's GPU time show where the solution still depends on them. NCCL's kernels are
|
||||
communication, reported apart and not counted as placeholders. --kernels lists the audited calls' kernels.
|
||||
|
||||
Bound
|
||||
-----
|
||||
Each timed workload's latency is shown with its bound (bounds.py with spec.bounds: the decode's FLOPs spread evenly
|
||||
over the world's GPUs at the 16-bit tensor-core peak, or the video and the weights at the memory bandwidth, whichever
|
||||
is slower) and the gap, latency / bound. The peak assumes the SM clock GUILD_SM_CLOCK_MHZ where the environment sets
|
||||
it (the guild's: 2062 MHz, the GB200's highest), else the GPU's highest.
|
||||
|
||||
In CI, and from tools/
|
||||
----------------------
|
||||
ci.py runs this file in child processes: --save DIR has rank 0 save each workload's checked output there (node-local
|
||||
scratch) instead of checking it, so that CI judges it in a process that runs no solution code; --no-check skips the
|
||||
check (the timing runs), --no-audit the audit, and --json writes the report. The kernel repo holds copies of this
|
||||
file, spec.py, loader.py, audit.py and bounds.py in tools/, which run as they do here: they read the data at $DATA
|
||||
(default /data) and the solution from solution/ under the current directory.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import collections
|
||||
import ctypes
|
||||
import functools
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
import random
|
||||
import socket
|
||||
import statistics
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
import traceback
|
||||
from datetime import timedelta
|
||||
from multiprocessing import connection
|
||||
|
||||
# The caching allocator stays as torch configures it by default: without expandable segments, whose memory
|
||||
# cudaIpcGetMemHandle cannot export, since a solution may share its buffers between ranks by CUDA IPC.
|
||||
import torch
|
||||
import torch.distributed as dist
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
import audit # noqa: E402
|
||||
import bounds # noqa: E402
|
||||
import loader # noqa: E402
|
||||
import spec # noqa: E402
|
||||
|
||||
# Trials per timed workload, fewer when one call takes long.
|
||||
TRIALS, LONG_TRIALS, LONG_CALL_S = 3, 2, 1.5
|
||||
# How often this process reads rank 0's SM clock while ranks run.
|
||||
CLOCK_PERIOD_S = 0.02
|
||||
NVML_CLOCK_SM = 1 # the nvmlClockType_t of the SM clock
|
||||
# Limits that stop a runaway: a session's start (NCCL, the module, the weights, prepare), and one workload (its calls,
|
||||
# its check or its save); a call of the release's bf16 decode takes 15 s at the longest clip.
|
||||
SESSION_START_SEC = 900
|
||||
WORKLOAD_SEC = 1800
|
||||
STOP_GRACE_SEC = 15
|
||||
# Elements of a video compared at once, in fp32 (1 GiB per operand).
|
||||
CHUNK_ELEMENTS = 1 << 28
|
||||
|
||||
|
||||
def gpu_holders():
|
||||
"""{GPU UUID (lower case, without "GPU-"): ids of the processes that hold it} now, as nvidia-smi gives them, or
|
||||
None when it cannot say."""
|
||||
try:
|
||||
got = subprocess.run(["nvidia-smi", "--query-compute-apps=gpu_uuid,pid", "--format=csv,noheader"],
|
||||
capture_output=True, text=True, timeout=30)
|
||||
except (OSError, subprocess.TimeoutExpired):
|
||||
return None
|
||||
if got.returncode != 0:
|
||||
return None
|
||||
out = collections.defaultdict(set)
|
||||
for line in got.stdout.splitlines():
|
||||
gpu, _, pid = line.partition(",")
|
||||
if pid.strip().isdigit():
|
||||
out[gpu.strip().lower().removeprefix("gpu-")].add(int(pid))
|
||||
return out
|
||||
|
||||
|
||||
def others(questions, uuids, ours):
|
||||
"""How many processes besides this run's held one of these GPUs at any of the questions (gpu_holders' answers), or
|
||||
None when one could not be asked. This run's processes are known by their ids where nvidia-smi gives the ids this
|
||||
process sees (one PID namespace, the usual case: then a rank that also holds another rank's GPU is still one of
|
||||
ours); otherwise each GPU is taken to hold one of ours, the rank on it."""
|
||||
if not questions or any(q is None for q in questions):
|
||||
return None
|
||||
total = 0
|
||||
for uuid in uuids:
|
||||
seen = set().union(*(q.get(uuid, set()) for q in questions))
|
||||
total += len(seen - ours) if seen & ours else max(0, len(seen) - 1)
|
||||
return total
|
||||
|
||||
|
||||
@functools.cache
|
||||
def sm_clock(uuid):
|
||||
"""A function that reads the SM clock of the GPU with this UUID now, in MHz (None when a read fails), or None where
|
||||
NVML cannot be loaded or does not know the GPU. NVML in this process takes microseconds per read."""
|
||||
try:
|
||||
nvml = ctypes.CDLL("libnvidia-ml.so.1")
|
||||
if nvml.nvmlInit_v2() != 0:
|
||||
return None
|
||||
handle, mhz = ctypes.c_void_p(), ctypes.c_uint()
|
||||
if not uuid or nvml.nvmlDeviceGetHandleByUUID(f"GPU-{uuid}".encode(), ctypes.byref(handle)) != 0:
|
||||
return None
|
||||
except (OSError, AttributeError):
|
||||
return None
|
||||
|
||||
def read():
|
||||
ok = nvml.nvmlDeviceGetClockInfo(handle, NVML_CLOCK_SM, ctypes.byref(mhz)) == 0
|
||||
return mhz.value if ok and mhz.value else None
|
||||
|
||||
return read if read() else None
|
||||
|
||||
|
||||
class Clock:
|
||||
"""The SM clocks of the world's GPUs (rank order), read every CLOCK_PERIOD_S by a thread of this process, as
|
||||
(monotonic time, [MHz or None per GPU])."""
|
||||
|
||||
def __init__(self, uuids):
|
||||
self.reads, self.samples, self.done = [sm_clock(u) for u in uuids], [], threading.Event()
|
||||
self.thread = threading.Thread(target=self._run, daemon=True) if any(self.reads) else None
|
||||
if self.thread:
|
||||
self.thread.start()
|
||||
|
||||
def _run(self):
|
||||
while not self.done.wait(CLOCK_PERIOD_S):
|
||||
self.samples.append((time.monotonic(), [read() if read else None for read in self.reads]))
|
||||
|
||||
def stop(self):
|
||||
self.done.set()
|
||||
if self.thread:
|
||||
self.thread.join()
|
||||
|
||||
def means(self, windows):
|
||||
"""Per GPU, the mean of its reads within these (start, stop) windows of time.monotonic(), or None without
|
||||
any."""
|
||||
inside = [mhz for t, mhz in list(self.samples) if any(a <= t <= b for a, b in windows)]
|
||||
out = []
|
||||
for i in range(len(self.reads)):
|
||||
got = [m[i] for m in inside if m[i]]
|
||||
out.append(statistics.mean(got) if got else None)
|
||||
return out
|
||||
|
||||
|
||||
class Pool:
|
||||
"""A workload's buffers for one tensor: each call's at a fresh address, shifted by multiples of 256 bytes."""
|
||||
|
||||
ALIGN, MAX_SHIFT = 256, 2048
|
||||
|
||||
def __init__(self, shape, dtype, device, calls, seed):
|
||||
rng = random.Random(seed)
|
||||
self.offsets = [0]
|
||||
for _ in range(calls - 1):
|
||||
self.offsets.append(self.offsets[-1] + rng.randint(1, self.MAX_SHIFT // self.ALIGN))
|
||||
self.shape, self.numel = tuple(shape), math.prod(shape)
|
||||
self.block = max(1, self.ALIGN // torch.empty(0, dtype=dtype).element_size())
|
||||
self.buffer = torch.empty(self.numel + self.offsets[-1] * self.block, dtype=dtype, device=device)
|
||||
self.calls = 0
|
||||
|
||||
def next(self):
|
||||
start = self.offsets[self.calls] * self.block
|
||||
self.calls += 1
|
||||
return self.buffer[start:start + self.numel].view(self.shape)
|
||||
|
||||
|
||||
def fingerprint(tensors):
|
||||
"""Each tensor's bytes summed as integers: whether run() wrote the weights."""
|
||||
out = []
|
||||
for t in tensors:
|
||||
raw = t.detach().contiguous().view(-1).view(torch.uint8)
|
||||
try:
|
||||
raw = raw.view(torch.int32)
|
||||
except RuntimeError:
|
||||
pass
|
||||
out.append(int(raw.sum(dtype=torch.int64)))
|
||||
return out
|
||||
|
||||
|
||||
def judge(out, ref, allowed):
|
||||
"""A video against the reference, or a later call's video against the checked one's: {"passed", "nonfinite",
|
||||
"outside" (elements beyond [-1, 1]), "max_abs", "rms", "psnr_db", "max_allowed", "rms_allowed"}, d = out - ref where
|
||||
both are finite (rms and PSNR over those elements, None where there is none), in fp32 on out's GPU (the current one
|
||||
for a video on the CPU), in chunks of frames, the squares summed in fp64."""
|
||||
device = out.device if out.is_cuda else torch.device("cuda", torch.cuda.current_device())
|
||||
step = max(1, CHUNK_ELEMENTS // max(1, out[:, :, :1].numel()))
|
||||
nonfinite = outside = compared = unmatched = 0
|
||||
max_abs = squares = 0.0
|
||||
for f in range(0, out.shape[2], step):
|
||||
x = out[:, :, f:f + step].to(device, torch.float32)
|
||||
y = ref[:, :, f:f + step].to(device, torch.float32)
|
||||
finite = torch.isfinite(x)
|
||||
nonfinite += int((~finite).sum())
|
||||
outside += int((x.abs() > 1).sum())
|
||||
# Where the checked call itself left an element unfinite, a later call is not compared there (the check said).
|
||||
both = finite & torch.isfinite(y)
|
||||
compared += int(both.sum())
|
||||
unmatched += int((~finite & torch.isfinite(y)).sum())
|
||||
d = torch.where(both, x - y, 0.0)
|
||||
max_abs = max(max_abs, float(d.abs().max()))
|
||||
squares += float(d.square().sum(dtype=torch.float64))
|
||||
del x, y, finite, both, d
|
||||
mse = squares / compared if compared else None
|
||||
rms = math.sqrt(mse) if compared else None
|
||||
passed = (nonfinite == 0 and outside == 0 and compared == out.numel() and max_abs <= allowed["max_abs"]
|
||||
and rms <= allowed["rms"])
|
||||
return {"passed": passed, "nonfinite": nonfinite, "unmatched": unmatched, "outside": outside,
|
||||
"max_abs": max_abs if compared else None, "rms": rms, "psnr_db": 10 * math.log10(4 / mse) if mse else None,
|
||||
"max_allowed": allowed["max_abs"], "rms_allowed": allowed["rms"]}
|
||||
|
||||
|
||||
def verdict(c):
|
||||
"""A check (judge's result) in a few words."""
|
||||
parts = []
|
||||
if c["nonfinite"]:
|
||||
parts.append(f"{c['nonfinite']} elements not finite (NaN: never written, or overflowed)")
|
||||
if c["outside"]:
|
||||
parts.append(f"{c['outside']} elements outside [-1, 1]")
|
||||
if c["max_abs"] is None:
|
||||
parts.append("no element to compare")
|
||||
else:
|
||||
parts.append(f"max |d| {c['max_abs']:.4g} of {c['max_allowed']:.4g} (x{c['max_abs'] / c['max_allowed']:.2f})")
|
||||
parts.append(f"rms {c['rms']:.4g} of {c['rms_allowed']:.4g} (x{c['rms'] / c['rms_allowed']:.2f})")
|
||||
parts.append(f"PSNR {c['psnr_db']:.1f} dB" if c["psnr_db"] is not None else "identical")
|
||||
return f"{'ok ' if c['passed'] else 'FAIL'} ({', '.join(parts)})"
|
||||
|
||||
|
||||
def run_workload(w, sol, weights, marks, rank, world, pg, harness, ranks, cfg):
|
||||
"""One workload on one rank, in step with the other ranks (see the module docstring); rank 0's result."""
|
||||
entry = spec.WORKLOADS[w]
|
||||
clip, device = entry["clip"], torch.device("cuda", rank)
|
||||
timed = entry["timed"] and cfg["time"]
|
||||
audited = entry["timed"] and cfg["audit"] and world == 1
|
||||
calls = 1 + audited + (TRIALS if timed else 0)
|
||||
seed = list(spec.WORKLOADS).index(w)
|
||||
z0 = spec.make_inputs(w, device)[0]
|
||||
zs = Pool(z0.shape, z0.dtype, device, calls, seed)
|
||||
outs = Pool(spec.output_shape(w), torch.bfloat16, device, calls, seed + 1) if rank == 0 else None
|
||||
allowed = spec.allowance(clip)
|
||||
res = {"clip": clip, "world": world, "timed": entry["timed"]}
|
||||
problems = []
|
||||
threads = threading.active_count()
|
||||
|
||||
def arguments():
|
||||
z = zs.next()
|
||||
z.copy_(z0)
|
||||
out = outs.next().fill_(math.nan) if outs else torch.empty(0, dtype=torch.bfloat16, device=device)
|
||||
return z, out
|
||||
|
||||
def unchanged(z):
|
||||
if not torch.equal(z, z0) and "run() wrote its input z" not in problems:
|
||||
problems.append("run() wrote its input z")
|
||||
|
||||
def call():
|
||||
z, out = arguments()
|
||||
torch.cuda.synchronize(device)
|
||||
dist.barrier(group=harness)
|
||||
start = time.monotonic()
|
||||
sol.fn(z, weights, rank, world, pg, out)
|
||||
torch.cuda.synchronize(device)
|
||||
dist.barrier(group=harness)
|
||||
stop = time.monotonic()
|
||||
unchanged(z)
|
||||
return out, start, stop
|
||||
|
||||
# The warm-up, which is the call checked.
|
||||
out, start, stop = call()
|
||||
warm = stop - start
|
||||
first, worst = None, 0.0
|
||||
if rank == 0:
|
||||
if cfg["save"]:
|
||||
path = os.path.join(cfg["save"], f"{w}.pt")
|
||||
torch.save(out.cpu(), path)
|
||||
res["saved"] = path
|
||||
elif cfg["check"]:
|
||||
res["check"] = judge(out, spec.load_reference(clip), allowed)
|
||||
if calls > 1:
|
||||
first = out.clone()
|
||||
|
||||
def agree(out, what):
|
||||
nonlocal worst
|
||||
# It agrees when it loses no element the checked call wrote, and is within the allowance of it elsewhere;
|
||||
# the checked call's own faults are its check's to report.
|
||||
c = judge(out, first, allowed)
|
||||
worst = max(worst, c["max_abs"] or 0.0)
|
||||
close = c["max_abs"] is None or (c["max_abs"] <= allowed["max_abs"] and c["rms"] <= allowed["rms"])
|
||||
if c["unmatched"] or not close:
|
||||
lost = f"{c['unmatched']} elements not finite where it was; " if c["unmatched"] else ""
|
||||
problems.append(f"{what}'s output does not agree with the checked call's: {lost}"
|
||||
f"{verdict(c).removeprefix('FAIL').removeprefix('ok').strip()}")
|
||||
|
||||
# World 1 alone: one call under CUPTI, which attributes every kernel.
|
||||
if audited:
|
||||
z, out = arguments()
|
||||
ops = audit.trace(sol.fn, z, weights, rank, world, pg, out)
|
||||
unchanged(z)
|
||||
agree(out, "the audited call")
|
||||
res |= {"kernels": audit.kernels(ops), "launches": sum(1 for op in ops if op.kind == "kernel"),
|
||||
"lib": sum(1 for op in ops if audit.placeholder(op.library)), "lib_share": audit.library_share(ops),
|
||||
"comm": sum(1 for op in ops if op.library in audit.COMMUNICATION), "comm_share": audit.comm_share(ops)}
|
||||
del ops
|
||||
|
||||
if timed:
|
||||
n = [TRIALS if warm < LONG_CALL_S else LONG_TRIALS]
|
||||
dist.broadcast_object_list(n, src=0, group=harness)
|
||||
questions, trials, windows = [], [], []
|
||||
for k in range(n[0]):
|
||||
if rank == 0:
|
||||
questions.append(gpu_holders())
|
||||
out, start, stop = call()
|
||||
trials.append(1e3 * (stop - start))
|
||||
windows.append((start, stop))
|
||||
if rank == 0:
|
||||
agree(out, f"trial {k + 1}")
|
||||
if rank == 0:
|
||||
questions.append(gpu_holders())
|
||||
res |= {"trials": trials, "ms": statistics.mean(trials), "min_ms": min(trials), "windows": windows}
|
||||
held = others(questions, [uuid for _, uuid in ranks], {pid for pid, _ in ranks} | {os.getppid()})
|
||||
if held:
|
||||
res["shared"] = held
|
||||
if first is not None:
|
||||
res["agree_max_abs"] = worst
|
||||
if fingerprint(weights) != marks:
|
||||
problems.append("run() wrote its weights (what prepare returned is read-only to run: keep scratch elsewhere)")
|
||||
if threading.active_count() > threads:
|
||||
problems.append(f"the solution started threads ({threading.active_count() - threads} more)")
|
||||
|
||||
# What every rank found, so that rank 0 reports it.
|
||||
found = [None] * world
|
||||
dist.all_gather_object(found, problems, group=harness)
|
||||
text = [p if world == 1 else f"rank {r}: {p}" for r, ps in enumerate(found) for p in ps]
|
||||
if text and rank == 0:
|
||||
res["error"] = "; ".join(text)
|
||||
return res
|
||||
|
||||
|
||||
def worker(rank, world, port, cfg, conn):
|
||||
"""The process of one rank (see A run in the module docstring): it reports to the process that spawned it through
|
||||
conn, ("ready", rank, uuid, prepare seconds), ("start", workload) and ("result", workload, result) on rank 0,
|
||||
("done", rank), or ("error", rank, workload or None, phase, traceback), after which it exits at once."""
|
||||
phase, current = "start", None
|
||||
try:
|
||||
torch.cuda.set_device(rank)
|
||||
device = torch.device("cuda", rank)
|
||||
timeout = timedelta(seconds=WORKLOAD_SEC)
|
||||
dist.init_process_group("nccl", init_method=f"tcp://127.0.0.1:{port}", rank=rank, world_size=world,
|
||||
device_id=device, timeout=timeout)
|
||||
harness = dist.new_group(backend="gloo", timeout=timeout)
|
||||
pg = dist.group.WORLD
|
||||
phase = "load"
|
||||
sol = loader.import_built(cfg["workdir"], cfg["spec"])
|
||||
phase = "weights"
|
||||
weights = spec.load_weights(device)
|
||||
phase = "prepare"
|
||||
prepare_s = None
|
||||
if sol.prepare is not None:
|
||||
torch.cuda.synchronize(device)
|
||||
start = time.monotonic()
|
||||
got = sol.prepare(weights, rank, world, pg)
|
||||
torch.cuda.synchronize(device)
|
||||
prepare_s = time.monotonic() - start
|
||||
if not isinstance(got, (list, tuple)) or not all(isinstance(t, torch.Tensor) for t in got):
|
||||
raise TypeError(f"prepare returned {type(got).__name__}, not a list of tensors")
|
||||
weights = list(got)
|
||||
uuid = str(torch.cuda.get_device_properties(device).uuid).lower().removeprefix("gpu-")
|
||||
ranks = [None] * world
|
||||
dist.all_gather_object(ranks, (os.getpid(), uuid), group=harness)
|
||||
conn.send(("ready", rank, uuid, prepare_s))
|
||||
phase = "workload"
|
||||
marks = fingerprint(weights)
|
||||
for w in cfg["workloads"]:
|
||||
current = w
|
||||
if rank == 0:
|
||||
conn.send(("start", w))
|
||||
res = run_workload(w, sol, weights, marks, rank, world, pg, harness, ranks, cfg)
|
||||
if rank == 0:
|
||||
conn.send(("result", w, res))
|
||||
current = None
|
||||
torch.cuda.empty_cache()
|
||||
phase = "end"
|
||||
dist.barrier(group=harness)
|
||||
conn.send(("done", rank))
|
||||
dist.destroy_process_group()
|
||||
except BaseException:
|
||||
try:
|
||||
conn.send(("error", rank, current, phase, traceback.format_exc(limit=8)))
|
||||
finally:
|
||||
sys.stdout.flush()
|
||||
sys.stderr.flush()
|
||||
# At once: NCCL's teardown would wait for the other ranks, which may wait for this one.
|
||||
os._exit(1)
|
||||
|
||||
|
||||
def free_port():
|
||||
with socket.socket() as s:
|
||||
s.bind(("127.0.0.1", 0))
|
||||
return s.getsockname()[1]
|
||||
|
||||
|
||||
def stop(procs, grace=0.0):
|
||||
"""Ends rank processes: those that do not exit within `grace` seconds get SIGTERM, then SIGKILL after
|
||||
STOP_GRACE_SEC."""
|
||||
deadline = time.monotonic() + grace
|
||||
for p in procs:
|
||||
p.join(max(0.0, deadline - time.monotonic()))
|
||||
for p in procs:
|
||||
if p.is_alive():
|
||||
p.terminate()
|
||||
deadline = time.monotonic() + STOP_GRACE_SEC
|
||||
for p in procs:
|
||||
p.join(max(0.1, deadline - time.monotonic()))
|
||||
if p.is_alive():
|
||||
p.kill()
|
||||
p.join(10)
|
||||
|
||||
|
||||
def exit_text(code):
|
||||
if code is None:
|
||||
return "did not exit"
|
||||
if code < 0:
|
||||
import signal
|
||||
|
||||
try:
|
||||
return f"was killed by {signal.Signals(-code).name}"
|
||||
except ValueError:
|
||||
return f"was killed by signal {-code}"
|
||||
return f"exited with code {code}"
|
||||
|
||||
|
||||
def session(world, workloads, cfg, report, show):
|
||||
"""One set of rank processes running these workloads at one world size, in order; results go into report as they
|
||||
arrive, and show(w) prints each. Returns the workloads it did not reach: after a failure at one workload (which gets
|
||||
the error), those after it; [] when every one ran, or when the failure came before any (every one gets it)."""
|
||||
ctx = torch.multiprocessing.get_context("spawn")
|
||||
port = free_port()
|
||||
pipes = [ctx.Pipe(duplex=False) for _ in range(world)]
|
||||
procs = [ctx.Process(target=worker, args=(r, world, port, dict(cfg, workloads=workloads), pipes[r][1]),
|
||||
daemon=True) for r in range(world)]
|
||||
for p in procs:
|
||||
p.start()
|
||||
for _, send in pipes:
|
||||
send.close()
|
||||
receivers = {pipes[r][0]: r for r in range(world)}
|
||||
done, errors, finished, uuids = set(), [], [], {}
|
||||
current, clock, failure = None, None, None
|
||||
deadline = time.monotonic() + SESSION_START_SEC
|
||||
started = time.monotonic()
|
||||
|
||||
def handle(msg):
|
||||
nonlocal current, clock, deadline
|
||||
kind = msg[0]
|
||||
if kind == "ready":
|
||||
_, rank, uuid, prepare_s = msg
|
||||
uuids[rank] = uuid
|
||||
if rank == 0:
|
||||
report.setdefault("prepare_s", {})[str(world)] = prepare_s
|
||||
if len(uuids) == world:
|
||||
clock = Clock([uuids[r] for r in range(world)])
|
||||
ready = f", prepare {report['prepare_s'][str(world)]:.1f} s" if prepare_s is not None else ""
|
||||
print(f"world {world}: {world} rank{'s' if world > 1 else ''} up in {time.monotonic() - started:.0f} s"
|
||||
f"{ready}", flush=True)
|
||||
elif kind == "start":
|
||||
current = msg[1]
|
||||
deadline = time.monotonic() + WORKLOAD_SEC
|
||||
elif kind == "result":
|
||||
_, w, res = msg
|
||||
windows = res.pop("windows", None)
|
||||
means = clock.means(windows) if clock and windows else []
|
||||
if means and means[0]:
|
||||
res["clock_mhz"] = means[0]
|
||||
if world > 1 and means and all(means):
|
||||
res["world_clock_mhz"] = [min(means), max(means)]
|
||||
report["workloads"][w] = res
|
||||
finished.append(w)
|
||||
current = None
|
||||
deadline = time.monotonic() + WORKLOAD_SEC
|
||||
show(w)
|
||||
elif kind == "error":
|
||||
errors.append(msg[1:])
|
||||
elif kind == "done":
|
||||
done.add(msg[1])
|
||||
|
||||
try:
|
||||
while len(done) < world:
|
||||
# A rank that exited cleanly may still have its last messages in its pipe: it fails once that is read.
|
||||
bad = [(r, p.exitcode) for r, p in enumerate(procs) if r not in done and p.exitcode is not None
|
||||
and (p.exitcode != 0 or pipes[r][0] not in receivers)]
|
||||
if bad:
|
||||
failure = "; ".join(f"rank {r} {exit_text(code)}" for r, code in bad)
|
||||
break
|
||||
left = deadline - time.monotonic()
|
||||
if left <= 0:
|
||||
failure = (f"timed out after {WORKLOAD_SEC} s" if current else
|
||||
f"the ranks did not start within {SESSION_START_SEC} s")
|
||||
break
|
||||
waits = list(receivers) + [p.sentinel for p in procs if p.exitcode is None]
|
||||
for obj in connection.wait(waits, timeout=min(left, 5.0)):
|
||||
if obj in receivers:
|
||||
try:
|
||||
handle(obj.recv())
|
||||
except (EOFError, OSError):
|
||||
del receivers[obj]
|
||||
# What the other ranks still say, such as the error that made one exit.
|
||||
drain = time.monotonic() + 2.0
|
||||
while failure and receivers and time.monotonic() < drain:
|
||||
for obj in connection.wait(list(receivers), timeout=max(0.0, drain - time.monotonic())):
|
||||
try:
|
||||
handle(obj.recv())
|
||||
except (EOFError, OSError):
|
||||
del receivers[obj]
|
||||
finally:
|
||||
stop(procs, 0.0 if failure else STOP_GRACE_SEC)
|
||||
if clock:
|
||||
clock.stop()
|
||||
if not failure:
|
||||
return []
|
||||
|
||||
# The first error a rank reported says what failed; else how a rank ended.
|
||||
if errors:
|
||||
rank, at, phase, text = errors[0]
|
||||
if phase == "load":
|
||||
report["load_error"] = text.strip().splitlines()[-1]
|
||||
text = (f"rank {rank}: " if world > 1 else "") + text.strip()
|
||||
else:
|
||||
text = failure
|
||||
rest = [w for w in workloads if w not in finished]
|
||||
at = current or (errors and errors[0][1])
|
||||
if at not in rest:
|
||||
at = rest[0] if rest and finished else None
|
||||
if not at or report.get("load_error"):
|
||||
for w in rest:
|
||||
report["workloads"][w] = {"error": text, "world": world, "clip": spec.WORKLOADS[w]["clip"],
|
||||
"timed": spec.WORKLOADS[w]["timed"]}
|
||||
show(w)
|
||||
return []
|
||||
report["workloads"][at] = {"error": text, "world": world, "clip": spec.WORKLOADS[at]["clip"],
|
||||
"timed": spec.WORKLOADS[at]["timed"]}
|
||||
show(at)
|
||||
return rest[rest.index(at) + 1:]
|
||||
|
||||
|
||||
def kernel_lines(res, limit=25):
|
||||
"""The audited call's kernels by GPU time, one line each."""
|
||||
rows = res.get("kernels", [])
|
||||
total = sum(r[3] for r in rows) or 1
|
||||
lines = [f" {'[' + lib + ']' if lib else '':<9} {ns / 1e6:10.2f} ms {100 * ns / total:5.1f}% x{n:<6} "
|
||||
f"{name if name.startswith('(') else audit.short_name(name, 110)}" for lib, name, n, ns in rows[:limit]]
|
||||
if len(rows) > limit:
|
||||
rest = rows[limit:]
|
||||
lines.append(f" {'':<9} {sum(r[3] for r in rest) / 1e6:10.2f} ms "
|
||||
f"{100 * sum(r[3] for r in rest) / total:5.1f}% {len(rest)} more kinds")
|
||||
return lines
|
||||
|
||||
|
||||
def main():
|
||||
p = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||
p.add_argument("--solution", default="solution", help="the solution directory (default: solution)")
|
||||
p.add_argument("--workload", nargs="*", help="workloads to run (default: every one this job has the GPUs for)")
|
||||
p.add_argument("--no-time", action="store_true", help="correctness only: no audit, no trials")
|
||||
p.add_argument("--no-check", action="store_true", help="do not check the outputs (CI's timing runs)")
|
||||
p.add_argument("--no-audit", action="store_true", help="no call under CUPTI")
|
||||
p.add_argument("--kernels", action="store_true", help="list the audited calls' kernels by GPU time")
|
||||
p.add_argument("--build-dir", help="write and build the solution here; reused when it holds the same build")
|
||||
p.add_argument("--no-cache", action="store_true", help="build fresh, without the node's cache of builds")
|
||||
p.add_argument("--save", help="CI: save each workload's checked output here instead of checking it")
|
||||
p.add_argument("--json", help="write the results to this file")
|
||||
a = p.parse_args()
|
||||
unknown = [w for w in a.workload or [] if w not in spec.WORKLOADS]
|
||||
if unknown:
|
||||
p.error(f"unknown workloads {unknown}; known: {list(spec.WORKLOADS)}")
|
||||
# Absolute paths, the data's too: the ranks run in the solution's working directory.
|
||||
solution = os.path.abspath(a.solution)
|
||||
if os.path.isfile(solution):
|
||||
solution = os.path.dirname(solution)
|
||||
a.build_dir, a.save, a.json = (x and os.path.abspath(x) for x in (a.build_dir, a.save, a.json))
|
||||
os.environ["DATA"] = os.path.abspath(spec.data_dir())
|
||||
report = {"workloads": {}}
|
||||
|
||||
def finish():
|
||||
if a.json:
|
||||
with open(a.json, "w") as f:
|
||||
json.dump(report, f)
|
||||
|
||||
rules = audit.rule_table(getattr(spec, "RULES", None))
|
||||
report["rules"] = audit.check(solution, rules)
|
||||
for v in report["rules"]:
|
||||
print(f"rule violated: {v}")
|
||||
print(f"stack rules: {'ok' if not report['rules'] else 'FAIL'} ({len(rules)} rules)")
|
||||
|
||||
# The workloads: those named, or every one this job has the GPUs for.
|
||||
gpus = torch.cuda.device_count()
|
||||
chosen = a.workload or [w for w, e in spec.WORKLOADS.items() if e["world"] <= gpus]
|
||||
skipped = [w for w in spec.WORKLOADS if w not in chosen and not a.workload]
|
||||
if skipped:
|
||||
print(f"skipped {', '.join(skipped)}: this job has {gpus} GPU{'s' if gpus != 1 else ''}")
|
||||
order = [w for w in spec.WORKLOADS if w in chosen]
|
||||
timed = [w for w in order if spec.WORKLOADS[w]["timed"] and not a.no_time]
|
||||
limits, dev, peak = bounds.workload_bounds(spec, timed)
|
||||
if limits:
|
||||
print(bounds.describe(dev, peak))
|
||||
|
||||
try:
|
||||
workdir, build_spec, seconds = loader.stage(solution, a.build_dir,
|
||||
cache=None if a.no_cache else loader.cache_dir())
|
||||
except Exception as e:
|
||||
report["load_error"] = str(e) if isinstance(e, loader.SolutionError) else traceback.format_exc(limit=6)
|
||||
print(f"FAILED to build {solution}:\n{report['load_error']}", flush=True)
|
||||
finish()
|
||||
sys.exit(1)
|
||||
report |= {"language": build_spec["languages"][0], "build_s": seconds}
|
||||
built = f"built in {seconds:.1f} s" if seconds is not None else "a build reused"
|
||||
print(f"{build_spec['entry_point']} ({', '.join(build_spec['languages'])}): {built}", flush=True)
|
||||
|
||||
width = max(len(w) for w in spec.WORKLOADS)
|
||||
|
||||
def show(w):
|
||||
res = report["workloads"][w]
|
||||
line = f"{w:<{width}}"
|
||||
if "ms" in res:
|
||||
at = f", SM clock {res['clock_mhz']:.0f} MHz" if "clock_mhz" in res else ""
|
||||
low, high = res.get("world_clock_mhz", (None, None))
|
||||
if low and "clock_mhz" in res and high - low >= 0.02 * res["clock_mhz"]:
|
||||
at += f", the world's GPUs {low:.0f}-{high:.0f} MHz"
|
||||
line += f" {res['ms']:9.1f} ms (fastest trial {res['min_ms']:.1f}{at})"
|
||||
if w in limits:
|
||||
res |= {"bound_ms": limits[w], "gap": res["ms"] / limits[w]}
|
||||
line += f" bound {limits[w]:.1f} ms, gap x{res['gap']:.2f}"
|
||||
if "check" in res:
|
||||
line += " " + verdict(res["check"])
|
||||
elif "saved" in res:
|
||||
line += " saved"
|
||||
if not res.get("timed", True):
|
||||
line += " (correctness only)"
|
||||
if "lib" in res:
|
||||
line += f" library kernels {res['lib']} of {res['launches']} ({100 * res['lib_share']:.0f}% of GPU time)"
|
||||
if res.get("comm"):
|
||||
line += f", NCCL {res['comm']} ({100 * res['comm_share']:.0f}%)"
|
||||
if "shared" in res:
|
||||
line += f" SHARED: {res['shared']} other process(es) held a GPU while it was timed"
|
||||
if "error" in res:
|
||||
line += " ERROR\n" + res["error"]
|
||||
print(line, flush=True)
|
||||
if a.kernels and "kernels" in res:
|
||||
print("\n".join(kernel_lines(res)), flush=True)
|
||||
finish()
|
||||
|
||||
cfg = {"workdir": workdir, "spec": build_spec, "time": not a.no_time, "check": not a.no_check,
|
||||
"audit": not a.no_audit and (not a.no_time or a.kernels), "save": a.save}
|
||||
for world in sorted({spec.WORKLOADS[w]["world"] for w in order}):
|
||||
todo = [w for w in order if spec.WORKLOADS[w]["world"] == world]
|
||||
if world > gpus or report.get("load_error"):
|
||||
why = report.get("load_error") or f"needs {world} GPUs; this job has {gpus}"
|
||||
for w in todo:
|
||||
report["workloads"][w] = {"error": why, "world": world, "clip": spec.WORKLOADS[w]["clip"],
|
||||
"timed": spec.WORKLOADS[w]["timed"]}
|
||||
show(w)
|
||||
continue
|
||||
while todo:
|
||||
todo = session(world, todo, cfg, report, show)
|
||||
|
||||
results = [report["workloads"][w] for w in order if w in report["workloads"]]
|
||||
times = [report["workloads"].get(w, {}).get("ms") for w in timed]
|
||||
if timed and all(times):
|
||||
gaps = [report["workloads"][w]["gap"] for w in timed if "gap" in report["workloads"][w]]
|
||||
gap = f", gap to the bound x{bounds.geomean(gaps):.2f}" if len(gaps) == len(timed) else ""
|
||||
clocks = [report["workloads"][w].get("clock_mhz") for w in timed]
|
||||
at = f", mean SM clock {statistics.mean(clocks):.0f} MHz" if all(clocks) else ""
|
||||
print(f"geomean {bounds.geomean(times):.1f} ms over {len(timed)} timed workloads (mean of trials){gap}{at}")
|
||||
shared = [w for w in order if "shared" in report["workloads"].get(w, {})]
|
||||
if shared:
|
||||
print(f"SHARED GPU: another process held one while {', '.join(shared)} "
|
||||
f"{'was' if len(shared) == 1 else 'were'} timed, so those times do not compare with another job's")
|
||||
checked = [r for r in results if "check" in r]
|
||||
if checked:
|
||||
ok = sum(1 for r in checked if r["check"]["passed"] and "error" not in r)
|
||||
print(f"correctness: ok at {ok} of {len(checked)} workloads checked")
|
||||
failed = [w for w in order if "error" in report["workloads"].get(w, {})]
|
||||
if failed:
|
||||
print(f"errors at {', '.join(failed)}")
|
||||
audited = [r for r in results if "kernels" in r]
|
||||
if audited:
|
||||
libs = collections.Counter()
|
||||
for r in audited:
|
||||
for lib, _, n, _ in r["kernels"]:
|
||||
if audit.placeholder(lib):
|
||||
libs[lib] += n
|
||||
detail = ", ".join(f"{k} {v}" for k, v in libs.most_common())
|
||||
share = statistics.mean(r["lib_share"] for r in audited)
|
||||
print(f"library kernels: {sum(libs.values())} launches in {len(audited)} audited calls"
|
||||
f"{' (' + detail + ')' if detail else ''}, {100 * share:.0f}% of GPU time on average")
|
||||
finish()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,156 @@
|
||||
"""The physical lower bound of a call's time, from the work it must do and what the GPUs can do per second.
|
||||
|
||||
A task's spec.py may define
|
||||
|
||||
bounds(workload) -> {"flops": float, "bytes": float, "gpus": int} optionally also "math": one of PEAKS ("f16")
|
||||
|
||||
lower bounds for one call of run() at that workload: the floating-point operations any correct algorithm performs
|
||||
(its convolutions and matrix products) and the bytes the busiest GPU must move to or from its memory at least once,
|
||||
and the GPUs the call runs on (default 1). The bound time is then
|
||||
|
||||
bound = max(flops / (gpus x peak FLOP/s of the chosen math), bytes / memory bandwidth)
|
||||
|
||||
with the peaks of the GPU it runs on at its SM clock: a call cannot be faster than the slower of the two, even with
|
||||
its FLOPs spread evenly over its GPUs and nothing else to do, so the gap, measured / bound, says how much of the way
|
||||
remains. The peaks are estimates, derived as SM count x SM clock x per-SM rate per clock (DEVICES), not measured; they
|
||||
are the same for every solution, so gaps compare. The SM clock is the one GUILD_SM_CLOCK_MHZ names, where a task's
|
||||
environment sets it, else the one DEVICES gives the GPU, else the GPU's highest SM clock: GB200 clocks are not locked,
|
||||
and read low while they idle.
|
||||
|
||||
python tools/bounds.py [--spec spec.py] the bound of every workload on this GPU (or the reference device)
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import math
|
||||
import os
|
||||
import subprocess
|
||||
|
||||
# Per compute capability: dense per-SM throughput per clock, and the memory bandwidth. Estimates from the
|
||||
# architecture's published figures; the memory clock does not follow the SM clock. `sm_clock_mhz`, where given, is the
|
||||
# clock to assume instead of the GPU's.
|
||||
DEVICES = {
|
||||
(10, 0): {
|
||||
"name": "Blackwell, compute capability 10.0",
|
||||
# FLOP per SM per clock, dense (no structured sparsity), fp32 accumulation.
|
||||
"per_sm_clk": {
|
||||
"f16": 8192, # FP16/BF16 tensor cores (tcgen05.mma kind::f16)
|
||||
"tf32": 4096, # TF32 tensor cores (kind::tf32)
|
||||
"f32": 256, # FP32 FMA on the CUDA cores: 128 lanes x 2
|
||||
},
|
||||
"bytes_per_s": 8.0e12, # HBM3e, about 8 TB/s per GPU
|
||||
},
|
||||
}
|
||||
PEAKS = {"f16": "16-bit tensor cores", "tf32": "TF32 tensor cores", "f32": "FP32 CUDA cores"}
|
||||
# Where no GPU is visible (on a laptop, say): a GB200 GPU at its highest SM clock.
|
||||
REFERENCE_DEVICE = {"capability": (10, 0), "sms": 152, "clock_mhz": 2062}
|
||||
|
||||
|
||||
def sm_clock_mhz(uuid=None):
|
||||
"""The highest SM clock of the GPU with this UUID, or the reference device's."""
|
||||
try:
|
||||
cmd = ["nvidia-smi", "--query-gpu=clocks.max.sm", "--format=csv,noheader,nounits"]
|
||||
if uuid:
|
||||
cmd.insert(1, f"--id={uuid}")
|
||||
out = subprocess.run(cmd, capture_output=True, text=True, timeout=20).stdout.split()
|
||||
return float(out[0]) if out else REFERENCE_DEVICE["clock_mhz"]
|
||||
except (OSError, ValueError, subprocess.SubprocessError):
|
||||
return REFERENCE_DEVICE["clock_mhz"]
|
||||
|
||||
|
||||
def assumed_clock_mhz():
|
||||
"""The SM clock a task's environment says the bound assumes (GUILD_SM_CLOCK_MHZ), or None where it says none."""
|
||||
try:
|
||||
value = float(os.environ.get("GUILD_SM_CLOCK_MHZ", ""))
|
||||
except ValueError:
|
||||
return None
|
||||
return value if value > 0 else None
|
||||
|
||||
|
||||
def device():
|
||||
"""{"capability", "sms", "clock_mhz"} of CUDA device 0, or REFERENCE_DEVICE where there is none. It asks the GPU's
|
||||
properties only, which makes no CUDA context: a process that times others must hold none."""
|
||||
try:
|
||||
import torch
|
||||
|
||||
if torch.cuda.is_available():
|
||||
p = torch.cuda.get_device_properties(0)
|
||||
uuid = str(getattr(p, "uuid", "") or "")
|
||||
uuid = uuid if not uuid or uuid.startswith(("GPU-", "MIG-")) else f"GPU-{uuid}"
|
||||
capability = (p.major, p.minor)
|
||||
clock = assumed_clock_mhz() or DEVICES.get(capability, {}).get("sm_clock_mhz") or sm_clock_mhz(uuid)
|
||||
return {"capability": capability, "sms": p.multi_processor_count, "clock_mhz": clock}
|
||||
except ImportError:
|
||||
pass
|
||||
return dict(REFERENCE_DEVICE)
|
||||
|
||||
|
||||
def peaks(dev):
|
||||
"""{"f16": FLOP/s, "tf32": ..., "f32": ..., "bytes_per_s": B/s} of one GPU, or None when DEVICES lacks it."""
|
||||
table = DEVICES.get(tuple(dev["capability"]))
|
||||
if table is None:
|
||||
return None
|
||||
hz = dev["clock_mhz"] * 1e6
|
||||
out = {k: v * dev["sms"] * hz for k, v in table["per_sm_clk"].items()}
|
||||
out["bytes_per_s"] = table["bytes_per_s"]
|
||||
return out
|
||||
|
||||
|
||||
def bound_ms(work, peak):
|
||||
"""The bound time in ms of one call doing `work` ({"flops", "bytes"[, "gpus", "math"]}) on GPUs with these peaks."""
|
||||
math_kind = work.get("math", "f16")
|
||||
if math_kind not in PEAKS:
|
||||
raise ValueError(f"bounds(): math must be one of {sorted(PEAKS)}, not {math_kind!r}")
|
||||
flops, nbytes = float(work.get("flops", 0.0)), float(work.get("bytes", 0.0))
|
||||
gpus = int(work.get("gpus", 1))
|
||||
return 1e3 * max(flops / (gpus * peak[math_kind]), nbytes / peak["bytes_per_s"])
|
||||
|
||||
|
||||
def describe(dev, peak):
|
||||
"""One line on how the bound is computed here."""
|
||||
return (f"bound = max(FLOPs / (GPUs x {peak['f16'] / 1e15:.2f} PFLOP/s) ({PEAKS['f16']}, dense), bytes / "
|
||||
f"{peak['bytes_per_s'] / 1e12:.1f} TB/s), estimated for {dev['sms']} SMs per GPU at "
|
||||
f"{dev['clock_mhz']:.0f} MHz")
|
||||
|
||||
|
||||
def workload_bounds(spec, workloads, dev=None):
|
||||
"""{workload: bound ms} for a spec that defines bounds(), with the device and its peaks; ({}, dev, None) when the
|
||||
spec has no bounds() or the device no peak figures."""
|
||||
dev = dev or device()
|
||||
peak = peaks(dev)
|
||||
fn = getattr(spec, "bounds", None)
|
||||
if fn is None or peak is None:
|
||||
return {}, dev, peak
|
||||
return {w: bound_ms(fn(w), peak) for w in workloads}, dev, peak
|
||||
|
||||
|
||||
def geomean(xs):
|
||||
xs = list(xs)
|
||||
return math.exp(sum(math.log(x) for x in xs) / len(xs)) if xs else math.nan
|
||||
|
||||
|
||||
def main():
|
||||
import importlib.util
|
||||
|
||||
here = os.path.dirname(os.path.abspath(__file__))
|
||||
p = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||
p.add_argument("--spec", default=os.path.join(os.environ.get("CHECKS", here), "spec.py"))
|
||||
a = p.parse_args()
|
||||
module_spec = importlib.util.spec_from_file_location("spec", a.spec)
|
||||
spec = importlib.util.module_from_spec(module_spec)
|
||||
module_spec.loader.exec_module(spec)
|
||||
workloads = [w for w, e in spec.WORKLOADS.items() if e.get("timed", True)]
|
||||
bounds, dev, peak = workload_bounds(spec, workloads)
|
||||
if peak is None:
|
||||
raise SystemExit(f"no peak figures for compute capability {dev['capability']}")
|
||||
if not bounds:
|
||||
raise SystemExit(f"{a.spec} defines no bounds(workload)")
|
||||
print(describe(dev, peak))
|
||||
for w, ms in bounds.items():
|
||||
work = spec.bounds(w)
|
||||
print(f"{w:<10} {ms:10.1f} ms {work['flops'] / 1e15:8.4f} PFLOP over {work.get('gpus', 1)} GPU(s) "
|
||||
f"{work['bytes'] / 1e9:6.2f} GB")
|
||||
print(f"geomean {geomean(bounds.values()):.1f} ms over {len(bounds)} workloads")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,542 @@
|
||||
"""Builds and loads a solution exactly as it is deployed, so that what runs here runs there.
|
||||
|
||||
A solution is a directory holding exactly the files that are deployed, plus `solution.json`, the deployment's build
|
||||
spec, with its field names and meaning:
|
||||
|
||||
{"languages": ["cuda_cpp"], "entry_point": "binding.cpp::run", "destination_passing_style": true,
|
||||
"compile_options": {"cflags": [...], "cuda_cflags": [...], "ld_flags": [...]}, "dependencies": []}
|
||||
|
||||
- `languages`: always ["cuda_cpp"].
|
||||
- `entry_point`: "<file>::<function>", the function the deployment calls, in a C++/CUDA file.
|
||||
- `destination_passing_style`: required, and true for this task: run() writes its output into `out`, its last
|
||||
argument, and returns nothing. The calling convention must never be left to inference.
|
||||
- `compile_options` (optional): extra flags; see the build below. `dependencies` (optional): a list of names, which
|
||||
does not change the build. `binding` (optional): null or "torch".
|
||||
Any other field is refused: the deployment would ignore it, so a misspelled field would silently fall back to its
|
||||
default. Every other file in the directory is a source: non-empty UTF-8 text at a relative path, a plain file (no
|
||||
links; `__pycache__` and `.pyc` files are ignored).
|
||||
|
||||
The module exports the entry, called positionally as the deployment calls it (argument names play no part):
|
||||
|
||||
run(z, weights, rank, world, pg, out) every call; writes out on rank 0
|
||||
prepare(weights, rank, world, pg) -> list[Tensor] optional, under that name: once per process, before run
|
||||
|
||||
(spec.py says what each argument is.) Load one solution per process.
|
||||
|
||||
stage(solution_dir) writes the sources into a fresh working directory and compiles every .cpp/.cc/.cxx/.c/.cu file at
|
||||
its top into one extension module, in a separate process, with torch.utils.cpp_extension.load, build_directory = the
|
||||
working directory, include paths [working directory, $CUTLASS_DIR/include, $CUTLASS_DIR/tools/util/include] ($CUTLASS_DIR
|
||||
defaults to fastvideo-kernel/include/cutlass, the CUTLASS submodule), and these
|
||||
flags on top of torch's own:
|
||||
- no compile_options: cuda_cflags [], cflags [], ld_flags ["-lcuda"];
|
||||
- compile_options given: its lists, where a missing one defaults to cflags [], cuda_cflags ["-O3",
|
||||
"--use_fast_math"], ld_flags ["-lcuda"];
|
||||
- unless a cuda_cflag names an architecture (-gencode, -arch), a -gencode flag for the GPU's own architecture with
|
||||
its arch-specific features goes first (sm_100a on compute capability 10.0), as the deployment does for its GPU.
|
||||
Files in subdirectories are not compiled, but can be included. import_built(working directory) then makes it the
|
||||
current directory, as the deployment does, imports the module and takes run (and prepare) from it; load() does both.
|
||||
The module's name is the build's, not the solution's: PYBIND11_MODULE must be given TORCH_EXTENSION_NAME, as the
|
||||
deployment builds the module under a name of its own. bench.py stages a build once and its ranks import that build.
|
||||
|
||||
Development builds are kept on each node, so that a job does not compile again what an earlier job on its node built:
|
||||
a content-addressed cache in a directory of the node that setup/node.sh makes for the agent user (CACHE_DIR, or
|
||||
$GUILD_BUILD_CACHE), keyed by everything that determines the build: the sources, solution.json, the flags, the target
|
||||
architecture, this loader, and the toolchain (the machine, Python, torch with its CUDA, nvcc, the host compiler, and
|
||||
the variables they read). A build found there is copied into the working directory in place of compiling it; one made
|
||||
is put there with its sources and module, without what the compiler left beside them. Only the commands of a
|
||||
development copy of the harness use it (tools/ in a repo: `loader.py check` and `build`, `bench.py`), unless they are
|
||||
given --no-cache. The copy in the task's checks/, which CI, the hooks and the task's commands run, never reads or
|
||||
writes it and builds fresh: the jobs of agents write that directory, so what it holds is theirs to forge. Past a
|
||||
quarter of its filesystem, a limit that only stops a runaway, the least recently used builds are removed.
|
||||
|
||||
python tools/loader.py check [solution] [--build-dir DIR] build and load it, print what was loaded
|
||||
python tools/loader.py build [solution] [DIR] only write and build it in DIR (default build/): with
|
||||
--arch (e.g. sm_100a), on a machine without a GPU,
|
||||
such as a CPU builder, where the module is not loaded
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import atexit
|
||||
import functools
|
||||
import hashlib
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
import platform
|
||||
import re
|
||||
import shutil
|
||||
import stat
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
|
||||
SPEC_FILE = "solution.json"
|
||||
# The name the build gives the module; solutions never name it themselves (see the module docstring). It is this
|
||||
# kernel's own: another kernel's loader may build an extension of a generic name (solution_ext), and in one process the
|
||||
# second extension of a name loads as the first one.
|
||||
MODULE = "wan22_vae_decode_ext"
|
||||
# The optional function a solution exports beside its entry, called once per process before any call of the entry.
|
||||
PREPARE = "prepare"
|
||||
BINDINGS = ("torch",)
|
||||
CPP_ENTRY_SUFFIXES = (".cu", ".cpp", ".cc", ".cxx", ".c", ".h", ".hpp", ".cuh")
|
||||
# The fields solution.json may hold.
|
||||
SPEC_FIELDS = ("languages", "entry_point", "dependencies", "destination_passing_style", "binding", "compile_options")
|
||||
OPTION_FIELDS = ("cflags", "cuda_cflags", "ld_flags")
|
||||
# The languages a solution may declare, and the files compiled.
|
||||
LANGUAGES = ("cuda_cpp",)
|
||||
CPP_SOURCES = (".cu", ".cpp", ".cc", ".cxx", ".c")
|
||||
DEFAULT_OPTIONS = {"cflags": [], "cuda_cflags": ["-O3", "--use_fast_math"], "ld_flags": ["-lcuda"]}
|
||||
BUILD_TIMEOUT_SEC = 1800
|
||||
# The first argument of a PYBIND11_MODULE in a source.
|
||||
MODULE_NAME = re.compile(r"\bPYBIND11_MODULE\s*\(\s*([A-Za-z_]\w*)")
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
# The node's cache of development builds (see the module docstring), and what each build there holds beside its sources.
|
||||
CACHE_DIR = "/scratch/kdev-builds"
|
||||
STAMP, SIZE = ".stamp", ".size"
|
||||
# The share of its filesystem past which the cache removes its least recently used builds: a limit that stops a runaway.
|
||||
CACHE_SHARE = 0.25
|
||||
# What a job that stopped while it stored a build left in the cache is removed after this long.
|
||||
STALE_SEC = 3600
|
||||
# The variables the compilers read, which change what they make.
|
||||
BUILD_VARIABLES = ("CC", "CXX", "CUDA_HOME", "CUDA_PATH", "CUTLASS_DIR", "NVCC_PREPEND_FLAGS", "NVCC_APPEND_FLAGS",
|
||||
"CPATH", "C_INCLUDE_PATH", "CPLUS_INCLUDE_PATH", "LIBRARY_PATH")
|
||||
|
||||
|
||||
class SolutionError(Exception):
|
||||
"""The solution is malformed, or fails to build or load."""
|
||||
|
||||
|
||||
def _nonempty_str(x):
|
||||
return isinstance(x, str) and len(x) >= 1
|
||||
|
||||
|
||||
def _str_list(x):
|
||||
return isinstance(x, list) and all(isinstance(s, str) for s in x)
|
||||
|
||||
|
||||
def spec_problems(spec):
|
||||
"""Problems of a build spec, as a list of strings (empty: valid); see the module docstring.
|
||||
|
||||
Stricter than the deployment only where it would coerce a value (a "true" string as a boolean) or fall back to a
|
||||
default (an unknown field, a missing destination_passing_style)."""
|
||||
if not isinstance(spec, dict):
|
||||
return [f"{SPEC_FILE} must hold an object"]
|
||||
out = []
|
||||
unknown = sorted(set(spec) - set(SPEC_FIELDS))
|
||||
if unknown:
|
||||
out.append(f"unknown fields {unknown} (known: {list(SPEC_FIELDS)})")
|
||||
langs = spec.get("languages")
|
||||
if not isinstance(langs, list) or not langs or any(lang not in LANGUAGES for lang in langs):
|
||||
out.append(f"languages: required, {list(LANGUAGES)}")
|
||||
entry = spec.get("entry_point")
|
||||
if not _nonempty_str(entry):
|
||||
out.append("entry_point: required, a non-empty string")
|
||||
elif entry.count("::") != 1:
|
||||
out.append(f'entry_point: {entry!r} is not "<file_path>::<function_name>"')
|
||||
elif os.path.splitext(entry.split("::")[0])[1] not in CPP_ENTRY_SUFFIXES:
|
||||
out.append(f"entry_point: needs a C++/CUDA entry file, not {entry.split('::')[0]!r}")
|
||||
deps = spec.get("dependencies", [])
|
||||
if not isinstance(deps, list) or not all(_nonempty_str(d) for d in deps):
|
||||
out.append("dependencies: a list of non-empty strings")
|
||||
if "destination_passing_style" not in spec:
|
||||
out.append("destination_passing_style: must be given, and true here (run writes out, its last argument)")
|
||||
elif spec["destination_passing_style"] is not True:
|
||||
out.append("destination_passing_style: true: this task's run() writes the video into out, its last argument, "
|
||||
"and returns nothing")
|
||||
if spec.get("binding") not in (None,) + BINDINGS:
|
||||
out.append(f"binding: null or one of {list(BINDINGS)}")
|
||||
options = spec.get("compile_options")
|
||||
if options is not None:
|
||||
if not isinstance(options, dict):
|
||||
out.append("compile_options: null or an object")
|
||||
else:
|
||||
for k in OPTION_FIELDS:
|
||||
if k in options and not _str_list(options[k]):
|
||||
out.append(f"compile_options.{k}: a list of strings")
|
||||
return out
|
||||
|
||||
|
||||
def read_spec(solution_dir):
|
||||
"""solution.json, checked against the build spec's rules (see the module docstring)."""
|
||||
path = os.path.join(solution_dir, SPEC_FILE)
|
||||
try:
|
||||
with open(path) as f:
|
||||
spec = json.load(f)
|
||||
except (OSError, ValueError) as e:
|
||||
raise SolutionError(f"cannot read {path}: {e}") from e
|
||||
problems = spec_problems(spec)
|
||||
if problems:
|
||||
raise SolutionError(f"{SPEC_FILE}: " + "; ".join(problems))
|
||||
return spec
|
||||
|
||||
|
||||
def sources(solution_dir):
|
||||
"""{relative path: text} of every file of the solution except solution.json."""
|
||||
out = {}
|
||||
for root, dirs, files in os.walk(solution_dir):
|
||||
dirs[:] = sorted(d for d in dirs if d != "__pycache__")
|
||||
for name in sorted(files):
|
||||
path = os.path.join(root, name)
|
||||
rel = os.path.relpath(path, solution_dir).replace(os.sep, "/")
|
||||
if rel == SPEC_FILE or name.endswith(".pyc"):
|
||||
continue
|
||||
if os.path.islink(path):
|
||||
raise SolutionError(f"{rel}: sources must be plain files, not links")
|
||||
try:
|
||||
with open(path, encoding="utf-8") as f:
|
||||
text = f.read()
|
||||
except (OSError, UnicodeDecodeError) as e:
|
||||
raise SolutionError(f"{rel}: sources must be UTF-8 text ({e})") from e
|
||||
if not text:
|
||||
raise SolutionError(f"{rel}: sources must not be empty")
|
||||
out[rel] = text
|
||||
if not out:
|
||||
raise SolutionError(f"{solution_dir} holds no sources")
|
||||
named = sorted({m for text in out.values() for m in MODULE_NAME.findall(text) if m != "TORCH_EXTENSION_NAME"})
|
||||
if named:
|
||||
raise SolutionError(f"PYBIND11_MODULE names its module {', '.join(named)}: give it TORCH_EXTENSION_NAME, the "
|
||||
f"name the build chooses (the deployment builds the module under a name of its own)")
|
||||
return out
|
||||
|
||||
|
||||
def read(solution_dir):
|
||||
"""(spec, {relative path: text}) of a solution, checked as stage() checks it before building: solution.json against
|
||||
the build spec's rules, the sources as the deployment takes them, the entry file among them."""
|
||||
spec = read_spec(solution_dir)
|
||||
files = sources(solution_dir)
|
||||
entry = spec["entry_point"].split("::")[0]
|
||||
if entry not in files:
|
||||
raise SolutionError(f"entry file {entry} is not among the sources")
|
||||
return spec, files
|
||||
|
||||
|
||||
def gpu_arch():
|
||||
"""The architecture solutions are built for, e.g. sm_100a: the GPU's own, with its arch-specific features. It asks
|
||||
the GPU's properties only, which makes no CUDA context."""
|
||||
import torch
|
||||
|
||||
if not torch.cuda.is_available():
|
||||
raise SolutionError("there is no GPU here to take the architecture from: name it (--arch sm_100a, or the "
|
||||
"architecture of the GPUs the build is for)")
|
||||
major, minor = torch.cuda.get_device_capability()
|
||||
return f"sm_{major}{minor}" + ("a" if major >= 9 else "")
|
||||
|
||||
|
||||
def compile_flags(spec, arch):
|
||||
"""(cflags, cuda_cflags, ld_flags) added to torch's own flags (see the module docstring)."""
|
||||
given = spec.get("compile_options")
|
||||
options = {"cflags": [], "cuda_cflags": [], "ld_flags": ["-lcuda"]} if given is None else {
|
||||
k: list(given.get(k, v)) for k, v in DEFAULT_OPTIONS.items()}
|
||||
if not any("-gencode" in f or "-arch" in f for f in options["cuda_cflags"]):
|
||||
options["cuda_cflags"].insert(0, f"-gencode=arch=compute_{arch[3:]},code={arch}")
|
||||
return options["cflags"], options["cuda_cflags"], options["ld_flags"]
|
||||
|
||||
|
||||
def cache_dir():
|
||||
"""The node's cache of development builds, or None where a build is made fresh: in the copy of the harness in the
|
||||
task's checks/ (CI, hooks and the task's commands run it, which must not load what a job could have put there), and
|
||||
on a node without a cache that this user may write."""
|
||||
checks = os.environ.get("CHECKS")
|
||||
if checks and os.path.realpath(checks) == os.path.realpath(HERE):
|
||||
return None
|
||||
cache = os.environ.get("GUILD_BUILD_CACHE", CACHE_DIR)
|
||||
return cache if os.path.isdir(cache) and os.access(cache, os.W_OK | os.X_OK) else None
|
||||
|
||||
|
||||
@functools.lru_cache(maxsize=None)
|
||||
def toolchain():
|
||||
"""What determines a build beside the solution, its flags and the architecture: this loader, the machine, Python,
|
||||
torch with its CUDA, nvcc, the host compiler, and the variables the compilers read."""
|
||||
import torch
|
||||
from torch.utils import cpp_extension
|
||||
|
||||
def version(cmd):
|
||||
try:
|
||||
p = subprocess.run(cmd, capture_output=True, text=True, timeout=120)
|
||||
return (p.stdout + p.stderr).strip()
|
||||
except (OSError, subprocess.TimeoutExpired) as e:
|
||||
return f"unavailable: {e}"
|
||||
|
||||
with open(os.path.abspath(__file__), "rb") as f:
|
||||
loader = hashlib.sha256(f.read()).hexdigest()
|
||||
return {"loader": loader, "machine": platform.machine(), "python": sys.version,
|
||||
"torch": [torch.__version__, torch.version.cuda, os.path.dirname(torch.__file__)],
|
||||
"nvcc": version([os.path.join(cpp_extension.CUDA_HOME or "/usr/local/cuda", "bin", "nvcc"), "--version"]),
|
||||
"cxx": version([os.environ.get("CXX", "c++"), "--version"]),
|
||||
"variables": {k: os.environ[k] for k in BUILD_VARIABLES if k in os.environ}}
|
||||
|
||||
|
||||
def cache_key(spec, files, flags, arch, tools):
|
||||
"""The key of a build in the cache: a hash of everything that determines it (see the module docstring)."""
|
||||
what = {"spec": spec, "files": sorted(files.items()), "flags": flags, "arch": arch, "toolchain": tools}
|
||||
return hashlib.sha256(json.dumps(what, sort_keys=True).encode()).hexdigest()
|
||||
|
||||
|
||||
def disk_usage(path):
|
||||
"""The bytes a directory takes on disk."""
|
||||
total = 0
|
||||
for root, _, names in os.walk(path):
|
||||
for name in names:
|
||||
try:
|
||||
st = os.lstat(os.path.join(root, name))
|
||||
except OSError:
|
||||
continue
|
||||
total += getattr(st, "st_blocks", 0) * 512 or st.st_size
|
||||
return total
|
||||
|
||||
|
||||
def fetch(cache, key, build_dir):
|
||||
"""Copies the build of a key from the cache into build_dir, in its place, and marks the build used; False when the
|
||||
cache has none, or it went while it was copied."""
|
||||
entry = os.path.join(cache, key)
|
||||
if not os.path.exists(os.path.join(entry, MODULE + ".so")):
|
||||
return False
|
||||
shutil.rmtree(build_dir, ignore_errors=True)
|
||||
try:
|
||||
shutil.copytree(entry, build_dir, symlinks=True, ignore=lambda d, _: [SIZE] if d == entry else [])
|
||||
os.utime(entry)
|
||||
except OSError:
|
||||
shutil.rmtree(build_dir, ignore_errors=True)
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def store(cache, key, build_dir, files):
|
||||
"""Puts the build in build_dir into the cache under its key: its sources, module and stamp, without what the
|
||||
compiler left beside them. A build of the key that another job put there first stays. Then the cache is kept within
|
||||
its limit. A cache that cannot take it (a full disk, say) is left as it is: the build stands without it."""
|
||||
entry = os.path.join(cache, key)
|
||||
if os.path.exists(entry):
|
||||
return
|
||||
try:
|
||||
tmp = tempfile.mkdtemp(prefix=f".{key[:16]}-", dir=cache)
|
||||
except OSError:
|
||||
return
|
||||
try:
|
||||
for rel in [*files, MODULE + ".so", STAMP]:
|
||||
dest = os.path.join(tmp, rel)
|
||||
os.makedirs(os.path.dirname(dest), exist_ok=True)
|
||||
shutil.copy2(os.path.join(build_dir, rel), dest)
|
||||
with open(os.path.join(tmp, SIZE), "w") as f:
|
||||
f.write(str(disk_usage(tmp)))
|
||||
os.rename(tmp, entry)
|
||||
except OSError:
|
||||
shutil.rmtree(tmp, ignore_errors=True)
|
||||
return
|
||||
try:
|
||||
st = os.statvfs(cache)
|
||||
evict(cache, CACHE_SHARE * st.f_blocks * st.f_frsize)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def evict(cache, limit):
|
||||
"""Removes the least recently used builds of the cache until it holds at most `limit` bytes, and what jobs that
|
||||
stopped while they stored a build left there (after STALE_SEC). Anything but a directory is left alone."""
|
||||
now = time.time()
|
||||
builds = []
|
||||
for name in os.listdir(cache):
|
||||
path = os.path.join(cache, name)
|
||||
try:
|
||||
st = os.lstat(path)
|
||||
except OSError:
|
||||
continue
|
||||
if not stat.S_ISDIR(st.st_mode):
|
||||
continue
|
||||
if name.startswith("."):
|
||||
if now - st.st_mtime > STALE_SEC:
|
||||
shutil.rmtree(path, ignore_errors=True)
|
||||
continue
|
||||
try:
|
||||
with open(os.path.join(path, SIZE)) as f:
|
||||
size = int(f.read())
|
||||
except (OSError, ValueError):
|
||||
size = disk_usage(path)
|
||||
builds.append((st.st_mtime, path, size))
|
||||
total = sum(size for _, _, size in builds)
|
||||
for _, path, size in sorted(builds):
|
||||
if total <= limit:
|
||||
break
|
||||
shutil.rmtree(path, ignore_errors=True)
|
||||
total -= size
|
||||
|
||||
|
||||
def stage(solution_dir, build_dir=None, arch=None, extra_cuda_cflags=(), cache=None):
|
||||
"""Writes the sources into build_dir (a fresh temporary directory if None) and builds them there.
|
||||
|
||||
Returns (workdir, spec, build seconds or None when an earlier build was reused). A build_dir that already holds
|
||||
a build of the same sources and flags is reused, so CI builds each solution once for its rounds. With a cache
|
||||
(cache_dir(): a development build), a build of the same key is copied from it, and one made is put there."""
|
||||
spec, files = read(solution_dir)
|
||||
arch = arch or gpu_arch()
|
||||
flags = compile_flags(spec, arch)
|
||||
# Flags a tool adds that do not change the code, such as -lineinfo for source-level profiles.
|
||||
flags[1].extend(extra_cuda_cflags)
|
||||
digest = hashlib.sha256(json.dumps([spec, flags, sorted(files.items())]).encode()).hexdigest()
|
||||
if build_dir is None:
|
||||
build_dir = tempfile.mkdtemp(prefix="solution-")
|
||||
atexit.register(shutil.rmtree, build_dir, True)
|
||||
build_dir = os.path.abspath(build_dir)
|
||||
stamp = os.path.join(build_dir, STAMP)
|
||||
try:
|
||||
with open(stamp) as f:
|
||||
if f.read() == digest and os.path.exists(os.path.join(build_dir, MODULE + ".so")):
|
||||
return build_dir, spec, None
|
||||
except OSError:
|
||||
pass
|
||||
key = cache and cache_key(spec, files, flags, arch, toolchain())
|
||||
if key and fetch(cache, key, build_dir):
|
||||
return build_dir, spec, None
|
||||
shutil.rmtree(build_dir, ignore_errors=True)
|
||||
os.makedirs(build_dir)
|
||||
for rel, text in files.items():
|
||||
dest = os.path.join(build_dir, rel)
|
||||
os.makedirs(os.path.dirname(dest), exist_ok=True)
|
||||
with open(dest, "w", encoding="utf-8") as f:
|
||||
f.write(text)
|
||||
start = time.monotonic()
|
||||
env = dict(os.environ, PYTORCH_ALLOC_CONF="expandable_segments:True")
|
||||
cmd = [sys.executable, "-s", os.path.abspath(__file__), "_build", json.dumps(flags)]
|
||||
try:
|
||||
p = subprocess.run(cmd, cwd=build_dir, env=env, capture_output=True, text=True, timeout=BUILD_TIMEOUT_SEC)
|
||||
except subprocess.TimeoutExpired as e:
|
||||
raise SolutionError(f"the build timed out after {BUILD_TIMEOUT_SEC} s") from e
|
||||
seconds = time.monotonic() - start
|
||||
if p.returncode != 0 or not os.path.exists(os.path.join(build_dir, MODULE + ".so")):
|
||||
log = (p.stdout + p.stderr).strip()
|
||||
raise SolutionError(f"the build failed after {seconds:.0f} s:\n{_errors(log)}")
|
||||
with open(stamp, "w") as f:
|
||||
f.write(digest)
|
||||
if key:
|
||||
store(cache, key, build_dir, files)
|
||||
return build_dir, spec, seconds
|
||||
|
||||
|
||||
def _errors(log, limit=6000):
|
||||
"""The compiler's error lines with some context, else the log's tail."""
|
||||
lines = log.splitlines()
|
||||
keep = [i for i, line in enumerate(lines) if "error" in line.lower()]
|
||||
if not keep:
|
||||
return log[-limit:]
|
||||
picked = sorted({j for i in keep for j in range(max(0, i - 2), min(len(lines), i + 3))})
|
||||
return "\n".join(lines[j] for j in picked)[-limit:]
|
||||
|
||||
|
||||
# fastvideo-kernel's CUTLASS submodule (git submodule update --init fastvideo-kernel/include/cutlass).
|
||||
_CUTLASS_SUBMODULE = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))), "include",
|
||||
"cutlass")
|
||||
|
||||
|
||||
def _build(flags):
|
||||
"""The compile step, run in the working directory by stage() in its own process."""
|
||||
import torch.utils.cpp_extension as ext
|
||||
|
||||
cflags, cuda_cflags, ld_flags = json.loads(flags)
|
||||
here = os.getcwd()
|
||||
srcs = sorted(os.path.join(here, p) for p in os.listdir(here)
|
||||
if p.endswith(CPP_SOURCES) and os.path.isfile(os.path.join(here, p)))
|
||||
if not srcs:
|
||||
raise RuntimeError("No CUDA/C++ source files found in working directory")
|
||||
cutlass = os.environ.get("CUTLASS_DIR") or _CUTLASS_SUBMODULE
|
||||
try:
|
||||
ext.load(name=MODULE, sources=srcs, extra_cuda_cflags=cuda_cflags, extra_cflags=cflags, extra_ldflags=ld_flags,
|
||||
extra_include_paths=[here, f"{cutlass}/include", f"{cutlass}/tools/util/include"],
|
||||
build_directory=here, verbose=True)
|
||||
except ImportError as e:
|
||||
# Built, and not loadable here: a machine without a GPU has no driver to load it against, and whoever loads it
|
||||
# says whether it loads where it runs. Where a GPU is, a module that does not load (an undefined symbol, a
|
||||
# missing library) is a build that failed, and is neither stamped nor kept in the cache.
|
||||
import torch
|
||||
|
||||
if torch.cuda.is_available():
|
||||
raise
|
||||
print(f"built; not loaded on this machine: {e}", flush=True)
|
||||
built = [p for p in os.listdir(here) if p.startswith(MODULE) and p.endswith(".so") and p != MODULE + ".so"]
|
||||
if built:
|
||||
os.rename(built[0], MODULE + ".so")
|
||||
|
||||
|
||||
class Solution:
|
||||
"""A loaded solution: fn, the entry (run), called as the deployment calls it, fn(*inputs, *outputs); prepare, the
|
||||
module's prepare function or None; spec (solution.json), entry, languages, language, module, workdir, and
|
||||
build_seconds (None when an earlier build was reused, or when the build was staged elsewhere)."""
|
||||
|
||||
def __init__(self, spec, fn, prepare, module, workdir, build_seconds):
|
||||
self.spec = spec
|
||||
self.languages = list(spec["languages"])
|
||||
self.language = self.languages[0]
|
||||
self.entry = spec["entry_point"]
|
||||
self.fn, self.prepare, self.module = fn, prepare, module
|
||||
self.workdir, self.build_seconds = workdir, build_seconds
|
||||
|
||||
|
||||
def import_built(workdir, spec, chdir=True, build_seconds=None):
|
||||
"""Imports the module that stage() built in workdir and takes its entry and prepare from it. chdir: make the
|
||||
working directory the current one, as in deployment."""
|
||||
import torch # noqa: F401 (the module needs libtorch loaded first)
|
||||
|
||||
if chdir:
|
||||
os.chdir(workdir)
|
||||
file, func = spec["entry_point"].split("::")
|
||||
module_spec = importlib.util.spec_from_file_location(MODULE, os.path.join(workdir, MODULE + ".so"))
|
||||
module = importlib.util.module_from_spec(module_spec)
|
||||
try:
|
||||
module_spec.loader.exec_module(module)
|
||||
except Exception as e:
|
||||
raise SolutionError(f"the built module does not load: {e}") from e
|
||||
fn = getattr(module, func, None)
|
||||
if not callable(fn):
|
||||
raise SolutionError(f"{file} defines no function {func} in its PYBIND11_MODULE")
|
||||
prepare = getattr(module, PREPARE, None)
|
||||
if prepare is not None and not callable(prepare):
|
||||
raise SolutionError(f"the module's {PREPARE} is not a function")
|
||||
return Solution(spec, fn, prepare, module, workdir, build_seconds)
|
||||
|
||||
|
||||
def load(solution_dir, build_dir=None, chdir=True, extra_cuda_cflags=(), cache=None):
|
||||
"""Builds and loads a solution as it is deployed; see the module docstring.
|
||||
|
||||
build_dir: where to write and build it (a fresh temporary directory if None; reused if it holds the same build).
|
||||
chdir: make the working directory the current one, as in deployment. extra_cuda_cflags: flags a tool adds to the
|
||||
deployed ones, that must not change the code (e.g. -lineinfo). cache: the node's cache of development builds
|
||||
(cache_dir()), or None to build fresh."""
|
||||
workdir, spec, seconds = stage(solution_dir, build_dir, extra_cuda_cflags=extra_cuda_cflags, cache=cache)
|
||||
return import_built(workdir, spec, chdir=chdir, build_seconds=seconds)
|
||||
|
||||
|
||||
def main():
|
||||
p = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||
sub = p.add_subparsers(dest="cmd", required=True)
|
||||
c = sub.add_parser("check", help="build and load a solution")
|
||||
c.add_argument("solution", nargs="?", default="solution")
|
||||
c.add_argument("--build-dir")
|
||||
d = sub.add_parser("build", help="write and build a solution in a directory, without loading it")
|
||||
d.add_argument("solution", nargs="?", default="solution")
|
||||
d.add_argument("build_dir", nargs="?", default="build")
|
||||
d.add_argument("--arch", help="the architecture to build for, e.g. sm_100a (default: this machine's GPU's)")
|
||||
for q in (c, d):
|
||||
q.add_argument("--no-cache", action="store_true", help="build fresh, without the node's cache of builds")
|
||||
b = sub.add_parser("_build")
|
||||
b.add_argument("flags")
|
||||
a = p.parse_args()
|
||||
if a.cmd == "_build":
|
||||
_build(a.flags)
|
||||
return
|
||||
cache = None if a.no_cache else cache_dir()
|
||||
try:
|
||||
if a.cmd == "build":
|
||||
where, _, seconds = stage(os.path.abspath(a.solution), os.path.abspath(a.build_dir), a.arch, cache=cache)
|
||||
module = os.path.join(where, MODULE + ".so")
|
||||
print(f"built in {seconds:.1f} s: {module}" if seconds is not None else f"reused a build: {module}")
|
||||
return
|
||||
sol = load(os.path.abspath(a.solution), a.build_dir and os.path.abspath(a.build_dir), cache=cache)
|
||||
except SolutionError as e:
|
||||
sys.exit(f"FAILED: {e}")
|
||||
built = f", built in {sol.build_seconds:.1f} s" if sol.build_seconds is not None else ", a build reused"
|
||||
prepare = f", with {PREPARE}" if sol.prepare is not None else f", without {PREPARE}"
|
||||
print(f"loaded {sol.entry} ({', '.join(sol.languages)}){prepare}{built}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,428 @@
|
||||
"""The task: decoding Wan2.2 VAE latents into video on one, two or four GB200 GPUs of one node, as bench.py, ci.py and
|
||||
bounds.py read it. It defines the clips and workloads, the weights' order, the data, the error allowance and the work
|
||||
of one decode.
|
||||
|
||||
What is computed
|
||||
----------------
|
||||
reference.py (in the kernel repo) defines the result: diffusers' AutoencoderKLWan._decode of the Wan2.2-TI2V-5B VAE,
|
||||
latents z [1, 48, T, 44, 78] to a video [1, 3, 4T-3, 704, 1248] in [-1, 1]: post_quant_conv, then the decoder one
|
||||
latent frame at a time with its causal caches (the first latent frame yields one frame, every later one four),
|
||||
unpatchify 2 x 2, clamp. The judge compares a solution with the fp32 decode of the same latents, cached as videos in
|
||||
$DATA (see Correctness).
|
||||
|
||||
The interface (as loader.py builds it and bench.py calls it, positional, destination passing)
|
||||
-------------
|
||||
run(z, weights, rank, world, pg, out)
|
||||
prepare(weights, rank, world, pg) -> list[Tensor] optional
|
||||
|
||||
- z: [1, 48, T, 44, 78] bf16, contiguous, the full latents on every rank: what a pipeline passes to
|
||||
vae.decode(latents), rounded to bf16.
|
||||
- weights: the 110 tensors of WEIGHTS below, bf16, on this rank's GPU; or what prepare returned.
|
||||
- rank, world: this process's rank and the number of ranks (1, 2 or 4), rank r on GPU r.
|
||||
- pg: torch.distributed's NCCL ProcessGroup over the ranks; a C++ entry takes it as
|
||||
`const c10::intrusive_ptr<c10d::ProcessGroup>&` (from <torch/csrc/distributed/c10d/ProcessGroup.hpp>).
|
||||
- out: on rank 0, the video [1, 3, 4T-3, 704, 1248] bf16, contiguous, from torch.empty and not zeroed (every element
|
||||
must be written); on the other ranks an empty tensor. When run has returned on every rank, rank 0's out holds the
|
||||
video.
|
||||
- prepare, when the module exports it, is called once per process before any timed call, and is not timed. It sees
|
||||
the weights, not the latents; its result replaces `weights` in every run.
|
||||
|
||||
Numerics: bf16 weights and activations with fp32 accumulation, and fp32 statistics in the RMS norms; nothing narrower
|
||||
(no FP8, FP6 or FP4). This is a rule for the reviewers: no check reads it from the code.
|
||||
|
||||
Workloads
|
||||
---------
|
||||
A workload is a clip decoded at a world size, named <clip>x<world>. A clip is the first T latent frames of one of
|
||||
three real 20 s clips encoded by the VAE (latents_<source>.pt in $DATA):
|
||||
|
||||
clip source latent frames T video frames 4T-3 worlds timed
|
||||
f1 row_000168 1 1 1, 4 no
|
||||
f5 row_000168 2 5 1, 4 no
|
||||
f17 row_000168 5 17 1, 4 no
|
||||
f121 row_000168 31 121 1, 2, 4 yes
|
||||
f241 row_000129 61 241 1, 2, 4 yes
|
||||
f481 row_000090 121 481 1, 2, 4 yes
|
||||
|
||||
The nine timed workloads (f121x1 ... f481x4) make the objective, their geomean; the six short ones (f1x1 ... f17x4)
|
||||
are checked for correctness and never timed: a decoder sees every clip length, and the first latent frame is a
|
||||
special case.
|
||||
|
||||
Correctness
|
||||
-----------
|
||||
On rank 0's out, against ref_<clip>.pt, the fp32 decode of the clip's latents (fp32 latents, TF32 off), stored as fp16:
|
||||
every element finite and within [-1, 1]; and with d = out - ref in fp32,
|
||||
|
||||
max |d| <= min(MAX_ABS_CAP, ERROR_FACTOR x bf16_max_abs)
|
||||
rms(d) <= ERROR_FACTOR x bf16_rms
|
||||
|
||||
bf16_max_abs and bf16_rms being the error of the release's bf16 decode (bf16 weights under bf16 autocast, the
|
||||
latents rounded to bf16) against that fp32 decode on the same clip, from calibration.json. PSNR, 10 log10(4 / mse), is
|
||||
shown beside them.
|
||||
|
||||
The weights
|
||||
-----------
|
||||
run() gets the decoder's parameters and post_quant_conv's, 110 tensors, as a list in diffusers' registration order
|
||||
(that of AutoencoderKLWan.named_parameters(), post_quant_conv first, then the decoder from conv_in to conv_out;
|
||||
$DATA/weight_names.json lists the same), bf16, in PyTorch's layouts: a conv3d's weight is [out, in, kt, kh, kw], a
|
||||
conv2d's [out, in, kh, kw], a bias [out], an RMS norm's gain [C, 1, 1, 1] (the attention's [C, 1, 1]).
|
||||
$DATA/decoder_weights_bf16.pt is that list: the release's fp32 weights rounded to bf16 to nearest even, as diffusers'
|
||||
from_pretrained(torch_dtype=torch.bfloat16) casts them. The order is not the order of execution: the mid block
|
||||
registers its attention before its two residual blocks, though it runs resnets.0, the attention, then resnets.1; an
|
||||
upsampler registers its 3x3 conv (resample.1) before its time conv, though the time conv runs first. The attention's
|
||||
to_qkv gives q, k and v as its output channels 0-1023, 1024-2047 and 2048-3071. A time conv C -> 2C gives frame 2t of
|
||||
its output as channels 0..C-1 and frame 2t+1 as channels C..2C-1. conv_out's 12 channels are (colour k, column offset
|
||||
j, row offset i) of 2 x 2 pixel patches: video[k, f, 2y + i, 2x + j] = channel 4k + 2j + i at (f, y, x).
|
||||
|
||||
# name shape role
|
||||
0 post_quant_conv.weight [48, 48, 1, 1, 1] 1x1x1 conv 48 -> 48, on the latents
|
||||
1 post_quant_conv.bias [48]
|
||||
2 decoder.conv_in.weight [1024, 48, 3, 3, 3] causal 3x3x3 conv 48 -> 1024
|
||||
3 decoder.conv_in.bias [1024]
|
||||
4 decoder.mid_block.attentions.0.norm.gamma [1024, 1, 1] RMS norm gain, before to_qkv
|
||||
5 decoder.mid_block.attentions.0.to_qkv.weight [3072, 1024, 1, 1] 1x1 conv2d 1024 -> 3072 (q, k, v)
|
||||
6 decoder.mid_block.attentions.0.to_qkv.bias [3072]
|
||||
7 decoder.mid_block.attentions.0.proj.weight [1024, 1024, 1, 1] 1x1 conv2d 1024 -> 1024
|
||||
8 decoder.mid_block.attentions.0.proj.bias [1024]
|
||||
9 decoder.mid_block.resnets.0.norm1.gamma [1024, 1, 1, 1] RMS norm gain, before conv1
|
||||
10 decoder.mid_block.resnets.0.conv1.weight [1024, 1024, 3, 3, 3] causal 3x3x3 conv 1024 -> 1024
|
||||
11 decoder.mid_block.resnets.0.conv1.bias [1024]
|
||||
12 decoder.mid_block.resnets.0.norm2.gamma [1024, 1, 1, 1] RMS norm gain, before conv2
|
||||
13 decoder.mid_block.resnets.0.conv2.weight [1024, 1024, 3, 3, 3] causal 3x3x3 conv 1024 -> 1024
|
||||
14 decoder.mid_block.resnets.0.conv2.bias [1024]
|
||||
15 decoder.mid_block.resnets.1.norm1.gamma [1024, 1, 1, 1] RMS norm gain, before conv1
|
||||
16 decoder.mid_block.resnets.1.conv1.weight [1024, 1024, 3, 3, 3] causal 3x3x3 conv 1024 -> 1024
|
||||
17 decoder.mid_block.resnets.1.conv1.bias [1024]
|
||||
18 decoder.mid_block.resnets.1.norm2.gamma [1024, 1, 1, 1] RMS norm gain, before conv2
|
||||
19 decoder.mid_block.resnets.1.conv2.weight [1024, 1024, 3, 3, 3] causal 3x3x3 conv 1024 -> 1024
|
||||
20 decoder.mid_block.resnets.1.conv2.bias [1024]
|
||||
21 decoder.up_blocks.0.resnets.0.norm1.gamma [1024, 1, 1, 1] RMS norm gain, before conv1
|
||||
22 decoder.up_blocks.0.resnets.0.conv1.weight [1024, 1024, 3, 3, 3] causal 3x3x3 conv 1024 -> 1024
|
||||
23 decoder.up_blocks.0.resnets.0.conv1.bias [1024]
|
||||
24 decoder.up_blocks.0.resnets.0.norm2.gamma [1024, 1, 1, 1] RMS norm gain, before conv2
|
||||
25 decoder.up_blocks.0.resnets.0.conv2.weight [1024, 1024, 3, 3, 3] causal 3x3x3 conv 1024 -> 1024
|
||||
26 decoder.up_blocks.0.resnets.0.conv2.bias [1024]
|
||||
27 decoder.up_blocks.0.resnets.1.norm1.gamma [1024, 1, 1, 1] RMS norm gain, before conv1
|
||||
28 decoder.up_blocks.0.resnets.1.conv1.weight [1024, 1024, 3, 3, 3] causal 3x3x3 conv 1024 -> 1024
|
||||
29 decoder.up_blocks.0.resnets.1.conv1.bias [1024]
|
||||
30 decoder.up_blocks.0.resnets.1.norm2.gamma [1024, 1, 1, 1] RMS norm gain, before conv2
|
||||
31 decoder.up_blocks.0.resnets.1.conv2.weight [1024, 1024, 3, 3, 3] causal 3x3x3 conv 1024 -> 1024
|
||||
32 decoder.up_blocks.0.resnets.1.conv2.bias [1024]
|
||||
33 decoder.up_blocks.0.resnets.2.norm1.gamma [1024, 1, 1, 1] RMS norm gain, before conv1
|
||||
34 decoder.up_blocks.0.resnets.2.conv1.weight [1024, 1024, 3, 3, 3] causal 3x3x3 conv 1024 -> 1024
|
||||
35 decoder.up_blocks.0.resnets.2.conv1.bias [1024]
|
||||
36 decoder.up_blocks.0.resnets.2.norm2.gamma [1024, 1, 1, 1] RMS norm gain, before conv2
|
||||
37 decoder.up_blocks.0.resnets.2.conv2.weight [1024, 1024, 3, 3, 3] causal 3x3x3 conv 1024 -> 1024
|
||||
38 decoder.up_blocks.0.resnets.2.conv2.bias [1024]
|
||||
39 decoder.up_blocks.0.upsampler.resample.1.weight [1024, 1024, 3, 3] 3x3 conv2d 1024 -> 1024, after x2
|
||||
40 decoder.up_blocks.0.upsampler.resample.1.bias [1024]
|
||||
41 decoder.up_blocks.0.upsampler.time_conv.weight [2048, 1024, 3, 1, 1] causal 3x1x1 conv 1024 -> 2048
|
||||
42 decoder.up_blocks.0.upsampler.time_conv.bias [2048]
|
||||
43 decoder.up_blocks.1.resnets.0.norm1.gamma [1024, 1, 1, 1] RMS norm gain, before conv1
|
||||
44 decoder.up_blocks.1.resnets.0.conv1.weight [1024, 1024, 3, 3, 3] causal 3x3x3 conv 1024 -> 1024
|
||||
45 decoder.up_blocks.1.resnets.0.conv1.bias [1024]
|
||||
46 decoder.up_blocks.1.resnets.0.norm2.gamma [1024, 1, 1, 1] RMS norm gain, before conv2
|
||||
47 decoder.up_blocks.1.resnets.0.conv2.weight [1024, 1024, 3, 3, 3] causal 3x3x3 conv 1024 -> 1024
|
||||
48 decoder.up_blocks.1.resnets.0.conv2.bias [1024]
|
||||
49 decoder.up_blocks.1.resnets.1.norm1.gamma [1024, 1, 1, 1] RMS norm gain, before conv1
|
||||
50 decoder.up_blocks.1.resnets.1.conv1.weight [1024, 1024, 3, 3, 3] causal 3x3x3 conv 1024 -> 1024
|
||||
51 decoder.up_blocks.1.resnets.1.conv1.bias [1024]
|
||||
52 decoder.up_blocks.1.resnets.1.norm2.gamma [1024, 1, 1, 1] RMS norm gain, before conv2
|
||||
53 decoder.up_blocks.1.resnets.1.conv2.weight [1024, 1024, 3, 3, 3] causal 3x3x3 conv 1024 -> 1024
|
||||
54 decoder.up_blocks.1.resnets.1.conv2.bias [1024]
|
||||
55 decoder.up_blocks.1.resnets.2.norm1.gamma [1024, 1, 1, 1] RMS norm gain, before conv1
|
||||
56 decoder.up_blocks.1.resnets.2.conv1.weight [1024, 1024, 3, 3, 3] causal 3x3x3 conv 1024 -> 1024
|
||||
57 decoder.up_blocks.1.resnets.2.conv1.bias [1024]
|
||||
58 decoder.up_blocks.1.resnets.2.norm2.gamma [1024, 1, 1, 1] RMS norm gain, before conv2
|
||||
59 decoder.up_blocks.1.resnets.2.conv2.weight [1024, 1024, 3, 3, 3] causal 3x3x3 conv 1024 -> 1024
|
||||
60 decoder.up_blocks.1.resnets.2.conv2.bias [1024]
|
||||
61 decoder.up_blocks.1.upsampler.resample.1.weight [1024, 1024, 3, 3] 3x3 conv2d 1024 -> 1024, after x2
|
||||
62 decoder.up_blocks.1.upsampler.resample.1.bias [1024]
|
||||
63 decoder.up_blocks.1.upsampler.time_conv.weight [2048, 1024, 3, 1, 1] causal 3x1x1 conv 1024 -> 2048
|
||||
64 decoder.up_blocks.1.upsampler.time_conv.bias [2048]
|
||||
65 decoder.up_blocks.2.resnets.0.norm1.gamma [1024, 1, 1, 1] RMS norm gain, before conv1
|
||||
66 decoder.up_blocks.2.resnets.0.conv1.weight [512, 1024, 3, 3, 3] causal 3x3x3 conv 1024 -> 512
|
||||
67 decoder.up_blocks.2.resnets.0.conv1.bias [512]
|
||||
68 decoder.up_blocks.2.resnets.0.norm2.gamma [512, 1, 1, 1] RMS norm gain, before conv2
|
||||
69 decoder.up_blocks.2.resnets.0.conv2.weight [512, 512, 3, 3, 3] causal 3x3x3 conv 512 -> 512
|
||||
70 decoder.up_blocks.2.resnets.0.conv2.bias [512]
|
||||
71 decoder.up_blocks.2.resnets.0.conv_shortcut.weight [512, 1024, 1, 1, 1] 1x1x1 conv 1024 -> 512, shortcut
|
||||
72 decoder.up_blocks.2.resnets.0.conv_shortcut.bias [512]
|
||||
73 decoder.up_blocks.2.resnets.1.norm1.gamma [512, 1, 1, 1] RMS norm gain, before conv1
|
||||
74 decoder.up_blocks.2.resnets.1.conv1.weight [512, 512, 3, 3, 3] causal 3x3x3 conv 512 -> 512
|
||||
75 decoder.up_blocks.2.resnets.1.conv1.bias [512]
|
||||
76 decoder.up_blocks.2.resnets.1.norm2.gamma [512, 1, 1, 1] RMS norm gain, before conv2
|
||||
77 decoder.up_blocks.2.resnets.1.conv2.weight [512, 512, 3, 3, 3] causal 3x3x3 conv 512 -> 512
|
||||
78 decoder.up_blocks.2.resnets.1.conv2.bias [512]
|
||||
79 decoder.up_blocks.2.resnets.2.norm1.gamma [512, 1, 1, 1] RMS norm gain, before conv1
|
||||
80 decoder.up_blocks.2.resnets.2.conv1.weight [512, 512, 3, 3, 3] causal 3x3x3 conv 512 -> 512
|
||||
81 decoder.up_blocks.2.resnets.2.conv1.bias [512]
|
||||
82 decoder.up_blocks.2.resnets.2.norm2.gamma [512, 1, 1, 1] RMS norm gain, before conv2
|
||||
83 decoder.up_blocks.2.resnets.2.conv2.weight [512, 512, 3, 3, 3] causal 3x3x3 conv 512 -> 512
|
||||
84 decoder.up_blocks.2.resnets.2.conv2.bias [512]
|
||||
85 decoder.up_blocks.2.upsampler.resample.1.weight [512, 512, 3, 3] 3x3 conv2d 512 -> 512, after x2
|
||||
86 decoder.up_blocks.2.upsampler.resample.1.bias [512]
|
||||
87 decoder.up_blocks.3.resnets.0.norm1.gamma [512, 1, 1, 1] RMS norm gain, before conv1
|
||||
88 decoder.up_blocks.3.resnets.0.conv1.weight [256, 512, 3, 3, 3] causal 3x3x3 conv 512 -> 256
|
||||
89 decoder.up_blocks.3.resnets.0.conv1.bias [256]
|
||||
90 decoder.up_blocks.3.resnets.0.norm2.gamma [256, 1, 1, 1] RMS norm gain, before conv2
|
||||
91 decoder.up_blocks.3.resnets.0.conv2.weight [256, 256, 3, 3, 3] causal 3x3x3 conv 256 -> 256
|
||||
92 decoder.up_blocks.3.resnets.0.conv2.bias [256]
|
||||
93 decoder.up_blocks.3.resnets.0.conv_shortcut.weight [256, 512, 1, 1, 1] 1x1x1 conv 512 -> 256, shortcut
|
||||
94 decoder.up_blocks.3.resnets.0.conv_shortcut.bias [256]
|
||||
95 decoder.up_blocks.3.resnets.1.norm1.gamma [256, 1, 1, 1] RMS norm gain, before conv1
|
||||
96 decoder.up_blocks.3.resnets.1.conv1.weight [256, 256, 3, 3, 3] causal 3x3x3 conv 256 -> 256
|
||||
97 decoder.up_blocks.3.resnets.1.conv1.bias [256]
|
||||
98 decoder.up_blocks.3.resnets.1.norm2.gamma [256, 1, 1, 1] RMS norm gain, before conv2
|
||||
99 decoder.up_blocks.3.resnets.1.conv2.weight [256, 256, 3, 3, 3] causal 3x3x3 conv 256 -> 256
|
||||
100 decoder.up_blocks.3.resnets.1.conv2.bias [256]
|
||||
101 decoder.up_blocks.3.resnets.2.norm1.gamma [256, 1, 1, 1] RMS norm gain, before conv1
|
||||
102 decoder.up_blocks.3.resnets.2.conv1.weight [256, 256, 3, 3, 3] causal 3x3x3 conv 256 -> 256
|
||||
103 decoder.up_blocks.3.resnets.2.conv1.bias [256]
|
||||
104 decoder.up_blocks.3.resnets.2.norm2.gamma [256, 1, 1, 1] RMS norm gain, before conv2
|
||||
105 decoder.up_blocks.3.resnets.2.conv2.weight [256, 256, 3, 3, 3] causal 3x3x3 conv 256 -> 256
|
||||
106 decoder.up_blocks.3.resnets.2.conv2.bias [256]
|
||||
107 decoder.norm_out.gamma [256, 1, 1, 1] RMS norm gain, before conv_out
|
||||
108 decoder.conv_out.weight [12, 256, 3, 3, 3] causal 3x3x3 conv 256 -> 12
|
||||
109 decoder.conv_out.bias [12]
|
||||
|
||||
Up blocks 0 and 1 run at 1024 channels, up block 2 from 1024 to 512, up block 3 from 512 to 256; a residual block's
|
||||
first conv changes the channel count, and its 1x1x1 conv_shortcut does so on the shortcut.
|
||||
|
||||
Data
|
||||
----
|
||||
$DATA (default /data), read-only: latents_<source>.pt ({"latents": fp32 [1, 48, 121, 44, 78]}), ref_<clip>.pt
|
||||
({"video": fp16 [1, 3, 4T-3, 704, 1248], "source", "latent_frames"}), calibration.json (per clip: bf16_max_abs,
|
||||
bf16_mean_abs, bf16_rms, bf16_psnr_db, fp32_decode_s, bf16_decode_s), weight_names.json and decoder_weights_bf16.pt.
|
||||
|
||||
The work of one decode (bounds)
|
||||
-------------------------------
|
||||
FLOPs, a multiply-add counting 2: every convolution at its full kernel over its output positions (2 x the weight's
|
||||
elements x frames x rows x columns, zero padding included, which adds a few percent at 44 x 78 and less than one
|
||||
beyond), plus the attention's two products per latent frame (2 x 2 x 3432^2 x 1024). The frames at each level, for T
|
||||
latent frames: T at 44 x 78 (post_quant_conv, conv_in, the mid block, up block 0's residual blocks); T - 1 for up
|
||||
block 0's time conv, which skips the first latent frame; 2T - 1 at 88 x 156 (up block 0's 3x3 conv, up block 1's
|
||||
residual blocks); 2T - 2 for up block 1's time conv; 4T - 3 at 176 x 312 (up block 1's 3x3 conv, up block 2's
|
||||
residual blocks) and at 352 x 624 (up block 2's 3x3 conv, up block 3, conv_out). The FLOPs are divided over the
|
||||
workload's GPUs. Bytes: the video written (on rank 0) and the weights read once.
|
||||
"""
|
||||
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
|
||||
LATENT_CHANNELS = 48
|
||||
LATENT_HW = (44, 78)
|
||||
VIDEO_HW = (704, 1248)
|
||||
# How far a solution may err, against what the release's bf16 decode errs by on the same clip (see Correctness).
|
||||
ERROR_FACTOR = 3.0
|
||||
MAX_ABS_CAP = 2.0
|
||||
WEIGHTS_FILE = "decoder_weights_bf16.pt"
|
||||
NAMES_FILE = "weight_names.json"
|
||||
|
||||
CLIPS = {
|
||||
"f1": {"source": "row_000168", "latent_frames": 1},
|
||||
"f5": {"source": "row_000168", "latent_frames": 2},
|
||||
"f17": {"source": "row_000168", "latent_frames": 5},
|
||||
"f121": {"source": "row_000168", "latent_frames": 31},
|
||||
"f241": {"source": "row_000129", "latent_frames": 61},
|
||||
"f481": {"source": "row_000090", "latent_frames": 121},
|
||||
}
|
||||
# The short clips are checked at one GPU and at four, never timed; the long ones are checked and timed at 1, 2 and 4.
|
||||
SHORT_CLIPS, LONG_CLIPS = ("f1", "f5", "f17"), ("f121", "f241", "f481")
|
||||
WORKLOADS = {
|
||||
**{f"{c}x{n}": {"clip": c, "world": n, "timed": False} for c in SHORT_CLIPS for n in (1, 4)},
|
||||
**{f"{c}x{n}": {"clip": c, "world": n, "timed": True} for c in LONG_CLIPS for n in (1, 2, 4)},
|
||||
}
|
||||
|
||||
# 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))
|
||||
RESNETS_PER_UP_BLOCK = 3
|
||||
|
||||
|
||||
def _weights():
|
||||
"""(name, shape) of run()'s weights in diffusers' registration order (see The weights)."""
|
||||
out = []
|
||||
|
||||
def conv(name, shape):
|
||||
out.extend([(f"{name}.weight", tuple(shape)), (f"{name}.bias", (shape[0],))])
|
||||
|
||||
def resnet(name, cin, cout):
|
||||
out.append((f"{name}.norm1.gamma", (cin, 1, 1, 1)))
|
||||
conv(f"{name}.conv1", (cout, cin, 3, 3, 3))
|
||||
out.append((f"{name}.norm2.gamma", (cout, 1, 1, 1)))
|
||||
conv(f"{name}.conv2", (cout, cout, 3, 3, 3))
|
||||
if cin != cout:
|
||||
conv(f"{name}.conv_shortcut", (cout, cin, 1, 1, 1))
|
||||
|
||||
conv("post_quant_conv", (48, 48, 1, 1, 1))
|
||||
conv("decoder.conv_in", (1024, 48, 3, 3, 3))
|
||||
out.append(("decoder.mid_block.attentions.0.norm.gamma", (1024, 1, 1)))
|
||||
conv("decoder.mid_block.attentions.0.to_qkv", (3072, 1024, 1, 1))
|
||||
conv("decoder.mid_block.attentions.0.proj", (1024, 1024, 1, 1))
|
||||
for j in range(2):
|
||||
resnet(f"decoder.mid_block.resnets.{j}", 1024, 1024)
|
||||
for i, (cin, cout, temporal, spatial) in enumerate(UP_BLOCKS):
|
||||
for j in range(RESNETS_PER_UP_BLOCK):
|
||||
resnet(f"decoder.up_blocks.{i}.resnets.{j}", cin if j == 0 else cout, cout)
|
||||
if spatial:
|
||||
conv(f"decoder.up_blocks.{i}.upsampler.resample.1", (cout, cout, 3, 3))
|
||||
if temporal:
|
||||
conv(f"decoder.up_blocks.{i}.upsampler.time_conv", (2 * cout, cout, 3, 1, 1))
|
||||
out.append(("decoder.norm_out.gamma", (256, 1, 1, 1)))
|
||||
conv("decoder.conv_out", (12, 256, 3, 3, 3))
|
||||
return tuple(out)
|
||||
|
||||
|
||||
WEIGHTS = _weights()
|
||||
WEIGHT_NAMES = [name for name, _ in WEIGHTS]
|
||||
|
||||
|
||||
def data_dir():
|
||||
"""The task's data: $DATA, else /data, where the jobs mount it."""
|
||||
return os.environ.get("DATA") or "/data"
|
||||
|
||||
|
||||
def frames(latent_frames):
|
||||
"""The video frames of T latent frames: one for the first, four for every later one."""
|
||||
return 4 * latent_frames - 3
|
||||
|
||||
|
||||
def output_shape(workload):
|
||||
"""The shape of rank 0's out at a workload."""
|
||||
return (1, 3, frames(CLIPS[WORKLOADS[workload]["clip"]]["latent_frames"]), *VIDEO_HW)
|
||||
|
||||
|
||||
def _load(path):
|
||||
import torch
|
||||
|
||||
try:
|
||||
return torch.load(path, map_location="cpu", weights_only=True, mmap=True)
|
||||
except (OSError, RuntimeError) as e:
|
||||
raise ValueError(f"cannot read {path}: {e}") from e
|
||||
|
||||
|
||||
def make_inputs(workload, device):
|
||||
"""[z]: the clip's latents, the first T latent frames of its source, rounded to bf16, contiguous on `device`."""
|
||||
import torch
|
||||
|
||||
clip = CLIPS[WORKLOADS[workload]["clip"]]
|
||||
path = os.path.join(data_dir(), f"latents_{clip['source']}.pt")
|
||||
latents = _load(path)["latents"]
|
||||
t = clip["latent_frames"]
|
||||
if latents.dim() != 5 or tuple(latents.shape[:2]) != (1, LATENT_CHANNELS) or tuple(latents.shape[3:]) != LATENT_HW \
|
||||
or latents.shape[2] < t:
|
||||
raise ValueError(f"{path}: latents {tuple(latents.shape)}, expected [1, 48, >= {t}, 44, 78]")
|
||||
return [latents[:, :, :t].to(device=device, dtype=torch.bfloat16).contiguous()]
|
||||
|
||||
|
||||
def load_weights(device):
|
||||
"""run()'s weights on `device`: decoder_weights_bf16.pt, checked against WEIGHTS (and weight_names.json where it
|
||||
is), so that a solution indexes what this file documents."""
|
||||
import torch
|
||||
|
||||
listed = os.path.join(data_dir(), NAMES_FILE)
|
||||
if os.path.exists(listed):
|
||||
with open(listed) as f:
|
||||
names = [(e["name"], tuple(e["shape"])) for e in json.load(f)]
|
||||
if names != list(WEIGHTS):
|
||||
raise ValueError(f"{listed} does not list spec.WEIGHTS: the weights' order or shapes changed")
|
||||
path = os.path.join(data_dir(), WEIGHTS_FILE)
|
||||
weights = _load(path)
|
||||
if not isinstance(weights, list) or len(weights) != len(WEIGHTS):
|
||||
raise ValueError(f"{path}: expected a list of {len(WEIGHTS)} tensors")
|
||||
for i, (t, (name, shape)) in enumerate(zip(weights, WEIGHTS)):
|
||||
if not isinstance(t, torch.Tensor) or t.dtype != torch.bfloat16 or tuple(t.shape) != shape:
|
||||
got = f"{t.dtype} {tuple(t.shape)}" if isinstance(t, torch.Tensor) else type(t).__name__
|
||||
raise ValueError(f"{path}: tensor {i} is {got}, expected {name}, bf16 {shape}")
|
||||
return [t.to(device).contiguous() for t in weights]
|
||||
|
||||
|
||||
def load_reference(clip):
|
||||
"""The clip's fp32 decode, as stored: a CPU fp16 tensor [1, 3, 4T-3, 704, 1248] (memory-mapped)."""
|
||||
import torch
|
||||
|
||||
path = os.path.join(data_dir(), f"ref_{clip}.pt")
|
||||
ref = _load(path)
|
||||
t, source = CLIPS[clip]["latent_frames"], CLIPS[clip]["source"]
|
||||
video = ref.get("video") if isinstance(ref, dict) else None
|
||||
shape = (1, 3, frames(t), *VIDEO_HW)
|
||||
if not isinstance(video, torch.Tensor) or tuple(video.shape) != shape or video.dtype != torch.float16:
|
||||
raise ValueError(f"{path}: expected video, fp16 {list(shape)}")
|
||||
if ref.get("source") != source or ref.get("latent_frames") != t:
|
||||
raise ValueError(f"{path} decodes {ref.get('source')}'s first {ref.get('latent_frames')} latent frames, not "
|
||||
f"{source}'s first {t}")
|
||||
return video
|
||||
|
||||
|
||||
def calibration(clip):
|
||||
"""The release's bf16 decode of the clip against its fp32 decode (calibration.json's entry)."""
|
||||
path = os.path.join(data_dir(), "calibration.json")
|
||||
try:
|
||||
with open(path) as f:
|
||||
entry = json.load(f)[clip]
|
||||
except (OSError, ValueError, KeyError) as e:
|
||||
raise ValueError(f"{path} has no entry for {clip}: {e}") from e
|
||||
if entry.get("source") != CLIPS[clip]["source"] or entry.get("latent_frames") != CLIPS[clip]["latent_frames"]:
|
||||
raise ValueError(f"{path}: {clip} is calibrated on another clip ({entry.get('source')}, "
|
||||
f"{entry.get('latent_frames')} latent frames)")
|
||||
return entry
|
||||
|
||||
|
||||
def allowance(clip):
|
||||
"""{"max_abs", "rms"}: how far a solution's video may be from the fp32 decode of the clip (see Correctness)."""
|
||||
entry = calibration(clip)
|
||||
return {"max_abs": min(MAX_ABS_CAP, ERROR_FACTOR * entry["bf16_max_abs"]), "rms": ERROR_FACTOR * entry["bf16_rms"]}
|
||||
|
||||
|
||||
def _conv_level(name, t):
|
||||
"""(output frames, upsampling factor in space) of the conv whose weight is `name`, decoding t latent frames."""
|
||||
if name.startswith(("post_quant_conv.", "decoder.conv_in.", "decoder.mid_block.", "decoder.up_blocks.0.resnets.")):
|
||||
return t, 1
|
||||
if name.startswith("decoder.up_blocks.0.upsampler.time_conv."):
|
||||
return t - 1, 1
|
||||
if name.startswith(("decoder.up_blocks.0.upsampler.resample.", "decoder.up_blocks.1.resnets.")):
|
||||
return 2 * t - 1, 2
|
||||
if name.startswith("decoder.up_blocks.1.upsampler.time_conv."):
|
||||
return 2 * t - 2, 2
|
||||
if name.startswith(("decoder.up_blocks.1.upsampler.resample.", "decoder.up_blocks.2.resnets.")):
|
||||
return 4 * t - 3, 4
|
||||
return 4 * t - 3, 8
|
||||
|
||||
|
||||
def decode_flops(t):
|
||||
"""FLOPs of one decode of t latent frames (see The work of one decode)."""
|
||||
h, w = LATENT_HW
|
||||
total = 0
|
||||
for name, shape in WEIGHTS:
|
||||
if name.endswith(".weight") and len(shape) >= 4:
|
||||
count, scale = _conv_level(name, t)
|
||||
total += 2 * math.prod(shape) * count * (h * scale) * (w * scale)
|
||||
return total + 2 * 2 * (h * w) ** 2 * 1024 * t
|
||||
|
||||
|
||||
def bounds(workload):
|
||||
"""{"flops", "bytes", "gpus"} of one call at the workload, for bounds.py: the decode's FLOPs (spread over the
|
||||
world's GPUs), and the bytes that must move: the video written and the weights read."""
|
||||
entry = WORKLOADS[workload]
|
||||
t = CLIPS[entry["clip"]]["latent_frames"]
|
||||
video = math.prod(output_shape(workload)) * 2
|
||||
weights = sum(math.prod(shape) for _, shape in WEIGHTS) * 2
|
||||
return {"flops": decode_flops(t), "bytes": video + weights, "gpus": entry["world"]}
|
||||
|
||||
|
||||
# The stack rules beside audit.py's: run() reads nothing but its arguments.
|
||||
RULES = {
|
||||
"file-io": {
|
||||
"include": r"(fstream|fcntl\.h|sys/mman\.h|filesystem|experimental/filesystem)",
|
||||
"text": r"\b(fopen|fopen64|freopen|fdopen|fread|fwrite|ifstream|ofstream|fstream|filebuf|mmap|mmap64|shm_open|"
|
||||
r"torch::load|torch::save|torch::pickle_load|torch::pickle_save|torch::jit::load)\b|"
|
||||
r"::\s*(open|openat|read|pread|write|pwrite)\s*\(",
|
||||
"why": "run() and prepare() read nothing but their arguments and write nothing but out: no files (the "
|
||||
"reference videos lie on the job's disk) and nothing that outlives a call"},
|
||||
}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
for w in WORKLOADS:
|
||||
b = bounds(w)
|
||||
print(f"{w:<8} {b['flops'] / 1e15:8.4f} PFLOP over {b['gpus']} GPU(s), {b['bytes'] / 1e9:6.2f} GB")
|
||||
@@ -0,0 +1,152 @@
|
||||
"""The decoder of the Wan2.2 VAE (Wan2.2-TI2V-5B, 48 latent channels) on sm_100a (B200 / GB200), on one or several GPUs
|
||||
of a node, built from solution/ on first use.
|
||||
|
||||
It decodes latents z [1, 48, T, 44, 78] (bf16) into the video [1, 3, 4T - 3, 704, 1248] (bf16, in [-1, 1]), as
|
||||
diffusers' AutoencoderKLWan.decode(z).sample computes it (reference.py defines the result), for any T >= 1. Other
|
||||
latent sizes and batches above one are refused: the kernels are specialised to 704 x 1248.
|
||||
|
||||
decoder = WanVAEDecoder(vae.state_dict(), device="cuda") # diffusers' AutoencoderKLWan, or its state dict
|
||||
video = decoder.decode(latents) # [1, 3, 4T - 3, 704, 1248] bf16
|
||||
|
||||
Over several GPUs, every rank builds a decoder with the same process group and calls decode with the full latents;
|
||||
rank 0 gets the video and the other ranks None. Without a process group, decode uses torch.distributed's default
|
||||
group, and initializes a single-rank NCCL group when there is none (one GPU only). With local=True each process decodes
|
||||
on its own GPU alone, whatever torch.distributed holds: one rank of one, so the kernels never communicate (comm.cu
|
||||
returns before any exchange below two ranks) and no group has to be created collectively.
|
||||
|
||||
frames_per_pass sets how many latent frames each GPU carries through one pass of the decoder, which trades memory for
|
||||
speed; the video is the same at any value. On one GB200 at 8 to 20 s of video: 2 frames peak at 14-16 GiB allocated
|
||||
(x0.85 the speed of 16 frames), 4 (the default) at 21-22 GiB (x0.92), 8 at 34-35 GiB (x0.97), 16 at 60-61 GiB. The
|
||||
allocator reserves more than it allocates; PYTORCH_CUDA_ALLOC_CONF=expandable_segments:True keeps the difference small.
|
||||
"""
|
||||
|
||||
import atexit
|
||||
import functools
|
||||
import importlib.util
|
||||
import os
|
||||
import shutil
|
||||
import socket
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
import torch
|
||||
import torch.distributed as dist
|
||||
from torch import Tensor
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
LATENT_CHANNELS, LATENT_HW, VIDEO_HW = 48, (44, 78), (704, 1248)
|
||||
|
||||
|
||||
def _own(path, name):
|
||||
"""The module at path, imported under a name of this kernel's own. Other kernels built the same way
|
||||
keep tools and references of the same file names; a bare import would hand whichever loaded first to both."""
|
||||
name = f"wan22_vae_decode_{name}"
|
||||
module = sys.modules.get(name)
|
||||
if module is None:
|
||||
module_spec = importlib.util.spec_from_file_location(name, path)
|
||||
module = importlib.util.module_from_spec(module_spec)
|
||||
sys.modules[name] = module
|
||||
try:
|
||||
module_spec.loader.exec_module(module)
|
||||
except BaseException:
|
||||
del sys.modules[name]
|
||||
raise
|
||||
return module
|
||||
|
||||
|
||||
@functools.lru_cache(maxsize=None)
|
||||
def _tools():
|
||||
"""tools/loader.py and tools/spec.py."""
|
||||
return (_own(os.path.join(HERE, "tools", "loader.py"), "loader"),
|
||||
_own(os.path.join(HERE, "tools", "spec.py"), "spec"))
|
||||
|
||||
|
||||
@functools.lru_cache(maxsize=None)
|
||||
def _module():
|
||||
"""The extension built from solution/, once per process, in a build directory of the process's own under
|
||||
WAN22_VAE_DECODE_BUILD_DIR (default ~/.cache/fastvideo/wan22_vae_decode), removed when the process exits.
|
||||
|
||||
Processes that build at once each write only their own directory. Finished builds go to the cache beside them,
|
||||
keyed by the sources, flags and toolchain and added by an atomic rename, so later processes copy one instead of
|
||||
building again."""
|
||||
loader, _ = _tools()
|
||||
base = os.environ.get("WAN22_VAE_DECODE_BUILD_DIR", os.path.expanduser("~/.cache/fastvideo/wan22_vae_decode"))
|
||||
cache = os.path.join(base, "cache")
|
||||
os.makedirs(cache, exist_ok=True)
|
||||
build_dir = tempfile.mkdtemp(prefix=f"build-{socket.gethostname()}-{os.getpid()}-", dir=base)
|
||||
atexit.register(shutil.rmtree, build_dir, True)
|
||||
return loader.load(os.path.join(HERE, "solution"), build_dir=build_dir, chdir=False, cache=cache).module
|
||||
|
||||
|
||||
def weight_names() -> list[str]:
|
||||
"""The 110 parameters the decoder takes, under diffusers' AutoencoderKLWan names, in the order it takes them."""
|
||||
return list(_tools()[1].WEIGHT_NAMES)
|
||||
|
||||
|
||||
def decoder_weights(state_dict, device=None) -> list[Tensor]:
|
||||
"""The decoder's weights from an AutoencoderKLWan state dict (or the module itself): its post_quant_conv and
|
||||
decoder parameters, in weight_names() order, as contiguous bf16 tensors on `device`."""
|
||||
if isinstance(state_dict, torch.nn.Module):
|
||||
state_dict = state_dict.state_dict()
|
||||
_, spec = _tools()
|
||||
weights = []
|
||||
for name, shape in spec.WEIGHTS:
|
||||
if name not in state_dict:
|
||||
raise KeyError(f"the state dict has no {name}: is it the Wan2.2-TI2V-5B VAE's?")
|
||||
w = state_dict[name]
|
||||
if tuple(w.shape) != shape:
|
||||
raise ValueError(f"{name} is {tuple(w.shape)}, expected {shape}")
|
||||
weights.append(w.detach().to(device=device, dtype=torch.bfloat16).contiguous())
|
||||
return weights
|
||||
|
||||
|
||||
def _destroy_if_initialized():
|
||||
# The group may be gone already (a caller destroyed it), and destroying it twice raises.
|
||||
if dist.is_initialized():
|
||||
dist.destroy_process_group()
|
||||
|
||||
|
||||
def _default_group():
|
||||
if not dist.is_initialized():
|
||||
dist.init_process_group("nccl", store=dist.HashStore(), rank=0, world_size=1)
|
||||
atexit.register(_destroy_if_initialized)
|
||||
return dist.group.WORLD
|
||||
|
||||
|
||||
class WanVAEDecoder:
|
||||
"""The decoder with its weights prepared once (the layout and the Winograd transforms the kernels read)."""
|
||||
|
||||
def __init__(self, state_dict, device=None, pg=None, local=False, frames_per_pass=4):
|
||||
if frames_per_pass < 1:
|
||||
raise ValueError(f"frames_per_pass must be at least 1, not {frames_per_pass}")
|
||||
self.frames_per_pass = int(frames_per_pass)
|
||||
self.device = torch.device(device if device is not None else "cuda")
|
||||
if self.device.index is None:
|
||||
self.device = torch.device("cuda", torch.cuda.current_device())
|
||||
with torch.cuda.device(self.device):
|
||||
if local:
|
||||
if pg is not None:
|
||||
raise ValueError("local=True decodes on this GPU alone; it takes no process group")
|
||||
# At world 1 the group is only carried, never used: any initialized group serves.
|
||||
self.pg = dist.group.WORLD if dist.is_initialized() else _default_group()
|
||||
self.rank, self.world = 0, 1
|
||||
else:
|
||||
self.pg = pg if pg is not None else _default_group()
|
||||
self.rank, self.world = dist.get_rank(self.pg), dist.get_world_size(self.pg)
|
||||
if self.world > 8:
|
||||
raise ValueError(f"the decoder splits over at most 8 GPUs, not {self.world}")
|
||||
self.weights = _module().prepare(decoder_weights(state_dict, self.device), self.rank, self.world,
|
||||
self.pg)
|
||||
|
||||
@torch.no_grad()
|
||||
def decode(self, z: Tensor) -> Tensor | None:
|
||||
"""The video of latents z [1, 48, T, 44, 78] on rank 0, None on the other ranks."""
|
||||
if z.dim() != 5 or z.shape[0] != 1 or z.shape[1] != LATENT_CHANNELS or tuple(z.shape[3:]) != LATENT_HW:
|
||||
raise ValueError(f"latents {tuple(z.shape)}: expected [1, 48, T, 44, 78]")
|
||||
z = z.to(device=self.device, dtype=torch.bfloat16).contiguous()
|
||||
frames = 4 * z.shape[2] - 3
|
||||
with torch.cuda.device(self.device):
|
||||
out = torch.empty((1, 3, frames, *VIDEO_HW) if self.rank == 0 else (0,), dtype=torch.bfloat16,
|
||||
device=self.device)
|
||||
_module().run_with(z, self.weights, self.rank, self.world, self.pg, out, self.frames_per_pass)
|
||||
return out if self.rank == 0 else None
|
||||
@@ -8,6 +8,7 @@ from fastvideo.configs.models.dits.hunyuanvideo15 import HunyuanVideo15Config
|
||||
from fastvideo.configs.models.dits.ltx2 import LTX2VideoConfig
|
||||
from fastvideo.configs.models.dits.magi_human import MagiHumanVideoConfig
|
||||
from fastvideo.configs.models.dits.minimax_h3 import MiniMaxH3Config
|
||||
from fastvideo.configs.models.dits.pan2 import PAN2VideoConfig
|
||||
from fastvideo.configs.models.dits.mmaudio import (
|
||||
MMAUDIO_44K_TRAINING_VARIANTS,
|
||||
MMAUDIO_TRAINING_VARIANTS,
|
||||
@@ -32,7 +33,7 @@ __all__ = [
|
||||
"FluxDiTConfig", "Flux2Config", "LTX2VideoConfig", "HYWorldConfig", "Kandinsky5VideoConfig", "Kandinsky6ArchConfig",
|
||||
"Kandinsky6VideoAudioConfig", "Kandinsky6SRArchConfig", "Kandinsky6SRConfig", "MagiHumanVideoConfig",
|
||||
"StableAudioConfig", "HeliosConfig", "LingBotWorld2CausalFastVideoConfig", "LingBotWorldFastVideoConfig",
|
||||
"LingBotVideoConfig", "MiniMaxH3Config", "MMAudioArchConfig", "MMAudioTransformerConfig",
|
||||
"LingBotVideoConfig", "MiniMaxH3Config", "PAN2VideoConfig", "MMAudioArchConfig", "MMAudioTransformerConfig",
|
||||
"MMAUDIO_VARIANT_ARCHITECTURES", "MMAUDIO_TRAINING_VARIANTS", "MMAUDIO_44K_TRAINING_VARIANTS",
|
||||
"get_mmaudio_transformer_config", "QwenImage21ArchConfig", "QwenImage21Config"
|
||||
]
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from fastvideo.configs.models.dits.base import DiTArchConfig, DiTConfig
|
||||
from fastvideo.platforms import AttentionBackendEnum
|
||||
|
||||
|
||||
def is_transformer_block(n: str, m) -> bool:
|
||||
return "transformer_blocks" in n and str.isdigit(n.split(".")[-1])
|
||||
|
||||
|
||||
def is_refiner_block(n: str, m) -> bool:
|
||||
return "context_refiner" in n and str.isdigit(n.split(".")[-1])
|
||||
|
||||
|
||||
@dataclass
|
||||
class PAN2ArchConfig(DiTArchConfig):
|
||||
_fsdp_shard_conditions: list = field(default_factory=lambda: [is_transformer_block, is_refiner_block])
|
||||
_supported_attention_backends: tuple[AttentionBackendEnum, ...] = (
|
||||
AttentionBackendEnum.FLASH_ATTN,
|
||||
AttentionBackendEnum.TORCH_SDPA,
|
||||
)
|
||||
|
||||
# Diffusers PAN2Transformer3DModel keys -> native keys. The per-stream Q/K/V projections are fused on load.
|
||||
param_names_mapping: dict = field(
|
||||
default_factory=lambda: {
|
||||
r"^x_embedder\.(.*)$": r"x_embedder.proj.\1",
|
||||
r"^time_embedder\.linear_1\.(.*)$": r"time_embedder.mlp.fc_in.\1",
|
||||
r"^time_embedder\.linear_2\.(.*)$": r"time_embedder.mlp.fc_out.\1",
|
||||
r"^context_embedder\.0\.(.*)$": r"context_norm.\1",
|
||||
r"^context_embedder\.1\.(.*)$": r"context_proj.\1",
|
||||
r"^context_refiner\.(\d+)\.attn\.to_q\.(.*)$": (r"context_refiner.\1.attn.to_qkv.\2", 0, 3),
|
||||
r"^context_refiner\.(\d+)\.attn\.to_k\.(.*)$": (r"context_refiner.\1.attn.to_qkv.\2", 1, 3),
|
||||
r"^context_refiner\.(\d+)\.attn\.to_v\.(.*)$": (r"context_refiner.\1.attn.to_qkv.\2", 2, 3),
|
||||
r"^context_refiner\.(\d+)\.attn\.to_out\.0\.(.*)$": r"context_refiner.\1.attn.to_out.\2",
|
||||
r"^context_refiner\.(\d+)\.ff\.net\.0\.proj\.(.*)$": r"context_refiner.\1.ff.fc_in.\2",
|
||||
r"^context_refiner\.(\d+)\.ff\.net\.2\.(.*)$": r"context_refiner.\1.ff.fc_out.\2",
|
||||
r"^transformer_blocks\.(\d+)\.attn\.to_q\.(.*)$": (r"transformer_blocks.\1.attn.to_qkv.\2", 0, 3),
|
||||
r"^transformer_blocks\.(\d+)\.attn\.to_k\.(.*)$": (r"transformer_blocks.\1.attn.to_qkv.\2", 1, 3),
|
||||
r"^transformer_blocks\.(\d+)\.attn\.to_v\.(.*)$": (r"transformer_blocks.\1.attn.to_qkv.\2", 2, 3),
|
||||
r"^transformer_blocks\.(\d+)\.attn\.add_q_proj\.(.*)$":
|
||||
(r"transformer_blocks.\1.attn.add_qkv_proj.\2", 0, 3),
|
||||
r"^transformer_blocks\.(\d+)\.attn\.add_k_proj\.(.*)$":
|
||||
(r"transformer_blocks.\1.attn.add_qkv_proj.\2", 1, 3),
|
||||
r"^transformer_blocks\.(\d+)\.attn\.add_v_proj\.(.*)$":
|
||||
(r"transformer_blocks.\1.attn.add_qkv_proj.\2", 2, 3),
|
||||
r"^transformer_blocks\.(\d+)\.attn\.to_out\.0\.(.*)$": r"transformer_blocks.\1.attn.to_out.\2",
|
||||
r"^transformer_blocks\.(\d+)\.(ff|ff_context)\.net\.0\.proj\.(.*)$": r"transformer_blocks.\1.\2.fc_in.\3",
|
||||
r"^transformer_blocks\.(\d+)\.(ff|ff_context)\.net\.2\.(.*)$": r"transformer_blocks.\1.\2.fc_out.\3",
|
||||
})
|
||||
|
||||
reverse_param_names_mapping: dict = field(default_factory=lambda: {})
|
||||
|
||||
patch_size: tuple[int, ...] = (1, 2, 2)
|
||||
in_channels: int = 97
|
||||
out_channels: int = 48
|
||||
num_attention_heads: int = 32
|
||||
attention_head_dim: int = 128
|
||||
num_layers: int = 44
|
||||
num_refiner_layers: int = 4
|
||||
mlp_ratio: float = 8.0
|
||||
context_mlp_ratio: float = 4.0
|
||||
refiner_mlp_ratio: float = 4.0
|
||||
text_embed_dim: int = 4096
|
||||
rope_axes_dim: tuple[int, ...] = (16, 56, 56)
|
||||
rope_theta: float = 256.0
|
||||
# Frame positions are rescaled to the frame rate the rotary embedding was trained at (16 fps of 24 fps).
|
||||
rope_temporal_scale: float = 16 / 24
|
||||
# PAN2 runs its text stream in the DiT dtype.
|
||||
cast_prompt_embeds_to_dit_dtype: bool = True
|
||||
exclude_lora_layers: list[str] = field(
|
||||
default_factory=lambda: ["x_embedder", "time_embedder", "context_norm", "context_proj", "context_refiner"])
|
||||
|
||||
def __post_init__(self):
|
||||
super().__post_init__()
|
||||
self.patch_size = tuple(self.patch_size)
|
||||
self.rope_axes_dim = tuple(self.rope_axes_dim)
|
||||
self.hidden_size: int = self.attention_head_dim * self.num_attention_heads
|
||||
self.num_channels_latents: int = self.out_channels
|
||||
|
||||
|
||||
@dataclass
|
||||
class PAN2VideoConfig(DiTConfig):
|
||||
arch_config: DiTArchConfig = field(default_factory=PAN2ArchConfig)
|
||||
|
||||
prefix: str = "PAN2"
|
||||
@@ -16,6 +16,7 @@ from fastvideo.configs.models.encoders.mistral3 import Mistral3TextConfig
|
||||
from fastvideo.configs.models.encoders.minimax_h3_qwen3_vl import (MiniMaxH3Qwen3VLArchConfig, MiniMaxH3Qwen3VLConfig)
|
||||
from fastvideo.configs.models.encoders.qwen3 import Qwen3TextConfig
|
||||
from fastvideo.configs.models.encoders.qwen_image21 import QwenImage21Qwen3VLArchConfig, QwenImage21Qwen3VLConfig
|
||||
from fastvideo.configs.models.encoders.qwen3_5 import Qwen3_5TextArchConfig, Qwen3_5TextConfig
|
||||
from fastvideo.configs.models.encoders.lingbot_video import LingBotVideoQwen3VLTextConfig
|
||||
from fastvideo.configs.models.encoders.stable_audio_conditioner import (
|
||||
StableAudioConditionerArchConfig,
|
||||
@@ -52,6 +53,8 @@ __all__ = [
|
||||
"Qwen3TextConfig",
|
||||
"QwenImage21Qwen3VLArchConfig",
|
||||
"QwenImage21Qwen3VLConfig",
|
||||
"Qwen3_5TextArchConfig",
|
||||
"Qwen3_5TextConfig",
|
||||
"Mistral3TextConfig",
|
||||
"LingBotWorld2UMT5ArchConfig",
|
||||
"LingBotWorld2UMT5Config",
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""Qwen3.5 text encoder configuration (hybrid Gated DeltaNet + gated attention)."""
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
from fastvideo.configs.models.encoders.base import (
|
||||
TextEncoderArchConfig,
|
||||
TextEncoderConfig,
|
||||
)
|
||||
|
||||
_LEGACY_LAYER_TYPES = {
|
||||
"mamba": "linear_attention",
|
||||
"conv": "linear_attention",
|
||||
"attention": "full_attention",
|
||||
}
|
||||
_DEFAULT_ROPE_THETA = 10000.0
|
||||
_DEFAULT_PARTIAL_ROTARY_FACTOR = 0.25
|
||||
|
||||
|
||||
def _is_transformer_layer(n: str, m: Any) -> bool:
|
||||
return "layers" in n and str.isdigit(n.split(".")[-1])
|
||||
|
||||
|
||||
def _is_embeddings(n: str, m: Any) -> bool:
|
||||
return n.endswith("embed_tokens")
|
||||
|
||||
|
||||
def _is_final_norm(n: str, m: Any) -> bool:
|
||||
return n == "norm"
|
||||
|
||||
|
||||
@dataclass
|
||||
class Qwen3_5TextArchConfig(TextEncoderArchConfig):
|
||||
"""Architecture config for the Qwen3.5 text model (``Qwen3_5TextModel``).
|
||||
|
||||
Layers alternate between Gated DeltaNet linear attention and gated full
|
||||
attention according to ``layer_types``. The defaults describe the 9B text
|
||||
tower; checkpoint ``config.json`` values override them at load time.
|
||||
"""
|
||||
|
||||
vocab_size: int = 248320
|
||||
hidden_size: int = 4096
|
||||
intermediate_size: int = 12288
|
||||
num_hidden_layers: int = 32
|
||||
num_attention_heads: int = 16
|
||||
num_key_value_heads: int = 4
|
||||
head_dim: int = 256
|
||||
hidden_act: str = "silu"
|
||||
max_position_embeddings: int = 32768
|
||||
initializer_range: float = 0.02
|
||||
rms_norm_eps: float = 1e-6
|
||||
use_cache: bool = True
|
||||
tie_word_embeddings: bool = False
|
||||
attention_bias: bool = False
|
||||
attention_dropout: float = 0.0
|
||||
linear_conv_kernel_dim: int = 4
|
||||
linear_key_head_dim: int = 128
|
||||
linear_value_head_dim: int = 128
|
||||
linear_num_key_heads: int = 16
|
||||
linear_num_value_heads: int = 32
|
||||
layer_types: list[str] | None = None
|
||||
full_attention_interval: int = 4
|
||||
# RoPE settings as written by Transformers 5 (``rope_parameters``) or by
|
||||
# older configs (top-level ``rope_theta`` / ``partial_rotary_factor`` /
|
||||
# ``rope_scaling``). Values inside ``rope_parameters`` take precedence.
|
||||
rope_parameters: dict[str, Any] | None = None
|
||||
rope_scaling: dict[str, Any] | None = None
|
||||
rope_theta: float | None = None
|
||||
partial_rotary_factor: float | None = None
|
||||
pad_token_id: int | None = None
|
||||
bos_token_id: int | None = None
|
||||
eos_token_id: int | list[int] | None = None
|
||||
|
||||
_fsdp_shard_conditions: list = field(
|
||||
default_factory=lambda: [_is_transformer_layer, _is_embeddings, _is_final_norm])
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
super().__post_init__()
|
||||
# Validate eagerly; the resolved views are recomputed on demand because
|
||||
# ``update_model_arch`` re-runs this hook after replacing raw fields.
|
||||
self.resolved_layer_types()
|
||||
self.resolved_rope_parameters()
|
||||
|
||||
def resolved_layer_types(self) -> list[str]:
|
||||
"""Return one ``linear_attention`` / ``full_attention`` entry per layer."""
|
||||
if self.layer_types is None:
|
||||
interval = self.full_attention_interval
|
||||
layer_types = [
|
||||
"linear_attention" if (index + 1) % interval else "full_attention"
|
||||
for index in range(self.num_hidden_layers)
|
||||
]
|
||||
else:
|
||||
layer_types = [_LEGACY_LAYER_TYPES.get(kind, kind) for kind in self.layer_types]
|
||||
if len(layer_types) < self.num_hidden_layers:
|
||||
raise ValueError(f"Qwen3.5 layer_types has {len(layer_types)} entries for "
|
||||
f"{self.num_hidden_layers} layers")
|
||||
unknown = set(layer_types) - {"linear_attention", "full_attention"}
|
||||
if unknown:
|
||||
raise ValueError(f"Unsupported Qwen3.5 layer types: {sorted(unknown)}")
|
||||
return layer_types[:self.num_hidden_layers]
|
||||
|
||||
def resolved_rope_parameters(self) -> dict[str, Any]:
|
||||
"""Return the effective text RoPE settings with Transformers' precedence."""
|
||||
rope = dict(self.rope_scaling or self.rope_parameters or {})
|
||||
rope.setdefault("rope_type", rope.get("type", "default"))
|
||||
rope.setdefault("rope_theta", self.rope_theta if self.rope_theta is not None else _DEFAULT_ROPE_THETA)
|
||||
rope.setdefault(
|
||||
"partial_rotary_factor",
|
||||
self.partial_rotary_factor if self.partial_rotary_factor is not None else _DEFAULT_PARTIAL_ROTARY_FACTOR,
|
||||
)
|
||||
return rope
|
||||
|
||||
|
||||
@dataclass
|
||||
class Qwen3_5TextConfig(TextEncoderConfig):
|
||||
"""Top-level config for the Qwen3.5 text encoder."""
|
||||
|
||||
arch_config: TextEncoderArchConfig = field(default_factory=Qwen3_5TextArchConfig)
|
||||
prefix: str = "qwen3_5"
|
||||
@@ -15,6 +15,7 @@ from fastvideo.configs.pipelines.matrixgame2 import MatrixGame2I2V480PConfig
|
||||
from fastvideo.configs.pipelines.matrixgame3 import MatrixGame3I2V720PConfig
|
||||
from fastvideo.configs.pipelines.mmaudio import MMAudioV2AConfig
|
||||
from fastvideo.configs.pipelines.qwen_image21 import QwenImage21PipelineConfig
|
||||
from fastvideo.configs.pipelines.pan2 import PAN2PipelineConfig
|
||||
from fastvideo.pipelines.basic.ltx2.pipeline_configs import LTX2T2VConfig
|
||||
from fastvideo.registry import get_pipeline_config_cls_from_name
|
||||
from fastvideo.models.wan.pipeline_config import (LucyEditDevConfig, SelfForcingWanT2V480PConfig, WanI2V480PConfig,
|
||||
@@ -27,5 +28,5 @@ __all__ = [
|
||||
"Kandinsky5T2VConfig", "Kandinsky5I2VConfig", "Kandinsky5DMDConfig", "Kandinsky6TI2VAConfig",
|
||||
"Kandinsky6SRPipelineConfig", "LingBotWorld2CausalFastI2V480PConfig", "LingBotWorldFastI2V480PConfig",
|
||||
"LingBotVideoT2VConfig", "MatrixGame2I2V480PConfig", "MatrixGame3I2V720PConfig", "MMAudioV2AConfig",
|
||||
"QwenImage21PipelineConfig", "get_pipeline_config_cls_from_name"
|
||||
"QwenImage21PipelineConfig", "PAN2PipelineConfig", "get_pipeline_config_cls_from_name"
|
||||
]
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""PAN2 pipeline configuration: text-to-video, and image-to-video from a first frame."""
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from fastvideo.configs.models import DiTConfig, EncoderConfig, VAEConfig
|
||||
from fastvideo.configs.models.dits import PAN2VideoConfig
|
||||
from fastvideo.configs.models.encoders import Qwen3_5TextConfig
|
||||
from fastvideo.configs.pipelines.base import PipelineConfig
|
||||
from fastvideo.models.wan.vae_config import WanVAEConfig
|
||||
|
||||
PAN2_SYSTEM_PROMPT = "You are a helpful assistant. Generate the video with the following instructions."
|
||||
|
||||
|
||||
@dataclass
|
||||
class PAN2PipelineConfig(PipelineConfig):
|
||||
"""One configuration serves text-to-video and, given a first frame, image-to-video."""
|
||||
|
||||
dit_config: DiTConfig = field(default_factory=PAN2VideoConfig)
|
||||
# The Wan2.2 48-channel VAE; its architecture and latent statistics come from vae/config.json at load time.
|
||||
vae_config: VAEConfig = field(default_factory=WanVAEConfig)
|
||||
text_encoder_configs: tuple[EncoderConfig, ...] = field(default_factory=lambda: (Qwen3_5TextConfig(), ))
|
||||
text_encoder_precisions: tuple[str, ...] = field(default_factory=lambda: ("bf16", ))
|
||||
|
||||
dit_precision: str = "bf16"
|
||||
vae_precision: str = "fp32"
|
||||
# Decode the video with fastvideo-kernel's Wan2.2 decoder (B200 / GB200 / B300; a 704 x 1248 video, batch 1): bf16
|
||||
# with fp32 accumulation, about 7x faster than the fp32 decode on a B300, at PSNR 61-63 dB and SSIM 0.998 against
|
||||
# it. Other devices and sizes, and False, keep the fp32 decode.
|
||||
vae_decode_kernel: bool = True
|
||||
# With vae_decode_kernel, split the decode over the sequence-parallel GPUs (2, 4 or 8) rather than decoding the
|
||||
# whole video on each of them. On B300s a 20 s video decodes in 3.0 s on one GPU, 1.55 s on 2 (the same video, bit
|
||||
# for bit), 0.79 s on 4 and 0.47 s on 8 (rounded differently, at the same PSNR against the fp32 decode).
|
||||
vae_decode_kernel_parallel: bool = True
|
||||
# Run the DiT's fused Triton kernels (Q/K RMSNorm + RoPE, LayerNorm + modulation): 21% less denoising time on 8
|
||||
# B300s, as far from the eager path's samples as bf16 rounding differences carry them.
|
||||
dit_fused_kernels: bool = True
|
||||
# Unset: the checkpoint's scheduler config decides, which is shift 9 for IFM/PAN2-TEST, the reference sampler's
|
||||
# fixed value. A fixed default here would override the released shift with whatever a test checkpoint declares.
|
||||
flow_shift: float | None = None
|
||||
# Image-to-video: the first latent frame (the conditioning image) takes the positive prediction instead of the
|
||||
# guided one, as the reference sampler does by default (``--i2v_skip_first_latent_cfg``).
|
||||
i2v_skip_first_latent_cfg: bool = True
|
||||
# PAN2 samples on fp32 latents; keep the Euler update out of autocast.
|
||||
scheduler_step_in_fp32: bool = True
|
||||
|
||||
# Check the prompt and every generated video with the PAN 2 guardrails (the `pan2-guardrail` package), as the
|
||||
# vLLM-Omni and Diffusers integrations do by default. A blocked prompt or video raises
|
||||
# `PAN2GuardrailViolationError`. False turns both checks off, at your own risk.
|
||||
enable_guardrails: bool = True
|
||||
# Keep the guard models on CPU and move each to the GPU only for its check.
|
||||
offload_guardrail_models: bool = False
|
||||
|
||||
# Prompt encoding: PAN2 conditions on the tokens after the chat template's user header, taken from the text
|
||||
# encoder's hidden states with its last two layers skipped (``hidden_states[-3]``).
|
||||
text_system_prompt: str = PAN2_SYSTEM_PROMPT
|
||||
text_encoder_skip_layers: int = 2
|
||||
text_max_sequence_length: int = 1536
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
# The encoder is needed for the image-to-video first frame.
|
||||
self.vae_config.load_encoder = True
|
||||
self.vae_config.load_decoder = True
|
||||
self.text_encoder_configs[0].arch_config.output_hidden_states = True
|
||||
@@ -162,6 +162,31 @@ def _quantize_video_frames_to_uint8(src: torch.Tensor) -> torch.Tensor:
|
||||
return rearrange(vid_u8, "b c t h w -> t b c h w")
|
||||
|
||||
|
||||
def _video_frames(src: torch.Tensor) -> list[np.ndarray]:
|
||||
"""`[b, c, t, h, w]` decoded output -> one host uint8 frame `[h, w, 3]` per time step, the batch tiled six to a
|
||||
row by `torchvision.utils.make_grid` (which also repeats a single channel three times).
|
||||
|
||||
One RGB video, every request of most pipelines, is quantized and laid out frames-first and channels-last on
|
||||
``src``'s device, copied to the host once, and its frames are views of that one array. Building each frame on the
|
||||
CPU instead (a channels-last copy per frame) took 0.48 s against 0.38 s for 241 frames of 704 x 1248 on a B300
|
||||
node, and 1-2 s in a job pinned to two CPUs. Same values either way; the out-of-memory fallback of
|
||||
`_quantize_video_frames_to_uint8` applies to both.
|
||||
"""
|
||||
if src.ndim == 5 and src.shape[0] == 1 and src.shape[1] == 3:
|
||||
try:
|
||||
host = pixels_to_uint8(src)[0].permute(1, 2, 3, 0).contiguous().cpu()
|
||||
except torch.cuda.OutOfMemoryError:
|
||||
logger.warning("GPU out of memory laying out the decoded video's frames; doing it on the CPU instead.")
|
||||
src_cpu = src.detach().to("cpu")
|
||||
host = pixels_to_uint8(src_cpu if src_cpu.dtype == torch.uint8 else src_cpu.float())[0].permute(
|
||||
1, 2, 3, 0).contiguous()
|
||||
return list(host.numpy())
|
||||
return [
|
||||
torchvision.utils.make_grid(x, nrow=6).permute(1, 2, 0).squeeze(-1).contiguous().numpy()
|
||||
for x in _quantize_video_frames_to_uint8(src)
|
||||
]
|
||||
|
||||
|
||||
def _validate_request_stage_overrides(model_path: str, request: GenerationRequest) -> None:
|
||||
"""Validate typed stage overrides against the model's registered preset."""
|
||||
if not request.stage_overrides:
|
||||
@@ -1041,10 +1066,7 @@ class VideoGenerator:
|
||||
# `samples` above is just the pinned-CPU mirror of `output_batch.output` (`samples.copy_(output)` or
|
||||
# `output.cpu()`) with no intervening preprocessing, so reading `output_batch.output` here is the same
|
||||
# data. See `_quantize_video_frames_to_uint8` for why this quantizes on-device and its OOM fallback.
|
||||
vid_u8 = _quantize_video_frames_to_uint8(output_batch.output)
|
||||
frames = [
|
||||
torchvision.utils.make_grid(x, nrow=6).permute(1, 2, 0).squeeze(-1).contiguous().numpy() for x in vid_u8
|
||||
]
|
||||
frames = _video_frames(output_batch.output)
|
||||
postprocess_time = time.perf_counter() - postprocess_start
|
||||
logger.info("PostDecodeFrameProcessStage completed in %.3f s", postprocess_time)
|
||||
if logging_info is not None:
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user