Spaces:
Running on Zero
Running on Zero
Cache the conditioner client with functools.cache, tidy comments
Browse files- README.md +10 -16
- app.py +12 -49
- h3_aoti.py +2 -3
- h3_split_blocks.py +16 -29
README.md
CHANGED
|
@@ -161,18 +161,14 @@ one-time `PIPE.to("cuda")` is inside the first row's 339 s and does not reappear
|
|
| 161 |
## Whose GPU quota pays
|
| 162 |
|
| 163 |
Two cards are booked per request — this Space's denoise loop and the conditioner's forward — and both are billed to the
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
identifies. Forwarding the header by hand is not needed and is actively worse: a cached `Client` would pin one stale
|
| 168 |
-
token, which ZeroGPU refuses with `Expired ZeroGPU proxy token`.
|
| 169 |
|
| 170 |
A caller with no token to forward — a `gradio_client` script rather than a browser — leaves the conditioner's booking
|
| 171 |
-
attributed to this Space's pod IP and its small shared quota.
|
| 172 |
-
|
| 173 |
-
(
|
| 174 |
-
where one combined booking of the old 300 s would be — and was — refused outright with `The requested GPU duration
|
| 175 |
-
(600s) is larger than the maximum allowed`.
|
| 176 |
|
| 177 |
## Secrets
|
| 178 |
|
|
@@ -184,10 +180,8 @@ is a public Space called on the requesting user's own ZeroGPU token, never on an
|
|
| 184 |
## Where diffusers comes from
|
| 185 |
|
| 186 |
MiniMax-H3 is modular-only and not in a released `diffusers`, so `requirements.txt` installs it from the canonical
|
| 187 |
-
pull request, [huggingface/diffusers#14371](https://github.com/huggingface/diffusers/pull/14371), pinned to the
|
| 188 |
-
|
| 189 |
|
| 190 |
-
That PR is a WIP
|
| 191 |
-
classes to cut the pipeline in two — has to be re-checked against the new head at the same time.
|
| 192 |
-
the blocks into one workflow-selected pipeline, so block names and the shape of the split are exactly what a new head
|
| 193 |
-
is liable to move.
|
|
|
|
| 161 |
## Whose GPU quota pays
|
| 162 |
|
| 163 |
Two cards are booked per request — this Space's denoise loop and the conditioner's forward — and both are billed to the
|
| 164 |
+
requesting user, with nothing here arranging it: `gradio_client` attaches the caller's own `x-ip-token` to every
|
| 165 |
+
outgoing call, reading it off gradio's `LocalContext` inside the event listener (`Client.send_data` ->
|
| 166 |
+
`add_zero_gpu_headers`), and ZeroGPU charges the booking to whatever that token identifies.
|
|
|
|
|
|
|
| 167 |
|
| 168 |
A caller with no token to forward — a `gradio_client` script rather than a browser — leaves the conditioner's booking
|
| 169 |
+
attributed to this Space's pod IP and its small shared quota. An unattributed caller may book at most 120 credits at a
|
| 170 |
+
time and an `xlarge` booking costs twice its seconds, so the conditioner books the encode (45 s) and a prompt upsample
|
| 171 |
+
(60 s) as two separate calls, each within that ceiling.
|
|
|
|
|
|
|
| 172 |
|
| 173 |
## Secrets
|
| 174 |
|
|
|
|
| 180 |
## Where diffusers comes from
|
| 181 |
|
| 182 |
MiniMax-H3 is modular-only and not in a released `diffusers`, so `requirements.txt` installs it from the canonical
|
| 183 |
+
pull request, [huggingface/diffusers#14371](https://github.com/huggingface/diffusers/pull/14371), pinned to the commit
|
| 184 |
+
`665f5782` (`refs/pull/14371/head`) rather than to the moving `minimax-h3-refactor` branch.
|
| 185 |
|
| 186 |
+
That PR is a WIP, so it needs re-pinning whenever it updates, and `h3_split_blocks.py` — which subclasses its block
|
| 187 |
+
classes to cut the pipeline in two — has to be re-checked against the new head at the same time.
|
|
|
|
|
|
app.py
CHANGED
|
@@ -7,6 +7,7 @@ import os
|
|
| 7 |
import tempfile
|
| 8 |
import time
|
| 9 |
import traceback
|
|
|
|
| 10 |
|
| 11 |
# First, and at module level. `import spaces` patches `torch.cuda` before any GPU is attached, which is what lets the
|
| 12 |
# 72 GiB load happen at **startup** rather than on GPU time; it also has to precede anything that initializes CUDA.
|
|
@@ -65,7 +66,6 @@ PIPE = None
|
|
| 65 |
MANAGER = None
|
| 66 |
LOAD_ERROR: str | None = None
|
| 67 |
LOADED_IN: float | None = None
|
| 68 |
-
CLIENT = None
|
| 69 |
|
| 70 |
|
| 71 |
def status() -> str:
|
|
@@ -117,8 +117,6 @@ def load_models() -> str | None:
|
|
| 117 |
blocks = MiniMaxH3GeneratorBlocks()
|
| 118 |
print(f"[gen] loading {[c.name for c in blocks.expected_components]} from {MODEL_REPO} ...", flush=True)
|
| 119 |
pipe = blocks.init_pipeline(MODEL_REPO, components_manager=manager, collection="h3")
|
| 120 |
-
# Every repository this Space reads is public — the checkpoint, the AoTI packages and the conditioner
|
| 121 |
-
# Space — so no token is passed anywhere.
|
| 122 |
pipe.load_components(dtype=torch.bfloat16)
|
| 123 |
pipe.transformer.set_attention_backend(ATTENTION)
|
| 124 |
|
|
@@ -170,45 +168,13 @@ def _arm_decode_hooks(pipe):
|
|
| 170 |
module.decode = armed
|
| 171 |
|
| 172 |
|
|
|
|
| 173 |
def conditioner():
|
| 174 |
-
"""The other half, over the gradio API.
|
|
|
|
|
|
|
| 175 |
|
| 176 |
-
|
| 177 |
-
by reading the `x-ip-token` of the request being served off gradio's `LocalContext` (`Client.send_data` ->
|
| 178 |
-
`add_zero_gpu_headers`). So calling this from inside an event listener — which is the only place it is called —
|
| 179 |
-
bills the conditioner's booking to the user who asked for the video, exactly as this Space's own booking is, and
|
| 180 |
-
forwarding the header by hand would only pin a stale token onto a cached client.
|
| 181 |
-
"""
|
| 182 |
-
global CLIENT
|
| 183 |
-
if CLIENT is None:
|
| 184 |
-
from gradio_client import Client
|
| 185 |
-
|
| 186 |
-
CLIENT = Client(CONDITIONER_SPACE)
|
| 187 |
-
return CLIENT
|
| 188 |
-
|
| 189 |
-
def encode_remote(prompt, image_path, last_image_path, canvas, num_frames, rewrite_prompt=False):
|
| 190 |
-
"""Ask the conditioner Space for `prompt_embeds` + `text_token_tags`. Off this Space's GPU time entirely.
|
| 191 |
-
|
| 192 |
-
`rewrite_prompt` is the conditioner's prompt upsampling: it rewrites the request into MiniMax-H3's trained format
|
| 193 |
-
with its own Qwen3-VL and encodes *that*, handing the rewrite back under the plan's `refined_prompt`. It runs on
|
| 194 |
-
the conditioner's GPU booking, and this whole call happens before `_generate` books a card here, so it costs this
|
| 195 |
-
Space's `get_duration` nothing.
|
| 196 |
-
"""
|
| 197 |
-
from gradio_client import handle_file
|
| 198 |
-
from safetensors import safe_open
|
| 199 |
-
|
| 200 |
-
path, plan = conditioner().predict(
|
| 201 |
-
prompt=prompt,
|
| 202 |
-
image_path=handle_file(image_path) if image_path else None,
|
| 203 |
-
last_image_path=handle_file(last_image_path) if last_image_path else None,
|
| 204 |
-
canvas=canvas,
|
| 205 |
-
num_frames=num_frames,
|
| 206 |
-
rewrite_prompt=bool(rewrite_prompt),
|
| 207 |
-
api_name="/encode",
|
| 208 |
-
)
|
| 209 |
-
with safe_open(path, framework="pt") as handle:
|
| 210 |
-
metadata = handle.metadata()
|
| 211 |
-
return handle.get_tensor("prompt_embeds"), handle.get_tensor("text_token_tags"), metadata, plan
|
| 212 |
|
| 213 |
|
| 214 |
|
|
@@ -262,7 +228,7 @@ def _generate(prompt_embeds, text_token_tags, image, last_image, height, width,
|
|
| 262 |
|
| 263 |
|
| 264 |
def generate(prompt, image_path=None, last_image_path=None, canvas=DEFAULT_CANVAS, duration=5, steps=28, seed=42, upsample=False, progress=gr.Progress(track_tqdm=True)):
|
| 265 |
-
"""One request. `upsample` is
|
| 266 |
if LOAD_ERROR:
|
| 267 |
raise gr.Error(LOAD_ERROR)
|
| 268 |
if PIPE is None:
|
|
@@ -285,9 +251,8 @@ def generate(prompt, image_path=None, last_image_path=None, canvas=DEFAULT_CANVA
|
|
| 285 |
height, width, num_frames = (int(metadata[key]) for key in ("height", "width", "num_frames"))
|
| 286 |
refined = plan.get("refined_prompt") or ""
|
| 287 |
|
| 288 |
-
# EXIF-transposed and in RGB
|
| 289 |
-
#
|
| 290 |
-
# would not be of the image the conditioner looked at.
|
| 291 |
def keyframe(path):
|
| 292 |
return ImageOps.exif_transpose(Image.open(path)).convert("RGB") if path else None
|
| 293 |
|
|
@@ -403,8 +368,7 @@ with gr.Blocks(title="MiniMax-H3") as demo:
|
|
| 403 |
with gr.Column():
|
| 404 |
video = gr.Video(label="Video + soundtrack")
|
| 405 |
report = gr.Markdown(visible=False)
|
| 406 |
-
#
|
| 407 |
-
# empty panel. The accordion is an output for that reason: its visibility is part of the answer.
|
| 408 |
with gr.Accordion("Upsampled prompt", open=False, visible=False) as upsampled_panel:
|
| 409 |
upsampled = gr.Textbox(show_label=False, lines=8, interactive=False)
|
| 410 |
|
|
@@ -425,9 +389,8 @@ with gr.Blocks(title="MiniMax-H3") as demo:
|
|
| 425 |
cache_mode="lazy",
|
| 426 |
)
|
| 427 |
|
| 428 |
-
# `upsample` is
|
| 429 |
-
#
|
| 430 |
-
# video and the report stay first and the upsampled prompt is appended last.
|
| 431 |
run.click(
|
| 432 |
generate,
|
| 433 |
[prompt, image, last_image, canvas, duration, steps, seed, upsample],
|
|
|
|
| 7 |
import tempfile
|
| 8 |
import time
|
| 9 |
import traceback
|
| 10 |
+
from functools import cache
|
| 11 |
|
| 12 |
# First, and at module level. `import spaces` patches `torch.cuda` before any GPU is attached, which is what lets the
|
| 13 |
# 72 GiB load happen at **startup** rather than on GPU time; it also has to precede anything that initializes CUDA.
|
|
|
|
| 66 |
MANAGER = None
|
| 67 |
LOAD_ERROR: str | None = None
|
| 68 |
LOADED_IN: float | None = None
|
|
|
|
| 69 |
|
| 70 |
|
| 71 |
def status() -> str:
|
|
|
|
| 117 |
blocks = MiniMaxH3GeneratorBlocks()
|
| 118 |
print(f"[gen] loading {[c.name for c in blocks.expected_components]} from {MODEL_REPO} ...", flush=True)
|
| 119 |
pipe = blocks.init_pipeline(MODEL_REPO, components_manager=manager, collection="h3")
|
|
|
|
|
|
|
| 120 |
pipe.load_components(dtype=torch.bfloat16)
|
| 121 |
pipe.transformer.set_attention_backend(ATTENTION)
|
| 122 |
|
|
|
|
| 168 |
module.decode = armed
|
| 169 |
|
| 170 |
|
| 171 |
+
@cache
|
| 172 |
def conditioner():
|
| 173 |
+
"""The other half, over the gradio API. `gradio_client` attaches the caller's own ZeroGPU token per call, off
|
| 174 |
+
gradio's `LocalContext`, so the conditioner's booking is billed to the user who asked for the video."""
|
| 175 |
+
from gradio_client import Client
|
| 176 |
|
| 177 |
+
return Client(CONDITIONER_SPACE)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 178 |
|
| 179 |
|
| 180 |
|
|
|
|
| 228 |
|
| 229 |
|
| 230 |
def generate(prompt, image_path=None, last_image_path=None, canvas=DEFAULT_CANVAS, duration=5, steps=28, seed=42, upsample=False, progress=gr.Progress(track_tqdm=True)):
|
| 231 |
+
"""One request. `upsample` is last and defaults off, so a positional API client is unaffected by it."""
|
| 232 |
if LOAD_ERROR:
|
| 233 |
raise gr.Error(LOAD_ERROR)
|
| 234 |
if PIPE is None:
|
|
|
|
| 251 |
height, width, num_frames = (int(metadata[key]) for key in ("height", "width", "num_frames"))
|
| 252 |
refined = plan.get("refined_prompt") or ""
|
| 253 |
|
| 254 |
+
# EXIF-transposed and in RGB, the same way the conditioner prepares it: the conditioning latents encoded here have
|
| 255 |
+
# to be of the image the conditioner looked at.
|
|
|
|
| 256 |
def keyframe(path):
|
| 257 |
return ImageOps.exif_transpose(Image.open(path)).convert("RGB") if path else None
|
| 258 |
|
|
|
|
| 368 |
with gr.Column():
|
| 369 |
video = gr.Video(label="Video + soundtrack")
|
| 370 |
report = gr.Markdown(visible=False)
|
| 371 |
+
# An output, so it can be revealed only for a request that asked for a rewrite.
|
|
|
|
| 372 |
with gr.Accordion("Upsampled prompt", open=False, visible=False) as upsampled_panel:
|
| 373 |
upsampled = gr.Textbox(show_label=False, lines=8, interactive=False)
|
| 374 |
|
|
|
|
| 389 |
cache_mode="lazy",
|
| 390 |
)
|
| 391 |
|
| 392 |
+
# `upsample` is last and defaults off, so a positional API client that predates it is unaffected; so is the
|
| 393 |
+
# output order, with the upsampled prompt appended after the video and the report.
|
|
|
|
| 394 |
run.click(
|
| 395 |
generate,
|
| 396 |
[prompt, image, last_image, canvas, duration, steps, seed, upsample],
|
h3_aoti.py
CHANGED
|
@@ -62,9 +62,8 @@ import os
|
|
| 62 |
from pathlib import Path
|
| 63 |
|
| 64 |
AOTI = os.environ.get("H3_AOTI", "0") == "1"
|
| 65 |
-
#
|
| 66 |
-
#
|
| 67 |
-
# `spaces.aoti_load` expects, so the download is done by hand either way (see `maybe_load`).
|
| 68 |
AOTI_REPO = os.environ.get("H3_AOTI_REPO", "multimodalart/minimax-h3-aoti")
|
| 69 |
AOTI_REPO_TYPE = os.environ.get("H3_AOTI_REPO_TYPE", "model")
|
| 70 |
# `dynamic` is the one package that serves every canvas, duration *and prompt*, and for bfloat16 it is what gets built:
|
|
|
|
| 62 |
from pathlib import Path
|
| 63 |
|
| 64 |
AOTI = os.environ.get("H3_AOTI", "0") == "1"
|
| 65 |
+
# The artifacts are keyed by quant/torch/arch under `<width>/torch<X.Y>/sm<cc>/<shape>` rather than laid out the way
|
| 66 |
+
# `spaces.aoti_load` expects, so the download is done by hand (see `maybe_load`).
|
|
|
|
| 67 |
AOTI_REPO = os.environ.get("H3_AOTI_REPO", "multimodalart/minimax-h3-aoti")
|
| 68 |
AOTI_REPO_TYPE = os.environ.get("H3_AOTI_REPO_TYPE", "model")
|
| 69 |
# `dynamic` is the one package that serves every canvas, duration *and prompt*, and for bfloat16 it is what gets built:
|
h3_split_blocks.py
CHANGED
|
@@ -1,14 +1,13 @@
|
|
| 1 |
"""The halves of a **split** MiniMax-H3 deployment, for both of its checkpoint partitions.
|
| 2 |
|
| 3 |
-
MiniMax-H3 is modular-only
|
| 4 |
-
|
| 5 |
|
| 6 |
before_encode -> text_encoder -> vae_encoder -> denoise -> after_denoise -> decode
|
| 7 |
|
| 8 |
-
where `before_encode`, `text_encoder`, `vae_encoder` and `denoise`
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
`transformer_ref`.
|
| 12 |
|
| 13 |
The conditioner (a 62.14 GiB Qwen3-VL) and the denoiser (a 61.73 GiB transformer plus ~20.5 GiB of float32 VAEs) do
|
| 14 |
not fit on one 95 GiB card unquantized, so this module cuts that sequence in two at the `text_encoder` step, once per
|
|
@@ -22,25 +21,15 @@ partition:
|
|
| 22 |
* `MiniMaxH3Ref2VAConditionerBlocks` / `MiniMaxH3Ref2VAGeneratorBlocks` are the same cut through the `ref2va`
|
| 23 |
branch, so one conditioner Space serves both partitions out of the weights it already holds.
|
| 24 |
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
* unpacking the denoised rows moved out of the decoders into `MiniMaxH3AfterDenoiseStep`, so the generating halves
|
| 35 |
-
carry that step explicitly.
|
| 36 |
-
* `model_name` is `"minimax-h3"` on every block now — the `"minimax-h3-ref2va"` pipeline mapping was dropped when
|
| 37 |
-
the two blocksets became workflows of one pipeline.
|
| 38 |
-
|
| 39 |
-
`resize` / `setup` run on both sides on purpose. They own no pretrained component (PIL, decoded media and
|
| 40 |
-
arithmetic), they resolve the canvas and prepare the keyframes or normalize the references — which the conditioner
|
| 41 |
-
needs to build its vision blocks and the generator needs to encode with the VAEs. Running them twice over the same
|
| 42 |
-
inputs is deterministic; both conditioner halves return the resolved `height` / `width` / `num_frames` anyway, so the
|
| 43 |
-
caller pins them explicitly on the generating half.
|
| 44 |
|
| 45 |
Only *text* encoding is remote. `vae_encoder` / `reference_encoder` stay on the denoising side: they run the two
|
| 46 |
autoencoders, which the conditioner Space does not hold.
|
|
@@ -68,8 +57,7 @@ from diffusers.modular_pipelines.modular_pipeline_utils import OutputParam
|
|
| 68 |
def _wire_outputs(num_frames: bool = True) -> list[OutputParam]:
|
| 69 |
"""The wire format of the split, plus the plan the caller pins on the generating half.
|
| 70 |
|
| 71 |
-
`num_frames` is
|
| 72 |
-
while the keyframe half's own resolution moved into the layout step, on the other side of the cut.
|
| 73 |
"""
|
| 74 |
return [
|
| 75 |
OutputParam.template("prompt_embeds"),
|
|
@@ -96,8 +84,7 @@ class MiniMaxH3ConditionerBlocks(SequentialPipelineBlocks):
|
|
| 96 |
return (
|
| 97 |
"The conditioner half of a split MiniMax-H3 deployment: puts the keyframes onto the target canvas and "
|
| 98 |
"encodes MiniMax-H3's presentation of the request into the `prompt_embeds` / `text_token_tags` pair the "
|
| 99 |
-
"denoising half consumes. The frame count is the caller's to align
|
| 100 |
-
"layout step, on the denoising side."
|
| 101 |
)
|
| 102 |
|
| 103 |
@property
|
|
|
|
| 1 |
"""The halves of a **split** MiniMax-H3 deployment, for both of its checkpoint partitions.
|
| 2 |
|
| 3 |
+
MiniMax-H3 is modular-only, and the whole model is one `MiniMaxH3Blocks` sequence whose branches are picked per
|
| 4 |
+
request — and per `workflow=` — from the inputs:
|
| 5 |
|
| 6 |
before_encode -> text_encoder -> vae_encoder -> denoise -> after_denoise -> decode
|
| 7 |
|
| 8 |
+
where `before_encode`, `text_encoder`, `vae_encoder` and `denoise` each switch on `references` (the `ref2va` workflow)
|
| 9 |
+
versus the keyframe inputs (`t2va` / `fl2va`), and `denoise` is itself `prepare_layout -> prepare_latents ->
|
| 10 |
+
set_timesteps -> denoise` against `transformer` or `transformer_ref`.
|
|
|
|
| 11 |
|
| 12 |
The conditioner (a 62.14 GiB Qwen3-VL) and the denoiser (a 61.73 GiB transformer plus ~20.5 GiB of float32 VAEs) do
|
| 13 |
not fit on one 95 GiB card unquantized, so this module cuts that sequence in two at the `text_encoder` step, once per
|
|
|
|
| 21 |
* `MiniMaxH3Ref2VAConditionerBlocks` / `MiniMaxH3Ref2VAGeneratorBlocks` are the same cut through the `ref2va`
|
| 22 |
branch, so one conditioner Space serves both partitions out of the weights it already holds.
|
| 23 |
|
| 24 |
+
`resize` / `setup` run on both sides on purpose. They own no pretrained component (PIL, decoded media and arithmetic),
|
| 25 |
+
they resolve the canvas and prepare the keyframes or normalize the references — which the conditioner needs to build
|
| 26 |
+
its vision blocks and the generator needs to encode with the VAEs. Running them twice over the same inputs is
|
| 27 |
+
deterministic; both conditioner halves return the resolved `height` / `width` / `num_frames` anyway, so the caller
|
| 28 |
+
pins them explicitly on the generating half.
|
| 29 |
+
|
| 30 |
+
Two things the blocks leave to the caller: a keyframe reaches them EXIF-transposed and in RGB, and the `t2va` /
|
| 31 |
+
`fl2va` frame count is aligned to `17 * n + 5` before the call, since that arithmetic lives in the layout step on the
|
| 32 |
+
denoising side of the cut. `ref2va` still resolves its own frame count, but requires one to be passed.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 33 |
|
| 34 |
Only *text* encoding is remote. `vae_encoder` / `reference_encoder` stay on the denoising side: they run the two
|
| 35 |
autoencoders, which the conditioner Space does not hold.
|
|
|
|
| 57 |
def _wire_outputs(num_frames: bool = True) -> list[OutputParam]:
|
| 58 |
"""The wire format of the split, plus the plan the caller pins on the generating half.
|
| 59 |
|
| 60 |
+
`num_frames` is declared by the `ref2va` half alone: it is the one whose setup step resolves a frame count.
|
|
|
|
| 61 |
"""
|
| 62 |
return [
|
| 63 |
OutputParam.template("prompt_embeds"),
|
|
|
|
| 84 |
return (
|
| 85 |
"The conditioner half of a split MiniMax-H3 deployment: puts the keyframes onto the target canvas and "
|
| 86 |
"encodes MiniMax-H3's presentation of the request into the `prompt_embeds` / `text_token_tags` pair the "
|
| 87 |
+
"denoising half consumes. The frame count is the caller's to align."
|
|
|
|
| 88 |
)
|
| 89 |
|
| 90 |
@property
|