multimodalart HF Staff commited on
Commit
5029048
·
verified ·
1 Parent(s): bf1199a

Cache the conditioner client with functools.cache, tidy comments

Browse files
Files changed (4) hide show
  1. README.md +10 -16
  2. app.py +12 -49
  3. h3_aoti.py +2 -3
  4. 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
- **requesting user**, with nothing in this repository arranging it. `gradio_client` attaches the caller's own
165
- `x-ip-token` to every outgoing call by itself, reading it off gradio's `LocalContext` inside the event listener
166
- (`Client.send_data` -> `add_zero_gpu_headers`), and ZeroGPU's `/schedule` charges the booking to whatever that token
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. That path is why the conditioner books small: an
172
- unattributed caller may book at most 120 credits at a time and an `xlarge` booking costs **twice** its seconds
173
- (`_gpu_size_units`), so the conditioner books the encode (45 s) and a prompt upsample (60 s) as two separate calls,
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
- **commit** `665f5782` (`refs/pull/14371/head` at deploy time) rather than to the moving `minimax-h3-refactor` branch.
189
 
190
- That PR is a WIP: it needs **re-pinning whenever it updates**, and `h3_split_blocks.py` — which subclasses its block
191
- classes to cut the pipeline in two — has to be re-checked against the new head at the same time. The PR refactored
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. Cached — building a `Client` costs a round trip to the Space config.
 
 
175
 
176
- No token is passed and none has to be: `gradio_client` attaches the caller's own ZeroGPU token itself, per call,
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 appended last and defaults off, so an existing API client is untouched by it."""
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 before the blocks see them, which the resize step that replaced the old setup step no
289
- # longer does itself. Both halves have to prepare a keyframe the same way or the conditioning latents encoded here
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
- # Only shown for a request that actually asked for a rewrite, so a plain request is not left with an
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 appended *after* every input that was already here and every existing input keeps its position, so
429
- # a positional API client that predates it keeps working and simply takes the default. Same on the way out: the
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
- # A public **model** repo. It used to be a private dataset, which is why the repo type is still a variable: the
66
- # artifacts are keyed by quant/torch/arch under `<width>/torch<X.Y>/sm<cc>/<shape>` rather than laid out the way
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. Since https://github.com/huggingface/diffusers/pull/14371 the whole model is *one*
4
- `MiniMaxH3Blocks` sequence whose branches are picked per 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` are each an auto-block that switches on
9
- `references` (the `ref2va` workflow) versus the keyframe inputs (`t2va` / `fl2va`), and `denoise` is a whole
10
- sub-sequence — `prepare_layout -> prepare_latents -> set_timesteps -> denoise` against `transformer` or
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
- What the refactor moved, and what that means for the cut:
26
-
27
- * the old `MiniMaxH3SetupStep` is gone. Its keyframe half is now `MiniMaxH3ResizeStep` (wrapped in the conditional
28
- `MiniMaxH3AutoResizeStep`, skipped entirely for a text-only request) and its geometry half moved *into*
29
- `MiniMaxH3PrepareLayoutStep`, which lives on the denoising side. So the `t2va` / `fl2va` conditioner half no
30
- longer resolves `num_frames` at all — the caller does, with `align_num_frames`, which is the one line of
31
- arithmetic that used to come back in the plan.
32
- * `MiniMaxH3Ref2VASetupStep` survived and still resolves the canvas *and* the frame count, but `num_frames` is now
33
- required there: a request that leaves the duration to its single audio-bearing reference resolves it caller-side.
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 only declared by the `ref2va` half: its setup step is the one that still resolves the frame count,
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 — that arithmetic now lives in the "
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