someone-in-the-world Claude Sonnet 5 commited on
Commit
39867e3
·
1 Parent(s): f30b43a

Skip text_encoder CPU offload in fast mode; type mode as an enum

Browse files

Logged phase timing showed the text_encoder-to-CPU offload before VAE
decode costs ~9.7s, while decode itself takes ~113ms. That offload was
freeing memory decode never needed at fast mode's small resolution
(measured peak stayed well under the MIG slice's headroom) — it exists
to prevent OOM at high-detail's much larger 2048px decode, so it's kept
there and only skipped for fast mode.

Replaced the raw "fast"/"high_detail" string comparisons scattered
across dimensions.py and app.py with a Mode enum (mode.py) that owns
both behaviors (max_dim, offloads_text_encoder_before_decode) as
properties on the variant.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

Files changed (5) hide show
  1. app.py +22 -16
  2. dimensions.py +0 -3
  3. mode.py +31 -0
  4. tests/test_dimensions.py +1 -12
  5. tests/test_mode.py +40 -0
app.py CHANGED
@@ -70,7 +70,8 @@ torch.backends.cudnn.allow_tf32 = True
70
  print("[startup] TF32 enabled", flush=True)
71
 
72
  print("[startup] importing dimensions...", flush=True)
73
- from dimensions import compute_output_dimensions, max_dim_for_mode, MAX_OUTPUT_DIM_FAST
 
74
  print("[startup] importing diffusers...", flush=True)
75
  from diffusers import FlowMatchEulerDiscreteScheduler
76
  from diffusers.models.normalization import RMSNorm
@@ -189,7 +190,7 @@ pipe = QwenImageEditPlusPipeline.from_pretrained(
189
  torch_dtype=dtype,
190
  )
191
  _hb.set()
192
- pipe.vae.enable_tiling(tile_sample_min_height=MAX_OUTPUT_DIM_FAST, tile_sample_min_width=MAX_OUTPUT_DIM_FAST)
193
  print(f"[startup] pipeline loaded in {time.perf_counter()-_t1_load:.1f}s", flush=True)
194
 
195
  print("[startup] setting cuDNN SDPA attention processor...", flush=True)
@@ -448,10 +449,11 @@ with open("templates/app.html") as _f:
448
  def infer(images_b64_json, prompt, seed, randomize_seed, guidance_scale, steps, mode, gpu_duration=20, progress=gr.Progress(track_tqdm=True)):
449
  # CPU-only preprocessing — GPU not yet allocated
450
  gc.collect()
 
451
  pil_images = b64_to_pil_list(images_b64_json)
452
  _validate_infer_inputs(pil_images, prompt)
453
  seed = _resolve_seed(seed, randomize_seed)
454
- width, height = update_dimensions_on_upload(pil_images[0], max_dim_for_mode(mode))
455
  t0 = time.perf_counter()
456
  try:
457
  result_image, seed, duration = _infer_gpu(pil_images, prompt, seed, guidance_scale, steps, width, height, mode, int(gpu_duration))
@@ -472,9 +474,9 @@ def infer(images_b64_json, prompt, seed, randomize_seed, guidance_scale, steps,
472
  raise
473
 
474
 
475
- def _log_infer_start(prompt, steps, guidance_scale, seed, gpu_duration, mode):
476
  print(f"[infer] ===== START =====")
477
- print(f"[infer] steps={steps}, guidance={guidance_scale}, seed={seed}, gpu_duration={gpu_duration}s, mode={mode}")
478
  print(f"[infer] prompt={repr(prompt[:120])}")
479
 
480
 
@@ -531,7 +533,7 @@ def _instrument_first_touch(modules_with_names, t0):
531
  handle_box["h"] = module.register_forward_pre_hook(_make_hook(name, handle_box))
532
 
533
 
534
- def _make_step_callback(steps, timer, t0):
535
  """Build the diffusers step callback that logs per-step timing and marks timer checkpoints."""
536
  step_times = []
537
  def _step_cb(pipeline, step_idx, timestep, cb_kwargs):
@@ -541,15 +543,19 @@ def _make_step_callback(steps, timer, t0):
541
  timer.mark("first_step")
542
  timer.mark("last_step") # overwritten each step; final value = end of last step
543
  # Text encoder is done after prompt encoding, before the denoising loop starts.
544
- # Drop it (~15GB) ahead of VAE decode's fp32-upcast memory spike. Skipped when
545
- # accelerate hooks are managing placement (offload-fallback path) to avoid
546
- # fighting their own device bookkeeping see the finally block below.
 
547
  if step_idx == steps - 1 and getattr(pipeline.text_encoder, "_hf_hook", None) is None:
548
- _offload_t0 = time.perf_counter()
549
- pipeline.text_encoder.to("cpu")
550
- torch.cuda.empty_cache()
551
- _offload_ms = (time.perf_counter() - _offload_t0) * 1000
552
- print(f"[infer] text_encoder offload to cpu — {_offload_ms:.0f}ms | t={time.perf_counter()-t0:.1f}s")
 
 
 
553
  delta_ms = (now - (step_times[-2] if len(step_times) > 1 else t0)) * 1000
554
  tag = " ← includes cold-start (offload hook install + first weight transfer)" if step_idx == 0 else ""
555
  print(f"[infer] step {step_idx+1}/{steps} done — {delta_ms:.0f}ms{tag} | t={now-t0:.1f}s")
@@ -568,7 +574,7 @@ def _log_infer_error(e, t0, timer):
568
 
569
 
570
  @spaces.GPU(duration=lambda *a, **kw: int(a[8]) if len(a) > 8 else 60)
571
- def _infer_gpu(pil_images, prompt, seed, guidance_scale, steps, width, height, mode, gpu_duration=20):
572
  _cuda_ok = torch.cuda.is_available()
573
  timer = _InferTimer(_cuda_ok)
574
  t0 = time.perf_counter()
@@ -588,7 +594,7 @@ def _infer_gpu(pil_images, prompt, seed, guidance_scale, steps, width, height, m
588
  print(f"[infer] {len(pil_images)} image(s) pre-decoded, output={width}x{height}, seed={seed}")
589
 
590
  generator = torch.Generator(device=device).manual_seed(seed)
591
- step_cb = _make_step_callback(steps, timer, t0)
592
 
593
  timer.mark("pipe_start")
594
  print(f"[infer] calling pipe... t={time.perf_counter()-t0:.1f}s")
 
70
  print("[startup] TF32 enabled", flush=True)
71
 
72
  print("[startup] importing dimensions...", flush=True)
73
+ from dimensions import compute_output_dimensions
74
+ from mode import Mode
75
  print("[startup] importing diffusers...", flush=True)
76
  from diffusers import FlowMatchEulerDiscreteScheduler
77
  from diffusers.models.normalization import RMSNorm
 
190
  torch_dtype=dtype,
191
  )
192
  _hb.set()
193
+ pipe.vae.enable_tiling(tile_sample_min_height=Mode.FAST.max_dim, tile_sample_min_width=Mode.FAST.max_dim)
194
  print(f"[startup] pipeline loaded in {time.perf_counter()-_t1_load:.1f}s", flush=True)
195
 
196
  print("[startup] setting cuDNN SDPA attention processor...", flush=True)
 
449
  def infer(images_b64_json, prompt, seed, randomize_seed, guidance_scale, steps, mode, gpu_duration=20, progress=gr.Progress(track_tqdm=True)):
450
  # CPU-only preprocessing — GPU not yet allocated
451
  gc.collect()
452
+ mode = Mode.from_value(mode)
453
  pil_images = b64_to_pil_list(images_b64_json)
454
  _validate_infer_inputs(pil_images, prompt)
455
  seed = _resolve_seed(seed, randomize_seed)
456
+ width, height = update_dimensions_on_upload(pil_images[0], mode.max_dim)
457
  t0 = time.perf_counter()
458
  try:
459
  result_image, seed, duration = _infer_gpu(pil_images, prompt, seed, guidance_scale, steps, width, height, mode, int(gpu_duration))
 
474
  raise
475
 
476
 
477
+ def _log_infer_start(prompt, steps, guidance_scale, seed, gpu_duration, mode: Mode):
478
  print(f"[infer] ===== START =====")
479
+ print(f"[infer] steps={steps}, guidance={guidance_scale}, seed={seed}, gpu_duration={gpu_duration}s, mode={mode.value}")
480
  print(f"[infer] prompt={repr(prompt[:120])}")
481
 
482
 
 
533
  handle_box["h"] = module.register_forward_pre_hook(_make_hook(name, handle_box))
534
 
535
 
536
+ def _make_step_callback(steps, timer, t0, mode: Mode):
537
  """Build the diffusers step callback that logs per-step timing and marks timer checkpoints."""
538
  step_times = []
539
  def _step_cb(pipeline, step_idx, timestep, cb_kwargs):
 
543
  timer.mark("first_step")
544
  timer.mark("last_step") # overwritten each step; final value = end of last step
545
  # Text encoder is done after prompt encoding, before the denoising loop starts.
546
+ # Dropping it (~15GB) ahead of VAE decode's fp32-upcast memory spike only pays off
547
+ # when that spike is big enough to need the headroom (see Mode.offloads_text_encoder_before_decode).
548
+ # Also skipped when accelerate hooks are managing placement (offload-fallback path)
549
+ # to avoid fighting their own device bookkeeping.
550
  if step_idx == steps - 1 and getattr(pipeline.text_encoder, "_hf_hook", None) is None:
551
+ if mode.offloads_text_encoder_before_decode:
552
+ _offload_t0 = time.perf_counter()
553
+ pipeline.text_encoder.to("cpu")
554
+ torch.cuda.empty_cache()
555
+ _offload_ms = (time.perf_counter() - _offload_t0) * 1000
556
+ print(f"[infer] text_encoder offload to cpu — {_offload_ms:.0f}ms | t={time.perf_counter()-t0:.1f}s")
557
+ else:
558
+ print(f"[infer] skipping text_encoder offload for mode={mode.value} (ample headroom at this resolution)")
559
  delta_ms = (now - (step_times[-2] if len(step_times) > 1 else t0)) * 1000
560
  tag = " ← includes cold-start (offload hook install + first weight transfer)" if step_idx == 0 else ""
561
  print(f"[infer] step {step_idx+1}/{steps} done — {delta_ms:.0f}ms{tag} | t={now-t0:.1f}s")
 
574
 
575
 
576
  @spaces.GPU(duration=lambda *a, **kw: int(a[8]) if len(a) > 8 else 60)
577
+ def _infer_gpu(pil_images, prompt, seed, guidance_scale, steps, width, height, mode: Mode, gpu_duration=20):
578
  _cuda_ok = torch.cuda.is_available()
579
  timer = _InferTimer(_cuda_ok)
580
  t0 = time.perf_counter()
 
594
  print(f"[infer] {len(pil_images)} image(s) pre-decoded, output={width}x{height}, seed={seed}")
595
 
596
  generator = torch.Generator(device=device).manual_seed(seed)
597
+ step_cb = _make_step_callback(steps, timer, t0, mode)
598
 
599
  timer.mark("pipe_start")
600
  print(f"[infer] calling pipe... t={time.perf_counter()-t0:.1f}s")
dimensions.py CHANGED
@@ -2,9 +2,6 @@ MAX_OUTPUT_DIM = 2048
2
  MAX_OUTPUT_DIM_FAST = 768
3
 
4
 
5
- def max_dim_for_mode(mode): return MAX_OUTPUT_DIM_FAST if mode == "fast" else MAX_OUTPUT_DIM
6
-
7
-
8
  def compute_output_dimensions(w, h, max_dim=MAX_OUTPUT_DIM):
9
  # Pin the long side to max_dim and scale the short side proportionally.
10
  # We snap to the nearest multiple of 8 (not floor) to minimise aspect ratio
 
2
  MAX_OUTPUT_DIM_FAST = 768
3
 
4
 
 
 
 
5
  def compute_output_dimensions(w, h, max_dim=MAX_OUTPUT_DIM):
6
  # Pin the long side to max_dim and scale the short side proportionally.
7
  # We snap to the nearest multiple of 8 (not floor) to minimise aspect ratio
mode.py ADDED
@@ -0,0 +1,31 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from enum import Enum
2
+
3
+ from dimensions import MAX_OUTPUT_DIM, MAX_OUTPUT_DIM_FAST
4
+
5
+
6
+ class Mode(str, Enum):
7
+ """UI speed/quality presets. Behavior that depends on the mode lives here as a
8
+ property on the variant, rather than call sites branching on a raw mode string."""
9
+
10
+ FAST = "fast"
11
+ HIGH_DETAIL = "high_detail"
12
+
13
+ @classmethod
14
+ def from_value(cls, value) -> "Mode":
15
+ try:
16
+ return cls(value)
17
+ except ValueError:
18
+ return cls.HIGH_DETAIL
19
+
20
+ @property
21
+ def max_dim(self) -> int:
22
+ return MAX_OUTPUT_DIM_FAST if self is Mode.FAST else MAX_OUTPUT_DIM
23
+
24
+ @property
25
+ def offloads_text_encoder_before_decode(self) -> bool:
26
+ # Fast mode's small decode (<=768px) fits comfortably in the headroom already
27
+ # freed by the fp8-resident transformer, so evicting the ~15GB text encoder
28
+ # first — measured at ~9.7s on this Space's MIG slice — buys nothing there.
29
+ # High-detail's decode is up to ~8.5x more pixels (2048px), where that
30
+ # headroom margin is unverified, so it keeps the safety net.
31
+ return self is Mode.HIGH_DETAIL
tests/test_dimensions.py CHANGED
@@ -1,5 +1,5 @@
1
  import pytest
2
- from dimensions import compute_output_dimensions, max_dim_for_mode, MAX_OUTPUT_DIM, MAX_OUTPUT_DIM_FAST
3
 
4
 
5
  def aspect_ratio_error(w_in, h_in, w_out, h_out):
@@ -97,14 +97,3 @@ def test_custom_max_dim():
97
  nw, nh = compute_output_dimensions(1920, 1080, max_dim=1024)
98
  assert nw == 1024
99
  assert nh % 8 == 0
100
-
101
-
102
- # --- mode -> max dimension mapping ---
103
-
104
- def test_fast_mode_uses_fast_max_dim():
105
- assert max_dim_for_mode("fast") == MAX_OUTPUT_DIM_FAST
106
-
107
-
108
- @pytest.mark.parametrize("mode", ["high_detail", "anything_else", None])
109
- def test_non_fast_modes_use_default_max_dim(mode):
110
- assert max_dim_for_mode(mode) == MAX_OUTPUT_DIM
 
1
  import pytest
2
+ from dimensions import compute_output_dimensions, MAX_OUTPUT_DIM
3
 
4
 
5
  def aspect_ratio_error(w_in, h_in, w_out, h_out):
 
97
  nw, nh = compute_output_dimensions(1920, 1080, max_dim=1024)
98
  assert nw == 1024
99
  assert nh % 8 == 0
 
 
 
 
 
 
 
 
 
 
 
tests/test_mode.py ADDED
@@ -0,0 +1,40 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import pytest
2
+ from dimensions import MAX_OUTPUT_DIM, MAX_OUTPUT_DIM_FAST
3
+ from mode import Mode
4
+
5
+
6
+ # --- from_value ---
7
+
8
+ def test_from_value_accepts_known_strings():
9
+ assert Mode.from_value("fast") is Mode.FAST
10
+ assert Mode.from_value("high_detail") is Mode.HIGH_DETAIL
11
+
12
+
13
+ @pytest.mark.parametrize("value", ["anything_else", None, "", "FAST"])
14
+ def test_from_value_falls_back_to_high_detail(value):
15
+ assert Mode.from_value(value) is Mode.HIGH_DETAIL
16
+
17
+
18
+ def test_from_value_is_idempotent_on_mode_instances():
19
+ assert Mode.from_value(Mode.FAST) is Mode.FAST
20
+ assert Mode.from_value(Mode.HIGH_DETAIL) is Mode.HIGH_DETAIL
21
+
22
+
23
+ # --- max_dim ---
24
+
25
+ def test_fast_mode_uses_fast_max_dim():
26
+ assert Mode.FAST.max_dim == MAX_OUTPUT_DIM_FAST
27
+
28
+
29
+ def test_high_detail_mode_uses_default_max_dim():
30
+ assert Mode.HIGH_DETAIL.max_dim == MAX_OUTPUT_DIM
31
+
32
+
33
+ # --- offloads_text_encoder_before_decode ---
34
+
35
+ def test_fast_mode_skips_text_encoder_offload():
36
+ assert Mode.FAST.offloads_text_encoder_before_decode is False
37
+
38
+
39
+ def test_high_detail_mode_keeps_text_encoder_offload():
40
+ assert Mode.HIGH_DETAIL.offloads_text_encoder_before_decode is True