Skip to content

MiniMax-H3: long-sequence flash attention, fused DiT glue and a faster video VAE decode (G4 render 63.6 to 37.0 s, bit-identical) - #18

Merged
oobabooga merged 17 commits into
perf/h3-gguf-speedfrom
perf/h3-gguf-speed-v2
Oct 9, 2026
Merged

oobabooga merged 17 commits into
perf/h3-gguf-speedfrom
perf/h3-gguf-speed-v2

Conversation

@danielhanchen

@danielhanchen danielhanchen commented Oct 1, 2026 •

Copy link
Copy Markdown
Member

Stacked on #17 (base branch perf/h3-gguf-speed); review that first.

Problem

With #17, a MiniMax-H3 GGUF render on an RTX PRO 6000 still takes 63.6 s against ComfyUI's 14.6 s warm. A denoising step is 11.3 s, about 4x ComfyUI's 2.7 s, and the video VAE decode is 12.9 s. Profiling one step and one decode:

  • Flash attention on H3's long unmasked sequences (19108 tokens, 56 heads x 128) loads every KV tile synchronously; the attention op alone is 112 ms on G4 against 29 ms for torch SDPA on the same VM.
  • DiT glue: many small kernels between the GEMMs (QKV split and views, MLP gating, per-segment modulation).
  • VAE decode: the decoder weights are re-uploaded for every temporal chunk, the rotary embedding and SwiGLU are built from several small ops, the f16 GEMM output conversion and bias add are separate kernels, and the 64-wide RMS norms launch full-width thread blocks.

Change

Each lever has its own switch; every default-on lever is bit-identical to #17.

