multimodalart HF Staff commited on
Commit
df252a6
·
verified ·
1 Parent(s): 4d76c8b

Upload folder using huggingface_hub

Browse files
README.md CHANGED
@@ -1,13 +1,166 @@
1
  ---
2
- title: Fasth3 4step Preview Demo
3
- emoji: 🐠
4
- colorFrom: pink
5
- colorTo: purple
6
  sdk: gradio
7
  sdk_version: 6.26.0
8
- python_version: '3.12'
9
  app_file: app.py
10
- pinned: false
 
 
 
11
  ---
12
 
13
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
+ title: FastH3 4-step Preview
3
+ emoji: 🎬
4
+ colorFrom: red
5
+ colorTo: gray
6
  sdk: gradio
7
  sdk_version: 6.26.0
 
8
  app_file: app.py
9
+ short_description: 4-step MiniMax-H3 with sparse attention — video + audio
10
+ python_version: "3.12"
11
+ startup_duration_timeout: 1h
12
+ suggested_hardware: zero-a10g
13
  ---
14
 
15
+ # FastH3 4-step Preview (VSA, data-free) — MiniMax-H3 in four forward passes
16
+
17
+ [`FastVideo/FastVideo-FastH3-4-step-Preview-v1-VSA-DataFree`](https://huggingface.co/FastVideo/FastVideo-FastH3-4-step-Preview-v1-VSA-DataFree)
18
+ is a **data-free DMD2 distillation** of [`MiniMaxAI/MiniMax-H3`](https://huggingface.co/MiniMaxAI/MiniMax-H3), the
19
+ 33B dual-modality DiT that emits a video *and* its synchronized soundtrack from one denoising pass. The student
20
+ keeps the teacher's architecture exactly and only replaces `transformer/`, so it is a drop-in for the same
21
+ `diffusers` modular pipeline — but it needs **four** DiT forwards instead of thirty, and it was distilled **with
22
+ Video Sparse Attention on**.
23
+
24
+ Everything runs unquantized at **bfloat16**.
25
+
26
+ ## The sampling contract: five grid points, four forwards
27
+
28
+ `MiniMaxH3Scheduler.set_timesteps(n)` builds `linspace(1, 0, n)`, applies the shift
29
+ `σ' = s·σ / (1 + (s−1)·σ)`, and then drops the trailing zero when it forms the timesteps — so **`n` sigma grid
30
+ points drive `n − 1` model evaluations**. The distilled ladder is `t = 999, 749, 500, 250 → 0`: five points, four
31
+ forwards.
32
+
33
+ This Space therefore fixes `num_inference_steps = 5`, matching the checkpoint's own
34
+ `fastvideo_inference.json` (`num_inference_steps: 5`, `transformer_forwards: 4`,
35
+ `dmd_denoising_steps: [999, 749, 500, 250]`, `guidance_scale: 1.0`). It is not a knob. There is no CFG and no
36
+ negative prompt — the teacher is guidance-distilled and the student inherits that.
37
+
38
+ ## Video Sparse Attention is not optional here
39
+
40
+ This checkpoint is the **VSA** variant. Its `fastvideo_inference.json` pins
41
+ `attention_backend: VIDEO_SPARSE_ATTN_H3`, `vsa_sparsity: 0.9`, `vsa_tile_size: 64`, and the transformer ships 50
42
+ trained `attn.to_gate_compress` tensors (~3.6 GiB) that only the sparse path consumes. FastVideo publishes a
43
+ separate `…-Dense-DataFree` checkpoint for people who want dense — running *this* one dense is running it
44
+ off-distribution.
45
+
46
+ The published kernel is `vsa_kernel: sm100a`, which is GB200-only, and the ZeroGPU pool is **sm120** (RTX PRO 6000
47
+ Blackwell). FastVideo's other officially supported route is `--vsa-kernel triton`, which is pure Triton and
48
+ architecture-agnostic — so this Space vendors those two files verbatim from
49
+ [FastVideo](https://github.com/hao-ai-lab/FastVideo) (Apache-2.0) into `vsa_kernel/` and ports the H3 backend on top
50
+ of them:
51
+
52
+ | File | What it is |
53
+ |---|---|
54
+ | `vsa_kernel/block_sparse_attn_triton.py` | FastVideo's Triton block-sparse attention, verbatim except that the autotune sweep is collapsed to the single config it lands on for Blackwell (re-enable it with `H3_VSA_AUTOTUNE=1`). |
55
+ | `vsa_kernel/index.py` | FastVideo's `map_to_index` / `topk_index_to_map`, verbatim. |
56
+ | `vsa_h3.py` | The port: `MiniMaxH3VSAAttnProcessor`, a `diffusers` attention processor reproducing `MiniMaxH3VSABackend`. |
57
+
58
+ `vsa_h3.py` follows FastVideo's `video_sparse_attn_h3.py` step for step: 64-token `(4, 4, 4)` tiles over the
59
+ post-patchify video grid, segment-pure prefix tiles, per-head fp32 pooled tile scores,
60
+ `topk = max(1, min(⌈(1 − sparsity)·n_video_tiles⌉, n_video_tiles))`, prefix keys exempt (always selected) and prefix
61
+ queries always dense, plus the compression branch `softmax(scores) @ pool(v)` broadcast back over each tile row and
62
+ scaled by the trained gate with **no** activation. The tile geometry is derived per-forward from the pipeline's own
63
+ `token_tags` / `position_ids`, so the `[text | cond | audio | video]` packing stays authoritative.
64
+
65
+ `diffusers` 0.40.0 has no `to_gate_compress`, so `vsa_h3.add_gate_compress_modules()` patches
66
+ `MiniMaxH3TransformerBlock.__init__` **before** the pipeline loads; otherwise the 50 gate tensors load as
67
+ "unexpected keys" and are silently dropped. Blocks whose gate is all-zero have it removed again after load, exactly
68
+ as FastVideo does.
69
+
70
+ A hidden `/selftest` API endpoint runs the ported kernel at sparsity 0 against `F.scaled_dot_product_attention` on
71
+ a real packed layout, so a wrong tile order or transpose is caught without spending a generation.
72
+
73
+ ## Split across two Spaces
74
+
75
+ MiniMax-H3 is ~196 GiB in bfloat16 and a ZeroGPU Space is evicted at **150 GB of storage**, so no single
76
+ unquantized Space can hold it. Cutting `MiniMaxH3Blocks` at its `text_encoder` step splits it in two, and both
77
+ halves fit:
78
+
79
+ | Space | Subfolders | Download |
80
+ |---|---|---|
81
+ | [`multimodalart/qwen3vl-conditioner`](https://huggingface.co/spaces/multimodalart/qwen3vl-conditioner) | `text_encoder/` + `tokenizer/` + `processor/` | 66.7 GB → 62.15 GiB bf16 |
82
+ | this one | `transformer/` + `vae/` + `audio_vae/` | ~85 GB → 75.7 GiB resident |
83
+
84
+ The conditioner is a **public** Space and is unchanged by the distillation — the student's
85
+ `modular_model_index.json` points its `text_encoder` at the same Qwen3-VL weights — so this Space reuses it as-is
86
+ and calls it over the gradio API for every request.
87
+
88
+ `h3_split_blocks.py` subclasses the pipeline's blocks with the `text_encoder` step removed. Dropping the step drops
89
+ the components it declares, so `load_components` resolves only `transformer` / `vae` / `audio_vae` / the two
90
+ schedulers, and `prompt_embeds` + `text_token_tags` become ordinary required pipeline inputs. The wire format is
91
+ those two tensors — `(1, num_text_tokens, 5120)` bfloat16 and `(num_text_tokens,)` int64 — in one safetensors file
92
+ with the resolved `height` / `width` / `num_frames` in its metadata header.
93
+
94
+ ## Text-to-video+audio only
95
+
96
+ The preview distills **the T2VA path only**, so this Space exposes no keyframe / reference inputs — the student's
97
+ `transformer_ref` tower is not packaged with the checkpoint. Image conditioning is what
98
+ [`multimodalart/minimax-h3`](https://huggingface.co/spaces/multimodalart/minimax-h3) (the undistilled 30-step
99
+ teacher) is for.
100
+
101
+ ## Prompt format
102
+
103
+ MiniMax-H3 is trained on a structured multimodal caption, not a bare sentence:
104
+
105
+ ```
106
+ integrated_multimodal_description: <shots, camera, subjects, action, lighting>
107
+ overall_soundscape: <diegetic sound>
108
+ non_diegetic_music: <score>
109
+ ```
110
+
111
+ **Expand prompt** (on by default) sends a short prompt through the conditioner's Qwen3-VL prompt rewriter, which
112
+ writes that structure for you and returns it. Turn it off when you have already written a full-format prompt — the
113
+ examples that carry MiniMax's own official prompts do exactly that.
114
+
115
+ ## Examples
116
+
117
+ The two long examples are MiniMax's own published prompts, taken verbatim from the base model's repo (Apache-2.0
118
+ code / docs):
119
+
120
+ - the starship-bridge two-shot from `scripts/readme/reproducible-768p-t2va-request.sh`
121
+ - the bakery two-shot, Case 1 of `docs/VIDEO_PROMPT_WRITING_GUIDE_base_en.md`
122
+
123
+ ## Generation constraints
124
+
125
+ Fixed by the checkpoint: 24 fps, a 768 pixel short edge at the training canvas, `num_frames` snapped up to the next
126
+ `17·n + 5`. The distillation's operating point is **1344×768 × 124 frames (≈5 s)** — that is the default, and it is
127
+ exactly the layout VSA was tuned on (`grid = 37×24×42`, 37 296 video rows, 672 tiles, 66 selected). The duration
128
+ slider reaches 8 s and the canvas dropdown offers smaller/faster grids, both outside the distilled operating point,
129
+ so quality degrades gracefully rather than being guaranteed. The 8 s ceiling is a VRAM limit, not a model limit:
130
+ 75.7 GiB of weights sit resident on a 95.0 GiB card and the sparse working set grows with sequence length.
131
+
132
+ The checkpoint itself is a **preview**.
133
+
134
+ ## Placement
135
+
136
+ `H3_PLACEMENT=lazy`: the weights move onto the card on the first GPU call and stay there. `spaces`' startup
137
+ `torch.pack()` would write a second on-disk copy of every resident CUDA tensor, and 85 + 75 GB exceeds the 150 GB
138
+ quota, so packing is not an option here. The one-time `.to("cuda")` plus the Triton JIT of the block-sparse kernels
139
+ lands inside the first request of a cold worker; after that there is **no offloading in the request path at all**.
140
+
141
+ ## Space variables
142
+
143
+ | Variable | Default | Meaning |
144
+ |---|---|---|
145
+ | `H3_MODEL_REPO` | `FastVideo/FastVideo-FastH3-4-step-Preview-v1-VSA-DataFree` | The distilled diffusers-layout checkpoint. Public. |
146
+ | `H3_CONDITIONER` | `multimodalart/qwen3vl-conditioner` | The public Space this one asks for embeddings. |
147
+ | `H3_ATTENTION` | `vsa` | The ported VSA-H3 backend. Any other value is passed to `set_attention_backend` as a dense escape hatch (e.g. `_native_cudnn`). |
148
+ | `H3_VSA_SPARSITY` | `0.9` | The checkpoint's trained sparsity. |
149
+ | `H3_VSA_AUTOTUNE` | unset | Set to `1` to restore FastVideo's full Triton autotune sweep instead of the pinned Blackwell config. |
150
+ | `H3_PLACEMENT` | `lazy` | `lazy` moves all weights onto the card on the first GPU call; `offload` hands placement to `ComponentsManager.enable_auto_cpu_offload`. |
151
+ | `H3_GPU_SIZE` | `xlarge` | ZeroGPU allocation size. `large` (48 GB) does not fit 75 GiB of weights. |
152
+
153
+ ## Secrets
154
+
155
+ None. Every weight this Space downloads is public, and the conditioner is a public Space called on the requesting
156
+ user's own ZeroGPU token.
157
+
158
+ ## License
159
+
160
+ The weights are under the **MiniMax H3 Community License**, inherited from the base model — it carries territory and
161
+ acceptable-use restrictions. Read
162
+ [the base model's license](https://huggingface.co/MiniMaxAI/MiniMax-H3/blob/main/LICENSE) before using outputs. The
163
+ vendored kernels in `vsa_kernel/` are Apache-2.0, from
164
+ [hao-ai-lab/FastVideo](https://github.com/hao-ai-lab/FastVideo).
165
+ </content>
166
+ </invoke>
app.py ADDED
@@ -0,0 +1,579 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """FastH3 v1 (VSA) — the 4-step DMD2 distillation of MiniMax-H3, text to video + synchronized audio.
2
+
3
+ `FastVideo/FastVideo-FastH3-4-step-Preview-v1-VSA-DataFree` replaces only the `transformer/` of the MiniMax-H3 release
4
+ with a data-free DMD2 student. Everything else in the repo (Qwen3-VL conditioner, both autoencoders, both schedulers)
5
+ is an unmodified copy of the base checkpoint, so it runs on the released `diffusers` modular pipeline — the two
6
+ differences at inference time are the step count and the **attention backend**.
7
+
8
+ **The sampling contract.** `num_inference_steps` counts sigma *grid points*, and `N` points drive `N - 1` transformer
9
+ forwards. The checkpoint's own `fastvideo_inference.json` states it exactly: `num_inference_steps: 5`,
10
+ `transformer_forwards: 4`, `dmd_denoising_steps: [999, 749, 500, 250]`, `guidance_scale: 1.0`. It is fixed here.
11
+
12
+ **VSA is not optional.** That same file records `attention_backend: VIDEO_SPARSE_ATTN_H3`, `vsa_tile_size: 64`,
13
+ `vsa_sparsity: 0.9`. This student was distilled *under* block-sparse attention and ships 50 trained
14
+ `attn.to_gate_compress` tensors that only the sparse path reads, so `vsa_h3.py` ports FastVideo's VSA-H3 backend onto
15
+ `MiniMaxH3Attention` and runs it, on FastVideo's own Triton kernels (vendored under `vsa_kernel/`). The checkpoint's
16
+ `vsa_kernel: sm100a` is the GB200-only fast path for the same mask semantics; this pool is sm120.
17
+
18
+ **Why the Space is split.** MiniMax-H3 is ~196 GiB in bfloat16 and a ZeroGPU Space is evicted at 150 GB of storage.
19
+ This half holds the distilled transformer and the two autoencoders (81 GB); the 62.15 GiB Qwen3-VL conditioner runs in
20
+ `multimodalart/qwen3vl-conditioner`, which this Space calls over the gradio API for every request. FastH3 ships the
21
+ base release's conditioner verbatim, so that Space encodes this checkpoint exactly. Nothing is quantized anywhere.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import os
27
+ import tempfile
28
+ import time
29
+ import traceback
30
+ from functools import cache
31
+
32
+ # Before anything that could initialize CUDA: `import spaces` patches `torch.cuda` so the 75 GiB load can happen at
33
+ # startup rather than on GPU time.
34
+ import spaces
35
+ import gradio as gr
36
+
37
+ MODEL_REPO = os.environ.get("H3_MODEL_REPO", "FastVideo/FastVideo-FastH3-4-step-Preview-v1-VSA-DataFree")
38
+ BASE_REPO = "MiniMaxAI/MiniMax-H3"
39
+ CONDITIONER_SPACE = os.environ.get("H3_CONDITIONER", "multimodalart/qwen3vl-conditioner")
40
+ # `lazy` moves all weights onto the card on the first GPU call and leaves them there; `offload` hands placement to
41
+ # `ComponentsManager.enable_auto_cpu_offload`. Packing at startup is not an option here: `spaces` writes every
42
+ # startup-resident CUDA tensor to a second on-disk copy, and this checkpoint's transformer (70.1 GB on disk) would
43
+ # bust the 150 GB quota.
44
+ PLACEMENT = os.environ.get("H3_PLACEMENT", "lazy").lower()
45
+ # `vsa` is the trained route (see the module docstring). `dense` is the escape hatch: it runs the released dense
46
+ # `diffusers` path with cuDNN's fused kernel, which is off-distribution for this student but useful to bisect against.
47
+ ATTENTION = os.environ.get("H3_ATTENTION", "vsa").lower()
48
+ # The checkpoint's own `vsa_sparsity`. Only read when `H3_ATTENTION=vsa`.
49
+ VSA_SPARSITY = float(os.environ.get("H3_VSA_SPARSITY", "0.9"))
50
+ # 75.7 GiB of weights plus activations does not fit a `large` (48 GiB) allocation.
51
+ GPU_SIZE = os.environ.get("H3_GPU_SIZE", "xlarge")
52
+
53
+ # The distilled ladder, as sigma grid points. 5 points -> 4 transformer forwards at t = 1000, 750, 500, 250.
54
+ SIGMA_GRID_POINTS = 5
55
+ NUM_FORWARDS = SIGMA_GRID_POINTS - 1
56
+
57
+ # Must stay identical to the conditioner's table: the *label* goes over the wire, so a canvas that half does not know
58
+ # is rejected there and surfaces as a failure here.
59
+ CANVASES = {
60
+ # 16:9
61
+ "960x544 · 16:9 fast": (544, 960),
62
+ "1024x576 · 16:9 fast": (576, 1024),
63
+ "1152x640 · 16:9": (640, 1152),
64
+ "1280x704 · 16:9": (704, 1280),
65
+ "1344x768 · 16:9 full": (768, 1344),
66
+ # 9:16
67
+ "544x960 · 9:16 fast": (960, 544),
68
+ "640x1152 · 9:16": (1152, 640),
69
+ "768x1344 · 9:16 full": (1344, 768),
70
+ # 1:1
71
+ "544x544 · 1:1 fast": (544, 544),
72
+ "768x768 · 1:1 full": (768, 768),
73
+ "1024x1024 · 1:1 max": (1024, 1024),
74
+ # 4:3 / 3:4
75
+ "768x576 · 4:3 fast": (576, 768),
76
+ "1024x768 · 4:3 full": (768, 1024),
77
+ "576x768 · 3:4 fast": (768, 576),
78
+ "768x1024 · 3:4 full": (1024, 768),
79
+ # 21:9
80
+ "1152x512 · 21:9 fast": (512, 1152),
81
+ "1536x672 · 21:9 full": (672, 1536),
82
+ }
83
+ # The distillation's own operating point: 768x1344, 124 frames, 24 fps.
84
+ DEFAULT_CANVAS = "1344x768 · 16:9 full"
85
+ DEFAULT_DURATION = 5
86
+ FPS, FRAMES_PER_CHUNK, LATENTS_PER_CHUNK = 24, 17, 5
87
+ # The ceiling holds for the *snapped* frame count. 75.74 GiB of weights are resident on a 95.0 GiB card, and the
88
+ # sparse path's own working set grows with the packed sequence, so this is a memory ceiling, not a policy one.
89
+ MIN_UI_DURATION, MAX_UI_DURATION = 2, 8
90
+
91
+
92
+ def snap_frames(seconds: float) -> int:
93
+ """The frame count MiniMax-H3's video VAE can decode: the next `17 * n + 5` at 24 fps."""
94
+ frames = max(1, round(float(seconds) * FPS))
95
+ while frames % FRAMES_PER_CHUNK != LATENTS_PER_CHUNK:
96
+ frames += 1
97
+ return frames
98
+
99
+
100
+ def lower_duration_floor(seconds: float = MIN_UI_DURATION) -> None:
101
+ """Let the pipeline generate below its 5 s floor. 56 frames (2.33 s) is fine on the released checkpoint."""
102
+ from diffusers.modular_pipelines.minimax_h3.modular_pipeline import MiniMaxH3ModularPipeline
103
+
104
+ MiniMaxH3ModularPipeline.min_duration = property(lambda self: float(seconds))
105
+
106
+
107
+ PIPE = None
108
+ MANAGER = None
109
+ LOAD_ERROR: str | None = None
110
+ LOADED_IN: float | None = None
111
+ VSA_BLOCKS = 0
112
+ VSA_GATES = 0
113
+
114
+
115
+ def status() -> str:
116
+ if LOAD_ERROR:
117
+ return LOAD_ERROR
118
+ if PIPE is None:
119
+ return f"Loading `{MODEL_REPO}` (transformer + VAEs, 81 GB). Watch the Space logs."
120
+ if ATTENTION == "vsa":
121
+ attention = (
122
+ f"**VSA-H3** block-sparse, tile 64 / sparsity {VSA_SPARSITY:g} on {VSA_BLOCKS} blocks "
123
+ f"({VSA_GATES} trained compression gates live)"
124
+ )
125
+ else:
126
+ attention = f"dense `{ATTENTION}` (off-distribution for this student)"
127
+ return (
128
+ f"Ready · distilled transformer + VAEs **bfloat16, unquantized** · {NUM_FORWARDS} transformer forwards "
129
+ f"({SIGMA_GRID_POINTS}-point sigma grid) · attention {attention} · placement `{PLACEMENT}` · "
130
+ f"loaded in {LOADED_IN:.0f}s · conditioner `{CONDITIONER_SPACE}`"
131
+ )
132
+
133
+
134
+ def load_models() -> str | None:
135
+ """Load the denoising half at startup.
136
+
137
+ `MiniMaxH3GeneratorBlocks` declares `transformer`, `vae`, `audio_vae`, the two schedulers and `video_processor`,
138
+ so `load_components` fetches exactly those subfolders — `text_encoder/` and `transformer_ref/` are never touched.
139
+ Both autoencoders carry `_keep_in_fp32_modules` over every module and stay float32: a bfloat16 audio VAE decodes
140
+ the soundtrack roughly 20 dB too quiet.
141
+
142
+ `add_gate_compress_modules()` has to run *before* the transformer is instantiated. The checkpoint carries 50
143
+ `transformer_blocks.*.attn.to_gate_compress.weight` tensors — the trained VSA compression gate — and stock
144
+ `MiniMaxH3Attention` does not declare the module, so `from_pretrained` would report them as unexpected and drop
145
+ them. Declaring it first is what makes them load.
146
+ """
147
+ global PIPE, MANAGER, LOAD_ERROR, LOADED_IN, VSA_BLOCKS, VSA_GATES
148
+
149
+ if PIPE is not None or LOAD_ERROR is not None:
150
+ return LOAD_ERROR
151
+
152
+ started = time.time()
153
+ try:
154
+ import torch
155
+ from diffusers import ComponentsManager
156
+
157
+ from h3_split_blocks import MiniMaxH3GeneratorBlocks
158
+
159
+ lower_duration_floor()
160
+ if ATTENTION == "vsa":
161
+ import vsa_h3
162
+
163
+ vsa_h3.add_gate_compress_modules()
164
+
165
+ manager = ComponentsManager()
166
+ blocks = MiniMaxH3GeneratorBlocks()
167
+ print(f"[gen] loading {[c.name for c in blocks.expected_components]} from {MODEL_REPO} ...", flush=True)
168
+ pipe = blocks.init_pipeline(MODEL_REPO, components_manager=manager, collection="fasth3")
169
+ pipe.load_components(dtype=torch.bfloat16)
170
+
171
+ if ATTENTION == "vsa":
172
+ VSA_BLOCKS, VSA_GATES = vsa_h3.install(pipe.transformer, sparsity=VSA_SPARSITY)
173
+ print(f"[gen] VSA-H3 on {VSA_BLOCKS} blocks, {VSA_GATES} trained gates", flush=True)
174
+ else:
175
+ pipe.transformer.set_attention_backend(ATTENTION)
176
+
177
+ if PLACEMENT == "offload":
178
+ manager.enable_auto_cpu_offload(device="cuda")
179
+ _arm_decode_hooks(pipe)
180
+
181
+ PIPE, MANAGER = pipe, manager
182
+ LOADED_IN = time.time() - started
183
+ print(f"[gen] ready in {LOADED_IN:.0f}s", flush=True)
184
+ except Exception as error:
185
+ traceback.print_exc()
186
+ LOAD_ERROR = (
187
+ f"**Loading `{MODEL_REPO}` failed** after {time.time() - started:.0f}s: "
188
+ f"`{type(error).__name__}: {error}`"
189
+ )
190
+ return LOAD_ERROR
191
+
192
+
193
+ def _arm_decode_hooks(pipe):
194
+ """Make the offload hooks fire for the two VAEs.
195
+
196
+ `enable_auto_cpu_offload` wraps `forward`, and the decode blocks call `vae.decode(...)` directly, so the hook
197
+ never runs and the VAE is still on the host when the latents arrive on the card.
198
+ """
199
+ for name in ("vae", "audio_vae"):
200
+ module = getattr(pipe, name)
201
+ inner = module.decode
202
+
203
+ def armed(*args, _module=module, _decode=inner, **kwargs):
204
+ hook = getattr(_module, "_hf_hook", None)
205
+ if hook is not None:
206
+ hook.pre_forward(_module)
207
+ return _decode(*args, **kwargs)
208
+
209
+ module.decode = armed
210
+
211
+
212
+ @cache
213
+ def conditioner():
214
+ """The other half, over the gradio API. `gradio_client` attaches the caller's own ZeroGPU token per call, so the
215
+ conditioner's booking is billed to whoever asked for the video."""
216
+ from gradio_client import Client
217
+
218
+ return Client(CONDITIONER_SPACE)
219
+
220
+
221
+ def encode_remote(prompt: str, canvas: str, num_frames: int, rewrite_prompt: bool = False):
222
+ """`/encode` on the conditioner Space: a safetensors file holding `prompt_embeds` + `text_token_tags`, with the
223
+ resolved `height` / `width` / `num_frames` in its metadata, plus the plan. `canvas` is the label."""
224
+ from safetensors import safe_open
225
+
226
+ path, plan = conditioner().predict(
227
+ prompt=prompt,
228
+ image_path=None,
229
+ last_image_path=None,
230
+ canvas=canvas,
231
+ num_frames=num_frames,
232
+ rewrite_prompt=bool(rewrite_prompt),
233
+ api_name="/encode",
234
+ )
235
+ with safe_open(path, framework="pt") as handle:
236
+ return handle.get_tensor("prompt_embeds"), handle.get_tensor("text_token_tags"), handle.metadata(), plan
237
+
238
+
239
+ # Seconds of GPU one request needs, from the packed rows it is about to denoise: linear in the rows for the matmuls
240
+ # and the two decoders, quadratic for the selection scores (which stay dense over tiles even though attention does
241
+ # not). Refit on this Space's own VSA measurements.
242
+ _DUR_A, _DUR_B, _DUR_BASE = 7.413e-4, 1.7507e-8, 5.0
243
+ # `lazy` placement: 75.74 GiB crosses PCIe once per cold worker. Plus the Triton kernels' one-time JIT compile, which
244
+ # is per new packed length and lands on whoever asks for that canvas first on a cold worker.
245
+ _PLACEMENT_ALLOWANCE = 30
246
+ # Booked over the estimate. Keep it small: an inflated duration burns the visitor's quota and drops queue priority.
247
+ _MARGIN = 1.15
248
+
249
+
250
+ def get_duration(prompt_embeds, text_token_tags, height, width, num_frames, seed, *a, **k):
251
+ height, width, num_frames = int(height), int(width), int(num_frames)
252
+ latent_frames = (num_frames - LATENTS_PER_CHUNK) // FRAMES_PER_CHUNK * LATENTS_PER_CHUNK + 2
253
+ rows = latent_frames * (height // 32) * (width // 32)
254
+ estimate = _DUR_A * rows + _DUR_B * rows**2 + _DUR_BASE + _PLACEMENT_ALLOWANCE
255
+ return max(60, int(estimate * _MARGIN) + 2)
256
+
257
+
258
+ @spaces.GPU(duration=get_duration, size=GPU_SIZE)
259
+ def _generate(prompt_embeds, text_token_tags, height: int, width: int, num_frames: int, seed: int):
260
+ """The only thing on GPU time: the four-forward packed-sequence denoise loop and the two decoders.
261
+
262
+ Only the three generated outputs come back — a `@spaces.GPU` return crosses a process boundary by pickling, and
263
+ the full `PipelineState` still holds the packed latents, the rotary grid and the row indices on the card.
264
+ """
265
+ import torch
266
+
267
+ if PLACEMENT == "lazy":
268
+ PIPE.to("cuda")
269
+
270
+ torch.cuda.reset_peak_memory_stats()
271
+ state = PIPE(
272
+ prompt_embeds=prompt_embeds.to("cuda"),
273
+ text_token_tags=text_token_tags,
274
+ height=height,
275
+ width=width,
276
+ num_frames=num_frames,
277
+ num_inference_steps=SIGMA_GRID_POINTS,
278
+ generator=torch.Generator("cpu").manual_seed(int(seed)),
279
+ )
280
+ peak = torch.cuda.max_memory_allocated() / 1024**3
281
+ print(f"[gen] peak allocated {peak:.2f} GiB", flush=True)
282
+ return state.get("videos")[0], state.get("audio")[0].cpu(), state.get("sampling_rate")
283
+
284
+
285
+ @spaces.GPU(duration=120, size=GPU_SIZE)
286
+ def selftest() -> str:
287
+ """Check the vendored VSA-H3 kernels against dense attention on this GPU.
288
+
289
+ At `sparsity = 0` the block map is all-true, so VSA-H3 has to reproduce full attention exactly (up to the tile
290
+ padding and the fp32 pooled selection, which cannot change an all-true mask). That is the one assertion that
291
+ catches a wrong tile order, a wrong `variable_block_sizes`, or a mis-transposed buffer — all of which would
292
+ otherwise show up only as a subtly wrong video. Runs on random tensors; no weights are touched.
293
+
294
+ Returns:
295
+ A markdown report: the dense-equivalence error, and how much of the dense output the 90%-sparse path keeps.
296
+ """
297
+ import torch
298
+ import torch.nn.functional as F
299
+
300
+ from diffusers.modular_pipelines.minimax_h3.before_denoise import MiniMaxH3PrepareLayoutStep
301
+
302
+ import vsa_h3
303
+
304
+ device = torch.device("cuda")
305
+ heads, dim = 8, 128
306
+ # A real packed layout, just a small one: 300 text rows, 5 latent frames of 8x12 video, its soundtrack.
307
+ _, token_tags, *_ = MiniMaxH3PrepareLayoutStep.build_packed_sequence(
308
+ torch.ones(300, dtype=torch.long), 5, 16, 24, 50, (1, 2, 2), 2, 2, 0, ()
309
+ )
310
+ position_ids = torch.zeros(token_tags.numel(), 3, dtype=torch.float64)
311
+ video_start = int((token_tags == 0).nonzero()[0])
312
+ frame = torch.cartesian_prod(torch.arange(8.0), torch.arange(12.0))
313
+ position_ids[video_start:, 0] = torch.arange(5.0).repeat_interleave(96)
314
+ position_ids[video_start:, 1:] = frame.repeat(5, 1)
315
+ token_tags, position_ids = token_tags.to(device), position_ids.to(device)
316
+
317
+ lines = []
318
+ generator = torch.Generator(device=device).manual_seed(0)
319
+ shape = (1, token_tags.numel(), heads, dim)
320
+ query, key, value = (
321
+ torch.randn(shape, generator=generator, device=device, dtype=torch.bfloat16) for _ in range(3)
322
+ )
323
+ reference = F.scaled_dot_product_attention(
324
+ query.transpose(1, 2), key.transpose(1, 2), value.transpose(1, 2)
325
+ ).transpose(1, 2)
326
+
327
+ for sparsity in (0.0, 0.9):
328
+ vsa_h3.reset_tile_buffers()
329
+ geometry = vsa_h3.geometry_from_layout(token_tags, position_ids, sparsity)
330
+ if geometry is None:
331
+ return "**FAILED**: `geometry_from_layout` did not recognize the standard packed layout."
332
+ out = vsa_h3.sparse_attention(query, key, value, None, geometry)
333
+ error = (out.float() - reference.float()).abs().max().item()
334
+ scale = reference.float().abs().max().item()
335
+ similarity = F.cosine_similarity(out.float().flatten(), reference.float().flatten(), dim=0).item()
336
+ lines.append(
337
+ f"| {sparsity:g} | {geometry.topk}/{geometry.num_video_tiles} | {error:.4f} | "
338
+ f"{error / scale:.2e} | {similarity:.6f} |"
339
+ )
340
+ if sparsity == 0.0 and error / scale > 0.02:
341
+ lines.append(f"\n**FAILED**: dense-equivalent VSA differs from SDPA by {error / scale:.3f} relative.")
342
+
343
+ return (
344
+ f"VSA-H3 on `{torch.cuda.get_device_name()}`, {token_tags.numel()} packed rows, "
345
+ f"{heads} heads x {dim}.\n\n"
346
+ "| sparsity | tiles kept | max abs err | relative | cosine |\n|---|---|---|---|---|\n" + "\n".join(lines)
347
+ )
348
+
349
+
350
+ def generate(
351
+ prompt: str,
352
+ canvas: str = DEFAULT_CANVAS,
353
+ duration: float = DEFAULT_DURATION,
354
+ upsample: bool = True,
355
+ seed: int = 42,
356
+ progress=gr.Progress(track_tqdm=True),
357
+ ):
358
+ """Generate a video with a synchronized soundtrack from a text prompt, in four transformer forwards.
359
+
360
+ Args:
361
+ prompt: The request. MiniMax-H3 was trained on a structured format
362
+ (`integrated_multimodal_description: ... overall_soundscape: ... non_diegetic_music: ...`); leave
363
+ `upsample` on to have the conditioner rewrite a plain sentence into it first.
364
+ canvas: One of the released canvases, as a `WIDTHxHEIGHT · ratio` label. The distillation's own operating
365
+ point is `1344x768 · 16:9 full`.
366
+ duration: Length in seconds, rounded up to the next frame count the video VAE can decode (`17 * n + 5`).
367
+ upsample: Rewrite the prompt into MiniMax-H3's trained format before encoding it.
368
+ seed: Random seed.
369
+
370
+ Returns:
371
+ The path of an mp4 holding h264 video and AAC audio, a one-line report of what ran, and the rewritten
372
+ prompt when there was one.
373
+ """
374
+ if LOAD_ERROR:
375
+ raise gr.Error(LOAD_ERROR)
376
+ if PIPE is None:
377
+ raise gr.Error("The denoiser is still loading.")
378
+ if not prompt or not prompt.strip():
379
+ raise gr.Error("MiniMax-H3 always takes a prompt.")
380
+
381
+ from diffusers.utils import encode_video
382
+
383
+ num_frames = snap_frames(duration)
384
+
385
+ progress(0.0, desc=f"{'Rewriting the prompt' if upsample else 'Conditioning'} on {CONDITIONER_SPACE} ...")
386
+ conditioned = time.time()
387
+ prompt_embeds, text_token_tags, metadata, plan = encode_remote(
388
+ prompt, canvas, num_frames, rewrite_prompt=bool(upsample)
389
+ )
390
+ condition_seconds = time.time() - conditioned
391
+ height, width, num_frames = (int(metadata[key]) for key in ("height", "width", "num_frames"))
392
+ refined = plan.get("refined_prompt") or ""
393
+
394
+ progress(0.2, desc=f"{NUM_FORWARDS} transformer forwards at {width}x{height}, {num_frames} frames ...")
395
+ started = time.time()
396
+ frames, audio, sampling_rate = _generate(prompt_embeds, text_token_tags, height, width, num_frames, seed)
397
+ generate_seconds = time.time() - started
398
+
399
+ directory = os.path.join(tempfile.gettempdir(), "fasth3-outputs")
400
+ os.makedirs(directory, exist_ok=True)
401
+ path = os.path.join(directory, f"fasth3-{int(time.time() * 1000)}.mp4")
402
+ encode_video(frames, fps=FPS, output_path=path, audio=audio, audio_sample_rate=sampling_rate)
403
+
404
+ report = (
405
+ f"`{width}x{height}`, {num_frames} frames ({num_frames / FPS:.3f} s), {NUM_FORWARDS} transformer forwards · "
406
+ f"conditioner {condition_seconds:.0f}s ({plan['num_text_tokens']} tokens"
407
+ f"{', rewritten' if refined else ''}) · denoise + decode {generate_seconds:.0f}s "
408
+ f"({generate_seconds / NUM_FORWARDS:.1f} s/forward) · seed {int(seed)}"
409
+ )
410
+ print(f"[gen] {report}", flush=True)
411
+ return path, report, refined
412
+
413
+
414
+ load_models()
415
+
416
+ INTRO = f"""# FastH3 v1 (VSA) — MiniMax-H3 in 4 steps, sparse
417
+
418
+ <div>
419
+ <a href="https://huggingface.co/{MODEL_REPO}" target="_blank" rel="noopener"><strong>[ model ]</strong></a> &nbsp;
420
+ <a href="https://github.com/hao-ai-lab/FastVideo" target="_blank" rel="noopener"><strong>[ FastVideo ]</strong></a> &nbsp;
421
+ <a href="https://huggingface.co/{BASE_REPO}" target="_blank" rel="noopener"><strong>[ base model ]</strong></a>
422
+ </div>
423
+
424
+ [`{MODEL_REPO}`](https://huggingface.co/{MODEL_REPO}) is a **data-free DMD2 distillation** of
425
+ [MiniMax-H3](https://huggingface.co/{BASE_REPO}), the 33B dual-modality transformer that generates video **and** a
426
+ fully synchronized soundtrack (ambience, foley, speech) in one denoising pass. The base model samples in 50 steps;
427
+ this student walks a trained 4-step ladder — `t = 999, 749, 500, 250` — for **{NUM_FORWARDS} transformer forwards**
428
+ per video.
429
+
430
+ It is also distilled **under Video Sparse Attention**: 64-token tiles at 90% sparsity, with a trained per-head
431
+ compression gate. This Space runs that sparse path, on FastVideo's own Triton kernels — not a dense substitute.
432
+ """
433
+
434
+ FORMAT_NOTE = """MiniMax-H3 was trained on a structured prompt, not a caption:
435
+
436
+ ```text
437
+ integrated_multimodal_description: [Shot 1] ... <d>[English] spoken line.</d> [Shot 2] At 00:04.500, ...
438
+ overall_soundscape: ...
439
+ non_diegetic_music: ...
440
+ ```
441
+
442
+ **Expand prompt** (on by default) sends a plain sentence through the Qwen3-VL conditioner's own language-model head
443
+ first, which writes that format with the same weights that are about to encode it. Turn it off when the prompt is
444
+ already written out — as the last two examples below are. See the base model's
445
+ [prompt writing guide](https://huggingface.co/MiniMaxAI/MiniMax-H3/blob/main/docs/VIDEO_PROMPT_WRITING_GUIDE_base_en.md).
446
+ """
447
+
448
+ # Both long examples are the MiniMax-H3 authors' own published T2VA prompts: the first is Case 1 of the prompt writing
449
+ # guide, the second is the reproducible 768p T2VA case from the model card
450
+ # (`scripts/readme/reproducible-768p-t2va-request.sh`). Both are documentation of the Apache-2.0 base repo.
451
+ GUIDE_CASE_1 = (
452
+ "integrated_multimodal_description: [Shot 1] Live-action, cinematic, a medium-wide shot frames a baker opening "
453
+ "the shutters of a small street bakery before sunrise. The camera pushes in with small amplitude at slow speed "
454
+ "as the middle-aged baker with a calm, slightly raspy voice (S1) places a fresh loaf on the wooden counter and "
455
+ "says: <d>[English] First batch of the morning.</d> [Shot 2] At 00:05.000, the camera cuts to a close-up of "
456
+ "steam rising from the sliced bread while the baker's final words carry over from the previous shot.\n\n"
457
+ "overall_soundscape: Wooden shutters scrape open over a quiet street as trays clink softly inside the bakery. "
458
+ "The doorbell rings once, followed by light footsteps and the crisp sound of bread being sliced.\n\n"
459
+ "non_diegetic_music: A soft acoustic-guitar pattern at a moderate tempo, joined by sparse upright-bass notes and "
460
+ "a gentle fade at the end."
461
+ )
462
+ OFFICIAL_T2VA = (
463
+ "integrated_multimodal_description: [Shot 1] Cinematic, medium wide shot, pushing in slowly. In the cavernous, "
464
+ "dimly lit bridge of a starship, sleek metallic consoles with glowing amber displays flank a massive, curved "
465
+ "observation window. A female captain, in her late 40s with an athletic build and short silver-streaked black "
466
+ "hair, stands in the center midground. She wears a structured, high-collared dark navy military tunic with "
467
+ "silver chest insignias. Her back is to the camera, silhouetted against the cool, ambient starlight pouring "
468
+ "through the thick glass. She stands perfectly still with her hands clasped tightly behind her back. Outside the "
469
+ "window, a massive armada of jagged, dark grey dreadnoughts hovers in tight formation against a deep purple "
470
+ "space nebula. The fleet's massive rear thrusters begin to glow with an intense, escalating bright blue light. "
471
+ "[Shot 2] At 00:04.500, the camera cuts to a close-up of the captain's face and shakes strongly. The brilliant "
472
+ "blue-white light from the fleet's gathering energy reflects vividly in her dark eyes. Suddenly, a blinding "
473
+ "white flash floods through the window, completely washing out the background as the fleet jumps to hyperspace. "
474
+ "The sheer spatial force violently jolts the bridge, causing the captain from Shot 1 to stagger slightly "
475
+ "forward, her shoulders tensing as she visibly braces herself against the physical tremors. As the intense "
476
+ "white light fades abruptly, leaving only the dim, empty expanse of the purple nebula reflected on her starkly "
477
+ "lit skin, her jaw clenches, and she slowly closes her eyes in the newly emptied space.\n"
478
+ "overall_soundscape: A low, resonant hum of the ship's ambient life support systems serves as the baseline, soon "
479
+ "drowned out by an audible, escalating, high-pitched electronic whine as the fleet outside charges its "
480
+ "hyperdrives. A massive, deafening, bass-heavy boom and sharp crackle erupts during the blinding flash, "
481
+ "accompanied by the loud metallic creaking, rattling, and deep thuds of the bridge's bulkheads vibrating under "
482
+ "immense physical stress. The intense roaring impact then cuts abruptly back to a hollow, echoing room tone, "
483
+ "leaving only the faint, steady hum of the isolated bridge.\n"
484
+ "non_diegetic_music: Cinematic space-opera orchestral score, slow tempo, featuring a solitary, mournful French "
485
+ "horn melody over deep, sustained string dissonances that build rapidly in volume and intensity, swelling to a "
486
+ "massive orchestral peak before snapping immediately into silence right after the jump."
487
+ )
488
+
489
+ CSS = """
490
+ .main.fillable {max-width: 1250px !important}
491
+ .dark .gradio-container { color: var(--body-text-color); }
492
+ """
493
+
494
+ with gr.Blocks(title="FastH3 v1 (VSA)") as demo:
495
+ gr.Markdown(INTRO)
496
+
497
+ with gr.Row():
498
+ with gr.Column():
499
+ prompt = gr.Textbox(
500
+ label="Prompt",
501
+ lines=5,
502
+ placeholder="A red fox trotting through a snowy pine forest at dawn, snow crunching underfoot",
503
+ value="A red fox trotting through a snowy pine forest at dawn, snow crunching underfoot",
504
+ )
505
+ upsample = gr.Checkbox(
506
+ label="Expand prompt into MiniMax-H3's trained format",
507
+ value=True,
508
+ info="Runs on the conditioner Space before encoding. Turn off for a prompt already in that format.",
509
+ )
510
+ run = gr.Button("Generate", variant="primary")
511
+ with gr.Accordion("Advanced options", open=False):
512
+ canvas = gr.Dropdown(label="Canvas", choices=list(CANVASES), value=DEFAULT_CANVAS)
513
+ duration = gr.Slider(
514
+ label="Duration (s)",
515
+ minimum=MIN_UI_DURATION,
516
+ maximum=MAX_UI_DURATION,
517
+ step=1,
518
+ value=DEFAULT_DURATION,
519
+ )
520
+ seed = gr.Number(label="Seed", value=42, precision=0)
521
+ gr.Markdown(
522
+ f"Steps are fixed at the trained ladder — a {SIGMA_GRID_POINTS}-point sigma grid, "
523
+ f"{NUM_FORWARDS} transformer forwards, exactly what the checkpoint's own "
524
+ "`fastvideo_inference.json` specifies. There is no guidance scale and no negative prompt: the "
525
+ "base model is guidance-distilled."
526
+ )
527
+
528
+ with gr.Column():
529
+ video = gr.Video(label="Video + soundtrack")
530
+ report = gr.Markdown()
531
+ with gr.Accordion("Expanded prompt", open=False):
532
+ upsampled = gr.Textbox(show_label=False, lines=10, interactive=False)
533
+
534
+ with gr.Accordion("Prompt format", open=False):
535
+ gr.Markdown(FORMAT_NOTE)
536
+ banner = gr.Markdown()
537
+
538
+ gr.Examples(
539
+ examples=[
540
+ [
541
+ "A red fox trotting through a snowy pine forest at dawn, snow crunching underfoot",
542
+ "960x544 · 16:9 fast",
543
+ 5,
544
+ True,
545
+ ],
546
+ [
547
+ "A cellist playing a slow, low melody alone in an empty concert hall",
548
+ "1344x768 · 16:9 full",
549
+ 5,
550
+ True,
551
+ ],
552
+ [GUIDE_CASE_1, "1344x768 · 16:9 full", 5, False],
553
+ [OFFICIAL_T2VA, "1344x768 · 16:9 full", 5, False],
554
+ ],
555
+ inputs=[prompt, canvas, duration, upsample],
556
+ outputs=[video, report, upsampled],
557
+ fn=generate,
558
+ cache_examples=True,
559
+ cache_mode="lazy",
560
+ label="Examples — the last two are the MiniMax-H3 authors' own published T2VA prompts",
561
+ )
562
+
563
+ run.click(
564
+ generate,
565
+ [prompt, canvas, duration, upsample, seed],
566
+ [video, report, upsampled],
567
+ api_name="generate",
568
+ )
569
+ demo.load(status, None, banner, api_name="status")
570
+
571
+ # No UI, API only: the sparse-attention equivalence check, so the kernel can be verified on this pool without
572
+ # spending a full generation.
573
+ diagnose = gr.Button(visible=False)
574
+ diagnose.click(selftest, None, gr.Markdown(visible=False), api_name="selftest")
575
+
576
+
577
+ if __name__ == "__main__":
578
+ # Gradio 6 moved `theme` and `css` off the `Blocks` constructor onto `launch`.
579
+ demo.launch(theme=gr.themes.Citrus(), css=CSS, show_error=True, max_threads=1000, mcp_server=True)
h3_split_blocks.py ADDED
@@ -0,0 +1,51 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """The denoising half of a **split** MiniMax-H3 deployment.
2
+
3
+ MiniMax-H3 is ~196 GiB in bfloat16 and a ZeroGPU Space is evicted at 150 GB of storage, so `MiniMaxH3Blocks` is cut at
4
+ its `text_encoder` step: the 62.14 GiB Qwen3-VL conditioner runs in its own Space and everything else — the distilled
5
+ transformer and the two autoencoders — runs here. `prompt_embeds` + `text_token_tags` is the whole wire format between
6
+ the two halves.
7
+
8
+ For a text-only (`t2va`) request the full `MiniMaxH3Blocks` sequence is `before_encode -> text_encoder -> vae_encoder
9
+ -> denoise -> decode`, and the two `Auto*` encoder steps select nothing without a keyframe or a reference. What is
10
+ left once the text encoder goes is exactly `MiniMaxH3CoreDenoiseStep -> MiniMaxH3DecodeStep`, and those two declare
11
+ only `transformer`, the two schedulers, both autoencoders and `video_processor` — so `load_components` resolves those
12
+ out of the checkpoint's `modular_model_index.json` and never fetches the conditioner or the `ref2va` partition.
13
+
14
+ Adapted from `multimodalart/minimax-h3`, trimmed to the `t2va` (text -> video + audio) branch this Space serves.
15
+ """
16
+
17
+ import torch
18
+ from diffusers.modular_pipelines.minimax_h3.modular_blocks_minimax_h3 import (
19
+ MiniMaxH3CoreDenoiseStep,
20
+ MiniMaxH3DecodeStep,
21
+ )
22
+ from diffusers.modular_pipelines.modular_pipeline import SequentialPipelineBlocks
23
+ from diffusers.modular_pipelines.modular_pipeline_utils import OutputParam
24
+
25
+
26
+ class MiniMaxH3GeneratorBlocks(SequentialPipelineBlocks):
27
+ """The denoising half of a split MiniMax-H3: the `t2va` branch of `MiniMaxH3Blocks` without its text encoder."""
28
+
29
+ model_name = "minimax-h3"
30
+ block_classes = [MiniMaxH3CoreDenoiseStep, MiniMaxH3DecodeStep]
31
+ block_names = ["denoise", "decode"]
32
+
33
+ @property
34
+ def description(self):
35
+ return (
36
+ "The denoising half of a split MiniMax-H3 deployment: the `t2va` branch of `MiniMaxH3Blocks` without its "
37
+ "text-encoder step, so `prompt_embeds` and `text_token_tags` come in as inputs and the 62.14 GiB Qwen3-VL "
38
+ "conditioner is never loaded here."
39
+ )
40
+
41
+ @property
42
+ def outputs(self):
43
+ return [
44
+ OutputParam.template("videos", description="The generated video."),
45
+ OutputParam(
46
+ "audio",
47
+ type_hint=torch.Tensor,
48
+ description="The generated soundtrack, of shape `(1, 2, num_samples)`.",
49
+ ),
50
+ OutputParam("sampling_rate", type_hint=int, description="Sample rate of the generated soundtrack in Hz."),
51
+ ]
packages.txt ADDED
@@ -0,0 +1 @@
 
 
1
+ ffmpeg
requirements.txt ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # MiniMax-H3 landed in diffusers 0.40.0 (modular pipelines only). Pinned because `h3_split_blocks.py` subclasses its
2
+ # block classes and `vsa_h3.py` patches `MiniMaxH3TransformerBlock` / `MiniMaxH3Attention` by name.
3
+ diffusers==0.40.0
4
+ # torch is deliberately unpinned: the ZeroGPU runtime preinstalls a supported build, and its bundled `pytorch-triton`
5
+ # is what compiles the vendored VSA block-sparse kernels. torchvision is not needed anywhere in this Space.
6
+ transformers
7
+ accelerate
8
+ # PyAV muxes the generated soundtrack onto the frames (`diffusers.utils.encode_video`).
9
+ av
10
+ pillow
11
+ numpy
12
+ safetensors>=0.8.0
13
+ # huggingface-hub is deliberately absent: it is platform-managed, and diffusers already constrains it to <2.
vsa_h3.py ADDED
@@ -0,0 +1,420 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """VSA-H3 — MiniMax-H3's Video Sparse Attention, as a `diffusers` attention processor.
2
+
3
+ `FastVideo-FastH3-4-step-Preview-v1-VSA-DataFree` is distilled **under** block-sparse attention
4
+ (`attention_backend: VIDEO_SPARSE_ATTN_H3`, `vsa_tile_size: 64`, `vsa_sparsity: 0.9` in the checkpoint's own
5
+ `fastvideo_inference.json`), and FastVideo's release notes are explicit that dense attention is *not* a drop-in
6
+ substitute for a VSA-trained student: the student learned to attend to the top-10% tiles its selector picks, and it
7
+ also ships 50 trained `attn.to_gate_compress` tensors that only the sparse path reads. So this Space runs the sparse
8
+ path.
9
+
10
+ `diffusers` has no VSA backend, so this module ports FastVideo's `MiniMaxH3VSABackend`
11
+ (`fastvideo/attention/backends/video_sparse_attn_h3.py`) onto `MiniMaxH3Attention`:
12
+
13
+ 1. **Tiling.** The packed sequence `[text | audio | video]` is cut into 64-token tiles: segment-pure prefix chunks
14
+ first, then `(4, 4, 4)` cubes of the post-patchify `(t, h, w)` video grid. Tiles are zero-padded to 64 and
15
+ `variable_block_sizes` carries each tile's true occupancy.
16
+ 2. **Selection.** Per head, tiles are mean-pooled in fp32, `scores = q_pooled @ k_pooledᵀ / √d`, and each query tile
17
+ keeps the top `ceil((1 - 0.9) * num_video_tiles)` video tiles. Prefix (text/audio) keys are *exempt* — always
18
+ selected — and prefix queries are always dense, which is FastVideo's default `vsa_mode`.
19
+ 3. **Kernel.** The resulting bool block map is compacted with FastVideo's `map_to_index` Triton kernel and consumed by
20
+ its `triton_block_sparse_attn_forward`, both vendored verbatim under `vsa_kernel/`. This is FastVideo's own
21
+ `--vsa-kernel triton` route; the checkpoint's `vsa_kernel: sm100a` is the GB200-only fast path for the *same* mask
22
+ semantics, and this Space's Blackwell RTX PRO 6000 is sm120.
23
+ 4. **Compression branch.** `out_c = softmax(scores) @ v_pooled` broadcast back over each tile's rows and scaled by the
24
+ trained per-row `to_gate_compress` gate, added to the sparse output. The base MiniMax-H3 release zero-initializes
25
+ this gate (branch inert); this student ships it trained.
26
+
27
+ The one deliberate deviation from the reference is memory layout, not math: buffers are tiled straight into the
28
+ kernel's `[B, H, S_pad, D]` layout and reused across the 50 blocks, and the gate is applied *after* untiling, so the
29
+ sparse path costs one persistent tile buffer per projection instead of four plus three transposed copies.
30
+ """
31
+
32
+ from __future__ import annotations
33
+
34
+ import functools
35
+ import math
36
+ from dataclasses import dataclass
37
+
38
+ import torch
39
+
40
+ # 64-token tiles: `(4, 4, 4)` over the post-patchify video grid. This is the checkpoint's `vsa_tile_size`, and it is
41
+ # the Triton kernels' native block size, so the block map needs no expansion.
42
+ TILE_ELEMS = 64
43
+ TILE_SHAPE = (4, 4, 4)
44
+ DEFAULT_SPARSITY = 0.9
45
+
46
+
47
+ # --------------------------------------------------------------------------------------------------------------
48
+ # Geometry — ported from `fastvideo.attention.backends.video_sparse_attn{,_h3}`
49
+ # --------------------------------------------------------------------------------------------------------------
50
+
51
+
52
+ def _tile_partition_indices(dit_seq_shape: tuple[int, int, int], device: torch.device) -> torch.Tensor:
53
+ """Row indices of the video grid in `(4, 4, 4)` tile order."""
54
+ grid_t, grid_h, grid_w = dit_seq_shape
55
+ ts, hs, ws = TILE_SHAPE
56
+ indices = torch.arange(grid_t * grid_h * grid_w, device=device, dtype=torch.long).reshape(grid_t, grid_h, grid_w)
57
+ chunks = []
58
+ for t in range(math.ceil(grid_t / ts)):
59
+ for h in range(math.ceil(grid_h / hs)):
60
+ for w in range(math.ceil(grid_w / ws)):
61
+ chunks.append(
62
+ indices[t * ts : min(t * ts + ts, grid_t), h * hs : min(h * hs + hs, grid_h), w * ws : min(w * ws + ws, grid_w)].flatten()
63
+ )
64
+ return torch.cat(chunks, dim=0)
65
+
66
+
67
+ def _video_block_sizes(dit_seq_shape: tuple[int, int, int], device: torch.device) -> torch.Tensor:
68
+ """Valid (non-padded) token count of every video tile, in the same tile order."""
69
+ ts, hs, ws = TILE_SHAPE
70
+
71
+ def sizes(length: int, tile: int) -> torch.Tensor:
72
+ count = math.ceil(length / tile)
73
+ out = torch.full((count,), tile, dtype=torch.long, device=device)
74
+ remainder = length - (count - 1) * tile
75
+ out[-1] = remainder if remainder > 0 else tile
76
+ return out
77
+
78
+ t_sizes, h_sizes, w_sizes = (sizes(length, tile) for length, tile in zip(dit_seq_shape, TILE_SHAPE))
79
+ return (t_sizes[:, None, None] * h_sizes[None, :, None] * w_sizes[None, None, :]).reshape(-1)
80
+
81
+
82
+ def _non_pad_index(variable_block_sizes: torch.Tensor) -> torch.Tensor:
83
+ """Padded-buffer slot of every real token, in tile order."""
84
+ device = variable_block_sizes.device
85
+ starts = torch.arange(variable_block_sizes.shape[0], device=device) * TILE_ELEMS
86
+ slots = starts[:, None] + torch.arange(TILE_ELEMS, device=device)[None, :]
87
+ keep = torch.arange(TILE_ELEMS, device=device)[None, :] < variable_block_sizes[:, None]
88
+ return slots[keep]
89
+
90
+
91
+ @dataclass(frozen=True, eq=False)
92
+ class VSAGeometry:
93
+ """Everything the sparse path needs about one packed layout. Cached per `(prefix_segments, grid)`."""
94
+
95
+ seq_len: int
96
+ padded_len: int
97
+ n_tiles: int
98
+ num_prefix_tiles: int
99
+ num_video_tiles: int
100
+ topk: int
101
+ # int32 tile occupancies, as the Triton kernels take them.
102
+ variable_block_sizes: torch.Tensor
103
+ # fp32 tile occupancies, for the pooled mean.
104
+ tile_divisor: torch.Tensor
105
+ # packed row -> padded tile-buffer slot (scatter on the way in, gather on the way out)
106
+ untile_index: torch.Tensor
107
+ # packed row -> its tile, so the compression branch can be applied after untiling
108
+ row_tile_index: torch.Tensor
109
+
110
+ @property
111
+ def dense(self) -> bool:
112
+ return self.topk >= self.num_video_tiles
113
+
114
+
115
+ @functools.lru_cache(maxsize=8)
116
+ def build_geometry(
117
+ prefix_segments: tuple[int, ...],
118
+ dit_seq_shape: tuple[int, int, int],
119
+ device: torch.device,
120
+ sparsity: float = DEFAULT_SPARSITY,
121
+ ) -> VSAGeometry:
122
+ prefix_len = sum(prefix_segments)
123
+
124
+ prefix_sizes: list[int] = []
125
+ for segment in prefix_segments:
126
+ full, remainder = divmod(segment, TILE_ELEMS)
127
+ prefix_sizes.extend([TILE_ELEMS] * full)
128
+ if remainder:
129
+ prefix_sizes.append(remainder)
130
+
131
+ video_sizes = _video_block_sizes(dit_seq_shape, device)
132
+ variable_block_sizes = torch.cat(
133
+ [torch.tensor(prefix_sizes, dtype=torch.long, device=device), video_sizes]
134
+ )
135
+ partition = torch.cat(
136
+ [
137
+ torch.arange(prefix_len, device=device, dtype=torch.long),
138
+ _tile_partition_indices(dit_seq_shape, device) + prefix_len,
139
+ ]
140
+ )
141
+ untile_index = _non_pad_index(variable_block_sizes)[torch.argsort(partition)]
142
+
143
+ n_tiles = int(variable_block_sizes.numel())
144
+ num_video_tiles = int(video_sizes.numel())
145
+ return VSAGeometry(
146
+ seq_len=int(partition.numel()),
147
+ padded_len=n_tiles * TILE_ELEMS,
148
+ n_tiles=n_tiles,
149
+ num_prefix_tiles=len(prefix_sizes),
150
+ num_video_tiles=num_video_tiles,
151
+ # FastVideo's `compute_topk`, clamped to [1, num_video_tiles].
152
+ topk=max(1, min(math.ceil((1.0 - sparsity) * num_video_tiles), num_video_tiles)),
153
+ variable_block_sizes=variable_block_sizes.to(torch.int32).contiguous(),
154
+ tile_divisor=variable_block_sizes.to(torch.float32).view(1, 1, -1, 1),
155
+ untile_index=untile_index,
156
+ row_tile_index=untile_index // TILE_ELEMS,
157
+ )
158
+
159
+
160
+ def geometry_from_layout(
161
+ token_tags: torch.Tensor,
162
+ position_ids: torch.Tensor,
163
+ sparsity: float = DEFAULT_SPARSITY,
164
+ ) -> VSAGeometry | None:
165
+ """Recover the VSA geometry from what the transformer is actually given.
166
+
167
+ The packed sequence a `t2va` request builds is `[text | audio | video]`, but nothing downstream is told that, so
168
+ the layout is read back off the two per-row descriptions the transformer already takes: `token_tags` (0 video,
169
+ 1 text, 2 audio) gives the segment boundaries, and the `(t, h, w)` rotary grid of the video rows gives the shape
170
+ of the video block. Returns `None` for any layout the sparse path does not cover, so the caller can stay dense.
171
+ """
172
+ tags = token_tags.tolist()
173
+ seq_len = len(tags)
174
+ if seq_len == 0 or tags[-1] != 0:
175
+ return None
176
+
177
+ video_start = seq_len
178
+ while video_start > 0 and tags[video_start - 1] == 0:
179
+ video_start -= 1
180
+ if video_start == 0:
181
+ return None
182
+
183
+ prefix_segments: list[int] = []
184
+ previous = None
185
+ for tag in tags[:video_start]:
186
+ if tag == previous:
187
+ prefix_segments[-1] += 1
188
+ else:
189
+ prefix_segments.append(1)
190
+ previous = tag
191
+
192
+ # The video rows are `torch.meshgrid(height_grid, width_grid, indexing="ij")` repeated once per latent frame, so
193
+ # the grid falls out of the run lengths of the leading rows.
194
+ grid = position_ids[video_start:]
195
+ rows_per_frame = int((grid[:, 0] == grid[0, 0]).sum())
196
+ grid_w = int((grid[:rows_per_frame, 1] == grid[0, 1]).sum())
197
+ if rows_per_frame == 0 or grid_w == 0 or rows_per_frame % grid_w:
198
+ return None
199
+ grid_h = rows_per_frame // grid_w
200
+ num_video_rows = seq_len - video_start
201
+ if num_video_rows % rows_per_frame:
202
+ return None
203
+ grid_t = num_video_rows // rows_per_frame
204
+
205
+ return build_geometry(tuple(prefix_segments), (grid_t, grid_h, grid_w), token_tags.device, sparsity)
206
+
207
+
208
+ # --------------------------------------------------------------------------------------------------------------
209
+ # The sparse attention itself
210
+ # --------------------------------------------------------------------------------------------------------------
211
+
212
+ # One reusable padded tile buffer per projection. Tiles are written by scatter and pad slots are never touched, so a
213
+ # buffer only has to be re-zeroed when the geometry behind it changes.
214
+ _TILE_BUFFERS: dict[str, tuple[torch.Tensor, int]] = {}
215
+
216
+
217
+ def reset_tile_buffers() -> None:
218
+ _TILE_BUFFERS.clear()
219
+
220
+
221
+ def _tile(x: torch.Tensor, geometry: VSAGeometry, slot: str) -> torch.Tensor:
222
+ """`[B, S, H, D]` -> the kernel's `[B, H, S_pad, D]`, pad slots zero."""
223
+ batch, _, heads, dim = x.shape
224
+ shape = (batch, heads, geometry.padded_len, dim)
225
+ cached = _TILE_BUFFERS.get(slot)
226
+ if cached is None or cached[0].shape != shape or cached[0].dtype != x.dtype or cached[0].device != x.device:
227
+ buffer = torch.zeros(shape, dtype=x.dtype, device=x.device)
228
+ else:
229
+ buffer = cached[0]
230
+ if cached[1] != id(geometry):
231
+ buffer.zero_()
232
+ buffer.index_copy_(2, geometry.untile_index, x.transpose(1, 2))
233
+ _TILE_BUFFERS[slot] = (buffer, id(geometry))
234
+ return buffer
235
+
236
+
237
+ def _pool(tiled: torch.Tensor, geometry: VSAGeometry) -> torch.Tensor:
238
+ """fp32 masked mean over each 64-token tile. `[B, H, S_pad, D]` -> `[B, H, n_tiles, D]`."""
239
+ batch, heads, _, dim = tiled.shape
240
+ pooled = tiled.view(batch, heads, geometry.n_tiles, TILE_ELEMS, dim).sum(dim=3, dtype=torch.float32)
241
+ return pooled / geometry.tile_divisor
242
+
243
+
244
+ def _block_mask(scores: torch.Tensor, geometry: VSAGeometry) -> torch.Tensor:
245
+ """Top-k video tiles per query tile, with the prefix exempt and prefix queries dense."""
246
+ prefix = geometry.num_prefix_tiles
247
+ mask = torch.zeros_like(scores, dtype=torch.bool)
248
+ indices = scores[..., prefix:].topk(geometry.topk, dim=-1).indices + prefix
249
+ mask.scatter_(-1, indices, True)
250
+ mask[..., :prefix] = True
251
+ mask[:, :, :prefix, :] = True
252
+ return mask
253
+
254
+
255
+ def sparse_attention(
256
+ query: torch.Tensor,
257
+ key: torch.Tensor,
258
+ value: torch.Tensor,
259
+ gate_compress: torch.Tensor | None,
260
+ geometry: VSAGeometry,
261
+ ) -> torch.Tensor:
262
+ """VSA-H3 over one packed sequence. All tensors are `[B, S, H, D]`; the result is too."""
263
+ from vsa_kernel import map_to_index, triton_block_sparse_attn_forward
264
+
265
+ query_tiled = _tile(query, geometry, "q")
266
+ key_tiled = _tile(key, geometry, "k")
267
+ value_tiled = _tile(value, geometry, "v")
268
+
269
+ scores = torch.matmul(_pool(query_tiled, geometry), _pool(key_tiled, geometry).transpose(-2, -1))
270
+ scores = scores / math.sqrt(query.shape[-1])
271
+
272
+ mask = _block_mask(scores, geometry)
273
+ q2k_index, q2k_num = map_to_index(mask)
274
+ out_tiled, _ = triton_block_sparse_attn_forward(
275
+ query_tiled,
276
+ key_tiled,
277
+ value_tiled,
278
+ q2k_index,
279
+ q2k_num,
280
+ geometry.variable_block_sizes,
281
+ )
282
+ out = out_tiled.index_select(2, geometry.untile_index)
283
+
284
+ if gate_compress is not None:
285
+ # The compression branch: dense attention over the pooled tiles, broadcast back to every row of its tile and
286
+ # scaled by the trained gate. Applied here rather than on the tile buffer so the gate never needs one.
287
+ pooled = torch.matmul(torch.softmax(scores, dim=-1), _pool(value_tiled, geometry)).to(out.dtype)
288
+ contribution = pooled.index_select(2, geometry.row_tile_index)
289
+ del pooled
290
+ contribution.mul_(gate_compress.transpose(1, 2))
291
+ out.add_(contribution)
292
+ del contribution
293
+
294
+ return out.transpose(1, 2)
295
+
296
+
297
+ # --------------------------------------------------------------------------------------------------------------
298
+ # The processor, and installing it on a loaded transformer
299
+ # --------------------------------------------------------------------------------------------------------------
300
+
301
+ # `forward` fills this in from the layout it was handed; the 50 block processors read it. One request at a time —
302
+ # `@spaces.GPU` serializes them anyway.
303
+ _ACTIVE: dict[str, VSAGeometry | None] = {"geometry": None}
304
+
305
+
306
+ class MiniMaxH3VSAAttnProcessor:
307
+ """`MiniMaxH3AttnProcessor` with `dispatch_attention_fn` replaced by VSA-H3."""
308
+
309
+ _attention_backend = None
310
+ _parallel_config = None
311
+
312
+ def __call__(self, attn, hidden_states, rotary_emb=None, attention_mask=None):
313
+ from diffusers.models.transformers.transformer_minimax_h3 import _apply_rotary_emb
314
+
315
+ geometry = _ACTIVE["geometry"]
316
+
317
+ query = attn.to_q(hidden_states).unflatten(-1, (attn.heads, -1))
318
+ key = attn.to_k(hidden_states).unflatten(-1, (attn.heads, -1))
319
+ value = attn.to_v(hidden_states).unflatten(-1, (attn.heads, -1))
320
+ query = attn.norm_q(query)
321
+ key = attn.norm_k(key)
322
+ if rotary_emb is not None:
323
+ query = _apply_rotary_emb(query, *rotary_emb)
324
+ key = _apply_rotary_emb(key, *rotary_emb)
325
+
326
+ if geometry is None or geometry.seq_len != hidden_states.shape[1]:
327
+ from diffusers.models.attention_dispatch import dispatch_attention_fn
328
+
329
+ out = dispatch_attention_fn(
330
+ query,
331
+ key,
332
+ value,
333
+ attn_mask=attention_mask,
334
+ dropout_p=0.0,
335
+ is_causal=False,
336
+ backend=self._attention_backend,
337
+ parallel_config=self._parallel_config,
338
+ )
339
+ else:
340
+ gate_compress = None
341
+ gate = getattr(attn, "to_gate_compress", None)
342
+ if gate is not None:
343
+ gate_compress = gate(hidden_states).unflatten(-1, (attn.heads, -1))
344
+ out = sparse_attention(query, key, value, gate_compress, geometry)
345
+
346
+ out = out.flatten(2, 3).type_as(query)
347
+ out = attn.to_out[0](out)
348
+ return attn.to_out[1](out)
349
+
350
+
351
+ def add_gate_compress_modules() -> None:
352
+ """Give every block's attention the `to_gate_compress` projection the base `diffusers` port has no use for.
353
+
354
+ The trained gate lives in the checkpoint as 50 `transformer_blocks.*.attn.to_gate_compress.weight` tensors, but
355
+ `MiniMaxH3Attention` does not declare the module, so `from_pretrained` reports them as unexpected and drops them.
356
+ Declaring it before the transformer is instantiated is what makes them load.
357
+ """
358
+ from diffusers.models.transformers import transformer_minimax_h3 as module
359
+
360
+ block_cls = module.MiniMaxH3TransformerBlock
361
+ if getattr(block_cls, "_vsa_gate_patched", False):
362
+ return
363
+ original_init = block_cls.__init__
364
+
365
+ def patched_init(self, hidden_size, num_attention_heads, attention_head_dim, *args, **kwargs):
366
+ original_init(self, hidden_size, num_attention_heads, attention_head_dim, *args, **kwargs)
367
+ self.attn.to_gate_compress = torch.nn.Linear(
368
+ hidden_size, num_attention_heads * attention_head_dim, bias=False
369
+ )
370
+
371
+ block_cls.__init__ = patched_init
372
+ block_cls._vsa_gate_patched = True
373
+
374
+
375
+ def install(transformer, sparsity: float = DEFAULT_SPARSITY) -> tuple[int, int]:
376
+ """Put the VSA processor on the 50 packed-sequence blocks and wrap `forward` to publish the layout.
377
+
378
+ The token refiner has its own block class, over the *text* stream rather than the packed sequence, so it is
379
+ untouched. Returns `(blocks, live gates)`.
380
+ """
381
+ installed = 0
382
+ gates = 0
383
+ for block in transformer.transformer_blocks:
384
+ gate = getattr(block.attn, "to_gate_compress", None)
385
+ if gate is not None and not bool((gate.weight != 0).any()):
386
+ # FastVideo's `_gate_active`: a zero (not-yet-finetuned) gate makes the branch a guaranteed zero — a full
387
+ # GEMM plus a pooled attention per layer for nothing — so drop it rather than pay for it.
388
+ block.attn.to_gate_compress = None
389
+ elif gate is not None:
390
+ gates += 1
391
+ block.attn.set_processor(MiniMaxH3VSAAttnProcessor())
392
+ installed += 1
393
+
394
+ if getattr(transformer, "_vsa_forward_wrapped", False):
395
+ return installed, gates
396
+ original_forward = transformer.forward
397
+
398
+ @functools.wraps(original_forward)
399
+ def forward(*args, **kwargs):
400
+ token_tags = kwargs.get("token_tags")
401
+ position_ids = kwargs.get("position_ids")
402
+ if token_tags is None or position_ids is None:
403
+ # `MiniMaxH3LoopDenoiser` passes the layout by keyword; bind positionally only as a fallback.
404
+ import inspect
405
+
406
+ bound = inspect.signature(original_forward).bind_partial(*args, **kwargs).arguments
407
+ token_tags = token_tags if token_tags is not None else bound.get("token_tags")
408
+ position_ids = position_ids if position_ids is not None else bound.get("position_ids")
409
+ geometry = None
410
+ if token_tags is not None and position_ids is not None:
411
+ geometry = geometry_from_layout(token_tags, position_ids, sparsity)
412
+ _ACTIVE["geometry"] = geometry
413
+ try:
414
+ return original_forward(*args, **kwargs)
415
+ finally:
416
+ _ACTIVE["geometry"] = None
417
+
418
+ transformer.forward = forward
419
+ transformer._vsa_forward_wrapped = True
420
+ return installed, gates
vsa_kernel/__init__.py ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Vendored FastVideo block-sparse attention Triton kernels.
2
+
3
+ Copied verbatim from `fastvideo-kernel` (https://github.com/hao-ai-lab/FastVideo,
4
+ `fastvideo-kernel/python/fastvideo_kernel/triton_kernels/`), Apache License 2.0.
5
+
6
+ Only the two pure-Triton modules are vendored: the package's default route is a
7
+ CUDA extension that has to be compiled per architecture (and whose fastest entry,
8
+ `block_sparse_attn_sm100a`, is GB200-only), while FastVideo's own
9
+ `--vsa-kernel triton` route -- these two files -- runs anywhere Triton does and
10
+ computes exactly the same masked attention.
11
+ """
12
+
13
+ from .block_sparse_attn_triton import triton_block_sparse_attn_forward
14
+ from .index import map_to_index
15
+
16
+ __all__ = ["triton_block_sparse_attn_forward", "map_to_index"]
vsa_kernel/block_sparse_attn_triton.py ADDED
@@ -0,0 +1,879 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Fused Attention
3
+ ===============
4
+
5
+ This is a Triton implementation of the Flash Attention v2 algorithm from Tri Dao
6
+ (https://tridao.me/publications/flash2/flash2.pdf)
7
+
8
+ Credits: OpenAI kernel team
9
+ """
10
+
11
+ import torch
12
+ import triton
13
+ import triton.language as tl
14
+
15
+ # ──────────────────────────── SPARSE ADDITION BEGIN ───────────────────────────
16
+ import math # small utility needed by the sparse wrapper
17
+ # ──────────────────────────── SPARSE ADDITION END ─────────────────────────────
18
+
19
+ # BLOCK_M / BLOCK_N are fixed at 64 because they are structural, not tunable:
20
+ # the kernel indexes the top-k list per BLOCK_M q-tile and addresses keys as
21
+ # kv_idx * BLOCK_N, so both must match the granularity q2k_index and
22
+ # variable_block_sizes were built at.
23
+ #
24
+ # num_stages / num_warps ARE free, and the previous {3, 4, 7} was inherited from
25
+ # the upstream tutorial rather than tuned here. It skips 5 and 6; on Blackwell
26
+ # (sm_121) the optimum is num_stages=5, so the search could not reach it. Both
27
+ # block paths independently select 5 once it is available. Autotune still picks
28
+ # per architecture, so other GPUs re-tune rather than inheriting this choice.
29
+ #
30
+ # VENDORING NOTE (the only edit made to this file): Triton re-benchmarks the sweep below on every new `N_CTX_Q` --
31
+ # i.e. on every canvas and duration a visitor picks, and again in each fresh ZeroGPU worker -- and at these sequence
32
+ # lengths one bench round costs seconds of the visitor's GPU quota. Triton skips benchmarking altogether when a
33
+ # single config is offered, so this pins the point the comment above says the search lands on for Blackwell.
34
+ # `H3_VSA_AUTOTUNE=1` restores the upstream sweep.
35
+ import os as _os
36
+
37
+ if _os.environ.get("H3_VSA_AUTOTUNE", "0") == "1":
38
+ configs = [
39
+ triton.Config({'BLOCK_M': BM, 'BLOCK_N': BN}, num_stages=s, num_warps=w) \
40
+ for BM in [64]\
41
+ for BN in [64]\
42
+ for s in [2, 3, 4, 5, 6, 7]\
43
+ for w in [4, 8]\
44
+ ]
45
+ else:
46
+ configs = [
47
+ triton.Config({
48
+ 'BLOCK_M': 64,
49
+ 'BLOCK_N': 64
50
+ },
51
+ num_stages=int(_os.environ.get("H3_VSA_STAGES", "5")),
52
+ num_warps=int(_os.environ.get("H3_VSA_WARPS", "4")))
53
+ ]
54
+
55
+
56
+ # ──────────────────────────── SPARSE ADDITION BEGIN ───────────────────────────
57
+ @triton.autotune(configs, key=["N_CTX_Q", "HEAD_DIM"])
58
+ @triton.jit
59
+ def _attn_fwd_sparse(
60
+ Q,
61
+ K,
62
+ V,
63
+ sm_scale, #
64
+ q2k_index,
65
+ q2k_num,
66
+ max_kv_blks, #
67
+ variable_block_sizes,
68
+ M,
69
+ Out, #
70
+ stride_qz,
71
+ stride_qh,
72
+ stride_qm,
73
+ stride_qk,
74
+ stride_kz,
75
+ stride_kh,
76
+ stride_kn,
77
+ stride_kk,
78
+ stride_vz,
79
+ stride_vh,
80
+ stride_vk,
81
+ stride_vn,
82
+ stride_oz,
83
+ stride_oh,
84
+ stride_om,
85
+ stride_on,
86
+ Z,
87
+ H,
88
+ N_CTX_Q, #
89
+ N_CTX_KV, #
90
+ HEAD_DIM: tl.constexpr, #
91
+ BLOCK_M: tl.constexpr,
92
+ BLOCK_N: tl.constexpr,
93
+ STAGE: tl.constexpr):
94
+ """
95
+ 64×64 **block-sparse** forward kernel. Back-prop kernels remain dense
96
+ (32×64 and 64×32) – memory footprint unchanged.
97
+ """
98
+
99
+ # ----- program-id mapping -----
100
+ q_blk = tl.program_id(0) # Q-tile index
101
+ off_hz = tl.program_id(1) # fused (batch, head)
102
+ b = off_hz // H
103
+ h = off_hz % H
104
+ q_tiles = N_CTX_Q // BLOCK_M
105
+ meta_base = ((b * H + h) * q_tiles + q_blk)
106
+
107
+ kv_blocks = tl.load(q2k_num + meta_base) # int32
108
+ kv_ptr = q2k_index + meta_base * max_kv_blks # ptr to list
109
+
110
+ # ----- base pointers -----
111
+ # Note: when q and kv have different sequence lengths, their per-(batch,head)
112
+ # strides differ, so we must compute separate base offsets.
113
+ q_off = (b.to(tl.int64) * stride_qz + h.to(tl.int64) * stride_qh)
114
+ k_off = (b.to(tl.int64) * stride_kz + h.to(tl.int64) * stride_kh)
115
+ v_off = (b.to(tl.int64) * stride_vz + h.to(tl.int64) * stride_vh)
116
+ o_off = (b.to(tl.int64) * stride_oz + h.to(tl.int64) * stride_oh)
117
+
118
+ Q_ptr = tl.make_block_ptr(base=Q + q_off,
119
+ shape=(N_CTX_Q, HEAD_DIM),
120
+ strides=(stride_qm, stride_qk),
121
+ offsets=(q_blk * BLOCK_M, 0),
122
+ block_shape=(BLOCK_M, HEAD_DIM),
123
+ order=(1, 0))
124
+
125
+ K_base = tl.make_block_ptr(base=K + k_off,
126
+ shape=(HEAD_DIM, N_CTX_KV),
127
+ strides=(stride_kk, stride_kn),
128
+ offsets=(0, 0),
129
+ block_shape=(HEAD_DIM, BLOCK_N),
130
+ order=(0, 1))
131
+
132
+ v_order: tl.constexpr = (0, 1) if V.dtype.element_ty == tl.float8e5 else (1, 0)
133
+ V_base = tl.make_block_ptr(base=V + v_off,
134
+ shape=(N_CTX_KV, HEAD_DIM),
135
+ strides=(stride_vk, stride_vn),
136
+ offsets=(0, 0),
137
+ block_shape=(BLOCK_N, HEAD_DIM),
138
+ order=v_order)
139
+
140
+ O_ptr = tl.make_block_ptr(base=Out + o_off,
141
+ shape=(N_CTX_Q, HEAD_DIM),
142
+ strides=(stride_om, stride_on),
143
+ offsets=(q_blk * BLOCK_M, 0),
144
+ block_shape=(BLOCK_M, HEAD_DIM),
145
+ order=(1, 0))
146
+
147
+ # ----- accumulators -----
148
+ offs_m = q_blk * BLOCK_M + tl.arange(0, BLOCK_M)
149
+ m_i = tl.full([BLOCK_M], -float("inf"), tl.float32)
150
+ l_i = tl.zeros([BLOCK_M], dtype=tl.float32) + 1.0
151
+ acc = tl.zeros([BLOCK_M, HEAD_DIM], dtype=tl.float32)
152
+ qk_scale = sm_scale * 1.44269504 # 1/ln2
153
+ q = tl.load(Q_ptr)
154
+
155
+ # ----- sparse loop over valid K/V tiles -----
156
+ for i in range(0, kv_blocks):
157
+ kv_idx = tl.load(kv_ptr + i).to(tl.int32)
158
+ block_size = tl.load(variable_block_sizes + kv_idx)
159
+ K_ptr = tl.advance(K_base, (0, kv_idx * BLOCK_N))
160
+ V_ptr = tl.advance(V_base, (kv_idx * BLOCK_N, 0))
161
+
162
+ k = tl.load(K_ptr)
163
+ qk = tl.dot(q, k)
164
+ # mask out invalid columns
165
+ mask = tl.arange(0, BLOCK_N) < block_size
166
+ qk = tl.where(mask[None, :], qk, -float("inf"))
167
+
168
+ m_ij = tl.maximum(m_i, tl.max(qk, 1) * qk_scale)
169
+ p = tl.math.exp2(qk * qk_scale - m_ij[:, None])
170
+ l_ij = tl.sum(p, 1)
171
+
172
+ alpha = tl.math.exp2(m_i - m_ij)
173
+ l_i = l_i * alpha + l_ij
174
+ acc = acc * alpha[:, None]
175
+
176
+ v = tl.load(V_ptr)
177
+ acc = tl.dot(p.to(tl.bfloat16), v, acc)
178
+ m_i = m_ij
179
+
180
+ # ----- epilogue -----
181
+ m_i += tl.math.log2(l_i)
182
+ acc = acc / l_i[:, None]
183
+ tl.store(M + off_hz * N_CTX_Q + offs_m, m_i)
184
+ tl.store(O_ptr, acc.to(Out.type.element_ty))
185
+
186
+
187
+ # ──────────────────────────── SPARSE ADDITION END ─────────────────────────────
188
+
189
+
190
+ @triton.jit
191
+ def _attn_bwd_preprocess(
192
+ O,
193
+ DO, #
194
+ Delta, #
195
+ Z,
196
+ H,
197
+ N_CTX, #
198
+ BLOCK_M: tl.constexpr,
199
+ HEAD_DIM: tl.constexpr #
200
+ ):
201
+ off_m = tl.program_id(0) * BLOCK_M + tl.arange(0, BLOCK_M)
202
+ off_hz = tl.program_id(1)
203
+ off_n = tl.arange(0, HEAD_DIM)
204
+ # load
205
+ o = tl.load(O + off_hz * HEAD_DIM * N_CTX + off_m[:, None] * HEAD_DIM + off_n[None, :])
206
+ do = tl.load(DO + off_hz * HEAD_DIM * N_CTX + off_m[:, None] * HEAD_DIM + off_n[None, :]).to(tl.float32)
207
+ delta = tl.sum(o * do, axis=1)
208
+ # write-back
209
+ tl.store(Delta + off_hz * N_CTX + off_m, delta)
210
+
211
+
212
+ # The main inner-loop logic for computing dK and dV.
213
+ @triton.jit
214
+ def _attn_bwd_dkdv(
215
+ dk,
216
+ dv, #
217
+ Q,
218
+ k,
219
+ v,
220
+ sm_scale, #
221
+ DO, #
222
+ M,
223
+ D, #
224
+ k2q_index,
225
+ k2q_num,
226
+ max_q_blks,
227
+ variable_block_sizes,
228
+ # shared by Q/K/V/DO.
229
+ stride_tok,
230
+ stride_d, #
231
+ H,
232
+ N_CTX_KV,
233
+ BLOCK_M1: tl.constexpr, #
234
+ BLOCK_N1: tl.constexpr, #
235
+ HEAD_DIM: tl.constexpr, #
236
+ # Filled in by the wrapper.
237
+ start_n,
238
+ start_m,
239
+ num_steps):
240
+ offs_m = start_m + tl.arange(0, BLOCK_M1)
241
+ offs_n = start_n + tl.arange(0, BLOCK_N1)
242
+ offs_k = tl.arange(0, HEAD_DIM)
243
+ qT_ptrs = Q + offs_m[None, :] * stride_tok + offs_k[:, None] * stride_d
244
+ do_ptrs = DO + offs_m[:, None] * stride_tok + offs_k[None, :] * stride_d
245
+ # BLOCK_N1 must be a multiple of BLOCK_M1, otherwise the code wouldn't work.
246
+ tl.static_assert(BLOCK_N1 % BLOCK_M1 == 0)
247
+ step_m = BLOCK_M1
248
+ kv_blk = tl.program_id(0) # Q-tile index
249
+ off_hz = tl.program_id(2) # fused (batch, head)
250
+ b = off_hz // H
251
+ h = off_hz % H
252
+ kv_tiles = N_CTX_KV // BLOCK_N1
253
+ meta_base = ((b * H + h) * kv_tiles + kv_blk)
254
+
255
+ q_blocks = tl.load(k2q_num + meta_base) # int32
256
+ q_ptr = k2q_index + meta_base * max_q_blks # ptr to list
257
+ block_size = tl.load(variable_block_sizes + kv_blk)
258
+
259
+ for blk_idx in range(q_blocks * 2):
260
+ block_sparse_offset = (tl.load(q_ptr + blk_idx // 2).to(tl.int32) * 2 + blk_idx % 2) * step_m
261
+ qT = tl.load(qT_ptrs + block_sparse_offset * stride_tok)
262
+ # Load m before computing qk to reduce pipeline stall.
263
+ offs_m = start_m + block_sparse_offset + tl.arange(0, BLOCK_M1)
264
+ m = tl.load(M + offs_m)
265
+ # Recompute logits exactly as the forward does: raw bf16 operands into
266
+ # the dot, fp32 scale after accumulation. A bf16 pre-scaled K perturbs
267
+ # the recomputed logits relative to the saved M by an error
268
+ # proportional to |logit|, which exp2 amplifies into arbitrarily wrong
269
+ # probabilities at large activations.
270
+ qkT = tl.dot(k, qT) * (sm_scale * 1.4426950408889634)
271
+ pT = tl.math.exp2(qkT - m[None, :])
272
+ mask = tl.arange(0, BLOCK_N1) < block_size
273
+ pT = tl.where(mask[:, None], pT, 0.0)
274
+
275
+ do = tl.load(do_ptrs + block_sparse_offset * stride_tok)
276
+ # Compute dV.
277
+ ppT = pT
278
+ ppT = ppT.to(tl.bfloat16)
279
+ dv += tl.dot(ppT, do)
280
+ # D (= delta) is pre-divided by ds_scale.
281
+ Di = tl.load(D + offs_m)
282
+ # Compute dP and dS.
283
+ dpT = tl.dot(v, tl.trans(do)).to(tl.float32)
284
+ dsT = pT * (dpT - Di[None, :])
285
+ dsT = dsT.to(tl.bfloat16)
286
+ dk += tl.dot(dsT, tl.trans(qT))
287
+ # Increment pointers.
288
+ return dk, dv
289
+
290
+
291
+ # the main inner-loop logic for computing dQ
292
+ @triton.jit
293
+ def _attn_bwd_dq(
294
+ dq,
295
+ q,
296
+ K,
297
+ V, #
298
+ do,
299
+ m,
300
+ D,
301
+ sm_scale,
302
+ # shared by Q/K/V/DO.
303
+ q2k_index,
304
+ q2k_num,
305
+ max_kv_blks,
306
+ variable_block_sizes,
307
+ stride_tok,
308
+ stride_d, #
309
+ H,
310
+ N_CTX, #
311
+ BLOCK_M2: tl.constexpr, #
312
+ BLOCK_N2: tl.constexpr, #
313
+ HEAD_DIM: tl.constexpr,
314
+ # Filled in by the wrapper.
315
+ start_m,
316
+ start_n,
317
+ num_steps):
318
+ offs_m = start_m + tl.arange(0, BLOCK_M2)
319
+ offs_n = start_n + tl.arange(0, BLOCK_N2)
320
+ offs_k = tl.arange(0, HEAD_DIM)
321
+ kT_ptrs = K + offs_n[None, :] * stride_tok + offs_k[:, None] * stride_d
322
+ vT_ptrs = V + offs_n[None, :] * stride_tok + offs_k[:, None] * stride_d
323
+ # D (= delta) is pre-divided by ds_scale.
324
+ Di = tl.load(D + offs_m)
325
+ # BLOCK_M2 must be a multiple of BLOCK_N2, otherwise the code wouldn't work.
326
+ tl.static_assert(BLOCK_M2 % BLOCK_N2 == 0)
327
+ step_n = BLOCK_N2
328
+
329
+ q_blk = tl.program_id(0) # Q-tile index
330
+ off_hz = tl.program_id(2) # fused (batch, head)
331
+ b = off_hz // H
332
+ h = off_hz % H
333
+ q_tiles = N_CTX // BLOCK_M2
334
+ meta_base = ((b * H + h) * q_tiles + q_blk)
335
+
336
+ kv_blocks = tl.load(q2k_num + meta_base) # int32
337
+ kv_ptr = q2k_index + meta_base * max_kv_blks # ptr to list
338
+
339
+ for blk_idx in range(kv_blocks * 2):
340
+ kv_idx = tl.load(kv_ptr + blk_idx // 2).to(tl.int32)
341
+ # variable_block_sizes is defined per KV block (tile). Mask must therefore
342
+ # use kv_idx (not q_blk). Also, because we split each 64-token block into
343
+ # two 32-token halves, the mask must account for the half-block offset.
344
+ block_size = tl.load(variable_block_sizes + kv_idx).to(tl.int32)
345
+ half = (blk_idx % 2).to(tl.int32)
346
+ block_sparse_offset = (kv_idx * 2 + half) * step_n * stride_tok
347
+ kT = tl.load(kT_ptrs + block_sparse_offset)
348
+ vT = tl.load(vT_ptrs + block_sparse_offset)
349
+ qk = tl.dot(q, kT) * (sm_scale * 1.4426950408889634)
350
+ p = tl.math.exp2(qk - m)
351
+ offs_in_block = half * step_n + tl.arange(0, BLOCK_N2)
352
+ mask = offs_in_block < block_size
353
+ p = tl.where(mask[None, :], p, 0.0)
354
+ # Compute dP and dS.
355
+ dp = tl.dot(do, vT).to(tl.float32)
356
+ ds = p * (dp - Di[:, None])
357
+ ds = ds.to(tl.bfloat16)
358
+ # Compute dQ (kT is raw; the caller applies sm_scale once at the end).
359
+ dq += tl.dot(ds, tl.trans(kT))
360
+ # Increment pointers.
361
+ return dq
362
+
363
+
364
+ @triton.jit
365
+ def _attn_bwd(
366
+ Q,
367
+ K,
368
+ V,
369
+ sm_scale, #
370
+ DO, #
371
+ DQ,
372
+ DK,
373
+ DV, #
374
+ M,
375
+ D,
376
+ q2k_index,
377
+ q2k_num,
378
+ max_kv_blks,
379
+ k2q_index,
380
+ k2q_num,
381
+ max_q_blks,
382
+ variable_block_sizes,
383
+ # shared by Q/K/V/DO.
384
+ stride_z,
385
+ stride_h,
386
+ stride_tok,
387
+ stride_d, #
388
+ H,
389
+ N_CTX, #
390
+ BLOCK_M1: tl.constexpr, #
391
+ BLOCK_N1: tl.constexpr, #
392
+ BLOCK_M2: tl.constexpr, #
393
+ BLOCK_N2: tl.constexpr, #
394
+ HEAD_DIM: tl.constexpr):
395
+ LN2 = 0.6931471824645996 # = ln(2)
396
+
397
+ bhid = tl.program_id(2)
398
+ off_chz = (bhid * N_CTX).to(tl.int64)
399
+ adj = (stride_h * (bhid % H) + stride_z * (bhid // H)).to(tl.int64)
400
+ pid = tl.program_id(0)
401
+
402
+ # offset pointers for batch/head
403
+ Q += adj
404
+ K += adj
405
+ V += adj
406
+ DO += adj
407
+ DQ += adj
408
+ DK += adj
409
+ DV += adj
410
+ M += off_chz
411
+ D += off_chz
412
+
413
+ # load scales
414
+ offs_k = tl.arange(0, HEAD_DIM)
415
+
416
+ start_n = pid * BLOCK_N1
417
+ start_m = 0
418
+
419
+ offs_n = start_n + tl.arange(0, BLOCK_N1)
420
+
421
+ dv = tl.zeros([BLOCK_N1, HEAD_DIM], dtype=tl.float32)
422
+ dk = tl.zeros([BLOCK_N1, HEAD_DIM], dtype=tl.float32)
423
+
424
+ # load K and V: they stay in SRAM throughout the inner loop.
425
+ k = tl.load(K + offs_n[:, None] * stride_tok + offs_k[None, :] * stride_d)
426
+ v = tl.load(V + offs_n[:, None] * stride_tok + offs_k[None, :] * stride_d)
427
+
428
+ num_steps = N_CTX // BLOCK_M1
429
+
430
+ dk, dv = _attn_bwd_dkdv( #
431
+ dk,
432
+ dv, #
433
+ Q,
434
+ k,
435
+ v,
436
+ sm_scale, #
437
+ DO, #
438
+ M,
439
+ D, #
440
+ k2q_index,
441
+ k2q_num,
442
+ max_q_blks,
443
+ variable_block_sizes,
444
+ stride_tok,
445
+ stride_d, #
446
+ H,
447
+ N_CTX, #
448
+ BLOCK_M1,
449
+ BLOCK_N1,
450
+ HEAD_DIM, #
451
+ start_n,
452
+ start_m,
453
+ num_steps #
454
+ )
455
+
456
+ dv_ptrs = DV + offs_n[:, None] * stride_tok + offs_k[None, :] * stride_d
457
+ tl.store(dv_ptrs, dv)
458
+
459
+ # Write back dK.
460
+ dk *= sm_scale
461
+ dk_ptrs = DK + offs_n[:, None] * stride_tok + offs_k[None, :] * stride_d
462
+ tl.store(dk_ptrs, dk)
463
+
464
+ # THIS BLOCK DOES DQ:
465
+ start_m = pid * BLOCK_M2
466
+ end_n = 0
467
+
468
+ offs_m = start_m + tl.arange(0, BLOCK_M2)
469
+
470
+ q = tl.load(Q + offs_m[:, None] * stride_tok + offs_k[None, :] * stride_d)
471
+ dq = tl.zeros([BLOCK_M2, HEAD_DIM], dtype=tl.float32)
472
+ do = tl.load(DO + offs_m[:, None] * stride_tok + offs_k[None, :] * stride_d)
473
+
474
+ m = tl.load(M + offs_m)
475
+ m = m[:, None]
476
+
477
+ num_steps = N_CTX // BLOCK_N2
478
+ dq = _attn_bwd_dq(
479
+ dq,
480
+ q,
481
+ K,
482
+ V, #
483
+ do,
484
+ m,
485
+ D, #
486
+ sm_scale,
487
+ q2k_index,
488
+ q2k_num,
489
+ max_kv_blks,
490
+ variable_block_sizes,
491
+ stride_tok,
492
+ stride_d, #
493
+ H,
494
+ N_CTX, #
495
+ BLOCK_M2,
496
+ BLOCK_N2,
497
+ HEAD_DIM, #
498
+ start_m,
499
+ end_n,
500
+ num_steps #
501
+ )
502
+ # Write back dQ.
503
+ dq_ptrs = DQ + offs_m[:, None] * stride_tok + offs_k[None, :] * stride_d
504
+ dq *= sm_scale
505
+ tl.store(dq_ptrs, dq)
506
+
507
+
508
+ @triton.jit
509
+ def _attn_bwd_dkdv_kernel(
510
+ Q,
511
+ K,
512
+ V,
513
+ sm_scale, #
514
+ DO, #
515
+ DK,
516
+ DV, #
517
+ M,
518
+ D,
519
+ k2q_index,
520
+ k2q_num,
521
+ max_q_blks,
522
+ variable_block_sizes,
523
+ # shared token/dim strides (assumed contiguous along token and dim)
524
+ stride_tok,
525
+ stride_d, #
526
+ # batch/head strides (may differ between Q and KV)
527
+ stride_qz,
528
+ stride_qh,
529
+ stride_kz,
530
+ stride_kh,
531
+ stride_vz,
532
+ stride_vh,
533
+ stride_doz,
534
+ stride_doh,
535
+ stride_dkz,
536
+ stride_dkh,
537
+ stride_dvz,
538
+ stride_dvh,
539
+ H,
540
+ N_CTX_Q,
541
+ N_CTX_KV,
542
+ BLOCK_M1: tl.constexpr, #
543
+ BLOCK_N1: tl.constexpr, #
544
+ HEAD_DIM: tl.constexpr):
545
+ """
546
+ Backward kernel that computes dK and dV for each KV block (64 tokens).
547
+ Grid:
548
+ pid0: kv_blk in [0, N_CTX_KV/BLOCK_N1)
549
+ pid2: fused (batch, head) in [0, B*H)
550
+ """
551
+ bhid = tl.program_id(2)
552
+ b = bhid // H
553
+ h = bhid % H
554
+ kv_blk = tl.program_id(0)
555
+
556
+ q_adj = (b.to(tl.int64) * stride_qz + h.to(tl.int64) * stride_qh)
557
+ kv_adj_k = (b.to(tl.int64) * stride_kz + h.to(tl.int64) * stride_kh)
558
+ kv_adj_v = (b.to(tl.int64) * stride_vz + h.to(tl.int64) * stride_vh)
559
+ do_adj = (b.to(tl.int64) * stride_doz + h.to(tl.int64) * stride_doh)
560
+ dk_adj = (b.to(tl.int64) * stride_dkz + h.to(tl.int64) * stride_dkh)
561
+ dv_adj = (b.to(tl.int64) * stride_dvz + h.to(tl.int64) * stride_dvh)
562
+
563
+ Q = Q + q_adj
564
+ K = K + kv_adj_k
565
+ V = V + kv_adj_v
566
+ DO = DO + do_adj
567
+ DK = DK + dk_adj
568
+ DV = DV + dv_adj
569
+
570
+ # M and D (delta) are always sized by Q length.
571
+ M = M + (bhid * N_CTX_Q).to(tl.int64)
572
+ D = D + (bhid * N_CTX_Q).to(tl.int64)
573
+
574
+ offs_k = tl.arange(0, HEAD_DIM)
575
+ start_n = kv_blk * BLOCK_N1
576
+ offs_n = start_n + tl.arange(0, BLOCK_N1)
577
+
578
+ # load K and V: they stay in SRAM throughout the inner loop.
579
+ k = tl.load(K + offs_n[:, None] * stride_tok + offs_k[None, :] * stride_d)
580
+ v = tl.load(V + offs_n[:, None] * stride_tok + offs_k[None, :] * stride_d)
581
+
582
+ dv_acc = tl.zeros([BLOCK_N1, HEAD_DIM], dtype=tl.float32)
583
+ dk_acc = tl.zeros([BLOCK_N1, HEAD_DIM], dtype=tl.float32)
584
+
585
+ num_steps = N_CTX_Q // BLOCK_M1
586
+ dk_acc, dv_acc = _attn_bwd_dkdv(
587
+ dk_acc,
588
+ dv_acc,
589
+ Q,
590
+ k,
591
+ v,
592
+ sm_scale,
593
+ DO,
594
+ M,
595
+ D,
596
+ k2q_index,
597
+ k2q_num,
598
+ max_q_blks,
599
+ variable_block_sizes,
600
+ stride_tok,
601
+ stride_d,
602
+ H,
603
+ N_CTX_KV,
604
+ BLOCK_M1=BLOCK_M1,
605
+ BLOCK_N1=BLOCK_N1,
606
+ HEAD_DIM=HEAD_DIM,
607
+ start_n=start_n,
608
+ start_m=0,
609
+ num_steps=num_steps,
610
+ )
611
+
612
+ dv_ptrs = DV + offs_n[:, None] * stride_tok + offs_k[None, :] * stride_d
613
+ tl.store(dv_ptrs, dv_acc)
614
+
615
+ dk_acc *= sm_scale
616
+ dk_ptrs = DK + offs_n[:, None] * stride_tok + offs_k[None, :] * stride_d
617
+ tl.store(dk_ptrs, dk_acc)
618
+
619
+
620
+ @triton.jit
621
+ def _attn_bwd_dq_kernel(
622
+ Q,
623
+ K,
624
+ V,
625
+ sm_scale,
626
+ DO, #
627
+ DQ,
628
+ M,
629
+ D,
630
+ q2k_index,
631
+ q2k_num,
632
+ max_kv_blks,
633
+ variable_block_sizes,
634
+ # shared token/dim strides (assumed contiguous along token and dim)
635
+ stride_tok,
636
+ stride_d, #
637
+ # batch/head strides (may differ between Q and KV)
638
+ stride_qz,
639
+ stride_qh,
640
+ stride_kz,
641
+ stride_kh,
642
+ stride_vz,
643
+ stride_vh,
644
+ stride_doz,
645
+ stride_doh,
646
+ stride_dqz,
647
+ stride_dqh,
648
+ H,
649
+ N_CTX_Q,
650
+ BLOCK_M2: tl.constexpr, #
651
+ BLOCK_N2: tl.constexpr, #
652
+ HEAD_DIM: tl.constexpr):
653
+ """
654
+ Backward kernel that computes dQ for each Q block (64 tokens).
655
+ Grid:
656
+ pid0: q_blk in [0, N_CTX_Q/BLOCK_M2)
657
+ pid2: fused (batch, head) in [0, B*H)
658
+ """
659
+ LN2 = 0.6931471824645996 # = ln(2)
660
+ bhid = tl.program_id(2)
661
+ b = bhid // H
662
+ h = bhid % H
663
+ q_blk = tl.program_id(0)
664
+
665
+ q_adj = (b.to(tl.int64) * stride_qz + h.to(tl.int64) * stride_qh)
666
+ kv_adj_k = (b.to(tl.int64) * stride_kz + h.to(tl.int64) * stride_kh)
667
+ kv_adj_v = (b.to(tl.int64) * stride_vz + h.to(tl.int64) * stride_vh)
668
+ do_adj = (b.to(tl.int64) * stride_doz + h.to(tl.int64) * stride_doh)
669
+ dq_adj = (b.to(tl.int64) * stride_dqz + h.to(tl.int64) * stride_dqh)
670
+
671
+ Q = Q + q_adj
672
+ K = K + kv_adj_k
673
+ V = V + kv_adj_v
674
+ DO = DO + do_adj
675
+ DQ = DQ + dq_adj
676
+
677
+ M = M + (bhid * N_CTX_Q).to(tl.int64)
678
+ D = D + (bhid * N_CTX_Q).to(tl.int64)
679
+
680
+ offs_k = tl.arange(0, HEAD_DIM)
681
+ start_m = q_blk * BLOCK_M2
682
+ offs_m = start_m + tl.arange(0, BLOCK_M2)
683
+
684
+ q = tl.load(Q + offs_m[:, None] * stride_tok + offs_k[None, :] * stride_d)
685
+ do = tl.load(DO + offs_m[:, None] * stride_tok + offs_k[None, :] * stride_d)
686
+ m = tl.load(M + offs_m)[:, None]
687
+
688
+ dq_acc = tl.zeros([BLOCK_M2, HEAD_DIM], dtype=tl.float32)
689
+ num_steps = 0 # unused in _attn_bwd_dq
690
+ dq_acc = _attn_bwd_dq(
691
+ dq_acc,
692
+ q,
693
+ K,
694
+ V,
695
+ do,
696
+ m,
697
+ D,
698
+ sm_scale,
699
+ q2k_index,
700
+ q2k_num,
701
+ max_kv_blks,
702
+ variable_block_sizes,
703
+ stride_tok,
704
+ stride_d,
705
+ H,
706
+ N_CTX_Q,
707
+ BLOCK_M2=BLOCK_M2,
708
+ BLOCK_N2=BLOCK_N2,
709
+ HEAD_DIM=HEAD_DIM,
710
+ start_m=start_m,
711
+ start_n=0,
712
+ num_steps=num_steps,
713
+ )
714
+
715
+ dq_ptrs = DQ + offs_m[:, None] * stride_tok + offs_k[None, :] * stride_d
716
+ dq_acc *= sm_scale
717
+ tl.store(dq_ptrs, dq_acc)
718
+
719
+
720
+ # ──────────────────────────── SPARSE ADDITION BEGIN ───────────────────────────
721
+ def triton_block_sparse_attn_forward(q, k, v, q2k_index, q2k_num, variable_block_sizes):
722
+ B, H, Tq, D = q.shape
723
+ Tkv = k.shape[2]
724
+ sm_scale = 1.0 / math.sqrt(D)
725
+ max_kv_blks = q2k_index.shape[-1]
726
+ assert Tq % 64 == 0, f"q length must be a multiple of 64, but got {Tq}"
727
+ assert Tkv % 64 == 0, f"kv length must be a multiple of 64, but got {Tkv}"
728
+ assert q2k_num.shape[
729
+ -1] == Tq // 64, f"shape mismatch, Tq // 64 = {Tq // 64}, q2k_num.shape[-2] = {q2k_num.shape[-2]}"
730
+ assert variable_block_sizes.numel() == Tkv // 64, (
731
+ f"shape mismatch, variable_block_sizes must have length {Tkv // 64}, "
732
+ f"got {variable_block_sizes.numel()}")
733
+ o = torch.empty_like(q)
734
+ M = torch.empty((B, H, Tq), dtype=torch.float32, device=q.device)
735
+
736
+ grid = lambda _: (triton.cdiv(Tq, 64), B * H, 1)
737
+ _attn_fwd_sparse[grid](q,
738
+ k,
739
+ v,
740
+ sm_scale,
741
+ q2k_index,
742
+ q2k_num,
743
+ max_kv_blks,
744
+ variable_block_sizes,
745
+ M,
746
+ o,
747
+ q.stride(0),
748
+ q.stride(1),
749
+ q.stride(2),
750
+ q.stride(3),
751
+ k.stride(0),
752
+ k.stride(1),
753
+ k.stride(2),
754
+ k.stride(3),
755
+ v.stride(0),
756
+ v.stride(1),
757
+ v.stride(2),
758
+ v.stride(3),
759
+ o.stride(0),
760
+ o.stride(1),
761
+ o.stride(2),
762
+ o.stride(3),
763
+ B,
764
+ H,
765
+ Tq,
766
+ Tkv,
767
+ HEAD_DIM=D,
768
+ STAGE=3)
769
+
770
+ return o, M
771
+
772
+
773
+ def triton_block_sparse_attn_backward(do, q, k, v, o, M, q2k_index, q2k_num, k2q_index, k2q_num, variable_block_sizes):
774
+ assert do.is_contiguous()
775
+
776
+ B, H, Tq, D = q.shape
777
+ Tkv = k.shape[2]
778
+ sm_scale = 1.0 / math.sqrt(D)
779
+ dq = torch.empty_like(q)
780
+ dk = torch.empty_like(k)
781
+ dv = torch.empty_like(v)
782
+ BATCH, N_HEAD = q.shape[:2]
783
+ BLOCK_M1, BLOCK_N1, BLOCK_M2, BLOCK_N2 = 32, 64, 64, 32
784
+ # K stays raw: the backward kernels apply sm_scale in fp32 after the dot,
785
+ # matching the forward's rounding exactly. (A bf16 pre-scaled K perturbs
786
+ # the recomputed logits vs the saved M; exp2 turns that into unboundedly
787
+ # wrong probabilities at large activations.)
788
+ arg_k = k
789
+ PRE_BLOCK = 64
790
+ assert Tq % PRE_BLOCK == 0
791
+ pre_grid = (Tq // PRE_BLOCK, BATCH * N_HEAD)
792
+ delta = torch.empty_like(M)
793
+ _attn_bwd_preprocess[pre_grid](
794
+ o,
795
+ do, #
796
+ delta, #
797
+ BATCH,
798
+ N_HEAD,
799
+ Tq, #
800
+ BLOCK_M=PRE_BLOCK,
801
+ HEAD_DIM=D #
802
+ )
803
+
804
+ max_q_blks = k2q_index.shape[-1]
805
+ max_kv_blks = q2k_index.shape[-1]
806
+
807
+ # dK/dV kernel: grid over KV blocks
808
+ grid_kv = (Tkv // BLOCK_N1, 1, BATCH * N_HEAD)
809
+ _attn_bwd_dkdv_kernel[grid_kv](
810
+ q,
811
+ arg_k,
812
+ v,
813
+ sm_scale,
814
+ do,
815
+ dk,
816
+ dv,
817
+ M,
818
+ delta,
819
+ k2q_index,
820
+ k2q_num,
821
+ max_q_blks,
822
+ variable_block_sizes,
823
+ q.stride(2),
824
+ q.stride(3),
825
+ q.stride(0),
826
+ q.stride(1),
827
+ arg_k.stride(0),
828
+ arg_k.stride(1),
829
+ v.stride(0),
830
+ v.stride(1),
831
+ do.stride(0),
832
+ do.stride(1),
833
+ dk.stride(0),
834
+ dk.stride(1),
835
+ dv.stride(0),
836
+ dv.stride(1),
837
+ N_HEAD,
838
+ Tq,
839
+ Tkv,
840
+ BLOCK_M1=BLOCK_M1,
841
+ BLOCK_N1=BLOCK_N1,
842
+ HEAD_DIM=D,
843
+ )
844
+
845
+ # dQ kernel: grid over Q blocks
846
+ grid_q = (Tq // BLOCK_M2, 1, BATCH * N_HEAD)
847
+ _attn_bwd_dq_kernel[grid_q](
848
+ q,
849
+ arg_k,
850
+ v,
851
+ sm_scale,
852
+ do,
853
+ dq,
854
+ M,
855
+ delta,
856
+ q2k_index,
857
+ q2k_num,
858
+ max_kv_blks,
859
+ variable_block_sizes,
860
+ q.stride(2),
861
+ q.stride(3),
862
+ q.stride(0),
863
+ q.stride(1),
864
+ arg_k.stride(0),
865
+ arg_k.stride(1),
866
+ v.stride(0),
867
+ v.stride(1),
868
+ do.stride(0),
869
+ do.stride(1),
870
+ dq.stride(0),
871
+ dq.stride(1),
872
+ N_HEAD,
873
+ Tq,
874
+ BLOCK_M2=BLOCK_M2,
875
+ BLOCK_N2=BLOCK_N2,
876
+ HEAD_DIM=D,
877
+ )
878
+
879
+ return dq, dk, dv
vsa_kernel/index.py ADDED
@@ -0,0 +1,250 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ## pytorch sdpa version of block sparse ##
2
+ from typing import Tuple
3
+
4
+ import triton
5
+ import triton.language as tl
6
+ import torch
7
+
8
+
9
+ @triton.jit
10
+ def topk_index_to_map_kernel(
11
+ map_ptr,
12
+ index_ptr,
13
+ map_bs_stride,
14
+ map_h_stride,
15
+ map_q_stride,
16
+ map_kv_stride,
17
+ index_bs_stride,
18
+ index_h_stride,
19
+ index_q_stride,
20
+ index_kv_stride,
21
+ topk,
22
+ ):
23
+ b, h, q = tl.program_id(0), tl.program_id(1), tl.program_id(2)
24
+ index_ptr_base = index_ptr + b * index_bs_stride + h * index_h_stride + q * index_q_stride
25
+ map_ptr_base = map_ptr + b * map_bs_stride + h * map_h_stride + q * map_q_stride
26
+
27
+ for i in tl.static_range(topk):
28
+ index = tl.load(index_ptr_base + i * index_kv_stride)
29
+ tl.store(map_ptr_base + index * map_kv_stride, 1.0)
30
+
31
+
32
+ @triton.jit
33
+ def map_to_index_kernel(
34
+ map_ptr,
35
+ index_ptr,
36
+ index_num_ptr,
37
+ map_bs_stride,
38
+ map_h_stride,
39
+ map_q_stride,
40
+ map_kv_stride,
41
+ index_bs_stride,
42
+ index_h_stride,
43
+ index_q_stride,
44
+ index_kv_stride,
45
+ index_num_bs_stride,
46
+ index_num_h_stride,
47
+ index_num_q_stride,
48
+ num_kv_blocks,
49
+ ):
50
+ b, h, q = tl.program_id(0), tl.program_id(1), tl.program_id(2)
51
+ index_ptr_base = index_ptr + b * index_bs_stride + h * index_h_stride + q * index_q_stride
52
+ map_ptr_base = map_ptr + b * map_bs_stride + h * map_h_stride + q * map_q_stride
53
+
54
+ num = 0
55
+ for i in tl.range(num_kv_blocks):
56
+ map_entry = tl.load(map_ptr_base + i * map_kv_stride)
57
+ if map_entry:
58
+ tl.store(index_ptr_base + num * index_kv_stride, i)
59
+ num += 1
60
+
61
+ tl.store(index_num_ptr + b * index_num_bs_stride + h * index_num_h_stride + q * index_num_q_stride, num)
62
+
63
+
64
+ def topk_index_to_map(index: torch.Tensor, num_kv_blocks: int, transpose_map: bool = False):
65
+ """
66
+ Convert topk indices to a map.
67
+
68
+ Args:
69
+ index: [bs, h, num_q_blocks, topk]
70
+ The topk indices tensor.
71
+ num_kv_blocks: int
72
+ The number of key-value blocks in the block_map returned
73
+ transpose_map: bool
74
+ If True, the block_map will be transposed on the final two dimensions.
75
+
76
+ Returns:
77
+ block_map: [bs, h, num_q_blocks, num_kv_blocks]
78
+ A binary map where 1 indicates that the q block attends to the kv block.
79
+ """
80
+ bs, h, num_q_blocks, topk = index.shape
81
+
82
+ if transpose_map is False:
83
+ block_map = torch.zeros((bs, h, num_q_blocks, num_kv_blocks), dtype=torch.bool, device=index.device)
84
+ else:
85
+ block_map = torch.zeros((bs, h, num_kv_blocks, num_q_blocks), dtype=torch.bool, device=index.device)
86
+ block_map = block_map.transpose(2, 3)
87
+
88
+ grid = (bs, h, num_q_blocks)
89
+ topk_index_to_map_kernel[grid](
90
+ block_map,
91
+ index,
92
+ block_map.stride(0),
93
+ block_map.stride(1),
94
+ block_map.stride(2),
95
+ block_map.stride(3),
96
+ index.stride(0),
97
+ index.stride(1),
98
+ index.stride(2),
99
+ index.stride(3),
100
+ topk=topk,
101
+ )
102
+
103
+ return block_map
104
+
105
+
106
+ def map_to_index(block_map: torch.Tensor):
107
+ """
108
+ Convert a block map to indices and counts.
109
+
110
+ Args:
111
+ block_map: [bs, h, num_q_blocks, num_kv_blocks]
112
+ The block map tensor.
113
+
114
+ Returns:
115
+ index: [bs, h, num_q_blocks, num_kv_blocks]
116
+ The indices of the blocks.
117
+ index_num: [bs, h, num_q_blocks]
118
+ The number of blocks for each q block.
119
+ """
120
+ bs, h, num_q_blocks, num_kv_blocks = block_map.shape
121
+
122
+ index = torch.full((block_map.shape), -1, dtype=torch.int32, device=block_map.device)
123
+ index_num = torch.empty((bs, h, num_q_blocks), dtype=torch.int32, device=block_map.device)
124
+
125
+ grid = (bs, h, num_q_blocks)
126
+ map_to_index_kernel[grid](
127
+ block_map,
128
+ index,
129
+ index_num,
130
+ block_map.stride(0),
131
+ block_map.stride(1),
132
+ block_map.stride(2),
133
+ block_map.stride(3),
134
+ index.stride(0),
135
+ index.stride(1),
136
+ index.stride(2),
137
+ index.stride(3),
138
+ index_num.stride(0),
139
+ index_num.stride(1),
140
+ index_num.stride(2),
141
+ num_kv_blocks=num_kv_blocks,
142
+ )
143
+
144
+ return index, index_num
145
+
146
+
147
+ @triton.jit
148
+ def _invert_indices_kernel(
149
+ q2k_idx_ptr,
150
+ q2k_num_ptr,
151
+ k2q_idx_ptr,
152
+ k2q_num_ptr,
153
+ q2k_idx_b,
154
+ q2k_idx_h,
155
+ q2k_idx_q,
156
+ q2k_idx_k,
157
+ q2k_num_b,
158
+ q2k_num_h,
159
+ q2k_num_q,
160
+ k2q_idx_b,
161
+ k2q_idx_h,
162
+ k2q_idx_k,
163
+ k2q_idx_q,
164
+ k2q_num_b,
165
+ k2q_num_h,
166
+ k2q_num_k,
167
+ MAX_KV_PER_Q: tl.constexpr,
168
+ ):
169
+ # One program per (b, h, q): reserve a slot in k2q via atomicAdd, write q.
170
+ pid_b = tl.program_id(0)
171
+ pid_h = tl.program_id(1)
172
+ pid_q = tl.program_id(2)
173
+
174
+ n = tl.load(q2k_num_ptr + pid_b * q2k_num_b + pid_h * q2k_num_h + pid_q * q2k_num_q)
175
+
176
+ q2k_row = (q2k_idx_ptr + pid_b * q2k_idx_b + pid_h * q2k_idx_h + pid_q * q2k_idx_q)
177
+
178
+ for i in tl.range(0, MAX_KV_PER_Q):
179
+ if i < n:
180
+ kv = tl.load(q2k_row + i * q2k_idx_k)
181
+ count_ptr = (k2q_num_ptr + pid_b * k2q_num_b + pid_h * k2q_num_h + kv * k2q_num_k)
182
+ pos = tl.atomic_add(count_ptr, 1)
183
+ tl.store(
184
+ k2q_idx_ptr + pid_b * k2q_idx_b + pid_h * k2q_idx_h + kv * k2q_idx_k + pos * k2q_idx_q,
185
+ pid_q,
186
+ )
187
+
188
+
189
+ def invert_indices(
190
+ q2k_idx: torch.Tensor,
191
+ q2k_num: torch.Tensor,
192
+ num_kv_blocks: int,
193
+ ) -> Tuple[torch.Tensor, torch.Tensor]:
194
+ """Transpose a Q->KV index list into a K->Q one via atomic compaction (GPU)."""
195
+ if q2k_idx.dim() != 4:
196
+ raise ValueError(f"q2k_idx must be [B, H, Nq, Mk], got shape={tuple(q2k_idx.shape)}")
197
+ if q2k_num.dim() != 3:
198
+ raise ValueError(f"q2k_num must be [B, H, Nq], got shape={tuple(q2k_num.shape)}")
199
+ if not q2k_idx.is_cuda or not q2k_num.is_cuda:
200
+ raise RuntimeError("invert_indices requires CUDA tensors.")
201
+
202
+ B, H, Nq, Mk = q2k_idx.shape
203
+ if q2k_num.shape != (B, H, Nq):
204
+ raise ValueError(f"q2k_num shape {tuple(q2k_num.shape)} does not match q2k_idx "
205
+ f"[B, H, Nq] = {(B, H, Nq)}")
206
+
207
+ q2k_idx = q2k_idx.contiguous()
208
+ q2k_num = q2k_num.contiguous()
209
+ if q2k_idx.dtype != torch.int32:
210
+ q2k_idx = q2k_idx.to(torch.int32)
211
+ if q2k_num.dtype != torch.int32:
212
+ q2k_num = q2k_num.to(torch.int32)
213
+
214
+ # Any KV block is attended by at most Nq Q blocks (one per Q row), so
215
+ # `Nq` is a tight upper bound on the compacted K->Q slots.
216
+ k2q_idx = torch.empty(
217
+ (B, H, num_kv_blocks, Nq),
218
+ dtype=torch.int32,
219
+ device=q2k_idx.device,
220
+ )
221
+ k2q_num = torch.zeros(
222
+ (B, H, num_kv_blocks),
223
+ dtype=torch.int32,
224
+ device=q2k_idx.device,
225
+ )
226
+
227
+ grid = (B, H, Nq)
228
+ _invert_indices_kernel[grid](
229
+ q2k_idx,
230
+ q2k_num,
231
+ k2q_idx,
232
+ k2q_num,
233
+ q2k_idx.stride(0),
234
+ q2k_idx.stride(1),
235
+ q2k_idx.stride(2),
236
+ q2k_idx.stride(3),
237
+ q2k_num.stride(0),
238
+ q2k_num.stride(1),
239
+ q2k_num.stride(2),
240
+ k2q_idx.stride(0),
241
+ k2q_idx.stride(1),
242
+ k2q_idx.stride(2),
243
+ k2q_idx.stride(3),
244
+ k2q_num.stride(0),
245
+ k2q_num.stride(1),
246
+ k2q_num.stride(2),
247
+ MAX_KV_PER_Q=Mk,
248
+ )
249
+
250
+ return k2q_idx, k2q_num