[diffusion] feat: support FastH3 (4-step VSA-distilled MiniMax-H3) with a VSA-H3 attention backend (#37480)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Kevin Mi
2026-09-02 21:39:54 +08:00
committed by GitHub
co-authored by Claude Fable 5
parent 99b9109553
commit f586654518
26 changed files with 1778 additions and 14 deletions
+92 -4
View File
@@ -643,7 +643,71 @@ SGLang projects those adapter factors onto the pruned coordinates at load time.
A structurally modified checkpoint without that metadata still fails closed,
and packed GGUF weights remain incompatible with LoRA.
## 6. Sampling and output controls
## 6. FastH3: 4-step distilled preview
[FastVideo/FastVideo-FastH3-4-step-Preview-v1-VSA-DataFree](https://huggingface.co/FastVideo/FastVideo-FastH3-4-step-Preview-v1-VSA-DataFree)
is a 4-step DMD2 distillation of MiniMax-H3, trained data-free with Video
Sparse Attention (VSA) at 0.9 sparsity and 64-token tiles. Only the T2VA
capability was distilled: requests must use `task: "t2va"`, and `fl2va` /
`ref2va` requests are rejected. The checkpoint inherits the MiniMax-H3
Community License.
Pass the repository directly to `--model-path`. The flat native-Diffusers
upload is materialized into the base-H3 layout through a registered model
overlay; the only non-symlink step is a one-time re-serialization of the
roughly 10 GB video VAE on first launch.
```bash 4×B300 VSA-H3
sglang serve \
--model-path FastVideo/FastVideo-FastH3-4-step-Preview-v1-VSA-DataFree \
--num-gpus 4 \
--attention-backend video_sparse_attn_h3 \
--attention-backend-config '{"VSA_sparsity": 0.9}' \
--port 30010
```
Requests use the same asynchronous video endpoint as the base model, with
`task: "t2va"`, `conditions: []`, and a target such as
`{"short_edge": 768, "aspect_ratio": "16:9", "duration_seconds": 5.0}`. The
request default is `num_inference_steps: 5`: five points on the standard
shift-12/shift-3 sigma grid, i.e. the four distilled DiT evaluations. Any
other step count is rejected.
`video_sparse_attn_h3` (VSA-H3) is the trained sparse policy: an in-tree
Triton block-sparse kernel (SM90 / SM100 / SM103) over segment-pure prefix
tiles and (4, 4, 4) video tiles, driven by the checkpoint's trained
`to_gate_compress` compression branch. Only the DiT runs sparse; the token
refiner, text encoder, and VAEs keep their dense defaults. Ulysses sequence
parallelism is supported. See
[Attention Backends](/docs/sglang-diffusion/attention_backends) for
`VSA_sparsity`, `vsa_mode`, `vsa_dense_first_n_steps`, and
`vsa_dense_layers`. Every dense backend that runs on base H3 (`fa`,
`torch_sdpa`, ...) also runs on the FastH3 weights without VSA flags, and
`sglang generate` takes the same flags as `sglang serve`.
Measured latencies for the 4× B300 recipe are in
[FastH3 on B300](#fasth3-on-b300).
FastH3 rejects deployment options that do not apply to the distilled preview
instead of silently ignoring them: `--model-variant`, `quality: "high"`,
`fl2va` / `ref2va` requests, and, with VSA-H3, `--ring-degree` greater than 1,
`torch.compile`, and breakable CUDA graph execution.
<Warning>
The sibling `FastVideo/FastVideo-FastH3-4-step-Preview-v1-LoRA` adapters carry
full-rank `.diff` / `.diff_b` deltas and `set_weight` gate tensors beyond the
LoRA contract; `--lora-path` rejects them with an explicit error. Serve the
merged VSA-DataFree checkpoint above instead.
</Warning>
<Note>
Upstream labels this checkpoint a preview. Quality gaps versus base H3 on hard
motion and fine detail are properties of the released distillation, not of the
SGLang port. Use base MiniMax-H3 when output quality matters more than
latency.
</Note>
## 7. Sampling and output controls
MiniMax-H3 supports more than one output per prompt. The video API accepts
`num_outputs_per_prompt` (or OpenAI-compatible `n`) from 1 through 10. Offline
@@ -818,7 +882,7 @@ has completed, but the `quality: "high"` path above remains fail-closed
to the audited 4×H200 workload.
</Warning>
## 7. Feature contracts and advanced recipes
## 8. Feature contracts and advanced recipes
The generated command already contains the recommended topology and encoder
setting. Use the detailed reference below only when applying an optional
@@ -1059,7 +1123,7 @@ fold decision is not node-boundary aware:
</Tabs>
## 8. Configuration notes
## 9. Configuration notes
- MiniMax-H3 produces the canonical 24 fps output; request duration is expressed through `target.duration_seconds`.
- `target.duration_seconds` must be between 4 and 15 seconds, inclusive. The command picker defaults to the verified 5-second profile.
@@ -1078,7 +1142,7 @@ fold decision is not node-boundary aware:
- `speed` keeps model components resident, while `auto` applies the model-aware 120 GiB residency threshold. `memory` prioritizes avoiding OOM and includes the executable VAE decoder in its default layerwise set. A measured recipe with sufficient headroom can opt into `--component-residency vae=resident`; the 2×H100 CI recipe does this because the VAE's 4.8 GiB/GPU cost avoids repeated decoder transfers during tiled decode. DiT residency and prefetch knobs remain scoped to the DiT. Use `speed` only after confirming that the complete target workload fits.
- Breakable CUDA graph execution is an explicit opt-in, not part of the recommended `speed` preset. It requires `--enable-breakable-cuda-graph`, every served size in `--warmup-resolutions`, and `--bcg-text-buckets` that cover the live H3 condition sequence. The validated 1344×768 Ref2VA recipe uses 5504; other task profiles and reference sets may need a different value. It preserves eager output for matching captured signatures, but graph capture consumes additional GPU memory and may provide little latency benefit when Ulysses attention and collectives dominate, so benchmark it on the target topology before enabling it.
## 9. Benchmarks
## 10. Benchmarks
The picker exposes resident and FSDP profiles on NVIDIA datacenter GPUs. GPU
counts are properties of the selected recipes, not a claim that every platform
@@ -1189,6 +1253,30 @@ python3 -m sglang.multimodal_gen.benchmarks.bench_serving \
| Ref2VA | FP8 | fold | 112.0 s | 34.44 s | **27.12 s** | 52,816 MB |
| Ref2VA | FP8 | replicate | 116.0 s | 33.42 s | **27.12 s** | 93,396 MB |
### FastH3 on B300
The same 4× B300 host served [FastH3](#6-fasth3-4-step-distilled-preview)
with the VSA-H3 recipe above (1344×768 at 24 fps with audio, `task: "t2va"`,
`num_inference_steps: 5`, seed 1000, eager BF16, Ulysses4, `VSA_sparsity` 0.9).
E2E is the client wall clock of a `/v1/videos` request including decode,
muxing, and file output, median of three requests after one warm request;
the stage columns are the server timings of the same request. H3 aligns the
requested durations to 124, 243, and 362 frames. Client RTF is E2E divided by
the video duration:
| Requested / aligned | Encoder | Denoise (4 forwards) | Decode | Transport + MP4 | E2E | Client RTF | Peak/GPU |
| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: |
| 5 s / 124 | 0.07 s | 2.18 s | 0.87 s | 0.9 s | **4.1 s** | 0.79 | 95,744 MB |
| 10 s / 243 | 0.07 s | 4.83 s | 1.71 s | 1.3 s | **8.0 s** | 0.79 | 102,666 MB |
| 15 s / 362 | 0.07 s | 8.80 s | 2.56 s | 1.8 s | **13.3 s** | 0.88 | 110,774 MB |
All three requests finish faster than playback. Dense FA on the same weights
and topology takes 3.77 / 9.84 / 18.45 s (`sglang generate`, stage sum): it is
competitive at 5 s, and VSA-H3 pulls ahead from 10 s on. At 5 s,
TP2 + Ulysses2 (3.42 s, 62,290 MB), FSDP + Ulysses4 (3.42 s, 50,984 MB), and
online `--quantization fp8` (2.93 s, 64,204 MB) trade a little latency for
peak memory.
### H200 topology comparison
The same four-card H200 host completed both lossless resident placements with
@@ -79,6 +79,11 @@ For SGLang-native pipelines, the CLI accepts the lowercase names of `AttentionBa
<td style={{padding: "9px 12px", backgroundColor: "rgba(255,255,255,0.05)", whiteSpace: "nowrap"}}>`VIDEO_SPARSE_ATTN`</td>
<td style={{padding: "9px 12px", backgroundColor: "rgba(255,255,255,0.02)"}}>Requires <code>vsa</code>. Configure <code>sparsity</code> via <code>--attention-backend-config</code>.</td>
</tr>
<tr>
<td style={{padding: "9px 12px", fontWeight: 500, backgroundColor: "rgba(255,255,255,0.02)"}}>`video_sparse_attn_h3`</td>
<td style={{padding: "9px 12px", backgroundColor: "rgba(255,255,255,0.05)", whiteSpace: "nowrap"}}>`VIDEO_SPARSE_ATTN_H3`</td>
<td style={{padding: "9px 12px", backgroundColor: "rgba(255,255,255,0.02)"}}>Video Sparse Attention for MiniMax-H3 / FastH3 (VSA-H3). In-tree Triton block-sparse kernel (SM90 / SM100 / SM103); no external package. Configure via <code>--attention-backend-config</code>.</td>
</tr>
<tr>
<td style={{padding: "9px 12px", fontWeight: 500, backgroundColor: "rgba(255,255,255,0.02)"}}>`vmoba_attn`</td>
<td style={{padding: "9px 12px", backgroundColor: "rgba(255,255,255,0.05)", whiteSpace: "nowrap"}}>`VMOBA_ATTN`</td>
@@ -238,6 +243,67 @@ SpargeAttention is approximate even when `topk=1`: the recommended upstream
kernel quantizes attention through SageAttention2. Validate output quality and
end-to-end latency on the target model and resolution before deployment.
**Video Sparse Attention for H3 (`video_sparse_attn_h3`)**
<table style={{width: "100%", borderCollapse: "collapse", tableLayout: "fixed"}}>
<colgroup>
<col style={{width: "24%"}} />
<col style={{width: "14%"}} />
<col style={{width: "44%"}} />
<col style={{width: "18%"}} />
</colgroup>
<thead>
<tr style={{borderBottom: "2px solid #d55816"}}>
<th style={{textAlign: "left", padding: "10px 12px", fontWeight: 700, whiteSpace: "nowrap", backgroundColor: "rgba(255,255,255,0.02)"}}>Parameter</th>
<th style={{textAlign: "left", padding: "10px 12px", fontWeight: 700, whiteSpace: "nowrap", backgroundColor: "rgba(255,255,255,0.05)"}}>Type</th>
<th style={{textAlign: "left", padding: "10px 12px", fontWeight: 700, whiteSpace: "nowrap", backgroundColor: "rgba(255,255,255,0.02)"}}>Description</th>
<th style={{textAlign: "left", padding: "10px 12px", fontWeight: 700, whiteSpace: "nowrap", backgroundColor: "rgba(255,255,255,0.05)"}}>Default</th>
</tr>
</thead>
<tbody>
<tr>
<td style={{padding: "9px 12px", fontWeight: 500, backgroundColor: "rgba(255,255,255,0.02)"}}>`VSA_sparsity`</td>
<td style={{padding: "9px 12px", backgroundColor: "rgba(255,255,255,0.05)"}}>`float`</td>
<td style={{padding: "9px 12px", backgroundColor: "rgba(255,255,255,0.02)"}}>Fraction of video tiles excluded from the top-k selection (0.0 - 1.0). `0.9` is the FastH3 trained policy.</td>
<td style={{padding: "9px 12px", backgroundColor: "rgba(255,255,255,0.05)"}}>`0.9`</td>
</tr>
<tr>
<td style={{padding: "9px 12px", fontWeight: 500, backgroundColor: "rgba(255,255,255,0.02)"}}>`vsa_mode`</td>
<td style={{padding: "9px 12px", backgroundColor: "rgba(255,255,255,0.05)"}}>`str`</td>
<td style={{padding: "9px 12px", backgroundColor: "rgba(255,255,255,0.02)"}}>`exempt`: non-video keys (text/audio prefix tiles) are always selected. `compete`: they compete with video tiles in the top-k.</td>
<td style={{padding: "9px 12px", backgroundColor: "rgba(255,255,255,0.05)"}}>`exempt`</td>
</tr>
<tr>
<td style={{padding: "9px 12px", fontWeight: 500, backgroundColor: "rgba(255,255,255,0.02)"}}>`vsa_dense_first_n_steps`</td>
<td style={{padding: "9px 12px", backgroundColor: "rgba(255,255,255,0.05)"}}>`int`</td>
<td style={{padding: "9px 12px", backgroundColor: "rgba(255,255,255,0.02)"}}>Use dense attention for the first N denoising steps.</td>
<td style={{padding: "9px 12px", backgroundColor: "rgba(255,255,255,0.05)"}}>`0`</td>
</tr>
<tr>
<td style={{padding: "9px 12px", fontWeight: 500, backgroundColor: "rgba(255,255,255,0.02)"}}>`vsa_dense_layers`</td>
<td style={{padding: "9px 12px", backgroundColor: "rgba(255,255,255,0.05)"}}>`list[int]`</td>
<td style={{padding: "9px 12px", backgroundColor: "rgba(255,255,255,0.02)"}}>Layer indices kept dense, e.g. `[0, 1]`.</td>
<td style={{padding: "9px 12px", backgroundColor: "rgba(255,255,255,0.05)"}}>`[]`</td>
</tr>
<tr>
<td style={{padding: "9px 12px", fontWeight: 500, backgroundColor: "rgba(255,255,255,0.02)"}}>`vsa_tile_size`</td>
<td style={{padding: "9px 12px", backgroundColor: "rgba(255,255,255,0.05)"}}>`int`</td>
<td style={{padding: "9px 12px", backgroundColor: "rgba(255,255,255,0.02)"}}>Kernel tile size. Only `64` (the trained (4, 4, 4) geometry) is accepted.</td>
<td style={{padding: "9px 12px", backgroundColor: "rgba(255,255,255,0.05)"}}>`64`</td>
</tr>
</tbody>
</table>
VSA-H3 constraints:
- Only the DiT runs sparse; the token refiner, text encoder, and VAEs keep
their dense defaults. An explicit `--component-attention-backends
text_encoder=fa` is rejected because the H3 text encoder has SDPA-only layers.
- Uses the checkpoint's trained `to_gate_compress` compression branch. Base
MiniMax-H3 weights load zero gates and run pure sparse.
- Ulysses sequence parallelism is supported; `--ring-degree` greater than 1,
`torch.compile`, and breakable CUDA graph execution are rejected.
**V-MoBA (`vmoba_attn`)**
<table style={{width: "100%", borderCollapse: "collapse", tableLayout: "fixed"}}>
@@ -549,6 +615,16 @@ end-to-end latency on the target model and resolution before deployment.
<td style={{padding: "9px 12px", backgroundColor: "rgba(255,255,255,0.05)"}}>❌</td>
<td style={{padding: "9px 12px", backgroundColor: "rgba(255,255,255,0.05)"}}>CUDA-only. Requires <code>vsa</code>. Configure <code>sparsity</code> via <code>--attention-backend-config</code>.</td>
</tr>
<tr>
<td style={{padding: "9px 12px", fontWeight: 500, backgroundColor: "rgba(255,255,255,0.02)"}}>`video_sparse_attn_h3`</td>
<td style={{padding: "9px 12px", backgroundColor: "rgba(255,255,255,0.05)"}}>Yes</td>
<td style={{padding: "9px 12px", backgroundColor: "rgba(255,255,255,0.02)"}}>No</td>
<td style={{padding: "9px 12px", backgroundColor: "rgba(255,255,255,0.05)"}}>No</td>
<td style={{padding: "9px 12px", backgroundColor: "rgba(255,255,255,0.02)"}}>No</td>
<td style={{padding: "9px 12px", backgroundColor: "rgba(255,255,255,0.05)"}}>❌</td>
<td style={{padding: "9px 12px", backgroundColor: "rgba(255,255,255,0.05)"}}>❌</td>
<td style={{padding: "9px 12px", backgroundColor: "rgba(255,255,255,0.05)"}}>CUDA-only (SM90 / SM100 / SM103). In-tree Triton kernel, no external dependency. Configure via <code>--attention-backend-config</code>.</td>
</tr>
<tr>
<td style={{padding: "9px 12px", fontWeight: 500, backgroundColor: "rgba(255,255,255,0.02)"}}><code>sla_attn</code></td>
<td style={{padding: "9px 12px", backgroundColor: "rgba(255,255,255,0.05)"}}>Yes</td>
@@ -137,6 +137,12 @@ Rows are grouped when a family shares the same runtime path or optimization supp
<td>T2VA / FL2VA / Ref2VA, 768p at 24 fps with synchronized audio</td>
<td><span className="sgd-chip">Cache-DiT</span><span className="sgd-chip">Sage</span><span className="sgd-chip">Online FP8</span><span className="sgd-chip">GGUF</span></td>
</tr>
<tr>
<td>FastH3</td>
<td><div className="sgd-id-list"><code>FastVideo/FastVideo-FastH3-4-step-Preview-v1-VSA-DataFree</code></div></td>
<td>T2VA only, 4-step distilled, 768p at 24 fps with synchronized audio</td>
<td><span className="sgd-chip">VSA-H3</span></td>
</tr>
<tr>
<td>Wan2.1 Fun</td>
<td><div className="sgd-id-list"><code>weizhou03/Wan2.1-Fun-1.3B-InP-Diffusers</code></div></td>
@@ -621,6 +627,21 @@ Optimization columns are abbreviated to keep the matrix readable:
<td style={{padding: "9px 12px", backgroundColor: "rgba(255,255,255,0.05)"}}>❌</td>
<td style={{padding: "9px 12px", backgroundColor: "rgba(255,255,255,0.02)"}}>❌</td>
</tr>
<tr>
<td style={{padding: "9px 12px", fontWeight: 500, backgroundColor: "rgba(255,255,255,0.02)"}}>FastH3 4-step (T2VA only)</td>
<td style={{padding: "9px 12px", backgroundColor: "rgba(255,255,255,0.05)"}}><code>FastVideo/FastVideo-FastH3-4-step-Preview-v1-VSA-DataFree</code></td>
<td style={{padding: "9px 8px", backgroundColor: "rgba(255,255,255,0.02)"}}>768p · 24 fps</td>
<td style={{padding: "9px 12px", backgroundColor: "rgba(255,255,255,0.05)"}}>❌</td>
<td style={{padding: "9px 12px", backgroundColor: "rgba(255,255,255,0.02)"}}>❌</td>
<td style={{padding: "9px 12px", backgroundColor: "rgba(255,255,255,0.05)"}}>✅</td>
<td style={{padding: "9px 12px", backgroundColor: "rgba(255,255,255,0.02)"}}>✅</td>
<td style={{padding: "9px 12px", backgroundColor: "rgba(255,255,255,0.05)"}}>❌</td>
<td style={{padding: "9px 12px", backgroundColor: "rgba(255,255,255,0.02)"}}>❌</td>
<td style={{padding: "9px 12px", backgroundColor: "rgba(255,255,255,0.05)"}}>❌</td>
<td style={{padding: "9px 12px", backgroundColor: "rgba(255,255,255,0.02)"}}>❌</td>
<td style={{padding: "9px 12px", backgroundColor: "rgba(255,255,255,0.05)"}}>❌</td>
<td style={{padding: "9px 12px", backgroundColor: "rgba(255,255,255,0.02)"}}>❌</td>
</tr>
<tr>
<td style={{padding: "9px 12px", fontWeight: 500, backgroundColor: "rgba(255,255,255,0.02)"}}>LTX-2.3 (one/two-stage/TI2V/HQ)</td>
<td style={{padding: "9px 12px", backgroundColor: "rgba(255,255,255,0.05)"}}><code>Lightricks/LTX-2.3</code></td>
@@ -704,6 +725,11 @@ Optimization columns are abbreviated to keep the matrix readable:
difference is transformer depth and width, picked up from
`transformer/config.json` at load time. A single checkpoint serves T2V,
I2V (`--image-path`), and T2I (`--num-frames 1`).
6. FastH3's VSA column refers to the dedicated `video_sparse_attn_h3` (VSA-H3)
backend. Dense backends that run on base MiniMax-H3 also run on the FastH3
weights.
FastH3 serves `t2va` only and rejects `--model-variant` and
`quality: "high"`.
</Accordion>
+2 -2
View File
@@ -373,7 +373,7 @@ For a pipeline whose primary DiT is named `transformer`, the shorter
MiniMax-H3 supports this path while preserving its required FP32 patch,
timestep, and output projections. See the
[MiniMax-H3 cookbook](/cookbook/diffusion/MiniMax/MiniMax-H3#7-feature-contracts-and-advanced-recipes)
[MiniMax-H3 cookbook](/cookbook/diffusion/MiniMax/MiniMax-H3#8-feature-contracts-and-advanced-recipes)
for its distributed serving recipe.
### MXFP4 Online Quantization
@@ -432,7 +432,7 @@ projections take that path.
<Warning>
`kitchen_int8` is approximate and is not a consistency ground-truth mode.
The BF16 path is unchanged when `comfy-kitchen` is not installed. See the
[MiniMax-H3 cookbook](/cookbook/diffusion/MiniMax/MiniMax-H3#7-feature-contracts-and-advanced-recipes)
[MiniMax-H3 cookbook](/cookbook/diffusion/MiniMax/MiniMax-H3#8-feature-contracts-and-advanced-recipes)
for the 24 GB offload recipe, including why `vae` must stay out of
`--layerwise-offload-components`.
</Warning>
@@ -314,6 +314,7 @@ Use the preset categories this way:
| `cosmos3-super-t2v-cfg2tp2` | `nvidia/Cosmos3-Super` | No | Explicit four-GPU TP2 x CFG2 throughput comparator. On H200 it was 48.00% faster end to end than TP2, but the topology changed the deterministic output (SSIM 0.914244, PSNR 29.469771 dB), so do not treat it as lossless-equivalent or select it automatically. |
| `wan-i2v` | `Wan-AI/Wan2.2-I2V-A14B-Diffusers` | Yes: `wan22_i2v_a14b_720p` | Nightly cat image and motion prompt, 1280x720, 81 frames, 4 GPUs, CFG parallel, Ulysses degree 2, text encoder CPU offload and pinned CPU memory |
| `minimax-h3-t2va` | `MiniMaxAI/MiniMax-H3` | Yes: `minimax_h3_t2va_5s` | H3 FL2VA-partition T2VA baseline: 1344x768 resolved canvas, 5 seconds / 124 frames at 24 fps, 50 joint video-audio steps, 4 GPUs, TP2 + Ulysses2, eager BF16/FP32. The helper writes H3's request contract to a generated config. |
| `fasth3-t2va-vsa` | `FastVideo/FastVideo-FastH3-4-step-Preview-v1-VSA-DataFree` | No | FastH3 4-step distilled T2VA on the trained VSA-H3 backend: 1344x768, 10 seconds / 243 frames, five sigma points = four DiT forwards, 4 GPUs, Ulysses 4, eager, 2-step warmup request. Compare against `--attention-backend fa` on the same weights for the dense-fallback gap. |
| `longcat-image` | `meituan-longcat/LongCat-Image` | No | Eager DiT baseline at 1024x1024, 50 steps, guidance 4.5; prompt rewrite is disabled so Qwen2.5-VL does not contaminate the DiT A/B. |
| `longcat-image-edit` | `meituan-longcat/LongCat-Image-Edit` | No | Native edit baseline using the public SGLang edit fixture. Its 1536x1024 source resolves to 1264x848 under the checkpoint's roughly-one-megapixel aspect-ratio rule, and the BCG comparator captures that exact serving canvas; prompt rewrite is disabled to isolate the DiT. |
| `longcat-image-edit-turbo` | `meituan-longcat/LongCat-Image-Edit-Turbo` | No | Matching distilled edit baseline using the same public fixture, prompt, and 1264x848 BCG canvas. Its registered sampling class owns the eight-step, guidance-1 schedule. |
@@ -425,6 +425,33 @@ MODELS = {
"num-inference-steps",
},
},
# H3 rejects a 1-step warmup request, hence --warmup-steps=2.
"fasth3-t2va-vsa": {
"path": "FastVideo/FastVideo-FastH3-4-step-Preview-v1-VSA-DataFree",
"prompt": (
"A curious raccoon peers through a vibrant field of yellow "
"sunflowers, its eyes wide with interest."
),
"seed": 1000,
"config_overrides": {
"task": "t2va",
"conditions": [],
"target": {
"short_edge": 768,
"aspect_ratio": "16:9",
"duration_seconds": 10.0,
},
"num_inference_steps": 5,
},
"extra_args": [
"--num-gpus=4",
"--attention-backend=video_sparse_attn_h3",
'--attention-backend-config={"VSA_sparsity": 0.9}',
"--enable-torch-compile=false",
"--warmup-steps=2",
],
"force_eager": True,
},
# Source-tracked extras from current registry / GPU test coverage.
"longcat-image": {
"path": "meituan-longcat/LongCat-Image",
+1 -1
View File
@@ -9,7 +9,7 @@ SGLang diffusion features an end-to-end unified pipeline for accelerating diffus
## Key Features
SGLang Diffusion has the following features:
- Broad model support: Wan, FastWan, FLUX, Qwen-Image, LongCat-Image, Z-Image, Ideogram 4, Krea-2, Cosmos3, LTX-2/LTX-2.3/LTX-2.5, MiniMax-H3, LingBot Video MoE, LingBot World, SANA-Video/SANA-WM, JoyEcho, MOVA, GLM-Image, ERNIE-Image, Hunyuan3D, and more
- Broad model support: Wan, FastWan, FLUX, Qwen-Image, LongCat-Image, Z-Image, Ideogram 4, Krea-2, Cosmos3, LTX-2/LTX-2.3/LTX-2.5, MiniMax-H3, FastH3, LingBot Video MoE, LingBot World, SANA-Video/SANA-WM, JoyEcho, MOVA, GLM-Image, ERNIE-Image, Hunyuan3D, and more
- Fast inference speed: empowered by optimized `sgl-kernel` kernels, scheduler/runtime improvements, caching acceleration, and native diffusion hot-path optimizations
- Ease of use: OpenAI-compatible api, CLI, and python sdk support
- Multi-platform support:
@@ -46,6 +46,7 @@ class MiniMaxH3DiTArchConfig(DiTArchConfig):
3,
),
r"^transformer_blocks\.(\d+)\.attn\.to_out\.0\.(.*)$": r"blocks.\1.attn.out_proj.\2",
r"^transformer_blocks\.(\d+)\.attn\.to_gate_compress\.(.*)$": r"blocks.\1.attn.to_gate_compress.\2",
r"^transformer_blocks\.(\d+)\.attn\.norm_q\.(.*)$": r"blocks.\1.attn.q_norm.\2",
r"^transformer_blocks\.(\d+)\.attn\.norm_k\.(.*)$": r"blocks.\1.attn.k_norm.\2",
r"^transformer_blocks\.(\d+)\.ff\.net\.0\.proj\.(.*)$": r"blocks.\1.mlp.fc1.\2",
@@ -99,6 +100,7 @@ class MiniMaxH3DiTArchConfig(DiTArchConfig):
final_norm_eps: float = 1e-5
checkpoint_uses_diffusers_layout: bool = False
adaln_affine_input_dim: int | None = None
has_gate_compress: bool = False
def __post_init__(self) -> None:
super().__post_init__()
@@ -24,6 +24,14 @@ class MiniMaxH3AudioVAEConfig(VAEConfig):
load_encoder: bool = True
load_decoder: bool = True
def update_model_arch(self, source_model_dict: dict) -> None:
# Native-Diffusers AutoencoderKLMiniMaxH3Audio config field name.
aliases = {"sampling_rate": "sample_rate"}
model_dict = {
aliases.get(key, key): value for key, value in source_model_dict.items()
}
super().update_model_arch(model_dict)
def post_init(self) -> None:
validate_minimax_h3_vae_latent_stats(
self.arch_config,
@@ -56,6 +56,17 @@ class MiniMaxH3VideoVAEConfig(VAEConfig):
f"{self.parallel_decode_mode!r}"
)
def update_model_arch(self, source_model_dict: dict) -> None:
# Native-Diffusers AutoencoderKLMiniMaxH3 config field names.
aliases = {
"clip_length": "vae_clip_length",
"token_drop": "vae_token_drop",
}
model_dict = {
aliases.get(key, key): value for key, value in source_model_dict.items()
}
super().update_model_arch(model_dict)
def post_init(self) -> None:
self.resolved_parallel_decode_mode()
validate_minimax_h3_vae_latent_stats(
@@ -44,6 +44,7 @@ from sglang.multimodal_gen.configs.pipeline_configs.ltx_2 import (
LTX23PipelineConfig,
)
from sglang.multimodal_gen.configs.pipeline_configs.minimax_h3 import (
FastH3PipelineConfig,
MiniMaxH3PipelineConfig,
)
from sglang.multimodal_gen.configs.pipeline_configs.mova import MOVAPipelineConfig
@@ -97,5 +98,6 @@ __all__ = [
"LingBotWorldCausalDMDConfig",
"LingBotWorldV2CausalDMDConfig",
"LingBotVideoMoEPipelineConfig",
"FastH3PipelineConfig",
"MiniMaxH3PipelineConfig",
]
@@ -260,6 +260,21 @@ class MiniMaxH3PipelineConfig(PipelineConfig):
)
if selected_backend is None:
return
if selected_backend is AttentionBackendEnum.VIDEO_SPARSE_ATTN_H3:
if server_args.ring_degree > 1:
raise ValueError(
"VSA-H3 does not support --ring-degree > 1; use Ulysses "
"sequence parallelism."
)
if (
server_args.enable_torch_compile
or server_args.enable_breakable_cuda_graph
):
raise ValueError(
"VSA-H3 builds per-step tile metadata eagerly and is not "
"validated under torch.compile or the breakable CUDA "
"graph; disable them or use --attention-backend fa."
)
get_attn_backend(
self.dit_config.arch_config.attention_head_dim,
torch.bfloat16,
@@ -279,4 +294,28 @@ class MiniMaxH3PipelineConfig(PipelineConfig):
return safetensors_list
__all__ = ["MiniMaxH3PipelineConfig"]
@dataclass
class FastH3PipelineConfig(MiniMaxH3PipelineConfig):
"""FastH3: 4-step VSA-distilled MiniMax-H3, t2va only."""
def __post_init__(self) -> None:
self.dit_config.arch_config.has_gate_compress = True
def validate_quality_deployment(self, server_args) -> None:
raise ValueError(
'quality="high" is audited only for the base MiniMax-H3 50-step '
"4xH200 deployment; the FastH3 4-step distilled checkpoint has no "
'audited high-quality deployment. Use quality="lossless".'
)
def validate_server_args(self, server_args) -> None:
if server_args.model_variant is not None:
raise ValueError(
"FastH3 ships one t2va-distilled weight partition; "
"--model-variant does not apply. FL2VA and Ref2VA tasks were "
"not distilled; use MiniMaxAI/MiniMax-H3 for those."
)
super().validate_server_args(server_args)
__all__ = ["FastH3PipelineConfig", "MiniMaxH3PipelineConfig"]
@@ -302,4 +302,26 @@ class MiniMaxH3SamplingParams(SamplingParams):
req.extra.update(self.build_request_extra())
__all__ = ["MiniMaxH3SamplingParams"]
@dataclass
class FastH3SamplingParams(MiniMaxH3SamplingParams):
"""FastH3: five sigma grid points, i.e. the four distilled DiT forwards."""
num_inference_steps: int = 5
def _validate(self) -> None:
super()._validate()
if self.num_inference_steps != 5:
raise ValueError(
"FastH3 is distilled for exactly five sigma grid points (four DiT "
f"forwards); got num_inference_steps={self.num_inference_steps}. "
"Use MiniMaxAI/MiniMax-H3 for other schedules."
)
if self.task is not None and self.task.strip().lower() != "t2va":
raise ValueError(
"FastH3 is distilled for t2va only; fl2va and ref2va were not "
f"distilled (got task={self.task!r}). Use MiniMaxAI/MiniMax-H3 "
"for those tasks."
)
__all__ = ["FastH3SamplingParams", "MiniMaxH3SamplingParams"]
+17 -1
View File
@@ -29,6 +29,7 @@ if TYPE_CHECKING:
from sglang.multimodal_gen.configs.pipeline_configs import (
Cosmos3Config,
FastH3PipelineConfig,
FastHunyuanConfig,
FluxPipelineConfig,
HeliosDistilledConfig,
@@ -162,7 +163,10 @@ from sglang.multimodal_gen.configs.sample.ltx_2 import (
LTX23SamplingParams,
)
from sglang.multimodal_gen.configs.sample.ltx_2_5 import LTX25SamplingParams
from sglang.multimodal_gen.configs.sample.minimax_h3 import MiniMaxH3SamplingParams
from sglang.multimodal_gen.configs.sample.minimax_h3 import (
FastH3SamplingParams,
MiniMaxH3SamplingParams,
)
from sglang.multimodal_gen.configs.sample.mova import (
MOVA_360P_SamplingParams,
MOVA_720P_SamplingParams,
@@ -334,6 +338,7 @@ _MODEL_NAME_DETECTORS: List[Tuple[str, Callable[[str], bool]]] = []
KNOWN_NON_DIFFUSERS_DIFFUSION_MODEL_PATTERNS: Dict[str, str] = {
"minimaxai/minimax-h3": "MiniMaxH3Pipeline",
"minimax/minimax-h3": "MiniMaxH3Pipeline",
"fastvideo/fastvideo-fasth3-4-step-preview-v1-vsa-datafree": "FastH3Pipeline",
"lerobot/pi05": "Pi05Pipeline",
"pi05": "Pi05Pipeline",
"pi0.5": "Pi05Pipeline",
@@ -972,6 +977,17 @@ def _register_configs():
in model_id.lower().replace("-", "").replace("_", "")
],
)
register_configs(
sampling_param_cls=FastH3SamplingParams,
pipeline_config_cls=FastH3PipelineConfig,
hf_model_paths=[
"FastVideo/FastVideo-FastH3-4-step-Preview-v1-VSA-DataFree",
],
model_detectors=[
lambda model_id: "fasth3"
in model_id.lower().replace("-", "").replace("_", "")
],
)
# FLUX
register_configs(
sampling_param_cls=FluxSamplingParams,
@@ -0,0 +1,488 @@
# Copied and adapted from: https://github.com/hao-ai-lab/FastVideo
# (the video_sparse_attn_h3 backend), rewritten for SGLang's packed-varlen
# MiniMax-H3 attention contract.
# SPDX-License-Identifier: Apache-2.0
"""VSA for MiniMax-H3's packed mixed-modality self-attention.
H3 runs one joint bidirectional attention over
``[text | condition keyframes | audio | generated video]``. Tiles are
``[segment-pure prefix chunks] + [3D video tiles]``; prefix tiles never
straddle segment boundaries. Selection is a top-k over pooled tile scores
emitted directly as the per-query-tile index lists the vendored Triton
tile-64 kernel consumes, with per-tile valid sizes so ragged interior tiles
mask exactly.
Non-video queries are always dense. Non-video keys are either
always-selected for every query ("exempt", default) or compete in top-k
under a FLOP-matched budget ("compete"). The compression branch is gated by
``to_gate_compress``: base H3 has no such weights and the gate loads as
zeros (pure sparse); VSA-distilled students (FastH3) ship trained gates
that activate the branch.
"""
import functools
import math
import re
from dataclasses import dataclass, field
from typing import Any
import torch
from sglang.multimodal_gen.runtime.layers.attention.backends.attention_backend import (
AttentionBackend,
AttentionImpl,
AttentionMetadata,
AttentionMetadataBuilder,
)
from sglang.multimodal_gen.runtime.layers.attention.backends.video_sparse_attn import (
construct_variable_block_sizes,
get_non_pad_index,
get_tile_partition_indices,
)
from sglang.multimodal_gen.runtime.layers.attention.backends.vsa_h3_kernels import (
vsa_h3_block_sparse_attn_forward,
vsa_h3_pack_tiles,
vsa_h3_untile,
)
from sglang.multimodal_gen.runtime.platforms import AttentionBackendEnum
# The FastH3 checkpoints are trained and served at the 64-token (4, 4, 4)
# tile geometry; the kernel block size matches it exactly.
VSA_H3_TILE_SHAPE = (4, 4, 4)
VSA_H3_TILE_ELEMS = math.prod(VSA_H3_TILE_SHAPE)
_DIT_BLOCK_PREFIX = re.compile(r"^blocks\.(\d+)\.")
@functools.lru_cache(maxsize=8)
def _h3_tile_geometry(
prefix_segments: tuple[int, ...],
dit_seq_shape: tuple[int, int, int],
device: torch.device,
) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor, int, int]:
"""Segment-pure prefix chunks, then video tiles.
Returns (variable_block_sizes int32 [n_tiles], pack_index int32 [S_pad]:
padded position -> packed row or -1, unpack_index int32 [used]: packed row
-> padded position, num_prefix_tiles, num_video_tiles).
"""
prefix_len = sum(prefix_segments)
prefix_sizes: list[int] = []
for segment in prefix_segments:
full, remainder = divmod(segment, VSA_H3_TILE_ELEMS)
prefix_sizes.extend([VSA_H3_TILE_ELEMS] * full)
if remainder:
prefix_sizes.append(remainder)
num_prefix_tiles = len(prefix_sizes)
num_tiles = (
math.ceil(dit_seq_shape[0] / VSA_H3_TILE_SHAPE[0]),
math.ceil(dit_seq_shape[1] / VSA_H3_TILE_SHAPE[1]),
math.ceil(dit_seq_shape[2] / VSA_H3_TILE_SHAPE[2]),
)
video_sizes = construct_variable_block_sizes(dit_seq_shape, num_tiles, device)
num_video_tiles = int(video_sizes.numel())
video_indices = (
get_tile_partition_indices(dit_seq_shape, VSA_H3_TILE_SHAPE, device)
+ prefix_len
)
tile_partition_indices = torch.cat(
[
torch.arange(prefix_len, device=device, dtype=torch.long),
video_indices,
]
)
variable_block_sizes = torch.cat(
[
torch.tensor(prefix_sizes, dtype=torch.long, device=device),
video_sizes.to(torch.long),
]
)
non_pad_index = get_non_pad_index(variable_block_sizes, VSA_H3_TILE_ELEMS)
total = prefix_len + math.prod(dit_seq_shape)
sizes_sum = int(variable_block_sizes.sum())
if sizes_sum != total or non_pad_index.numel() != total:
raise ValueError(
f"VSA-H3 tile geometry mismatch for prefix={prefix_segments}, "
f"video={dit_seq_shape}: sizes sum {sizes_sum}, non-pad "
f"{non_pad_index.numel()}, expected {total}."
)
seq_pad = variable_block_sizes.numel() * VSA_H3_TILE_ELEMS
pack_index = torch.full((seq_pad,), -1, dtype=torch.int32, device=device)
pack_index[non_pad_index] = tile_partition_indices.to(torch.int32)
unpack_index = torch.empty(total, dtype=torch.int32, device=device)
unpack_index[tile_partition_indices] = non_pad_index.to(torch.int32)
return (
variable_block_sizes.to(torch.int32),
pack_index,
unpack_index,
num_prefix_tiles,
num_video_tiles,
)
class VideoSparseAttentionH3Backend(AttentionBackend):
accept_output_buffer: bool = True
@staticmethod
def get_supported_head_sizes() -> list[int]:
return [64, 128]
@staticmethod
def get_enum() -> AttentionBackendEnum:
return AttentionBackendEnum.VIDEO_SPARSE_ATTN_H3
@staticmethod
def get_impl_cls() -> type["VideoSparseAttentionH3Impl"]:
return VideoSparseAttentionH3Impl
@staticmethod
def get_metadata_cls() -> type["VideoSparseAttentionH3Metadata"]:
return VideoSparseAttentionH3Metadata
@staticmethod
def get_builder_cls() -> type["VideoSparseAttentionH3MetadataBuilder"]:
return VideoSparseAttentionH3MetadataBuilder
@dataclass
class VideoSparseAttentionH3Metadata(AttentionMetadata):
VSA_sparsity: float
total_seq_length: int
num_prefix_tiles: int
num_video_tiles: int
exempt: bool
variable_block_sizes: torch.Tensor
pack_index: torch.Tensor
unpack_index: torch.Tensor
dense_layers: tuple[int, ...] = ()
workspace_cache: dict = field(default_factory=dict)
@property
def num_tiles(self) -> int:
return self.num_prefix_tiles + self.num_video_tiles
class VideoSparseAttentionH3MetadataBuilder(AttentionMetadataBuilder):
def __init__(self) -> None:
self._workspace_cache: dict = {}
def prepare(self) -> None:
pass
def build( # type: ignore[override]
self,
current_timestep: int,
raw_latent_shape: tuple[int, int, int],
patch_size: tuple[int, int, int],
VSA_sparsity: float,
prefix_segments: tuple[int, ...],
device: torch.device,
exempt: bool = True,
dense_layers: tuple[int, ...] = (),
dense_first_n_steps: int = 0,
**kwargs: dict[str, Any],
) -> VideoSparseAttentionH3Metadata:
dit_seq_shape = (
raw_latent_shape[0] // patch_size[0],
raw_latent_shape[1] // patch_size[1],
raw_latent_shape[2] // patch_size[2],
)
prefix_segments = tuple(int(s) for s in prefix_segments if s > 0)
if current_timestep < dense_first_n_steps:
VSA_sparsity = 0.0
(
variable_block_sizes,
pack_index,
unpack_index,
num_prefix_tiles,
num_video_tiles,
) = _h3_tile_geometry(prefix_segments, dit_seq_shape, device)
return VideoSparseAttentionH3Metadata(
current_timestep=current_timestep,
VSA_sparsity=float(VSA_sparsity),
total_seq_length=sum(prefix_segments) + math.prod(dit_seq_shape),
num_prefix_tiles=num_prefix_tiles,
num_video_tiles=num_video_tiles,
exempt=exempt,
variable_block_sizes=variable_block_sizes,
pack_index=pack_index,
unpack_index=unpack_index,
dense_layers=tuple(int(layer) for layer in dense_layers),
workspace_cache=self._workspace_cache,
)
def _compute_topk(sparsity: float, num_video_tiles: int) -> int:
keep = math.ceil((1.0 - sparsity) * num_video_tiles)
return max(1, min(keep, num_video_tiles))
def _topk_tile_lists(
scores: torch.Tensor,
num_prefix_tiles: int,
num_video_tiles: int,
sparsity: float,
exempt: bool,
) -> torch.Tensor:
"""scores [H, n_tiles, n_tiles] -> ascending int32 kv-tile lists
[H, num_video_tiles, width]; width = num_prefix_tiles + keep (exempt) or
min(keep + num_prefix_tiles, n_tiles) (compete)."""
prefix = num_prefix_tiles
keep = _compute_topk(sparsity, num_video_tiles)
video_rows = scores[:, prefix:, :]
if exempt or prefix == 0:
picked = video_rows[:, :, prefix:].topk(keep, dim=-1).indices + prefix
picked = picked.sort(dim=-1).values
if prefix == 0:
return picked.to(torch.int32)
prefix_cols = torch.arange(prefix, device=scores.device).expand(
*picked.shape[:-1], prefix
)
return torch.cat([prefix_cols, picked], dim=-1).to(torch.int32)
keep_total = min(keep + prefix, scores.shape[-1])
return (
video_rows.topk(keep_total, dim=-1).indices.sort(dim=-1).values.to(torch.int32)
)
def _workspace_key(
meta: VideoSparseAttentionH3Metadata,
heads: int,
head_dim: int,
has_gate: bool,
dtype: torch.dtype,
device: torch.device,
) -> tuple:
return (
meta.num_tiles,
meta.num_prefix_tiles,
meta.exempt,
heads,
head_dim,
has_gate,
dtype,
device,
)
class _Workspace:
"""Per-geometry scratch: tiled q/k/v(/gate) [3|4, H, S_pad, D], pooled fp32
tile means [3, H, n_tiles, D], and the kernel index lists (prefix rows and
prefix columns are static; only the top-k video columns change per layer).
"""
def __init__(
self,
meta: VideoSparseAttentionH3Metadata,
heads: int,
head_dim: int,
has_gate: bool,
dtype: torch.dtype,
device: torch.device,
) -> None:
n_tiles = meta.num_tiles
seq_pad = n_tiles * VSA_H3_TILE_ELEMS
self.key = _workspace_key(meta, heads, head_dim, has_gate, dtype, device)
self.tiled = torch.empty(
(3 + int(has_gate), heads, seq_pad, head_dim), dtype=dtype, device=device
)
self.pooled = torch.empty(
(3, heads, n_tiles, head_dim), dtype=torch.float32, device=device
)
self.out_tiled = torch.empty(
(heads, seq_pad, head_dim), dtype=dtype, device=device
)
all_tiles = torch.arange(n_tiles, dtype=torch.int32, device=device)
self.dense_index = all_tiles.repeat(heads, n_tiles, 1)
self.dense_num = torch.full(
(heads, n_tiles), n_tiles, dtype=torch.int32, device=device
)
self.q2k_index = self.dense_index.clone()
self.q2k_num = self.dense_num.clone()
def sparse_lists(
self, video_lists: torch.Tensor, num_prefix_tiles: int
) -> tuple[torch.Tensor, torch.Tensor]:
width = video_lists.shape[-1]
self.q2k_index[:, num_prefix_tiles:, :width] = video_lists
self.q2k_num[:, num_prefix_tiles:] = width
return self.q2k_index, self.q2k_num
def _get_workspace(
meta: VideoSparseAttentionH3Metadata, query: torch.Tensor, has_gate: bool
) -> _Workspace:
heads, head_dim = query.shape[-2], query.shape[-1]
key = _workspace_key(meta, heads, head_dim, has_gate, query.dtype, query.device)
workspace = meta.workspace_cache.get("workspace")
if workspace is None or workspace.key != key:
workspace = _Workspace(
meta, heads, head_dim, has_gate, query.dtype, query.device
)
meta.workspace_cache["workspace"] = workspace
return workspace
def _select_kv_lists(
ws: _Workspace,
meta: VideoSparseAttentionH3Metadata,
scores: torch.Tensor | None,
sparsity: float,
) -> tuple[torch.Tensor, torch.Tensor]:
"""Kernel index lists for this layer: dense, or top-k video columns."""
keep = _compute_topk(sparsity, meta.num_video_tiles)
if sparsity <= 0.0 or keep >= meta.num_video_tiles:
return ws.dense_index, ws.dense_num
return ws.sparse_lists(
_topk_tile_lists(
scores,
meta.num_prefix_tiles,
meta.num_video_tiles,
sparsity,
meta.exempt,
),
meta.num_prefix_tiles,
)
class VideoSparseAttentionH3Impl(AttentionImpl):
def __init__(
self,
num_heads: int,
head_size: int,
causal: bool,
softmax_scale: float,
num_kv_heads: int | None = None,
prefix: str = "",
**extra_impl_args,
) -> None:
self.num_heads = num_heads
self.head_size = head_size
self.softmax_scale = softmax_scale
self.prefix = prefix
match = _DIT_BLOCK_PREFIX.match(prefix)
self.layer_idx = int(match.group(1)) if match else None
# The token refiner and any other non-packed caller resolve the same
# backend object; they run the exact dense kernel instead.
from sglang.multimodal_gen.runtime.layers.attention.backends.flash_attn import (
FlashAttentionImpl,
)
self._dense_fallback = FlashAttentionImpl(
num_heads=num_heads,
head_size=head_size,
causal=causal,
softmax_scale=softmax_scale,
num_kv_heads=num_kv_heads,
prefix=prefix,
)
def forward(
self,
query: torch.Tensor,
key: torch.Tensor,
value: torch.Tensor,
attn_metadata: AttentionMetadata,
) -> torch.Tensor:
raise NotImplementedError(
"VSA-H3 serves MiniMax-H3's packed varlen attention; use " "forward_varlen."
)
def forward_varlen(
self,
query: torch.Tensor,
key: torch.Tensor,
value: torch.Tensor,
*,
cu_seqlens: torch.Tensor,
max_seqlen: int,
cu_seqlens_host: tuple[int, ...] | None = None,
attn_metadata: VideoSparseAttentionH3Metadata | None = None,
gate_compress: torch.Tensor | None = None,
) -> torch.Tensor:
"""query/key/value: [T, H, D] packed rows (post-norm, post-RoPE)."""
if self.layer_idx is None or attn_metadata is None:
if attn_metadata is None and self.layer_idx is not None:
raise RuntimeError(
"VSA-H3 needs per-step attention metadata from the "
"MiniMax-H3 denoising stage; none was set in the forward "
"context."
)
return self._dense_fallback.forward_varlen(
query,
key,
value,
cu_seqlens=cu_seqlens,
max_seqlen=max_seqlen,
cu_seqlens_host=cu_seqlens_host,
)
meta = attn_metadata
bounds = (
cu_seqlens_host
if cu_seqlens_host is not None
else tuple(int(item) for item in cu_seqlens.tolist())
)
used = int(bounds[1])
if used != meta.total_seq_length:
raise ValueError(
f"VSA-H3 metadata was built for {meta.total_seq_length} packed "
f"rows, got {used}. The step metadata and the packed sequence "
"layout have diverged."
)
sparsity = 0.0 if self.layer_idx in meta.dense_layers else meta.VSA_sparsity
has_gate = gate_compress is not None
ws = _get_workspace(meta, query, has_gate)
vsa_h3_pack_tiles(
query,
key,
value,
gate_compress,
meta.pack_index,
meta.variable_block_sizes,
ws.tiled,
ws.pooled,
)
q_pooled, k_pooled, v_pooled = ws.pooled
scores = None
if sparsity > 0.0 or has_gate:
scores = torch.matmul(q_pooled, k_pooled.transpose(-2, -1)) * (
self.head_size**-0.5
)
q2k_index, q2k_num = _select_kv_lists(ws, meta, scores, sparsity)
vsa_h3_block_sparse_attn_forward(
ws.tiled[0:1],
ws.tiled[1:2],
ws.tiled[2:3],
q2k_index[None],
q2k_num[None],
meta.variable_block_sizes,
out=ws.out_tiled[None],
)
out_compress = None
if has_gate:
out_compress = torch.matmul(torch.softmax(scores, dim=-1), v_pooled)
result = torch.empty(query.shape, dtype=query.dtype, device=query.device)
vsa_h3_untile(
ws.out_tiled,
ws.tiled[3] if has_gate else None,
out_compress,
meta.unpack_index,
used,
result,
)
return result
@@ -0,0 +1,343 @@
# Copied and adapted from: https://github.com/hao-ai-lab/FastVideo
# (fastvideo-kernel triton_kernels/block_sparse_attn_triton.py).
# Inference-only subset: the block-sparse forward. Backward stays upstream;
# SGLang serves no-grad forwards. The tile pack/unpack kernels are SGLang's.
# SPDX-License-Identifier: Apache-2.0
"""Triton 64-token block-sparse attention for MiniMax-H3 VSA.
The attention kernel consumes an explicit per-query-block index list
(``q2k_index`` / ``q2k_num``) plus per-key-block valid token counts
(``variable_block_sizes``), so ragged interior tiles - segment-pure prefix
chunks and 3D video tiles whose dimensions do not divide the tile shape - mask
their pad columns exactly.
``vsa_h3_pack_tiles`` gathers packed ``[T, H, D]`` rows into the head-major
padded tile layout the attention kernel reads and pools each tile in the same
pass; ``vsa_h3_untile`` scatters the attention output back to packed rows and
folds in the gated compression branch. Both replace chains of index copies
and transposes that otherwise cost more than the attention kernel itself.
"""
import math
import torch
import triton
import triton.language as tl
from triton.tools.tensor_descriptor import TensorDescriptor
# BLOCK_M / BLOCK_N are structural, not tunable: the kernel indexes the top-k
# list per BLOCK_M q-tile and addresses keys as kv_idx * BLOCK_N, so both must
# match the granularity q2k_index and variable_block_sizes were built at.
VSA_H3_KERNEL_BLOCK = 64
# Pinned instead of autotuned: on B300 num_warps=4 wins at every sequence
# length and num_stages=5 is within 0.5% of the best (7 spills); FastVideo
# reports the same optimum for Blackwell.
_ATTN_NUM_WARPS = 4
_ATTN_NUM_STAGES = 5
@triton.jit
def _attn_fwd_sparse(
desc_q,
desc_k,
desc_v,
desc_o,
sm_scale,
q2k_index,
q2k_num,
max_kv_blks,
variable_block_sizes,
H,
N_CTX_Q,
HEAD_DIM: tl.constexpr,
BLOCK_M: tl.constexpr,
BLOCK_N: tl.constexpr,
):
q_blk = tl.program_id(0)
off_hz = tl.program_id(1)
b = off_hz // H
h = off_hz % H
q_tiles = N_CTX_Q // BLOCK_M
meta_base = off_hz * q_tiles + q_blk
kv_blocks = tl.load(q2k_num + meta_base)
kv_ptr = q2k_index + meta_base.to(tl.int64) * max_kv_blks
q = desc_q.load([b, h, q_blk * BLOCK_M, 0]).reshape([BLOCK_M, HEAD_DIM])
m_i = tl.full([BLOCK_M], -float("inf"), tl.float32)
l_i = tl.zeros([BLOCK_M], dtype=tl.float32) + 1.0
acc = tl.zeros([BLOCK_M, HEAD_DIM], dtype=tl.float32)
qk_scale = sm_scale * 1.44269504 # 1/ln2
for i in range(0, kv_blocks):
kv_idx = tl.load(kv_ptr + i).to(tl.int32)
block_size = tl.load(variable_block_sizes + kv_idx)
k = desc_k.load([b, h, kv_idx * BLOCK_N, 0]).reshape([BLOCK_N, HEAD_DIM])
qk = tl.dot(q, tl.trans(k))
mask = tl.arange(0, BLOCK_N) < block_size
qk = tl.where(mask[None, :], qk, -float("inf"))
m_ij = tl.maximum(m_i, tl.max(qk, 1) * qk_scale)
p = tl.math.exp2(qk * qk_scale - m_ij[:, None])
l_ij = tl.sum(p, 1)
alpha = tl.math.exp2(m_i - m_ij)
l_i = l_i * alpha + l_ij
acc = acc * alpha[:, None]
v = desc_v.load([b, h, kv_idx * BLOCK_N, 0]).reshape([BLOCK_N, HEAD_DIM])
acc = tl.dot(p.to(tl.bfloat16), v, acc)
m_i = m_ij
acc = acc / l_i[:, None]
desc_o.store(
[b, h, q_blk * BLOCK_M, 0],
acc.to(desc_o.dtype).reshape([1, 1, BLOCK_M, HEAD_DIM]),
)
def vsa_h3_block_sparse_attn_forward(
q: torch.Tensor,
k: torch.Tensor,
v: torch.Tensor,
q2k_index: torch.Tensor,
q2k_num: torch.Tensor,
variable_block_sizes: torch.Tensor,
out: torch.Tensor | None = None,
) -> torch.Tensor:
"""q/k/v: contiguous [B, H, S_pad, D] bf16 with S_pad = n_tiles * 64; pad
rows zero. q2k_index/q2k_num: contiguous [B, H, n_tiles, max_kv] /
[B, H, n_tiles] int32."""
batch, heads, seq_q, head_dim = q.shape
seq_kv = k.shape[2]
if seq_q % VSA_H3_KERNEL_BLOCK or seq_kv % VSA_H3_KERNEL_BLOCK:
raise ValueError(
f"VSA-H3 kernel needs 64-multiple sequence lengths, got q={seq_q}, "
f"kv={seq_kv}"
)
if variable_block_sizes.numel() != seq_kv // VSA_H3_KERNEL_BLOCK:
raise ValueError(
"variable_block_sizes must have one entry per 64-token key block: "
f"{variable_block_sizes.numel()} vs {seq_kv // VSA_H3_KERNEL_BLOCK}"
)
if out is None:
out = torch.empty_like(q)
block = [1, 1, VSA_H3_KERNEL_BLOCK, head_dim]
desc_q, desc_k, desc_v, desc_o = (
TensorDescriptor.from_tensor(t, block_shape=block) for t in (q, k, v, out)
)
grid = (seq_q // VSA_H3_KERNEL_BLOCK, batch * heads, 1)
_attn_fwd_sparse[grid](
desc_q,
desc_k,
desc_v,
desc_o,
1.0 / math.sqrt(head_dim),
q2k_index,
q2k_num,
q2k_index.shape[-1],
variable_block_sizes,
heads,
seq_q,
HEAD_DIM=head_dim,
BLOCK_M=VSA_H3_KERNEL_BLOCK,
BLOCK_N=VSA_H3_KERNEL_BLOCK,
num_warps=_ATTN_NUM_WARPS,
num_stages=_ATTN_NUM_STAGES,
)
return out
@triton.jit
def _pack_tiles_kernel(
Q,
K,
V,
G,
src_index,
variable_block_sizes,
Tiled,
Pooled,
stride_q_row,
stride_q_head,
stride_k_row,
stride_k_head,
stride_v_row,
stride_v_head,
stride_g_row,
stride_g_head,
H,
S_PAD,
N_TILES,
HAS_GATE: tl.constexpr,
HEAD_DIM: tl.constexpr,
BLOCK: tl.constexpr,
):
tile = tl.program_id(0)
h = tl.program_id(1)
rows = tile * BLOCK + tl.arange(0, BLOCK)
cols = tl.arange(0, HEAD_DIM)
src = tl.load(src_index + rows)
valid = src >= 0
src = tl.where(valid, src, 0).to(tl.int64)
mask = valid[:, None]
size = tl.load(variable_block_sizes + tile).to(tl.float32)
tensor_stride = H.to(tl.int64) * S_PAD * HEAD_DIM
out_off = (
h.to(tl.int64) * S_PAD * HEAD_DIM + rows[:, None] * HEAD_DIM + cols[None, :]
)
pool_off = (h * N_TILES + tile).to(tl.int64) * HEAD_DIM + cols
x = tl.load(
Q + src[:, None] * stride_q_row + h * stride_q_head + cols[None, :],
mask=mask,
other=0.0,
)
tl.store(Tiled + out_off, x)
tl.store(Pooled + pool_off, tl.sum(x.to(tl.float32), 0) / size)
x = tl.load(
K + src[:, None] * stride_k_row + h * stride_k_head + cols[None, :],
mask=mask,
other=0.0,
)
tl.store(Tiled + tensor_stride + out_off, x)
tl.store(
Pooled + H * N_TILES * HEAD_DIM + pool_off,
tl.sum(x.to(tl.float32), 0) / size,
)
x = tl.load(
V + src[:, None] * stride_v_row + h * stride_v_head + cols[None, :],
mask=mask,
other=0.0,
)
tl.store(Tiled + 2 * tensor_stride + out_off, x)
tl.store(
Pooled + 2 * H * N_TILES * HEAD_DIM + pool_off,
tl.sum(x.to(tl.float32), 0) / size,
)
if HAS_GATE:
x = tl.load(
G + src[:, None] * stride_g_row + h * stride_g_head + cols[None, :],
mask=mask,
other=0.0,
)
tl.store(Tiled + 3 * tensor_stride + out_off, x)
def vsa_h3_pack_tiles(
q: torch.Tensor,
k: torch.Tensor,
v: torch.Tensor,
gate: torch.Tensor | None,
src_index: torch.Tensor,
variable_block_sizes: torch.Tensor,
tiled: torch.Tensor,
pooled: torch.Tensor,
) -> None:
"""Gather packed [T, H, D] rows into ``tiled`` [3|4, H, S_pad, D] and write
fp32 per-tile means of q/k/v into ``pooled`` [3, H, n_tiles, D].
``src_index`` maps each padded position to its packed row, or -1 (pad -> 0).
"""
_, heads, seq_pad, head_dim = tiled.shape
n_tiles = seq_pad // VSA_H3_KERNEL_BLOCK
has_gate = gate is not None
g = gate if has_gate else q
assert all(t.stride(-1) == 1 for t in (q, k, v, g))
_pack_tiles_kernel[(n_tiles, heads)](
q,
k,
v,
g,
src_index,
variable_block_sizes,
tiled,
pooled,
q.stride(0),
q.stride(1),
k.stride(0),
k.stride(1),
v.stride(0),
v.stride(1),
g.stride(0),
g.stride(1),
heads,
seq_pad,
n_tiles,
HAS_GATE=has_gate,
HEAD_DIM=head_dim,
BLOCK=VSA_H3_KERNEL_BLOCK,
)
@triton.jit
def _untile_kernel(
OutTiled,
Gate,
OutC,
dst_index,
Res,
used,
total,
S_PAD,
N_TILES,
HAS_GATE: tl.constexpr,
HEAD_DIM: tl.constexpr,
BLOCK: tl.constexpr,
):
row_block = tl.program_id(0)
h = tl.program_id(1)
H = tl.num_programs(1)
rows = row_block * BLOCK + tl.arange(0, BLOCK)
cols = tl.arange(0, HEAD_DIM)
in_used = rows < used
pos = tl.load(dst_index + rows, mask=in_used, other=0).to(tl.int64)
head_base = h.to(tl.int64) * S_PAD * HEAD_DIM
off = head_base + pos[:, None] * HEAD_DIM + cols[None, :]
o = tl.load(OutTiled + off, mask=in_used[:, None], other=0.0).to(tl.float32)
if HAS_GATE:
g = tl.load(Gate + off, mask=in_used[:, None], other=0.0).to(tl.float32)
tile = pos // BLOCK
c = tl.load(
OutC + (h * N_TILES + tile)[:, None] * HEAD_DIM + cols[None, :],
mask=in_used[:, None],
other=0.0,
)
o = o + c * g
res_off = (rows[:, None] * H + h).to(tl.int64) * HEAD_DIM + cols[None, :]
tl.store(Res + res_off, o.to(Res.type.element_ty), mask=(rows < total)[:, None])
def vsa_h3_untile(
out_tiled: torch.Tensor,
gate_tiled: torch.Tensor | None,
out_compress: torch.Tensor | None,
dst_index: torch.Tensor,
used: int,
result: torch.Tensor,
) -> None:
"""Scatter ``out_tiled`` [H, S_pad, D] to packed rows of ``result`` [T, H, D]
(rows past ``used`` are zero), adding ``out_compress`` [H, n_tiles, D] fp32
scaled by ``gate_tiled`` when given."""
heads, seq_pad, head_dim = out_tiled.shape
total = result.shape[0]
has_gate = gate_tiled is not None
_untile_kernel[(triton.cdiv(total, VSA_H3_KERNEL_BLOCK), heads)](
out_tiled,
gate_tiled if has_gate else out_tiled,
out_compress if has_gate else out_tiled,
dst_index,
result,
used,
total,
seq_pad,
seq_pad // VSA_H3_KERNEL_BLOCK,
HAS_GATE=has_gate,
HEAD_DIM=head_dim,
BLOCK=VSA_H3_KERNEL_BLOCK,
)
@@ -67,6 +67,7 @@ from sglang.multimodal_gen.runtime.layers.quantization.configs.base_config impor
)
from sglang.multimodal_gen.runtime.layers.usp import _ring_attention_varlen
from sglang.multimodal_gen.runtime.loader.utils import get_param_names_mapping
from sglang.multimodal_gen.runtime.managers.forward_context import get_forward_context
from sglang.multimodal_gen.runtime.managers.memory_managers.layerwise_offload import (
LayerwiseOffloadableModuleMixin,
is_layerwise_offloaded_module,
@@ -86,6 +87,18 @@ logger = init_logger(__name__)
_ARCH_DEFAULTS = MiniMaxH3DiTArchConfig()
_NON_LORA_DELTA_SUFFIXES = (".diff", ".diff_b", ".set_weight")
def _reject_non_lora_delta_tensors(adapter: dict[str, torch.Tensor]) -> None:
offending = sorted(key for key in adapter if key.endswith(_NON_LORA_DELTA_SUFFIXES))
if offending:
raise ValueError(
f"LoRA adapter carries {len(offending)} non-LoRA tensors "
f"(.diff/.diff_b/.set_weight, e.g. {offending[0]}) that no MiniMax-H3 "
"LoRA mapping rule applies; serve a checkpoint with them merged instead."
)
def _diffusers_h3_checkpoint(
iterator: Iterable[tuple[str, torch.Tensor]],
@@ -592,6 +605,7 @@ def _minimax_h3_attention_core_impl(
ulysses_active: bool,
subblock_sparse_query_block_mask: torch.Tensor | None = None,
ring_active: bool = False,
gate_compress: torch.Tensor | None = None,
) -> torch.Tensor:
"""Dynamic varlen attention and Ulysses/Ring collectives.
@@ -602,11 +616,14 @@ def _minimax_h3_attention_core_impl(
if ulysses_active:
from sglang.multimodal_gen.runtime.layers.usp import (
_usp_input_all_to_all,
_usp_input_all_to_all_packed_qkv,
_usp_output_all_to_all,
)
q, k, v = _usp_input_all_to_all_packed_qkv(q, k, v)
if gate_compress is not None:
gate_compress = _usp_input_all_to_all(gate_compress[None], head_dim=2)[0]
if attention._attention_impl is None:
attention._set_attention_backend(
@@ -618,6 +635,26 @@ def _minimax_h3_attention_core_impl(
)
)
if attention._attention_backend_enum is AttentionBackendEnum.VIDEO_SPARSE_ATTN_H3:
attn_metadata = (
get_forward_context().attn_metadata
if attention.prefix.startswith("blocks.")
else None
)
out = attention._attention_impl.forward_varlen(
q,
k,
v,
cu_seqlens=cu_seqlens,
max_seqlen=max_seqlen,
cu_seqlens_host=cu_seqlens_host,
attn_metadata=attn_metadata,
gate_compress=gate_compress,
)
if ulysses_active:
out = _usp_output_all_to_all(out[None], head_dim=2)[0]
return out
if ring_active:
ring_ws, _ = get_ring_ctx()
if attention._attention_backend_enum is not AttentionBackendEnum.FA:
@@ -767,6 +804,18 @@ class MiniMaxH3Attention(nn.Module):
quant_config=quant_config,
prefix=f"{prefix}.out_proj",
)
# VSA compression gate; stays bf16 and unquantized (zero gate == pure sparse).
self.to_gate_compress: ColumnParallelLinear | None = None
if arch.has_gate_compress and prefix.startswith("blocks."):
self.to_gate_compress = ColumnParallelLinear(
arch.hidden_size,
self.inner_dim,
bias=False,
gather_output=False,
params_dtype=_BF16_DTYPE,
quant_config=None,
prefix=f"{prefix}.to_gate_compress",
)
def _set_attention_backend(self, backend) -> None:
if (
@@ -1032,6 +1081,14 @@ class MiniMaxH3Attention(nn.Module):
)
q, k = _apply_rope_qk(q, k, cos_sin_cache, positions)
gate_compress = None
if (
self._attention_backend_enum is AttentionBackendEnum.VIDEO_SPARSE_ATTN_H3
and self.to_gate_compress is not None
):
gate_flat, _ = self.to_gate_compress(x)
gate_compress = gate_flat.view(total, self.num_heads, self.head_dim)
attention_core = (
_minimax_h3_attention_core_bcg
if self.bcg_breakpoint
@@ -1048,6 +1105,7 @@ class MiniMaxH3Attention(nn.Module):
subblock_sparse_query_block_mask=subblock_sparse_query_block_mask,
ulysses_active=ulysses_active,
ring_active=ring_active,
gate_compress=gate_compress,
)
out = out.reshape(total, self.num_heads * self.head_dim)
out, _ = self.out_proj(out)
@@ -1780,6 +1838,7 @@ class MiniMaxH3DiTModel(BaseDiT, LayerwiseOffloadableModuleMixin):
self, adapter: dict[str, torch.Tensor]
) -> dict[str, torch.Tensor]:
"""Project released-checkpoint AdaLN LoRAs onto pruned coordinates."""
_reject_non_lora_delta_tensors(adapter)
full_width = self.arch.adaln_affine_input_dim
if full_width is None:
return adapter
@@ -2102,6 +2161,16 @@ class MiniMaxH3DiTModel(BaseDiT, LayerwiseOffloadableModuleMixin):
get_global_forced_attn_backend()
or self._component_attention_backend_override
)
if selected_backend is None:
selected_backend = next(
(
module._selected_attention_backend
for module in self.modules()
if isinstance(module, MiniMaxH3Attention)
and module._selected_attention_backend is not None
),
None,
)
backend = get_attn_backend(
self.arch.attention_head_dim,
_BF16_DTYPE,
@@ -170,4 +170,17 @@ class MiniMaxH3Pipeline(LoRAPipeline, ComposedPipelineBase):
)
EntryClass = MiniMaxH3Pipeline
class FastH3Pipeline(MiniMaxH3Pipeline):
"""FastH3: 4-step DMD2-distilled MiniMax-H3 (t2va only).
The flat single-partition repo is materialized into the base-H3 layout by
the bundled model overlay (see model_overlays/), so every stage, loader,
and admission path below is exactly the MiniMax-H3 one. There is no
FL2VA/Ref2VA partition layout to default into.
"""
pipeline_name = "FastH3Pipeline"
default_model_subfolder = None
EntryClass = [MiniMaxH3Pipeline, FastH3Pipeline]
@@ -5,7 +5,7 @@ single-branch execution, and payload validation.
from __future__ import annotations
from collections.abc import Mapping
from collections.abc import Callable, Mapping
from contextlib import contextmanager
from functools import partial
from typing import Any
@@ -39,7 +39,10 @@ from sglang.multimodal_gen.runtime.pipelines_core.stages.validators import (
from sglang.multimodal_gen.runtime.pipelines_core.stages.validators import (
VerificationResult,
)
from sglang.multimodal_gen.runtime.platforms import current_platform
from sglang.multimodal_gen.runtime.platforms import (
AttentionBackendEnum,
current_platform,
)
from sglang.multimodal_gen.runtime.server_args import ServerArgs
from sglang.multimodal_gen.runtime.utils.logging_utils import init_logger
from sglang.multimodal_gen.runtime.utils.nvtx_pytorch_hooks import maybe_nvtx_range
@@ -731,6 +734,13 @@ class MiniMaxH3DenoisingStage(DenoisingStage):
device,
placement_managed=placement_managed,
)
build_vsa_h3_step_metadata = _maybe_prepare_vsa_h3_step_metadata(
model=model,
packed=packed,
ctx=ctx,
server_args=server_args,
device=device,
)
positive = MiniMaxH3DenoiseBranch(
packed=packed,
text_embeddings=emb["hidden_states"],
@@ -770,6 +780,7 @@ class MiniMaxH3DenoisingStage(DenoisingStage):
self._forward_dit,
batch=batch,
attn_metadata=attn_metadata,
build_vsa_h3_step_metadata=build_vsa_h3_step_metadata,
),
positive=positive,
initial_video_rows=initial_video,
@@ -823,6 +834,7 @@ class MiniMaxH3DenoisingStage(DenoisingStage):
*,
batch: Req,
attn_metadata: CubeSparseAttentionMetadata | None = None,
build_vsa_h3_step_metadata: Callable[[int], Any] | None = None,
) -> tuple[torch.Tensor, torch.Tensor]:
"""Route the custom full loop through the native denoising runner."""
@@ -832,7 +844,11 @@ class MiniMaxH3DenoisingStage(DenoisingStage):
with set_forward_context(
current_timestep=step_index,
attn_metadata=attn_metadata,
attn_metadata=(
build_vsa_h3_step_metadata(step_index)
if build_vsa_h3_step_metadata is not None
else attn_metadata
),
forward_batch=batch,
):
runner = self._maybe_get_bcg_runner(model)
@@ -964,6 +980,73 @@ def _assemble_condition_rows(ctx: _FullLoopContext) -> None:
ctx.keyframe_frame_count = int(ctx.keyframe["frame_count"])
def _maybe_prepare_vsa_h3_step_metadata(
*,
model: Any,
packed: Mapping[str, torch.Tensor],
ctx: _FullLoopContext,
server_args: ServerArgs,
device: torch.device,
) -> Callable[[int], Any] | None:
"""Per-step VSA-H3 metadata builder over the request-static packed layout,
or None off the VSA path."""
model._resolve_attention_backend_once()
if (
model._resolved_attention_backend
is not AttentionBackendEnum.VIDEO_SPARSE_ATTN_H3
):
return None
if ctx.is_ref2va:
raise NotImplementedError(
"VSA-H3 supports the t2va/fl2va packed layout; the ref2va "
"reference-block layout is not tiled yet. Use --attention-backend "
"fa for ref2va."
)
config = server_args.attention_backend_config or {}
tile_size = int(config.get("vsa_tile_size", 64))
if tile_size != 64:
raise ValueError(
"VSA-H3 in SGLang serves the trained 64-token (4, 4, 4) tile "
f"geometry; got vsa_tile_size={tile_size}."
)
sparsity = float(config.get("VSA_sparsity", config.get("sparsity", 0.9)))
if not 0.0 <= sparsity < 1.0:
raise ValueError(f"VSA sparsity must be in [0, 1), got {sparsity}")
mode = str(config.get("vsa_mode", "exempt"))
if mode not in ("exempt", "compete"):
raise ValueError(f"vsa_mode must be 'exempt' or 'compete', got {mode!r}")
dense_first_n_steps = int(config.get("vsa_dense_first_n_steps", 0))
dense_layers = tuple(int(layer) for layer in config.get("vsa_dense_layers", ()))
text_len = int(packed["text_pos"].numel())
video_rows = int(packed["update_mask"].sum())
cond_rows = int(packed["img_pos"].numel()) - video_rows
audio_rows = int(packed["audio_pos"].numel())
patch_size = server_args.pipeline_config.dit_config.arch_config.patch_size
from sglang.multimodal_gen.runtime.layers.attention.backends.video_sparse_attn_h3 import (
VideoSparseAttentionH3MetadataBuilder,
)
builder = VideoSparseAttentionH3MetadataBuilder()
def build(step_index: int):
return builder.build(
current_timestep=step_index,
raw_latent_shape=(ctx.latent_t, ctx.latent_h, ctx.latent_w),
patch_size=patch_size,
VSA_sparsity=sparsity,
prefix_segments=(text_len, cond_rows, audio_rows),
device=device,
exempt=mode == "exempt",
dense_layers=dense_layers,
dense_first_n_steps=dense_first_n_steps,
)
return build
def _build_packed_layout(
ctx: _FullLoopContext,
emb: Mapping[str, Any],
@@ -252,6 +252,40 @@ class _VideoSparseAttentionBackendResolver(_CudaAttentionBackendResolver):
raise ImportError("Video Sparse Attention backend is not installed.") from e
class _VideoSparseAttentionH3BackendResolver(_CudaAttentionBackendResolver):
backend = AttentionBackendEnum.VIDEO_SPARSE_ATTN_H3
# The vendored Triton tile-64 kernel is written against Hopper and
# Blackwell block-sparse geometry; older architectures fail closed.
supported_capabilities = {(9, 0), (10, 0), (10, 3)}
@classmethod
def resolve(cls, platform) -> str:
capability = platform.get_device_capability()
capability_tuple = (
(capability.major, capability.minor) if capability is not None else None
)
if capability_tuple not in cls.supported_capabilities:
found = capability.as_version_str() if capability else "unknown"
raise ValueError(
"VSA-H3 (video_sparse_attn_h3) needs compute capability 9.0 "
"(Hopper), 10.0 (B200 / GB200) or 10.3 (B300 / GB300); "
f"this device reports {found}."
)
try:
from sglang.multimodal_gen.runtime.layers.attention.backends.video_sparse_attn_h3 import ( # noqa: F401
VideoSparseAttentionH3Backend,
)
return "sglang.multimodal_gen.runtime.layers.attention.backends.video_sparse_attn_h3.VideoSparseAttentionH3Backend"
except Exception as e:
logger.error("Failed to import VSA-H3 attention backend: %s", str(e))
raise ImportError(
"VSA-H3 attention needs Triton and the in-tree tile-64 "
"block-sparse kernel."
) from e
class _CubeSparseAttentionBackendResolver(_CudaAttentionBackendResolver):
backend = AttentionBackendEnum.CUBE_SPARSE_ATTN
@@ -435,6 +469,7 @@ _CUDA_ATTENTION_BACKEND_RESOLVERS = {
_SageAttention3BackendResolver,
_SpargeAttentionBackendResolver,
_VideoSparseAttentionBackendResolver,
_VideoSparseAttentionH3BackendResolver,
_CubeSparseAttentionBackendResolver,
_SparseVideoGen2AttentionBackendResolver,
_SolAttnBackendResolver,
@@ -35,6 +35,7 @@ class AttentionBackendEnum(enum.Enum):
SAGE_ATTN_3 = enum.auto()
SPARGE_ATTN = enum.auto()
VIDEO_SPARSE_ATTN = enum.auto()
VIDEO_SPARSE_ATTN_H3 = enum.auto()
SPARSE_VIDEO_GEN_2_ATTN = enum.auto()
VMOBA_ATTN = enum.auto()
AITER = enum.auto()
@@ -57,6 +58,7 @@ class AttentionBackendEnum(enum.Enum):
return self in {
AttentionBackendEnum.SLIDING_TILE_ATTN,
AttentionBackendEnum.VIDEO_SPARSE_ATTN,
AttentionBackendEnum.VIDEO_SPARSE_ATTN_H3,
AttentionBackendEnum.SPARSE_VIDEO_GEN_2_ATTN,
AttentionBackendEnum.VMOBA_ATTN,
AttentionBackendEnum.SLA_ATTN,
@@ -42,6 +42,10 @@ BUILTIN_MODEL_OVERLAY_REGISTRY: dict[str, dict[str, Any]] = {
"overlay_repo_id": "AgainstEntropy/SANA-WM_streaming-overlay",
"overlay_revision": "62c6840871ecc3559189047513ba0670e1bf62e7",
},
"FastVideo/FastVideo-FastH3-4-step-Preview-v1-VSA-DataFree": {
"overlay_repo_id": "kevin-mi/FastH3-4step-Preview-overlay",
"overlay_revision": "f769cb8001dae335089de7b250364335bc7cb183",
},
}
@@ -688,6 +688,49 @@ MINIMAX_H3_FOUR_GPU_H100_CASES = [
run_models_api_check=False,
run_t2v_input_reference_check=False,
),
DiffusionTestCase(
"fasth3_t2va_vsa_4gpu_h100",
DiffusionServerArgs(
model_path="FastVideo/FastVideo-FastH3-4-step-Preview-v1-VSA-DataFree",
modality="video",
num_gpus=4,
extras=[
"--attention-backend",
"video_sparse_attn_h3",
"--attention-backend-config",
'{"VSA_sparsity": 0.9}',
"--enable-torch-compile",
"false",
],
),
DiffusionSamplingParams(
prompt=(
"A curious raccoon peers through a vibrant field of yellow "
"sunflowers, its eyes wide with interest."
),
output_size="1344x768",
seconds=5,
output_format="mp4",
expect_audio_output=True,
num_outputs_per_prompt=1,
extras={
"task": "t2va",
"conditions": [],
"target": {
"short_edge": 768,
"aspect_ratio": "16:9",
"duration_seconds": 5.0,
},
"num_inference_steps": 5,
"seed": 42,
},
),
run_perf_check=False,
run_consistency_check=False,
run_component_accuracy_check=False,
run_models_api_check=False,
run_t2v_input_reference_check=False,
),
]
TWO_GPU_CASES = [
@@ -99,6 +99,7 @@ class TestDiffusionBenchmarkSkill(unittest.TestCase):
"lingbot-world",
"lingbot-world-v2",
"fastwan21-t2v-1.3b",
"fasth3-t2va-vsa",
"wan22-t2v-nvfp4",
"krea2-turbo",
"krea2-raw",
@@ -0,0 +1,124 @@
# SPDX-License-Identifier: Apache-2.0
"""FastH3 (4-step VSA-distilled MiniMax-H3) registration and admission contracts."""
from __future__ import annotations
import re
from types import SimpleNamespace
import pytest
import torch
from sglang.multimodal_gen.configs.pipeline_configs.minimax_h3 import (
FastH3PipelineConfig,
MiniMaxH3PipelineConfig,
)
from sglang.multimodal_gen.configs.sample.minimax_h3 import FastH3SamplingParams
from sglang.multimodal_gen.registry import (
get_model_info,
get_non_diffusers_pipeline_name,
)
from sglang.multimodal_gen.runtime.distributed.parallel_state import (
maybe_init_distributed_environment_and_model_parallel,
model_parallel_is_initialized,
)
from sglang.multimodal_gen.runtime.layers.linear import UnquantizedLinearMethod
from sglang.multimodal_gen.runtime.layers.quantization.fp8 import Fp8Config
from sglang.multimodal_gen.runtime.models.dits.minimax_h3 import MiniMaxH3DiTModel
from sglang.multimodal_gen.test.single_test_file.component_accuracy.utils import (
ensure_distributed_env_defaults,
)
FASTH3_MODEL_ID = "FastVideo/FastVideo-FastH3-4-step-Preview-v1-VSA-DataFree"
def _ensure_single_process_parallel_runtime() -> None:
if model_parallel_is_initialized():
return
ensure_distributed_env_defaults()
maybe_init_distributed_environment_and_model_parallel(tp_size=1, sp_size=1)
def test_registry_resolves_fasth3_configs() -> None:
info = get_model_info(FASTH3_MODEL_ID)
assert info.sampling_param_cls is FastH3SamplingParams
assert info.pipeline_config_cls is FastH3PipelineConfig
assert get_non_diffusers_pipeline_name(FASTH3_MODEL_ID) == "FastH3Pipeline"
materialized = (
"/cache/materialized_models/"
"FastVideo__FastVideo-FastH3-4-step-Preview-v1-VSA-DataFree-0123abcd"
)
assert get_model_info(materialized).sampling_param_cls is FastH3SamplingParams
def test_fasth3_sampling_defaults_and_task_rejection() -> None:
params = FastH3SamplingParams(prompt="p")
assert params.num_inference_steps == 5
assert params.guidance_scale == 1.0
with pytest.raises(ValueError, match="exactly five sigma grid points"):
FastH3SamplingParams(prompt="p", num_inference_steps=50)
with pytest.raises(ValueError, match="distilled for t2va only"):
FastH3SamplingParams(
prompt="p",
task="fl2va",
conditions=[{"type": "image", "uri": "x.png", "role": "first_frame"}],
target={
"short_edge": 768,
"aspect_ratio": "16:9",
"duration_seconds": 5.0,
},
)
def test_fasth3_pipeline_config_gates_and_rejections() -> None:
config = FastH3PipelineConfig()
assert config.dit_config.arch_config.has_gate_compress
assert not MiniMaxH3PipelineConfig().dit_config.arch_config.has_gate_compress
mapping = config.dit_config.arch_config.param_names_mapping
source = "transformer_blocks.7.attn.to_gate_compress.weight"
targets = [
re.sub(pattern, target if isinstance(target, str) else target[0], source)
for pattern, target in mapping.items()
if re.match(pattern, source)
]
assert targets == ["blocks.7.attn.to_gate_compress.weight"]
with pytest.raises(ValueError, match="--model-variant does not apply"):
config.validate_server_args(SimpleNamespace(model_variant="ref2va"))
with pytest.raises(ValueError, match="no.*audited high-quality deployment"):
config.validate_quality_deployment(server_args=None)
def test_fasth3_lora_bundle_is_rejected_loudly() -> None:
model = SimpleNamespace(arch=SimpleNamespace(adaln_affine_input_dim=None))
plain = {
"blocks.0.attn.qkv_proj.lora_A": torch.zeros(3, 64, 8),
"blocks.0.attn.qkv_proj.lora_B": torch.zeros(3, 8, 64),
}
assert MiniMaxH3DiTModel.prepare_lora_adapter(model, dict(plain)) == plain
bundle = dict(plain)
bundle["blocks.0.attn.qkv_proj.diff"] = torch.zeros(3, 64, 64)
bundle["audio_patch_proj.diff_b"] = torch.zeros(64)
bundle["blocks.0.attn.to_gate_compress.set_weight"] = torch.zeros(64, 64)
with pytest.raises(ValueError, match="3 non-LoRA tensors.*set_weight"):
MiniMaxH3DiTModel.prepare_lora_adapter(model, bundle)
def test_fasth3_gates_stay_bf16_under_runtime_quantization() -> None:
_ensure_single_process_parallel_runtime()
with torch.device("meta"):
model = MiniMaxH3DiTModel(
config=FastH3PipelineConfig().dit_config,
hf_config={},
quant_config=Fp8Config(),
)
attn = model.blocks[0].attn
assert not isinstance(attn.qkv_proj.quant_method, UnquantizedLinearMethod)
assert isinstance(attn.to_gate_compress.quant_method, UnquantizedLinearMethod)
assert attn.to_gate_compress.weight.dtype == torch.bfloat16
assert attn.to_gate_compress.weight.missing_param_init == "error"
assert model.token_refiner.blocks[0].attn.to_gate_compress is None
@@ -0,0 +1,241 @@
# SPDX-License-Identifier: Apache-2.0
"""VSA-H3 backend contracts.
The load-bearing check is sparsity -> 0: every tile is inside the budget, so
the block-sparse kernel must reproduce dense attention over the packed rows to
bf16 rounding. That single assertion pins the tile routing indices, ragged
tile masking, the untile permutation, and the softmax scale.
"""
from __future__ import annotations
import math
import pytest
import torch
from sglang.multimodal_gen.runtime.layers.attention.backends.video_sparse_attn_h3 import (
VSA_H3_TILE_ELEMS,
VideoSparseAttentionH3Impl,
VideoSparseAttentionH3MetadataBuilder,
_topk_tile_lists,
)
requires_cuda = pytest.mark.skipif(
not torch.cuda.is_available(), reason="VSA-H3 kernels need CUDA"
)
# Ragged on purpose: text 70 and audio 100 are not tile multiples, and the
# video canvas (5, 6, 10) is ragged in every tile dimension.
PREFIX_SEGMENTS = (70, 0, 100)
VIDEO_SHAPE = (5, 6, 10)
HEADS = 4
HEAD_DIM = 128
def _build_metadata(sparsity: float, device, **kwargs):
return VideoSparseAttentionH3MetadataBuilder().build(
current_timestep=0,
raw_latent_shape=VIDEO_SHAPE,
patch_size=(1, 1, 1),
VSA_sparsity=sparsity,
prefix_segments=PREFIX_SEGMENTS,
device=device,
**kwargs,
)
def _packed_qkv(device, seed: int = 7):
used = sum(PREFIX_SEGMENTS) + math.prod(VIDEO_SHAPE)
total = (used + 63) // 64 * 64
generator = torch.Generator(device="cpu").manual_seed(seed)
tensors = [
torch.randn(
(total, HEADS, HEAD_DIM), generator=generator, dtype=torch.float32
).to(device=device, dtype=torch.bfloat16)
for _ in range(3)
]
return used, total, tensors
def _dense_reference(q, k, v, used: int) -> torch.Tensor:
qf = q[:used].float().permute(1, 0, 2)
kf = k[:used].float().permute(1, 0, 2)
vf = v[:used].float().permute(1, 0, 2)
scores = qf @ kf.transpose(-2, -1) / math.sqrt(HEAD_DIM)
return (torch.softmax(scores, dim=-1) @ vf).permute(1, 0, 2)
def _impl():
impl = VideoSparseAttentionH3Impl(
num_heads=HEADS,
head_size=HEAD_DIM,
causal=False,
softmax_scale=HEAD_DIM**-0.5,
num_kv_heads=HEADS,
prefix="blocks.3.attn",
)
assert impl.layer_idx == 3
return impl
def _run(impl, meta, used, total, q, k, v, gate=None):
cu = torch.tensor([0, used, total], dtype=torch.int32, device=q.device)
return impl.forward_varlen(
q,
k,
v,
cu_seqlens=cu,
max_seqlen=used,
cu_seqlens_host=(0, used, total),
attn_metadata=meta,
gate_compress=gate,
)
@requires_cuda
def test_zero_sparsity_matches_dense() -> None:
device = torch.device("cuda")
meta = _build_metadata(0.0, device)
used, total, (q, k, v) = _packed_qkv(device)
assert meta.total_seq_length == used
out = _run(_impl(), meta, used, total, q, k, v)
reference = _dense_reference(q, k, v, used)
diff = (out[:used].float() - reference).abs().max().item()
assert diff < 2e-2, f"sparse(0) vs dense max diff {diff}"
assert torch.all(out[used:] == 0)
@requires_cuda
def test_zero_gate_is_noop_and_trained_gate_activates() -> None:
device = torch.device("cuda")
meta = _build_metadata(0.5, device)
used, total, (q, k, v) = _packed_qkv(device)
impl = _impl()
base = _run(impl, meta, used, total, q, k, v).clone()
zero_gate = torch.zeros_like(q)
gated_zero = _run(impl, meta, used, total, q, k, v, gate=zero_gate)
assert torch.equal(base, gated_zero)
gate = torch.randn_like(q) * 0.1
gated = _run(impl, meta, used, total, q, k, v, gate=gate)
assert not torch.equal(base, gated)
@requires_cuda
def test_dense_overrides() -> None:
device = torch.device("cuda")
meta = _build_metadata(0.9, device, dense_layers=(3,))
used, total, (q, k, v) = _packed_qkv(device)
out = _run(_impl(), meta, used, total, q, k, v)
reference = _dense_reference(q, k, v, used)
diff = (out[:used].float() - reference).abs().max().item()
assert diff < 2e-2, f"dense-layer opt-out vs dense max diff {diff}"
assert _build_metadata(0.9, device, dense_first_n_steps=0).VSA_sparsity == 0.9
assert _build_metadata(0.9, device, dense_first_n_steps=1).VSA_sparsity == 0.0
def _lists_to_mask(lists: torch.Tensor, n_tiles: int) -> torch.Tensor:
mask = torch.zeros(*lists.shape[:-1], n_tiles, dtype=torch.bool)
mask.scatter_(-1, lists.long(), True)
return mask
def test_topk_tile_list_semantics() -> None:
num_prefix, num_video = 3, 10
n_tiles = num_prefix + num_video
scores = torch.randn(2, n_tiles, n_tiles)
keep = math.ceil(0.5 * num_video)
exempt = _topk_tile_lists(scores, num_prefix, num_video, 0.5, True)
assert exempt.shape == (2, num_video, num_prefix + keep)
assert exempt.dtype == torch.int32
assert torch.equal(exempt, exempt.sort(dim=-1).values)
mask = _lists_to_mask(exempt, n_tiles)
assert mask[..., :num_prefix].all()
assert (mask[..., num_prefix:].sum(dim=-1) == keep).all()
compete = _topk_tile_lists(scores, num_prefix, num_video, 0.5, False)
assert compete.shape == (2, num_video, min(keep + num_prefix, n_tiles))
assert (_lists_to_mask(compete, n_tiles).sum(dim=-1) == keep + num_prefix).all()
def _masked_dense_reference(meta, used, q, k, v, gate, sparsity):
"""fp32 reference over the padded tile layout with the top-k tile mask."""
n_tiles = meta.num_tiles
seq_pad = n_tiles * VSA_H3_TILE_ELEMS
valid = meta.pack_index >= 0
src = meta.pack_index.clamp(min=0).long()
def tile(x):
t = x[src].float().permute(1, 0, 2) # [H, S_pad, D]
return t * valid.to(t.dtype)[None, :, None]
qt, kt, vt, gt = tile(q), tile(k), tile(v), tile(gate)
sizes = meta.variable_block_sizes.float()
pooled = [
t.view(HEADS, n_tiles, VSA_H3_TILE_ELEMS, HEAD_DIM).sum(2)
/ sizes[None, :, None]
for t in (qt, kt, vt)
]
scores = pooled[0] @ pooled[1].transpose(-1, -2) / math.sqrt(HEAD_DIM)
lists = _topk_tile_lists(
scores, meta.num_prefix_tiles, meta.num_video_tiles, sparsity, meta.exempt
)
tile_mask = torch.ones(HEADS, n_tiles, n_tiles, dtype=torch.bool, device=q.device)
tile_mask[:, meta.num_prefix_tiles :] = _lists_to_mask(lists.cpu(), n_tiles).to(
q.device
)
row_mask = tile_mask.repeat_interleave(VSA_H3_TILE_ELEMS, 1).repeat_interleave(
VSA_H3_TILE_ELEMS, 2
)
row_mask &= valid[None, None, :]
logits = qt @ kt.transpose(-1, -2) / math.sqrt(HEAD_DIM)
logits = logits.masked_fill(~row_mask, float("-inf"))
out = torch.softmax(logits, dim=-1) @ vt
compress = torch.softmax(scores, dim=-1) @ pooled[2] # [H, n_tiles, D]
out = out + compress.repeat_interleave(VSA_H3_TILE_ELEMS, 1) * gt
return out[:, meta.unpack_index.long()].permute(1, 0, 2) # [used, H, D]
@requires_cuda
def test_sparse_gated_matches_masked_dense_reference() -> None:
device = torch.device("cuda")
for exempt in (True, False):
meta = _build_metadata(0.5, device, exempt=exempt)
used, total, (q, k, v) = _packed_qkv(device)
gate = (torch.randn_like(q) * 0.1).to(torch.bfloat16)
out = _run(_impl(), meta, used, total, q, k, v, gate=gate)
reference = _masked_dense_reference(meta, used, q, k, v, gate, 0.5)
diff = (out[:used].float() - reference).abs().max().item()
assert diff < 2e-2, f"exempt={exempt}: sparse+gate vs reference {diff}"
assert torch.all(out[used:] == 0)
def test_metadata_tile_geometry_accounts_every_row() -> None:
device = torch.device("cpu")
meta = _build_metadata(0.9, device)
used = sum(PREFIX_SEGMENTS) + math.prod(VIDEO_SHAPE)
assert int(meta.variable_block_sizes.sum()) == used
assert meta.unpack_index.numel() == used
assert int((meta.pack_index >= 0).sum()) == used
assert meta.pack_index.numel() == meta.num_tiles * VSA_H3_TILE_ELEMS
# Prefix chunks never straddle segment boundaries: 70 -> 64+6, 100 -> 64+36.
assert meta.num_prefix_tiles == 4
assert meta.variable_block_sizes[: meta.num_prefix_tiles].tolist() == [
64,
6,
64,
36,
]
video_tiles = (
math.ceil(VIDEO_SHAPE[0] / 4)
* math.ceil(VIDEO_SHAPE[1] / 4)
* math.ceil(VIDEO_SHAPE[2] / 4)
)
assert meta.num_video_tiles == video_tiles
assert meta.variable_block_sizes.numel() == meta.num_prefix_tiles + video_tiles
assert VSA_H3_TILE_ELEMS == 64