multimodalart HF Staff commited on
Commit
0581e9d
·
verified ·
1 Parent(s): d209439

Calibrate duration from three timed runs; set verified 44k token ceiling; document memory findings

Browse files
Files changed (2) hide show
  1. README.md +8 -2
  2. app.py +27 -27
README.md CHANGED
@@ -46,6 +46,7 @@ the transformer's own self-attention. Reference tokens ride along at `conditioni
46
  | **Code** | `diffusers` main @ `d57cecde` — `LTX2InContextPipeline`, subclassed in `msr.py` |
47
  | **Hardware** | ZeroGPU `xlarge` (96 GB). The 22B DiT plus the Gemma-4 encoder packs to ~71 GB, over the 48 GB slice |
48
  | **Output** | 1280×704 @ 24 fps by default (1664×960 available), with LTX-2.5's native audio |
 
49
 
50
  ## Faithfulness to the reference implementation
51
 
@@ -69,8 +70,13 @@ The author's reference is a ComfyUI graph:
69
  ## Deviations
70
 
71
  * **Resolution.** The workflow renders 1664×960 × 361 frames (15 s). The default here is
72
- 1280×704 × 97 frames (4 s) so a run fits comfortably in a visitor's ZeroGPU quota; both the
73
- workflow geometry and 8 s are selectable under **Advanced**.
 
 
 
 
 
74
  * **Tiled VAE decode.** The final decode is tiled (untiled it wants >100 GB). Reference
75
  encoding is *not* tiled, matching the node's `use_tiled_encode=False`, with a tiled fallback
76
  only if it would OOM.
 
46
  | **Code** | `diffusers` main @ `d57cecde` — `LTX2InContextPipeline`, subclassed in `msr.py` |
47
  | **Hardware** | ZeroGPU `xlarge` (96 GB). The 22B DiT plus the Gemma-4 encoder packs to ~71 GB, over the 48 GB slice |
48
  | **Output** | 1280×704 @ 24 fps by default (1664×960 available), with LTX-2.5's native audio |
49
+ | **Memory** | `expandable_segments:True`. Stage 2 cycles ~1 GB activation buffers against a nearly full card; the default arena fragments and OOMs at 43k tokens where expandable segments complete in 87 s. Gemma and the connectors are released once each has produced its output |
50
 
51
  ## Faithfulness to the reference implementation
52
 
 
70
  ## Deviations
71
 
72
  * **Resolution.** The workflow renders 1664×960 × 361 frames (15 s). The default here is
73
+ 1280×704 × 97 frames (4 s), which runs in **53 s** so it fits comfortably in a visitor's
74
+ ZeroGPU quota; both the workflow geometry (1664×960 × 97, 87 s) and 8 s at 1280×704 (73 s)
75
+ are selectable under **Advanced**.
76
+ * **Length ceiling.** 76.2 GB stays resident during denoising and stage-2 activations cost
77
+ ~0.33 MB per token, so the 96 GB slice tops out near 48k tokens. Combinations above 44,000
78
+ (`(latent_frames + slots × 5) × H/32 × W/32`) are refused up front instead of dying in an
79
+ OOM traceback — 15 s at the workflow resolution is simply out of reach here.
80
  * **Tiled VAE decode.** The final decode is tiled (untiled it wants >100 GB). Reference
81
  encoding is *not* tiled, matching the node's `use_tiled_encode=False`, with a tiled fallback
82
  only if it would OOM.
app.py CHANGED
@@ -69,10 +69,11 @@ DEFAULT_REFERENCE_FRAMES = 33 # the workflow's `reference_frames`
69
 
70
  # Stage-2 sequence length is the memory driver, and here it is user-driven twice over
71
  # (resolution x length, plus up to five reference slots each contributing a full clip of