lever default switch
ggml-cuda flash attention for long unmasked sequences, head dim 64 / 128, GPUs with async copy (Ampere and newer): full KV tiles load asynchronously, only the last partial tile synchronously, with the stock kernel's work split on GGML_CUDA_FA_LONGSEQ=0
wider 128-column FA tile (faster on A100 and L4, not bit-identical) off GGML_CUDA_FA_LONGSEQ_NCOLS=128 opts in
DiT glue: three fused ggml ops plus value-preserving graph rewrites on SD_H3_GRAPH_FAST=0 (finer: SD_H3_FAST_QKV, _MLP, _SEGMENTS, _VIEWS)
VAE weights stay on the GPU across temporal chunks on SD_H3_VAE_KEEP_RESIDENT=0
leaner VAE rotary embedding and SwiGLU on SD_H3_VAE_GRAPH_OPT=0
bias add fused into the f16 GEMM output conversion on GGML_CUDA_CUBLAS_EPILOGUE_FUSION=0
narrow thread blocks for 64-wide RMS norms on GGML_CUDA_NORM_SMALL_ROWS=0
several VAE tiles per decoder graph (53 dB vs #17, only A100 gains) off SD_H3_VAE_TILE_BATCH=auto or =N opts in
  • The ggml changes are carried as scripts/unsloth/ggml-patches/0002 to 0004. With 0001 they apply cleanly to the pinned ggml commit, and the patched tree matches the development clone exactly.
  • tiling.cpp: a batched tile callback, and tile planes merged on several threads.
  • docs/minimax_h3.md lists the switches.

Results

Colab, MiniMax-H3 UD-Q3_K_XL, 960x544, 124 frames, 4 steps, seed 42, one run per arm on the same VM. ComfyUI v0.38.0 with its int8 H3 graph on the same VM. Total seconds, s/step in brackets.

card default: #17 default: this PR speed_mode=max: #17 speed_mode=max: this PR ComfyUI cold / warm (s/step)
RTX PRO 6000 (G4), resident 63.6 (11.32), decode 12.9 37.0 (6.11), decode 7.5 39.7 (5.42) 30.2 (4.42) 21.6 / 14.6 (2.73)
L4, offloaded 434.8 (64.1), decode 77.3 361.7 (51.6), decode 54.6 309.3 (32.8) 263.4 (27.4) 221 / 96 (19.2)
A100 40 GB, resident 192.3 (24.75), decode 26.8 108.0 (17.43), decode 15.5 72.4 (8.43) 54.6 (7.15) 154.5 / 28.3 (4.8)
  • The A100 default totals include a cold page cache for the text encoder (34 s and 15 s against 4 s in later arms); compare s/step and decode there.
  • G4 default, by lever: flash attention -3.3 s/step, DiT glue -1.8 s/step, VAE levers -3 s of decode. With every switch off the G4 render is back at 61.1 s.
  • Attention op alone (19108 tokens, 56 x 128): G4 112 to 47 ms, A100 154 to 75 ms, B200 160 to 40 ms. L4 barely moves (497 to 460 ms; 365 ms with the 128-column opt-in).

Accuracy

Tests

  • test-backend-ops, unmasked FLASH_ATTN_EXT: 844 / 844 on G4, L4 and A100 under stock, with the new path forced on, and with the 128-column tile forced on, including new cases for partial last KV tiles and GQA.
  • Full FLASH_ATTN_EXT on B200 (combined build): 2964 / 2964. The new fused ops plus RMS_NORM, MUL_MAT, GLU and ADD: 1372 / 1372.
  • Builds compile for ggml-cuda on every prebuilt arch (sm75 to sm120), ggml HIP (gfx1100, gfx942, gfx1151), ggml and sd.cpp Vulkan, sd.cpp CPU-only and sd.cpp HIP.

Validation on other hardware

Base #17, same seed, Studio's H3 flags.

hardware result
RTX 6000 Ada (sm89), 960x544x124 bit-identical to #17 in pure, default and max modes; all switches off identical. Default 112.8 / 112.3 -> 93.6 / 95.6 s, max 86.5 -> 72.7 s, pure 124.0 -> 107.1 s.
RTX 3090 (sm86) default 174.9 / 174.8 -> 124.9 / 125.3 s. test-backend-ops: FLASH_ATTN_EXT 2962 (also with GGML_CUDA_FA_LONGSEQ=0 and _NCOLS=128), RMS_NORM, MUL_MAT (also with the epilogue off), ROPE_PE_PERMUTE, MODULATE_ROWS, SWIGLU_SCALED pass.
Strix Halo gfx1151, Vulkan (Linux), 640x384x56 bit-identical to #17 (the fused ops are unsupported and fall back); decode 53.7 -> 48.3 / 50.2 s.
Strix Halo, ROCm 7.2.1 every op test above passes (the fused ops run on HIP); sampling unchanged in a 2-step bisect over the patch prefixes, decode 22.8 -> 18.4 s. Not bit-identical: the fused DiT ops round differently there (about 43 dB on a 2-step render) and the cuBLAS epilogue lever moves the VAE by 58 dB.
Strix Halo, Vulkan (Windows, MSVC) builds; with the full stack, steady-state renders after a warm-up go 183.3 -> 175.2 s and 182.0 -> 174.9 s (decode 56.7 -> 51.6 s). Not bit-identical: with SD_H3_GRAPH_FAST=0 the DiT matches #17 again, so the DiT rewrites round differently on that driver.
CPU backend (--backend cpu) DiT, including the CPU fused ops, bit-identical to #17; 73.1 -> 63.3 s on a small render.
Apple M1 (Metal) fused ops unsupported (fallback), FLASH_ATTN_EXT passes, tiled image renders identical.

Not covered

  • T4 was not run (the Colab T4 had too little host RAM for the offloaded bundle); T4 has no async copy, so the flash attention change does not apply there.
  • sm90 was not timed.
  • One run per arm. The G4 MiniMax-H3: flash attention in the video VAE, faster tile stitching, ggml-cuda speed patch #17 default repeated at 63.4 to 65.7 s across runs.
  • Steps are still 1.4 to 1.6x slower than ComfyUI in max mode (BF16 matmul against their int8, and attention), and the decode is still slower.
  • Studio picks this up only after a new prebuilt from this branch and a pin bump.

scripts/unsloth/ggml-patches/0002 pipelines the unmasked, non-GQA mma
FlashAttention kernels (all full KV tiles via cp.async, only the partial last
tile synchronously). The MiniMax-H3 DiT (56 heads x 128, 19108 tokens) and the
H3 video VAE (32 heads x 64) attention take this path under --diffusion-fa.
GGML_CUDA_FA_LONGSEQ=0 restores the stock kernels.
process_tiles_2d_batched walks the same tile plan and merge order as process_tiles_2d but
hands up to batch_size(remaining) consecutive tiles to one callback, which returns them
stacked as contiguous blocks; the merge reads each tile in place instead of from a copy.
process_tiles_2d is now a one-tile wrapper around it.

The merge splits the output planes across threads. Each element is still written by one
thread with the same arithmetic, so outputs are bitwise identical.
…ident, leaner rope and SwiGLU

The video VAE decoder (a 36-layer ViT) ran one graph per 16x16 latent tile: 105 graph
builds, allocations and output readbacks for 960x544x124, and every temporal chunk ended
with runner_end(), which evicted the 5.5 GB of weights and reuploaded them for the next chunk.

- Several tiles now go through one decoder graph. The first tile runs alone to measure its
  compute buffer and the batch is sized from free device memory, capped at 4
  (SD_H3_VAE_TILE_BATCH_MAX); SD_H3_VAE_TILE_BATCH=N forces N and 1 restores one graph per
  tile. Projections run as one 2D matmul over all tiles' tokens and each tile keeps its own
  attention call: a broadcast batched cuBLAS GEMM and a batched flash-attention launch both
  changed the summation order relative to the single-tile decode. With this layout the
  decoded frames match the per-tile decode exactly.
- Weights stay resident across temporal chunks and runner_end() runs once after the last
  chunk (SD_H3_VAE_KEEP_RESIDENT=0 restores the per-chunk release).
- Rotary embedding reads the two rotated halves as permuted views of the projection layout
  and multiplies them by precomputed coefficient tables, with the same separate mul/mul/add
  per element as before, instead of per-half permutes, repeats, a tail permute and concat.
  The feed-forward uses ggml_swiglu instead of silu + mul. SD_H3_VAE_GRAPH_OPT=0 restores
  the previous graph.
- Chunks are blended in place with plain loops instead of index() lookups, and the decoded
  chunks are concatenated once at the end instead of growing the result chunk by chunk.
…MS norm blocks

Carried on top of the pinned ggml submodule like 0001 (the prebuilt workflow applies
scripts/unsloth/ggml-patches/*.patch in order). In the H3 video VAE decoder every linear
layer is an F16 cuBLAS matmul followed by a bias add and then either a scale + residual
add or SWIGLU; these now run inside the F16 -> F32 conversion of the GEMM output instead
of as separate passes over F32 activations, bitwise identical
(GGML_CUDA_CUBLAS_EPILOGUE_FUSION=0 disables). The per-head q/k RMS norm (64 columns) now
launches 64-thread blocks instead of 256, also bitwise identical
(GGML_CUDA_NORM_SMALL_ROWS=0 restores 256). Applies cleanly with or without 0002.
The DiT spent about 1.9 s of an 8.6 s B200 step (960x544x124, Q3) outside
matmul and attention: chunk copies of q/k/v and of the MLP gate, partial RoPE
as slice + permute copies + repeat + mul/add + concat, K/V scale then cast,
per-segment adaLN modulation and gated residuals as slice copies + mul/add +
concat, and separate scale passes around every MLP Linear.

Each lever builds the same arithmetic with the same rounding points, so the
output is bit-identical:

- q/k/v are read in place from the fused projection; one ggml_rope_pe_permute
  per tensor applies the partial RoPE, writes the head-major layout flash
  attention reads and, for K/V, the kv scale and F16 cast
  (SD_H3_FAST_QKV=0 restores the old graph).
- The MLP gate is one swiglu on two views of fc1's output instead of two chunk
  copies + silu + mul (SD_H3_FAST_MLP=0).
- adaLN modulation and gated residuals use one in-place ggml_modulate_rows /
  ggml_gated_add_rows per sequence segment, and the MLP Linear scales are
  folded into them and into ggml_swiglu_scaled (SD_H3_FAST_SEGMENTS=0).
- Modulation vectors and segment slices are views, not copies
  (SD_H3_FAST_VIEWS=0).

SD_H3_GRAPH_FAST=0 turns all of them off. Backends without the fused ops
(and upstream ggml builds) keep the original graph.
scripts/unsloth/ggml-patches/0004 adds ggml_rope_pe_permute,
ggml_modulate_rows / ggml_gated_add_rows and ggml_swiglu_scaled (CPU + CUDA,
test-backend-ops cases) on top of 0001 and 0002. The MiniMax-H3 DiT uses them
when the backend supports them and keeps the unfused graph otherwise.
forward_folded is only reachable when the fused ops exist; return from inside
the guarded branch so the upstream-ggml build does not reference an undeclared
result.
…wide tile opt-in

The pipelined variant now sizes its stream-k grid from the stock kernel so the
work split, and with it the result, is identical on every GPU (on L4 the two
kernels' occupancy differs). The 128-column tile is opt-in only
(GGML_CUDA_FA_LONGSEQ_NCOLS=128).
The batched decoder graph runs each projection as one matmul over all tiles'
tokens. On an RTX PRO 6000 Blackwell (sm120) cuBLAS picks a different kernel for
that larger M and the frames come out at 53 dB PSNR against the per-tile decode
(identical on B200), and the batched graph measured no faster there (VAE decode
7.6 s batched vs 7.5 s per tile, the other VAE levers on). Decode one tile per
graph by default; SD_H3_VAE_TILE_BATCH=auto or =N opts in.
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 1, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-10-09T03:31:49.768540Z d2f58e3 New commits
🔒 Security Review ✅ Completed 2026-10-01T17:08:12.411463Z b92b2a2 PR opened
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: b92b2a2cbc

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +327 to +329
q = ggml_rope_pe_permute(ctx->ggml_ctx, q, pe, n_rot, 1.f, GGML_TYPE_F32);
k = ggml_rope_pe_permute(ctx->ggml_ctx, k, pe, n_rot, kv_scale, GGML_TYPE_F16);
auto v = ggml_rope_pe_permute(ctx->ggml_ctx, part(2), nullptr, 0, kv_scale, GGML_TYPE_F16);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Ship the new ggml operators in the default build

When users build a normal recursive checkout with the documented default SD_USE_UPSTREAM_GGML=OFF, this call is compiled, but the commit leaves the ggml gitlink at f583f393..., whose headers do not contain ggml_rope_pe_permute, ggml_modulate_rows, or ggml_swiglu_scaled. Those APIs are supplied only by the new patch files, and the only repository code applying those patches is the Unsloth prebuilt workflow at .github/workflows/unsloth-sd-prebuilt.yml:227-230; regular CMake builds never apply them. Consequently, the default CPU/CUDA build from docs/build.md fails on undeclared operators. Update the pinned submodule, apply the patches in the regular build path, or feature-gate these calls independently of SD_USE_UPSTREAM_GGML.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 5ab239d: cmake/ggml.cmake checks whether the selected ggml.h declares ggml_rope_pe_permute and defines SD_GGML_H3_FUSED_OPS only then, and minimax_h3.hpp gates the fused ops on that macro instead of SD_USE_UPSTREAM_GGML, so an unpatched default build compiles and runs the unfused graph. 703f519 documents how to apply scripts/unsloth/ggml-patches to a source checkout.

@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.
To continue using code reviews, add credits to your account and enable them for code reviews in your settings.

@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.
To continue using code reviews, add credits to your account and enable them for code reviews in your settings.

@oobabooga

Copy link
Copy Markdown
Member

Reached Codex review convergence at oobabooga#4.

@oobabooga

Copy link
Copy Markdown
Member

No changes were needed beyond merging #17's fix down into this branch (d2f58e3). Every backend gets faster, and on CUDA, Linux Vulkan and CPU the output is bit-identical to #17.

Tested 5f222b8 / d2f58e3 against #17. The flags and arm interleaving match #17's comment, and times are generate_video in seconds.

Hardware Mode Render, #17 → PR VAE decode, #17 → PR
RTX 6000 Ada (sm89), 960x544x124 default 112.8 / 112.3 → 93.6 / 95.6 24.2 / 23.8 → 16.2 / 16.4
max 86.5 → 72.7 22.6 → 14.7
pure (MMQ) 124.0 → 107.1 23.1 → 15.1
RTX 3090 (sm86) default 165.7 / 165.7 → 115.9 / 116.1 41.8 / 40.9 → 21.1 / 22.9
Strix Halo, ROCm (640x384x56, 2 steps) Studio flags 167.4 → 151.6 22.0 → 17.7
Strix Halo, Vulkan (Linux) Studio flags 147.3 / 147.0 → 140.0 / 142.1 53.8 / 53.6 → 48.3 / 50.2
Strix Halo, Vulkan (Windows), #17-#20 stack vs master Studio flags, after warm-up 183.3 / 182.0 → 175.2 / 174.9 56.7 / 55.7 → 51.6 / 51.3
CPU backend (256x160x22) --backend cpu 73.1 → 63.3

A 2-step HIP bisect over the patch prefixes 0001 to 0004 (0001-0002, 0001-0003, etc.) shows no slowdown from any of them: every variant runs at 64 to 71 s per step.

Output:

Where Result
Ada, pure / default / max bit-identical to #17, frames and audio
Ada, all switches off identical to #17
Linux Vulkan bit-identical; the fused ops are unsupported there and fall back
CPU backend DiT bit-identical, including the CPU fused ops; VAE 65.5 dB
HIP the fused DiT ops round differently (about 43 dB on a 2-step render); the cuBLAS epilogue moves the VAE by 58 dB
Windows Vulkan with SD_H3_GRAPH_FAST=0 the DiT matches again, so the stock swiglu op rounds differently on that driver

To check that the HIP and Windows differences add no error, each GPU render was compared with a CPU-backend render of the same frames (640x384x56, music prompt, 2 steps):

Backend Video vs CPU, master → stack Audio SNR vs CPU, master → stack
ROCm, 3 seeds (mean) 24.77 → 24.71 dB 8.9 → 9.1 dB (the sign flips between seeds)
Vulkan, Windows 35.17 → 35.03 dB 16.6 → 16.3 dB
Vulkan, Linux 35.88 → 35.88 dB 16.1 → 16.1 dB

test-backend-ops on sm86, sm89 and HIP all pass:

Op Result
FLASH_ATTN_EXT 2962, including the 42 new long-seq cases; also with GGML_CUDA_FA_LONGSEQ=0 and _NCOLS=128
ROPE_PE_PERMUTE 40
MODULATE_ROWS 16
SWIGLU_SCALED 8
RMS_NORM 21, also with GGML_CUDA_NORM_SMALL_ROWS=0
MUL_MAT 1186, also with the epilogue off
ADD / MUL / SWIGLU 99 / 91 / 24

Metal reports the fused ops as unsupported and passes FLASH_ATTN_EXT 4834/4834; tiled renders are identical there.

CI job Status
HIP, op tests + renders ops pass; head renders on that runner were slow (79-91 s/step), not reproduced on two other runners (below)
HIP, patch bisect pass
HIP, one lever at a time pass
Vulkan, Linux pass, bit-identical
Windows, stack vs master pass
Windows, lever diagnosis pass
Windows, timing pass
CPU reference: ROCm, seed 7, seed 1234, Vulkan, Windows pass

Harness.

@oobabooga

Copy link
Copy Markdown
Member

Reached Codex review convergence at oobabooga#4.

@oobabooga
oobabooga merged commit 8f61483 into perf/h3-gguf-speed Oct 9, 2026
oobabooga added a commit that referenced this pull request Oct 9, 2026
* MiniMax-H3: flash attention in the video VAE under --diffusion-fa; carry ggml-cuda speed patch

The H3 video VAE decoder is a 36-layer ViT run per 16x16 latent tile. With only
--diffusion-fa (what Studio passes) it used mul_mat + scale + softmax attention,
materialising a 32 x L x L f32 score matrix per tile and layer. --diffusion-fa now
also covers it; SD_H3_VAE_FLASH_ATTN=0 restores the old path. The text encoder is
unchanged (that is what --fa would also switch, which moves the conditioning).

scripts/unsloth/ggml-patches/ carries two ggml-cuda commits on top of the pinned
submodule (BF16 cuBLAS for large-batch quantized matmul behind
GGML_CUDA_QUANT_CUBLAS_MIN_BATCH, a cached device integrated flag, a vectorized
row copy); the prebuilt workflow applies them after the submodule checkout.

* tiling: walk tile planes contiguously when splitting and merging

process_tiles_2d copied and blended each pixel across all planes in the inner
loop, striding a whole output plane (960x544 floats for H3) per element. A video
tile has frames x channels planes, so every element was a cache and TLB miss:
on MiniMax-H3 960x544x124 (105 tiles) this was about 10 s of single-threaded
host time per decode. Loop planes outermost and precompute the per-column and
per-row blend weights; each element still computes old + new * wy * wx in the
same order, so the output is bitwise identical.

* Carry the pipelined long-sequence ggml-cuda FlashAttention patch

scripts/unsloth/ggml-patches/0002 pipelines the unmasked, non-GQA mma
FlashAttention kernels (all full KV tiles via cp.async, only the partial last
tile synchronously). The MiniMax-H3 DiT (56 heads x 128, 19108 tokens) and the
H3 video VAE (32 heads x 64) attention take this path under --diffusion-fa.
GGML_CUDA_FA_LONGSEQ=0 restores the stock kernels.

* ggml patch 0002: 128-column long-sequence FlashAttention tile on sm80 / sm89

* docs: MiniMax-H3 long-sequence flash attention switches

* tiling: batched tile callback, merge tile planes on several threads

process_tiles_2d_batched walks the same tile plan and merge order as process_tiles_2d but
hands up to batch_size(remaining) consecutive tiles to one callback, which returns them
stacked as contiguous blocks; the merge reads each tile in place instead of from a copy.
process_tiles_2d is now a one-tile wrapper around it.

The merge splits the output planes across threads. Each element is still written by one
thread with the same arithmetic, so outputs are bitwise identical.

* MiniMax-H3: batch video VAE tiles per decoder graph, keep weights resident, leaner rope and SwiGLU

The video VAE decoder (a 36-layer ViT) ran one graph per 16x16 latent tile: 105 graph
builds, allocations and output readbacks for 960x544x124, and every temporal chunk ended
with runner_end(), which evicted the 5.5 GB of weights and reuploaded them for the next chunk.

- Several tiles now go through one decoder graph. The first tile runs alone to measure its
  compute buffer and the batch is sized from free device memory, capped at 4
  (SD_H3_VAE_TILE_BATCH_MAX); SD_H3_VAE_TILE_BATCH=N forces N and 1 restores one graph per
  tile. Projections run as one 2D matmul over all tiles' tokens and each tile keeps its own
  attention call: a broadcast batched cuBLAS GEMM and a batched flash-attention launch both
  changed the summation order relative to the single-tile decode. With this layout the
  decoded frames match the per-tile decode exactly.
- Weights stay resident across temporal chunks and runner_end() runs once after the last
  chunk (SD_H3_VAE_KEEP_RESIDENT=0 restores the per-chunk release).
- Rotary embedding reads the two rotated halves as permuted views of the projection layout
  and multiplies them by precomputed coefficient tables, with the same separate mul/mul/add
  per element as before, instead of per-half permutes, repeats, a tail permute and concat.
  The feed-forward uses ggml_swiglu instead of silu + mul. SD_H3_VAE_GRAPH_OPT=0 restores
  the previous graph.
- Chunks are blended in place with plain loops instead of index() lookups, and the decoded
  chunks are concatenated once at the end instead of growing the result chunk by chunk.

* ggml-patches: 0003 fused cuBLAS bias epilogues and narrow short-row RMS norm blocks

Carried on top of the pinned ggml submodule like 0001 (the prebuilt workflow applies
scripts/unsloth/ggml-patches/*.patch in order). In the H3 video VAE decoder every linear
layer is an F16 cuBLAS matmul followed by a bias add and then either a scale + residual
add or SWIGLU; these now run inside the F16 -> F32 conversion of the GEMM output instead
of as separate passes over F32 activations, bitwise identical
(GGML_CUDA_CUBLAS_EPILOGUE_FUSION=0 disables). The per-head q/k RMS norm (64 columns) now
launches 64-thread blocks instead of 256, also bitwise identical
(GGML_CUDA_NORM_SMALL_ROWS=0 restores 256). Applies cleanly with or without 0002.

* MiniMax-H3: value-preserving DiT graph rewrites

The DiT spent about 1.9 s of an 8.6 s B200 step (960x544x124, Q3) outside
matmul and attention: chunk copies of q/k/v and of the MLP gate, partial RoPE
as slice + permute copies + repeat + mul/add + concat, K/V scale then cast,
per-segment adaLN modulation and gated residuals as slice copies + mul/add +
concat, and separate scale passes around every MLP Linear.

Each lever builds the same arithmetic with the same rounding points, so the
output is bit-identical:

- q/k/v are read in place from the fused projection; one ggml_rope_pe_permute
  per tensor applies the partial RoPE, writes the head-major layout flash
  attention reads and, for K/V, the kv scale and F16 cast
  (SD_H3_FAST_QKV=0 restores the old graph).
- The MLP gate is one swiglu on two views of fc1's output instead of two chunk
  copies + silu + mul (SD_H3_FAST_MLP=0).
- adaLN modulation and gated residuals use one in-place ggml_modulate_rows /
  ggml_gated_add_rows per sequence segment, and the MLP Linear scales are
  folded into them and into ggml_swiglu_scaled (SD_H3_FAST_SEGMENTS=0).
- Modulation vectors and segment slices are views, not copies
  (SD_H3_FAST_VIEWS=0).

SD_H3_GRAPH_FAST=0 turns all of them off. Backends without the fused ops
(and upstream ggml builds) keep the original graph.

* Carry the fused DiT ops ggml patch; document the H3 graph switches

scripts/unsloth/ggml-patches/0004 adds ggml_rope_pe_permute,
ggml_modulate_rows / ggml_gated_add_rows and ggml_swiglu_scaled (CPU + CUDA,
test-backend-ops cases) on top of 0001 and 0002. The MiniMax-H3 DiT uses them
when the backend supports them and keeps the unfused graph otherwise.

* MiniMax-H3: keep the folded MLP path compiling with SD_USE_UPSTREAM_GGML

forward_folded is only reachable when the fused ops exist; return from inside
the guarded branch so the upstream-ggml build does not reference an undeclared
result.

* ggml patch 0002: long-sequence flash attention keeps the stock grid, wide tile opt-in

The pipelined variant now sizes its stream-k grid from the stock kernel so the
work split, and with it the result, is identical on every GPU (on L4 the two
kernels' occupancy differs). The 128-column tile is opt-in only
(GGML_CUDA_FA_LONGSEQ_NCOLS=128).

* MiniMax-H3: video VAE tile batching opt-in (SD_H3_VAE_TILE_BATCH=auto|N)

The batched decoder graph runs each projection as one matmul over all tiles'
tokens. On an RTX PRO 6000 Blackwell (sm120) cuBLAS picks a different kernel for
that larger M and the frames come out at 53 dB PSNR against the per-tile decode
(identical on B200), and the batched graph measured no faster there (VAE decode
7.6 s batched vs 7.5 s per tile, the other VAE levers on). Decode one tile per
graph by default; SD_H3_VAE_TILE_BATCH=auto or =N opts in.

* MiniMax-H3: sage attention takes the fused RoPE / head-major q/k/v op

With --sage-attn, forward_head_major returned nullptr, so every block fell back to the
unfused chunk / slice / rope / concat / scale / cast chain (about 0.75 s per 960x544x124
step on B200). ggml_rope_pe_permute already writes [head_dim, tokens, heads, batch], the
layout ggml_sage_attn reads: emit F32 Q, F32 K * kv_scale and F16 V * kv_scale and call
sage directly. Same arithmetic and rounding points as the unfused sage branch; the
rendered video and audio are bit-identical. SD_H3_FAST_SAGE_QKV=0 restores the old chain.

* MiniMax-H3 video VAE: keep the device busy between tiles

The 960x544x124 decode left the B200 idle for about 2 s between its 105 tile graphs:
the host blended each tile, zero-filled and assembled the frames, rebuilt the rotary
tables and queried free device memory four times per tile while the GPU waited.

- tiling: the blend of a batch into the output runs on a worker thread while the next
  batch is split and computed; merges stay serial and in tile order with the same
  arithmetic (SD_TILE_ASYNC_MERGE=0 merges inline).
- H3 decode: each temporal chunk is trimmed, cross-faded and copied straight into the
  trimmed output on a worker thread while the next chunk decodes, instead of
  concatenating and slicing at the end (SD_H3_VAE_ASYNC_ASSEMBLY=0 restores it).
- rotary tables are rebuilt only when the tile shape changes.
- DeviceMemoryRequest::reuse_device_query: while a runner repeats identical computes,
  a capacity check that allocates nothing new (no pending bytes, all parameters
  resident) reuses the owner's earlier free-memory reading; runner_end() drops it.
  The H3 decode turns it on for its duration (SD_H3_VAE_REUSE_MEMQUERY=0 disables).

Warm decode on B200 6.8-7.0 s -> 5.06 s; frames and audio bit-identical.

* MiniMax-H3: audio VAE anti-aliased activations use direct depthwise convs

The up/down-sampling filters of every Activation1D went through ggml_conv_1d /
ggml_conv_1d_dw: an F16 im2col plus a single-column matmul per call. Route them
through CONV_2D_DW (H=1, F32 per-channel kernel) instead, for the MiniMax-H3
audio VAE only. Falls back to the im2col graph when the backend lacks the op.
SD_H3_AUDIO_DIRECT_DW=0 restores the previous graph.

* MiniMax-H3 video VAE: fused RoPE / head-major q/k/v for decoder attention

The decoder attention chunk-copied q/k/v out of the fused projection, applied the
partial RoPE as table mul/add/concat chains and then permuted and cast K/V for flash
attention. With one tile per graph, normalise q/k in place on the projection and let
ggml_rope_pe_permute (already used by the DiT) write the F32 Q and F16 K/V head-major
tensors ggml_ext_attention_prepared reads. Same products, sums and casts as the table
path, so the decoded frames are bit-identical. SD_H3_VAE_FUSED_QKV=0 restores the old
graph; batched tiles, sage and non-flash decodes keep it.

* MiniMax-H3 video VAE: fold the q/k RMS norm into the fused RoPE op

Carry ggml patch 0005 (ggml_rope_pe_permute_rms) and use it for the decoder's q/k:
the per-head norm was 7560 launches of a 64-thread kernel, about 0.45 s per
960x544x124 decode on B200. The fused op normalises each head row with the same block
reduction as the standalone norm, so the frames stay bit-identical. Backends without
it (HIP, Vulkan, GGML_CUDA_NORM_SMALL_ROWS=0) keep the separate norm;
SD_H3_VAE_FUSED_QK_NORM=0 forces that.

* MiniMax-H3 video VAE: SD_H3_VAE_TILE opt-in latent tile size

Fewer, larger tiles cut the per-tile fixed cost (20x20 decodes 960x544x124 in 3.07 s
vs 3.77 s at 16x16 on B200) but move the seams: 36.2 dB PSNR / 0.09 LPIPS vs the
16x16 decode, slightly closer to an untiled decode than 16x16 is. Default stays 16.

* Tile merge / H3 assembly / capacity reading: harden the reuse paths

- The saved device free-memory reading is now taken only by a check that allocates
  nothing, so it is never the reading from before this owner's compute buffer was
  allocated, and any check that does not fit drops the owner's readings so the
  reclaim / evict retries see fresh ones.
- If a worker thread cannot be started, the tile merge and the chunk assembly run
  inline instead of throwing.

* MiniMax-H3 / ggml-cuda: cuDNN fused attention on Ampere and newer

New ggml patch 0006 adds an optional cuDNN path to ggml-cuda's FLASH_ATTN_EXT
and SAGE_ATTN (AUTO mode): GGML_CUDA_CUDNN=ON compiles it against cudnn.h and
the header-only cudnn-frontend v1.26.0 (MIT, downloaded by CMake and pinned by
checksum); libcudnn.so.9 is opened at run time, so the binaries keep no cuDNN
dependency and run the existing kernels wherever cuDNN cannot be loaded, on
Turing and older, for small attention calls and for shapes cuDNN declines.
GGML_CUDA_CUDNN_ATTN=0 restores the previous kernels everywhere and
GGML_CUDA_CUDNN_SAGE=0 keeps the sage kernel for --sage-attn.

On a B200 the H3 DiT attention at 960x544x124 (19315 tokens x 56 heads) takes
about 8 ms per call against 28 ms for the sage kernel and 43 ms for the ggml
flash attention kernel, and is closer to an fp64 reference than either.

The Linux CUDA prebuilt fetches the pinned cuDNN 9.27 headers package, builds
with GGML_CUDA_CUDNN=ON and fails if either binary ends up linked to libcudnn.
docs/minimax_h3.md describes the run-time requirements.

* Tighten H3 VAE and tiling comments

* Build without the fused H3 ops when ggml lacks patch 0004

* Document that the H3 ggml speedups need the carried patches

* Gate the H3 VAE fused attention on ggml patch 0004 and its fused norm on 0005

* Tighten the H3 VAE, sage and tiling comments

* Ship cudnn-frontend's MIT notice in the CUDA bundle

* Keep the H3 VAE on its old attention outside CUDA and ROCm

* Take cuDNN attention by default only where it measured faster

* Install the NVRTC header the cuDNN frontend includes in the CUDA prebuilt leg

---------

Co-authored-by: Daniel Han <danielhanchen@gmail.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants