observantdistressed/minimax-h3

Space

MiniMax-H3 — unquantized, split across two Spaces

21

stars

132

commits

1

linked in READMEs

Sep 15, 2026

updated

gradio

README

MiniMax-H3 — unquantized, split across two Spaces

Joint video and soundtrack out of a single denoising pass, at bfloat16 with no quantization anywhere.

This Space is the denoising half: the 61.73 GiB transformer and the two autoencoders. The 62.14 GiB Qwen3-VL conditioner runs in qwen3vl-conditioner, which this Space calls over the gradio API for every request. The weights are the public MiniMaxAI/MiniMax-H3 diffusers checkpoint.

Turbo mode (optional checkbox) applies the larryvrh distillation LoRA at generation time, reducing inference from 25 steps to 7 for ~4× faster generation with minimal quality loss.

Why split

MiniMax-H3 is 195.9 GiB in bfloat16 and a ZeroGPU Space is evicted at 150 GB of storage. An unquantized single Space is therefore impossible, which is why quantized demos of it run NVFP4 or float8 weights. Cut the MiniMaxH3Blocks sequence at its text_encoder step and both halves fit unquantized:

SpaceSubfoldersDownloadResident
qwen3vl-conditionertext_encoder/ + tokenizer/ + processor/66.7 GB62.15 GiB bf16
this onetransformer/ + vae/ + audio_vae/77.3 GB61.73 GiB bf16 + 10.43 GiB float32

Besides the quality argument, unquantized weights are the ones AoTI can export; an NVFP4 checkpoint cannot be exported at all.

AoTI-compiled blocks