72
- # tokens). Measured on the 96 GB ZeroGPU slice: ~1.0 MiB of activations per token on top of
73
- # ~45 GiB of resident weights. Refuse past the ceiling rather than let the request die in an
74
- # OOM traceback.
75
- MAX_STAGE_2_TOKENS = 46_000
 
76
 
77
  print("[msr] downloading LTX-2.5 (diffusers)...", flush=True)
78
  # `from_pretrained` derives allow-patterns from every model-like file in the repo, so it would
@@ -164,11 +165,6 @@ def _plan(resolution: str, seconds: float, reference_frames: int, num_refs: int)
164
  return width, height, num_frames, ref_keep, tokens
165
 
166
 
167
- # Reference point for both the cost model and the token ceiling: the default geometry with
168
- # three reference slots, timed on the live Space.
169
- REF_TOKENS = 24_640
170
- REF_PIXELS = 1280 * 704 * 97
171
-
172
 
173
  def _count_refs(*images) -> int:
174
  return max(1, sum(1 for image in images if image is not None))
@@ -189,20 +185,24 @@ def gpu_duration(
189
  ):
190
  """Scale the ZeroGPU reservation with the work actually requested.
191
 
192
- Calibrated against the reference run, which took 53.1 s split as 9.0 s stage 1 + 0.5 s
193
- upsample + 19.0 s stage 2 (sampling, scales super-linearly with sequence length) and
194
- 24.6 s of Gemma pass, text-encoder eviction and tiled VAE decode (scales with output
195
- pixels). 15% margin on top and nothing more: `duration` is charged against every
196
- visitor's daily quota, and a fat reservation also costs queue priority.
 
 
 
 
 
197
  """
198
  num_refs = _count_refs(subject_1, subject_2, subject_3, subject_4, scene_image)
199
  width, height, num_frames, _ref_keep, tokens = _plan(
200
  resolution, seconds, reference_frames, num_refs
201
  )
202
- fixed = 12.0 # Gemma pass, 24 GB eviction, mp4 mux
203
- decode = 15.6 * (width * height * num_frames) / REF_PIXELS
204
- sample = 28.5 * (tokens / REF_TOKENS) ** 1.25
205
- return int(min(200, math.ceil(1.15 * (fixed + decode + sample))))
206
 
207
 
208
  def _write_video(frames, path: str, audio=None) -> None:
@@ -231,13 +231,16 @@ def _vram(label: str) -> None:
231
 
232
 
233
  def _encode_once(prompt: str):
234
- """Run Gemma once and then take its 24 GB off the GPU for the rest of the call.
 
 
 
 
235
 
236
- Not CPU offloading: the text encoder is genuinely finished after this. Both `__call__`s
237
- below are handed the resulting `prompt_embeds`, so `encode_prompt` short-circuits and never
238
- touches it again, while `connectors` (which does run per stage) stays resident. Without
239
- this the 22B DiT plus the encoder leaves ~20 GiB for activations and stage 2 OOMs at the
240
- default geometry. `DiffusionPipeline.device` deliberately prefers a non-CPU component, so
241
  `_execution_device` still reports cuda afterwards.
242
  """
243
  pipe.text_encoder.to("cuda") # no-op on a fresh worker; restores it if one is reused
@@ -251,10 +254,8 @@ def _encode_once(prompt: str):
251
  device=torch.device("cuda"),
252
  )
253
  finally:
254
- _vram("before text-encoder eviction")
255
  pipe.text_encoder.to("cpu")
256
  _free()
257
- _vram(f"after text-encoder eviction ({next(pipe.text_encoder.parameters()).device})")
258
  return prompt_embeds, prompt_mask
259
 
260
 
