Spaces:
Running on Zero
Running on Zero
Sync the split MiniMax-H3 Spaces
Browse files
README.md
CHANGED
|
@@ -31,11 +31,15 @@ reason alone. Cut the `MiniMaxH3Blocks` sequence at its `text_encoder` step and
|
|
| 31 |
| Space | Subfolders | Download | Resident |
|
| 32 |
|---|---|---|---|
|
| 33 |
| [`minimax-h3-conditioner`](https://huggingface.co/spaces/diffusers-internal-dev/minimax-h3-conditioner) | `text_encoder/` + `tokenizer/` + `processor/` | 66.7 GB | 62.15 GiB bf16 |
|
| 34 |
-
| this one | `transformer/` + `vae/` + `audio_vae/` | 77.3 GB | 61.73 GiB bf16 +
|
| 35 |
|
| 36 |
Besides the quality argument, unquantized weights are the ones AoTI can export; an NVFP4 checkpoint cannot be
|
| 37 |
exported at all.
|
| 38 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 39 |
## How the split is expressed
|
| 40 |
|
| 41 |
`MiniMaxH3Blocks` is a `SequentialPipelineBlocks` of eight steps:
|
|
@@ -71,17 +75,47 @@ before any GPU is attached, and these are plain bfloat16 tensors, so ZeroGPU's s
|
|
| 71 |
torchao `Float8Tensor` that cannot be packed, and there is none here). The conditioner round trip is a network call
|
| 72 |
on this Space's CPU. A `@spaces.GPU` call is therefore only the denoise loop and the two decoders.
|
| 73 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 74 |
## Generation constraints
|
| 75 |
|
| 76 |
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`,
|
| 77 |
no CFG and no negative prompt (it is guidance-distilled, so every step is one forward pass).
|
| 78 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 79 |
## Space variables
|
| 80 |
|
| 81 |
| Variable | Default | Meaning |
|
| 82 |
|---|---|---|
|
| 83 |
| `H3_CONDITIONER` | `diffusers-internal-dev/minimax-h3-conditioner` | The Space this one asks for embeddings. |
|
| 84 |
-
| `H3_PLACEMENT` | `
|
| 85 |
| `H3_ATTENTION` | `_native_cudnn` | cuDNN'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. |
|
| 86 |
| `H3_GPU_DURATION` | `900` | Seconds per request; the pool applies a 1.5 duration factor. |
|
| 87 |
| `H3_GPU_SIZE` | `xlarge` | ZeroGPU allocation size. `large` does not fit. |
|
|
|
|
| 31 |
| Space | Subfolders | Download | Resident |
|
| 32 |
|---|---|---|---|
|
| 33 |
| [`minimax-h3-conditioner`](https://huggingface.co/spaces/diffusers-internal-dev/minimax-h3-conditioner) | `text_encoder/` + `tokenizer/` + `processor/` | 66.7 GB | 62.15 GiB bf16 |
|
| 34 |
+
| this one | `transformer/` + `vae/` + `audio_vae/` | 77.3 GB | 61.73 GiB bf16 + 10.43 GiB float32 |
|
| 35 |
|
| 36 |
Besides the quality argument, unquantized weights are the ones AoTI can export; an NVFP4 checkpoint cannot be
|
| 37 |
exported at all.
|
| 38 |
|
| 39 |
+
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
|
| 40 |
+
*unquantized* MiniMax-H3 is also the fastest one measured here: **10.6 s/step** against the 19–21 s/step the 4 bit
|
| 41 |
+
Space pays, where the whole cost is the traffic auto-offload has to move.
|
| 42 |
+
|
| 43 |
## How the split is expressed
|
| 44 |
|
| 45 |
`MiniMaxH3Blocks` is a `SequentialPipelineBlocks` of eight steps:
|
|
|
|
| 75 |
torchao `Float8Tensor` that cannot be packed, and there is none here). The conditioner round trip is a network call
|
| 76 |
on this Space's CPU. A `@spaces.GPU` call is therefore only the denoise loop and the two decoders.
|
| 77 |
|
| 78 |
+
### The 150 GB quota, not the 95 GiB card, is what limits startup placement
|
| 79 |
+
|
| 80 |
+
`spaces`' startup `torch.pack()` writes every startup-resident CUDA tensor to a **second copy on disk**, and only
|
| 81 |
+
deletes the downloaded originals afterwards (`Cleaned 62.13GB of tensor files ... after packing`). Moving all
|
| 82 |
+
77.3 GB onto the card therefore needs 154.6 GB at once, and the Space is evicted mid-pack:
|
| 83 |
+
|
| 84 |
+
```
|
| 85 |
+
ZeroGPU tensors packing: 0%| | 0.00/77.3G
|
| 86 |
+
OSError: [Errno 28] No space left on device # os.posix_fallocate in spaces/zero/torch/packing.py
|
| 87 |
+
```
|
| 88 |
+
|
| 89 |
+
So only the transformer is preplaced — a 66.3 GB pack — and `app.py` unlinks the downloaded shards itself *before*
|
| 90 |
+
`launch()` rather than after, which `.to("cuda")` has already made unreferenced. The autoencoders stay on the host
|
| 91 |
+
and cross PCIe on the first request, 10.43 GiB and a no-op on every request after it.
|
| 92 |
+
|
| 93 |
## Generation constraints
|
| 94 |
|
| 95 |
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`,
|
| 96 |
no CFG and no negative prompt (it is guidance-distilled, so every step is one forward pass).
|
| 97 |
|
| 98 |
+
## Measured
|
| 99 |
+
|
| 100 |
+
An `rtx-pro-6000` Job — the same silicon as the ZeroGPU pool (RTX PRO 6000 Blackwell, sm120, 95.0 GiB) — running
|
| 101 |
+
exactly this blockset over the wire format, 1344x768, 124 frames, 30 steps, bfloat16, cuDNN attention, everything
|
| 102 |
+
resident:
|
| 103 |
+
|
| 104 |
+
| | |
|
| 105 |
+
|---|---|
|
| 106 |
+
| `load_components` (77.3 GB, warm Xet) | 43 s |
|
| 107 |
+
| `.to("cuda")` | 10 s |
|
| 108 |
+
| resident weights | 72.16 GiB |
|
| 109 |
+
| denoise + decode | 317 s, **10.58 s/step** |
|
| 110 |
+
| peak allocated / reserved | 78.54 / 85.37 GiB |
|
| 111 |
+
| output | h264 1344x768 @ 24 fps, 5.167 s + stereo AAC @ 32 kHz |
|
| 112 |
+
|
| 113 |
## Space variables
|
| 114 |
|
| 115 |
| Variable | Default | Meaning |
|
| 116 |
|---|---|---|
|
| 117 |
| `H3_CONDITIONER` | `diffusers-internal-dev/minimax-h3-conditioner` | The Space this one asks for embeddings. |
|
| 118 |
+
| `H3_PLACEMENT` | `preplace` | `preplace` puts the transformer on the card at startup and the autoencoders on the first request; `offload` hands placement to `ComponentsManager.enable_auto_cpu_offload`. |
|
| 119 |
| `H3_ATTENTION` | `_native_cudnn` | cuDNN'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. |
|
| 120 |
| `H3_GPU_DURATION` | `900` | Seconds per request; the pool applies a 1.5 duration factor. |
|
| 121 |
| `H3_GPU_SIZE` | `xlarge` | ZeroGPU allocation size. `large` does not fit. |
|
app.py
CHANGED
|
@@ -30,9 +30,11 @@ import gradio as gr
|
|
| 30 |
|
| 31 |
MODEL_REPO = os.environ.get("H3_MODEL_REPO", "diffusers-internal-dev/MiniMax-H3")
|
| 32 |
CONDITIONER_SPACE = os.environ.get("H3_CONDITIONER", "diffusers-internal-dev/minimax-h3-conditioner")
|
| 33 |
-
# `
|
| 34 |
-
#
|
| 35 |
-
|
|
|
|
|
|
|
| 36 |
# cuDNN's fused attention is 10-20% faster than the SDPA default on this pool and needs nothing installed.
|
| 37 |
# flash-attention 3 is sm90-only and this card is sm120 (the `zero-a10g` flavour name is legacy).
|
| 38 |
ATTENTION = os.environ.get("H3_ATTENTION", "_native_cudnn").lower()
|
|
@@ -113,10 +115,12 @@ def load_models() -> str | None:
|
|
| 113 |
pipe.load_components(dtype=torch.bfloat16, token=token)
|
| 114 |
pipe.transformer.set_attention_backend(ATTENTION)
|
| 115 |
|
| 116 |
-
if PLACEMENT == "
|
| 117 |
# Plain bfloat16 tensors, so ZeroGPU's startup packing handles them — the thing that cannot be moved at
|
| 118 |
-
# startup is a torchao `Float8Tensor`, whose `aten.empty_like(pin_memory=True)` is unimplemented.
|
| 119 |
-
|
|
|
|
|
|
|
| 120 |
else:
|
| 121 |
manager.enable_auto_cpu_offload(device="cuda")
|
| 122 |
_arm_decode_hooks(pipe)
|
|
@@ -130,6 +134,46 @@ def load_models() -> str | None:
|
|
| 130 |
return LOAD_ERROR
|
| 131 |
|
| 132 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 133 |
def _arm_decode_hooks(pipe):
|
| 134 |
"""Make the offload hooks fire for the two VAEs.
|
| 135 |
|
|
@@ -180,10 +224,20 @@ def encode_remote(prompt, image_path, last_image_path, canvas, num_frames):
|
|
| 180 |
|
| 181 |
@spaces.GPU(duration=GPU_DURATION, size=GPU_SIZE)
|
| 182 |
def _generate(prompt_embeds, text_token_tags, image, last_image, height, width, num_frames, steps, seed):
|
| 183 |
-
"""The only thing on GPU time: the packed-sequence denoise loop and the two decoders.
|
|
|
|
|
|
|
|
|
|
|
|
|
| 184 |
import torch
|
| 185 |
|
| 186 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 187 |
prompt_embeds=prompt_embeds.to("cuda"),
|
| 188 |
text_token_tags=text_token_tags,
|
| 189 |
image=image,
|
|
@@ -194,6 +248,7 @@ def _generate(prompt_embeds, text_token_tags, image, last_image, height, width,
|
|
| 194 |
num_inference_steps=int(steps),
|
| 195 |
generator=torch.Generator("cpu").manual_seed(int(seed)),
|
| 196 |
)
|
|
|
|
| 197 |
|
| 198 |
|
| 199 |
def generate(prompt, image_path, last_image_path, canvas, duration, steps, seed, progress=gr.Progress()):
|
|
@@ -220,7 +275,7 @@ def generate(prompt, image_path, last_image_path, canvas, duration, steps, seed,
|
|
| 220 |
|
| 221 |
progress(0.1, desc=f"Denoising {steps} steps at {width}x{height}, {num_frames} frames ...")
|
| 222 |
started = time.time()
|
| 223 |
-
|
| 224 |
prompt_embeds,
|
| 225 |
text_token_tags,
|
| 226 |
Image.open(image_path) if image_path else None,
|
|
@@ -236,13 +291,7 @@ def generate(prompt, image_path, last_image_path, canvas, duration, steps, seed,
|
|
| 236 |
directory = os.path.join(tempfile.gettempdir(), "h3-outputs")
|
| 237 |
os.makedirs(directory, exist_ok=True)
|
| 238 |
path = os.path.join(directory, f"h3-{int(time.time() * 1000)}.mp4")
|
| 239 |
-
encode_video(
|
| 240 |
-
state.get("videos")[0],
|
| 241 |
-
fps=FPS,
|
| 242 |
-
output_path=path,
|
| 243 |
-
audio=state.get("audio")[0],
|
| 244 |
-
audio_sample_rate=state.get("sampling_rate"),
|
| 245 |
-
)
|
| 246 |
|
| 247 |
report = (
|
| 248 |
f"`{width}x{height}`, {num_frames} frames ({num_frames / FPS:.3f} s), {int(steps)} steps · "
|
|
|
|
| 30 |
|
| 31 |
MODEL_REPO = os.environ.get("H3_MODEL_REPO", "diffusers-internal-dev/MiniMax-H3")
|
| 32 |
CONDITIONER_SPACE = os.environ.get("H3_CONDITIONER", "diffusers-internal-dev/minimax-h3-conditioner")
|
| 33 |
+
# `preplace` moves the 61.73 GiB transformer onto the card at startup and leaves the 10.43 GiB of float32
|
| 34 |
+
# autoencoders for the first GPU call; `offload` hands the whole thing to
|
| 35 |
+
# `ComponentsManager.enable_auto_cpu_offload` instead. See `_drop_cached_weight_blobs` for why the *transformer
|
| 36 |
+
# alone* rather than everything: the 150 GB storage quota, not the 95 GiB card, is the binding constraint at startup.
|
| 37 |
+
PLACEMENT = os.environ.get("H3_PLACEMENT", "preplace").lower()
|
| 38 |
# cuDNN's fused attention is 10-20% faster than the SDPA default on this pool and needs nothing installed.
|
| 39 |
# flash-attention 3 is sm90-only and this card is sm120 (the `zero-a10g` flavour name is legacy).
|
| 40 |
ATTENTION = os.environ.get("H3_ATTENTION", "_native_cudnn").lower()
|
|
|
|
| 115 |
pipe.load_components(dtype=torch.bfloat16, token=token)
|
| 116 |
pipe.transformer.set_attention_backend(ATTENTION)
|
| 117 |
|
| 118 |
+
if PLACEMENT == "preplace":
|
| 119 |
# Plain bfloat16 tensors, so ZeroGPU's startup packing handles them — the thing that cannot be moved at
|
| 120 |
+
# startup is a torchao `Float8Tensor`, whose `aten.empty_like(pin_memory=True)` is unimplemented. The
|
| 121 |
+
# autoencoders deliberately stay behind; `_generate` moves them on the first request.
|
| 122 |
+
pipe.transformer.to("cuda")
|
| 123 |
+
_log_disk(f"freed {_drop_cached_weight_blobs():.1f} GiB of downloaded weights")
|
| 124 |
else:
|
| 125 |
manager.enable_auto_cpu_offload(device="cuda")
|
| 126 |
_arm_decode_hooks(pipe)
|
|
|
|
| 134 |
return LOAD_ERROR
|
| 135 |
|
| 136 |
|
| 137 |
+
def _log_disk(label: str) -> None:
|
| 138 |
+
import shutil
|
| 139 |
+
|
| 140 |
+
from huggingface_hub.constants import HF_HUB_CACHE
|
| 141 |
+
|
| 142 |
+
probe = HF_HUB_CACHE if os.path.isdir(HF_HUB_CACHE) else os.path.expanduser("~")
|
| 143 |
+
_total, used, free = shutil.disk_usage(probe)
|
| 144 |
+
print(f"[gen] {label} · disk {used / 2**30:.1f} used, {free / 2**30:.1f} free", flush=True)
|
| 145 |
+
|
| 146 |
+
|
| 147 |
+
def _drop_cached_weight_blobs() -> float:
|
| 148 |
+
"""Delete the downloaded weight files once their tensors are copies in (fake-)CUDA memory.
|
| 149 |
+
|
| 150 |
+
A ZeroGPU Space is evicted at **150 GB of storage**, and `spaces`' startup `torch.pack()` writes every
|
| 151 |
+
startup-resident CUDA tensor to a *second* copy on disk before deleting the originals itself. This half
|
| 152 |
+
downloads 77.3 GB, so packing all of it needs 154.6 GB and the Space dies with `OSError: [Errno 28] No space
|
| 153 |
+
left on device` in `os.posix_fallocate` — which is exactly how its first bring-up died.
|
| 154 |
+
|
| 155 |
+
Two things keep it under the cap. Only the transformer is moved onto the card, so the pack is 66.3 GB rather
|
| 156 |
+
than 77.3 GB; and its shards are dropped here, before `launch()`, rather than after the pack. `.to("cuda")`
|
| 157 |
+
copies each parameter out of the memory-mapped shard, so those blobs are unreferenced by then and unlinking
|
| 158 |
+
them frees the space for real. The autoencoders are still mapped, so their ~11 GB survives the unlink — which
|
| 159 |
+
is fine, 66.3 + 11 is nowhere near the cap, and it is the arithmetic this Space is designed around:
|
| 160 |
+
|
| 161 |
+
worst case, nothing frees 77.3 (download) + 66.3 (pack) = 143.6 GB
|
| 162 |
+
expected 11 (still-mapped VAEs) + 66.3 (pack) = 77.3 GB
|
| 163 |
+
"""
|
| 164 |
+
import glob
|
| 165 |
+
|
| 166 |
+
from huggingface_hub.constants import HF_HUB_CACHE
|
| 167 |
+
|
| 168 |
+
blobs = os.path.join(HF_HUB_CACHE, "models--" + MODEL_REPO.replace("/", "--"), "blobs", "*")
|
| 169 |
+
freed = 0
|
| 170 |
+
for blob in glob.glob(blobs):
|
| 171 |
+
size = os.path.getsize(blob)
|
| 172 |
+
os.unlink(blob)
|
| 173 |
+
freed += size
|
| 174 |
+
return freed / 2**30
|
| 175 |
+
|
| 176 |
+
|
| 177 |
def _arm_decode_hooks(pipe):
|
| 178 |
"""Make the offload hooks fire for the two VAEs.
|
| 179 |
|
|
|
|
| 224 |
|
| 225 |
@spaces.GPU(duration=GPU_DURATION, size=GPU_SIZE)
|
| 226 |
def _generate(prompt_embeds, text_token_tags, image, last_image, height, width, num_frames, steps, seed):
|
| 227 |
+
"""The only thing on GPU time: the packed-sequence denoise loop and the two decoders.
|
| 228 |
+
|
| 229 |
+
Only the three generated outputs come back. A `@spaces.GPU` return crosses a process boundary by pickling, and
|
| 230 |
+
the full `PipelineState` still holds the packed latents, the rotary grid and the row indices on the card.
|
| 231 |
+
"""
|
| 232 |
import torch
|
| 233 |
|
| 234 |
+
if PLACEMENT == "preplace":
|
| 235 |
+
# The two autoencoders were left on the host so that ZeroGPU's startup pack stayed inside the storage quota;
|
| 236 |
+
# 10.43 GiB across PCIe on the first request, and a no-op walk on every one after it.
|
| 237 |
+
PIPE.vae.to("cuda")
|
| 238 |
+
PIPE.audio_vae.to("cuda")
|
| 239 |
+
|
| 240 |
+
state = PIPE(
|
| 241 |
prompt_embeds=prompt_embeds.to("cuda"),
|
| 242 |
text_token_tags=text_token_tags,
|
| 243 |
image=image,
|
|
|
|
| 248 |
num_inference_steps=int(steps),
|
| 249 |
generator=torch.Generator("cpu").manual_seed(int(seed)),
|
| 250 |
)
|
| 251 |
+
return state.get("videos")[0], state.get("audio")[0].cpu(), state.get("sampling_rate")
|
| 252 |
|
| 253 |
|
| 254 |
def generate(prompt, image_path, last_image_path, canvas, duration, steps, seed, progress=gr.Progress()):
|
|
|
|
| 275 |
|
| 276 |
progress(0.1, desc=f"Denoising {steps} steps at {width}x{height}, {num_frames} frames ...")
|
| 277 |
started = time.time()
|
| 278 |
+
frames, audio, sampling_rate = _generate(
|
| 279 |
prompt_embeds,
|
| 280 |
text_token_tags,
|
| 281 |
Image.open(image_path) if image_path else None,
|
|
|
|
| 291 |
directory = os.path.join(tempfile.gettempdir(), "h3-outputs")
|
| 292 |
os.makedirs(directory, exist_ok=True)
|
| 293 |
path = os.path.join(directory, f"h3-{int(time.time() * 1000)}.mp4")
|
| 294 |
+
encode_video(frames, fps=FPS, output_path=path, audio=audio, audio_sample_rate=sampling_rate)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 295 |
|
| 296 |
report = (
|
| 297 |
f"`{width}x{height}`, {num_frames} frames ({num_frames / FPS:.3f} s), {int(steps)} steps · "
|