With H3_AOTI=1 the 50 repeated transformer blocks run from a compiled package, multimodalart/minimax-h3-aoti:bf16/torch2.11/sm120/dynamic — a single dynamic-sequence artifact that serves every canvas, duration and prompt length. It carries no weights (it reads each block's live ones), so patching it in is startup CPU work and costs no GPU time.

It removes a near-constant ~0.5 s/step — 50 blocks' worth of kernel-launch overhead plus the norm / rotary / AdaLN epilogues around the matmuls — and cannot touch the matmuls themselves. So it pays best where the block is not compute bound, i.e. on the small canvases:

canvas (HxW)eager s/stepAoTI s/stepfaster
768x134410.209.73+4.6%
640x11526.465.88+9.1%
544x9604.023.58+11.0%

At 72.16 GiB of weights on a 95.0 GiB card there is no offloading in the request path at all, which is why an unquantized MiniMax-H3 is also the fastest one measured here: 10.6 s/step against the 19–21 s/step the 4 bit Space pays, where the whole cost is the traffic auto-offload has to move.

How the split is expressed

MiniMaxH3Blocks is a SequentialPipelineBlocks whose branches are picked per request — and per workflow= — from the inputs:

before_encode -> text_encoder -> vae_encoder -> denoise -> after_denoise -> decode

where denoise is itself prepare_layout -> prepare_latents -> set_timesteps -> denoise.

h3_split_blocks.py subclasses it with the text_encoder step removed. Dropping the step drops the three components it declares, so load_components resolves transformer / vae / audio_vae / the two schedulers out of the shared modular_model_index.json and never fetches the conditioner — and prompt_embeds and text_token_tags become ordinary required inputs of the pipeline call:

pipe = MiniMaxH3GeneratorBlocks().init_pipeline("MiniMaxAI/MiniMax-H3")
pipe.load_components(dtype=torch.bfloat16)
state = pipe(prompt_embeds=..., text_token_tags=..., height=768, width=1344, num_frames=124, num_inference_steps=30)

The wire format is exactly those two tensors — (1, num_text_tokens, 5120) bfloat16 and (num_text_tokens,) int64 — carried as one safetensors file with the resolved height / width / num_frames in its metadata header. A text-only request is 246 KB of it; one 768x1344 keyframe adds 1016 vision rows and takes it to 10.7 MB.

The keyframe resize step runs on both halves. It owns no pretrained component (PIL and arithmetic) and it puts the keyframes onto the target canvas — which the conditioner needs to build its vision blocks and this Space needs to encode with the video VAE. It is deterministic, and the conditioner returns the plan it resolved so this Space pins the same canvas rather than re-deriving it. Two things that step no longer does, and that both halves therefore do themselves: EXIF-transposing a keyframe into upright RGB, and snapping num_frames to 17 * n + 5 — the frame count is resolved by the layout step, which lives on this side of the cut.

Nothing is paid for with GPU time

The 77.3 GB download and the load happen at startup: import spaces at module top patches torch.cuda before any GPU is attached, so nothing about the load needs a card. The conditioner round trip is a network call on this Space's CPU. A @spaces.GPU call is therefore only the placement (once) and the denoise loop and the two decoders.

The 150 GB quota, not the 95 GiB card, is what rules out startup placement

One thing does not happen at startup: the move onto the card. spaces' startup torch.pack() writes every startup-resident CUDA tensor to a second copy on disk and only deletes the downloaded originals afterwards (Cleaned 62.13GB of tensor files ... after packing, which is what keeps the conditioner half comfortable at 66.7 GB). Packing 77.3 GB needs 154.6 GB at once, and this Space is evicted mid-pack:

ZeroGPU tensors packing:   0%|          | 0.00/77.3G
OSError: [Errno 28] No space left on device      # os.posix_fallocate, spaces/zero/torch/packing.py

Unlinking the shards first does not rescue it. .to("cuda") under the startup patch does not release the memory-mapped safetensors, so nothing is freed — and the pack's own cleanup walks those still-open mappings and lstats them, so a deleted blob becomes FileNotFoundError: .../blobs/3d449... (deleted).

Placement therefore happens on the first GPU call, PIPE.to("cuda") at the top of the @spaces.GPU function: about 10 s of PCIe once, then a no-op walk, and the denoise loop runs with everything resident and no offloading at all. It is the same trick the 4 bit Space uses, for the same reason.

Generation constraints

Fixed by the checkpoint: 24 fps, a 768 pixel short edge, 5 to 15 s, num_frames snapped up to the next 17 * n + 5, no CFG and no negative prompt (it is guidance-distilled, so every step is one forward pass).

Turbo LoRA (optional)

The UI includes a Turbo checkbox that applies the InstantX/MiniMax-H3-Turbo-Lora-Diffusers distillation LoRA (larryvrh, ckpt500 diffusers conversion) at generation time:

  • 7 steps instead of the default 25 — ~4× faster generation with minimal quality loss.
  • The LoRA is not merged at startup. Instead, A/B factor matrices (~1.2 GB total for 363 modules) are stored on CPU and moved to GPU on first turbo call. The delta (B @ A) is computed on-the-fly per module (~57 MB temporary, freed each iteration), keeping peak extra GPU memory under 60 MB.
  • Merge takes ~0.35 s, unmerge ~0.01 s — effectively zero overhead.
  • The @spaces.GPU duration budget includes a 120 s bonus on the first turbo merge to account for the A/B CPU→GPU transfer.

NSFW / anatomy LoRAs (optional)

The UI includes an NSFW Innie Pussy (HMInnie V1) checkbox that applies the HMInnie_v1_e50 anatomy LoRA from Hearmeman/minimax-h3-loras at generation time:

  • Standard LoRA format; ComfyUI keys (diffusion_model.blocks.N.attn.out_proj.lora_A/B.weight, etc.) are converted to the diffusers module-tree keys at load (including the token_refiner blocks).
  • Trigger token inniepussy, strength 1.0.
  • Lazy-loaded on CPU and merged on GPU only when the checkbox is selected — no startup cost.
  • Note: LoKR-format LoRAs (e.g. HMPussy v0.5 / hmpussy_v6_epoch30) are not supported by the loader.

Measured

An rtx-pro-6000 Job — the same silicon as the ZeroGPU pool (RTX PRO 6000 Blackwell, sm120, 95.0 GiB) — running exactly this blockset over the wire format, 1344x768, 124 frames, 30 steps, bfloat16, cuDNN attention, everything resident:

load_components (77.3 GB, warm Xet)43 s
.to("cuda"), once10 s
resident weights72.16 GiB
denoise + decode317 s, 10.58 s/step
peak allocated / reserved78.54 / 85.37 GiB
outputh264 1344x768 @ 24 fps, 5.167 s + stereo AAC @ 32 kHz

And on this Space itself, driven over gradio_client. Startup is 93 s — the 77.3 GB download and the load, with no placement and therefore no pack.

RequestConditionerDenoise + decodeSteadyRound trip
text only, 18 tokens7 s339 s10.53 s/step353 s
one 768x1344 keyframe, 1034 tokens9 s370 s11.39 s/step386 s

The keyframe costs about 8% per step rather than a placement penalty: it puts 1016 vision rows in front of the prompt and 1016 conditioning rows in the packed sequence, and MiniMax-H3 attends over all of it every layer. The one-time PIPE.to("cuda") is inside the first row's 339 s and does not reappear in the second.

Space variables

VariableDefaultMeaning
H3_MODEL_REPOMiniMaxAI/MiniMax-H3The diffusers-layout checkpoint. Public.
H3_CONDITIONERmultimodalart/qwen3vl-conditionerThe Space this one asks for embeddings.
H3_PLACEMENTpackpack moves the transformer at startup and packs it to disk; the ~10 GB fp32 VAEs move on the first GPU call. lazy moves all 72 GiB on the first GPU call; offload hands placement to ComponentsManager.enable_auto_cpu_offload instead.
H3_ATTENTION_native_cudnncuDNN's fused kernel, 10–20% faster than the SDPA default and needs nothing installed. flash-attention 3 is sm90-only and this pool is sm120.
H3_GPU_DURATION900Seconds per request; the pool applies a 1.5 duration factor.
H3_GPU_SIZExlargeZeroGPU allocation size. large does not fit.

Whose GPU quota pays

Two cards are booked per request — this Space's denoise loop and the conditioner's forward — and both are billed to the requesting user, with nothing here arranging it: gradio_client attaches the caller's own x-ip-token to every outgoing call, reading it off gradio's LocalContext inside the event listener (Client.send_data -> add_zero_gpu_headers), and ZeroGPU charges the booking to whatever that token identifies.

A caller with no token to forward — a gradio_client script rather than a browser — leaves the conditioner's booking attributed to this Space's pod IP and its small shared quota. An unattributed caller may book at most 120 credits at a time and an xlarge booking costs twice its seconds, so the conditioner books the encode (45 s) and a prompt upsample (60 s) as two separate calls, each within that ceiling.

Secrets

None are required. Everything this Space downloads is public — the MiniMaxAI/MiniMax-H3 checkpoint and the multimodalart/minimax-h3-aoti packages — and the conditioner is a public Space called on the requesting user's own ZeroGPU token, never on an org token.

Where diffusers comes from

MiniMax-H3 is modular-only and not in a released diffusers, so requirements.txt installs it from the canonical pull request, huggingface/diffusers#14371, pinned to the commit 665f5782 (refs/pull/14371/head) rather than to the moving minimax-h3-refactor branch.

That PR is a WIP, so it needs re-pinning whenever it updates, and h3_split_blocks.py — which subclasses its block classes to cut the pipeline in two — has to be re-checked against the new head at the same time.

Contributors

observantdistressed/minimax-h3

Space

MiniMax-H3 — unquantized, split across two Spaces

21

stars

132

commits

1

linked in READMEs

Sep 15, 2026

updated

gradio

README

MiniMax-H3 — unquantized, split across two Spaces

Joint video and soundtrack out of a single denoising pass, at bfloat16 with no quantization anywhere.

This Space is the denoising half: the 61.73 GiB transformer and the two autoencoders. The 62.14 GiB Qwen3-VL conditioner runs in qwen3vl-conditioner, which this Space calls over the gradio API for every request. The weights are the public MiniMaxAI/MiniMax-H3 diffusers checkpoint.

Turbo mode (optional checkbox) applies the larryvrh distillation LoRA at generation time, reducing inference from 25 steps to 7 for ~4× faster generation with minimal quality loss.

Why split

MiniMax-H3 is 195.9 GiB in bfloat16 and a ZeroGPU Space is evicted at 150 GB of storage. An unquantized single Space is therefore impossible, which is why quantized demos of it run NVFP4 or float8 weights. Cut the MiniMaxH3Blocks sequence at its text_encoder step and both halves fit unquantized:

SpaceSubfoldersDownloadResident
qwen3vl-conditionertext_encoder/ + tokenizer/ + processor/66.7 GB62.15 GiB bf16
this onetransformer/ + vae/ + audio_vae/77.3 GB61.73 GiB bf16 + 10.43 GiB float32

Besides the quality argument, unquantized weights are the ones AoTI can export; an NVFP4 checkpoint cannot be exported at all.

AoTI-compiled blocks

With H3_AOTI=1 the 50 repeated transformer blocks run from a compiled package, multimodalart/minimax-h3-aoti:bf16/torch2.11/sm120/dynamic — a single dynamic-sequence artifact that serves every canvas, duration and prompt length. It carries no weights (it reads each block's live ones), so patching it in is startup CPU work and costs no GPU time.

It removes a near-constant ~0.5 s/step — 50 blocks' worth of kernel-launch overhead plus the norm / rotary / AdaLN epilogues around the matmuls — and cannot touch the matmuls themselves. So it pays best where the block is not compute bound, i.e. on the small canvases:

canvas (HxW)eager s/stepAoTI s/stepfaster
768x134410.209.73+4.6%
640x11526.465.88+9.1%
544x9604.023.58+11.0%

At 72.16 GiB of weights on a 95.0 GiB card there is no offloading in the request path at all, which is why an unquantized MiniMax-H3 is also the fastest one measured here: 10.6 s/step against the 19–21 s/step the 4 bit Space pays, where the whole cost is the traffic auto-offload has to move.

How the split is expressed

MiniMaxH3Blocks is a SequentialPipelineBlocks whose branches are picked per request — and per workflow= — from the inputs:

before_encode -> text_encoder -> vae_encoder -> denoise -> after_denoise -> decode

where denoise is itself prepare_layout -> prepare_latents -> set_timesteps -> denoise.

h3_split_blocks.py subclasses it with the text_encoder step removed. Dropping the step drops the three components it declares, so load_components resolves transformer / vae / audio_vae / the two schedulers out of the shared modular_model_index.json and never fetches the conditioner — and prompt_embeds and text_token_tags become ordinary required inputs of the pipeline call:

pipe = MiniMaxH3GeneratorBlocks().init_pipeline("MiniMaxAI/MiniMax-H3")
pipe.load_components(dtype=torch.bfloat16)
state = pipe(prompt_embeds=..., text_token_tags=..., height=768, width=1344, num_frames=124, num_inference_steps=30)

The wire format is exactly those two tensors — (1, num_text_tokens, 5120) bfloat16 and (num_text_tokens,) int64 — carried as one safetensors file with the resolved height / width / num_frames in its metadata header. A text-only request is 246 KB of it; one 768x1344 keyframe adds 1016 vision rows and takes it to 10.7 MB.

The keyframe resize step runs on both halves. It owns no pretrained component (PIL and arithmetic) and it puts the keyframes onto the target canvas — which the conditioner needs to build its vision blocks and this Space needs to encode with the video VAE. It is deterministic, and the conditioner returns the plan it resolved so this Space pins the same canvas rather than re-deriving it. Two things that step no longer does, and that both halves therefore do themselves: EXIF-transposing a keyframe into upright RGB, and snapping num_frames to 17 * n + 5 — the frame count is resolved by the layout step, which lives on this side of the cut.

Nothing is paid for with GPU time

The 77.3 GB download and the load happen at startup: import spaces at module top patches torch.cuda before any GPU is attached, so nothing about the load needs a card. The conditioner round trip is a network call on this Space's CPU. A @spaces.GPU call is therefore only the placement (once) and the denoise loop and the two decoders.

The 150 GB quota, not the 95 GiB card, is what rules out startup placement

One thing does not happen at startup: the move onto the card. spaces' startup torch.pack() writes every startup-resident CUDA tensor to a second copy on disk and only deletes the downloaded originals afterwards (Cleaned 62.13GB of tensor files ... after packing, which is what keeps the conditioner half comfortable at 66.7 GB). Packing 77.3 GB needs 154.6 GB at once, and this Space is evicted mid-pack:

ZeroGPU tensors packing:   0%|          | 0.00/77.3G
OSError: [Errno 28] No space left on device      # os.posix_fallocate, spaces/zero/torch/packing.py

Unlinking the shards first does not rescue it. .to("cuda") under the startup patch does not release the memory-mapped safetensors, so nothing is freed — and the pack's own cleanup walks those still-open mappings and lstats them, so a deleted blob becomes FileNotFoundError: .../blobs/3d449... (deleted).

Placement therefore happens on the first GPU call, PIPE.to("cuda") at the top of the @spaces.GPU function: about 10 s of PCIe once, then a no-op walk, and the denoise loop runs with everything resident and no offloading at all. It is the same trick the 4 bit Space uses, for the same reason.

Generation constraints

Fixed by the checkpoint: 24 fps, a 768 pixel short edge, 5 to 15 s, num_frames snapped up to the next 17 * n + 5, no CFG and no negative prompt (it is guidance-distilled, so every step is one forward pass).

Turbo LoRA (optional)

The UI includes a Turbo checkbox that applies the InstantX/MiniMax-H3-Turbo-Lora-Diffusers distillation LoRA (larryvrh, ckpt500 diffusers conversion) at generation time:

  • 7 steps instead of the default 25 — ~4× faster generation with minimal quality loss.
  • The LoRA is not merged at startup. Instead, A/B factor matrices (~1.2 GB total for 363 modules) are stored on CPU and moved to GPU on first turbo call. The delta (B @ A) is computed on-the-fly per module (~57 MB temporary, freed each iteration), keeping peak extra GPU memory under 60 MB.
  • Merge takes ~0.35 s, unmerge ~0.01 s — effectively zero overhead.
  • The @spaces.GPU duration budget includes a 120 s bonus on the first turbo merge to account for the A/B CPU→GPU transfer.

NSFW / anatomy LoRAs (optional)

The UI includes an NSFW Innie Pussy (HMInnie V1) checkbox that applies the HMInnie_v1_e50 anatomy LoRA from Hearmeman/minimax-h3-loras at generation time:

  • Standard LoRA format; ComfyUI keys (diffusion_model.blocks.N.attn.out_proj.lora_A/B.weight, etc.) are converted to the diffusers module-tree keys at load (including the token_refiner blocks).
  • Trigger token inniepussy, strength 1.0.
  • Lazy-loaded on CPU and merged on GPU only when the checkbox is selected — no startup cost.
  • Note: LoKR-format LoRAs (e.g. HMPussy v0.5 / hmpussy_v6_epoch30) are not supported by the loader.

Measured

An rtx-pro-6000 Job — the same silicon as the ZeroGPU pool (RTX PRO 6000 Blackwell, sm120, 95.0 GiB) — running exactly this blockset over the wire format, 1344x768, 124 frames, 30 steps, bfloat16, cuDNN attention, everything resident:

load_components (77.3 GB, warm Xet)43 s
.to("cuda"), once10 s
resident weights72.16 GiB
denoise + decode317 s, 10.58 s/step
peak allocated / reserved78.54 / 85.37 GiB
outputh264 1344x768 @ 24 fps, 5.167 s + stereo AAC @ 32 kHz

And on this Space itself, driven over gradio_client. Startup is 93 s — the 77.3 GB download and the load, with no placement and therefore no pack.

RequestConditionerDenoise + decodeSteadyRound trip
text only, 18 tokens7 s339 s10.53 s/step353 s
one 768x1344 keyframe, 1034 tokens9 s370 s11.39 s/step386 s

The keyframe costs about 8% per step rather than a placement penalty: it puts 1016 vision rows in front of the prompt and 1016 conditioning rows in the packed sequence, and MiniMax-H3 attends over all of it every layer. The one-time PIPE.to("cuda") is inside the first row's 339 s and does not reappear in the second.

Space variables

VariableDefaultMeaning
H3_MODEL_REPOMiniMaxAI/MiniMax-H3The diffusers-layout checkpoint. Public.
H3_CONDITIONERmultimodalart/qwen3vl-conditionerThe Space this one asks for embeddings.
H3_PLACEMENTpackpack moves the transformer at startup and packs it to disk; the ~10 GB fp32 VAEs move on the first GPU call. lazy moves all 72 GiB on the first GPU call; offload hands placement to ComponentsManager.enable_auto_cpu_offload instead.
H3_ATTENTION_native_cudnncuDNN's fused kernel, 10–20% faster than the SDPA default and needs nothing installed. flash-attention 3 is sm90-only and this pool is sm120.
H3_GPU_DURATION900Seconds per request; the pool applies a 1.5 duration factor.
H3_GPU_SIZExlargeZeroGPU allocation size. large does not fit.

Whose GPU quota pays

Two cards are booked per request — this Space's denoise loop and the conditioner's forward — and both are billed to the requesting user, with nothing here arranging it: gradio_client attaches the caller's own x-ip-token to every outgoing call, reading it off gradio's LocalContext inside the event listener (Client.send_data -> add_zero_gpu_headers), and ZeroGPU charges the booking to whatever that token identifies.

A caller with no token to forward — a gradio_client script rather than a browser — leaves the conditioner's booking attributed to this Space's pod IP and its small shared quota. An unattributed caller may book at most 120 credits at a time and an xlarge booking costs twice its seconds, so the conditioner books the encode (45 s) and a prompt upsample (60 s) as two separate calls, each within that ceiling.

Secrets

None are required. Everything this Space downloads is public — the MiniMaxAI/MiniMax-H3 checkpoint and the multimodalart/minimax-h3-aoti packages — and the conditioner is a public Space called on the requesting user's own ZeroGPU token, never on an org token.

Where diffusers comes from

MiniMax-H3 is modular-only and not in a released diffusers, so requirements.txt installs it from the canonical pull request, huggingface/diffusers#14371, pinned to the commit 665f5782 (refs/pull/14371/head) rather than to the moving minimax-h3-refactor branch.

That PR is a WIP, so it needs re-pinning whenever it updates, and h3_split_blocks.py — which subclasses its block classes to cut the pipeline in two — has to be re-checked against the new head at the same time.

Contributors