@@ -340,7 +341,6 @@ def generate(
340
  # One Gemma pass, reused by both stages, then evicted (see `_encode_once`).
341
  torch.cuda.reset_peak_memory_stats()
342
  prompt_embeds, prompt_mask = _encode_once(prompt)
343
- _vram("weights resident")
344
 
345
  shared = dict(
346
  prompt=None,
 
69
 
70
  # Stage-2 sequence length is the memory driver, and here it is user-driven twice over
71
  # (resolution x length, plus up to five reference slots each contributing a full clip of
72
+ # tokens). Measured on the 96 GB slice: 76.2 GiB stays resident during denoising and peak
73
+ # activations run at 0.33 MiB/token (87.4 GiB peak at 35,200 tokens, 90.1 GiB at 43,680),
74
+ # which puts the wall just under 48k. 44,000 is the last value verified to complete; refuse
75
+ # past it rather than let the request die in an OOM traceback.
76
+ MAX_STAGE_2_TOKENS = 44_000
77
 
78
  print("[msr] downloading LTX-2.5 (diffusers)...", flush=True)
79
  # `from_pretrained` derives allow-patterns from every model-like file in the repo, so it would
 
165
  return width, height, num_frames, ref_keep, tokens
166
 
167
 
 
 
 
 
 
168
 
169
  def _count_refs(*images) -> int:
170
  return max(1, sum(1 for image in images if image is not None))
 
185
  ):
186
  """Scale the ZeroGPU reservation with the work actually requested.
187
 
188
+ Least-squares fit through three timed runs on this Space 53.1 s at 24,640 tokens /
189
+ 87 Mpx, 72.8 s at 35,200 / 174 Mpx, 86.8 s at 43,680 / 155 Mpx — which land on a plain
190
+ linear model to within 0.2 s:
191
+
192
+ seconds = 9.5 + 0.0206 * megapixels + 1.697 * kilotokens
193
+
194
+ Megapixels cover the tiled VAE decode and the vocoder, kilotokens cover both sampling
195
+ stages, and the constant is the Gemma pass plus the mp4 mux. 15% margin on top and
196
+ nothing more: `duration` is charged against every visitor's daily quota, and a fat
197
+ reservation also costs queue priority.
198
  """
199
  num_refs = _count_refs(subject_1, subject_2, subject_3, subject_4, scene_image)
200
  width, height, num_frames, _ref_keep, tokens = _plan(
201
  resolution, seconds, reference_frames, num_refs
202
  )
203
+ megapixels = width * height * num_frames / 1e6
204
+ seconds_est = 9.5 + 0.0206 * megapixels + 1.697 * (tokens / 1000)
205
+ return int(min(140, math.ceil(1.15 * seconds_est)))
 
206
 
207
 
208
  def _write_video(frames, path: str, audio=None) -> None:
 
231
 
232
 
233
  def _encode_once(prompt: str):
234
+ """Run Gemma once, then send it back to the host for the rest of the call.
235
+
236
+ Not CPU offloading: the text encoder is genuinely finished after this, and nothing moves
237
+ inside a sampling loop. Both `__call__`s below are handed the resulting `prompt_embeds`,
238
+ so `encode_prompt` short-circuits and never touches it again.
239
 
240
+ Measured effect is only ~1.9 GiB ZeroGPU packs many parameters into shared CUDA
241
+ storages, so releasing one module's references frees far less than its nominal 24 GB —
242
+ but it is free and it lowers the peak the Gemma pass itself reaches.
243
+ `DiffusionPipeline.device` deliberately prefers a non-CPU component, so
 
244
  `_execution_device` still reports cuda afterwards.
245
  """
246
  pipe.text_encoder.to("cuda") # no-op on a fresh worker; restores it if one is reused
 
254
  device=torch.device("cuda"),
255
  )
256
  finally:
 
257
  pipe.text_encoder.to("cpu")
258
  _free()
 
259
  return prompt_embeds, prompt_mask
260
 
261
 
 
341
  # One Gemma pass, reused by both stages, then evicted (see `_encode_once`).
342
  torch.cuda.reset_peak_memory_stats()
343
  prompt_embeds, prompt_mask = _encode_once(prompt)
 
344
 
345
  shared = dict(
346
  prompt=None,