qwen-image-editor / modes.py
techfreakworm's picture
Add custom user-LoRA (Quality mode) for the MPS route
47baa19 unverified
Raw
History Blame Contribute Delete
15.8 kB
"""Mode handlers — pure functions over QwenImageEditPlusPipeline + params dict."""
from __future__ import annotations
import random
import threading
from typing import Any
from PIL import Image
import models
# Serialize MPS inference: one GPU, one shared pipeline whose scheduler/adapter are
# swapped per request. Two concurrent runs would race that state + corrupt the
# memory peak measurement + race the calibration cache. (The app also caps queue
# concurrency to 1; this is correct-by-construction belt-and-suspenders.)
_GPU_LOCK = threading.Lock()
def _is_gpu_oom(e: BaseException) -> bool:
"""True for any GPU allocation-failure RuntimeError (MPS or CUDA).
Covers the raw allocator OOM ("MPS backend out of memory" / "CUDA out of memory"),
the MPS high-watermark throw (a DIFFERENT message), and CUDA OOM variants — otherwise
those would escape the OOM-retry net and crash, defeating the never-OOM guarantee.
"""
s = str(e).lower()
return any(
k in s
for k in (
"out of memory", "watermark", "mps allocated", "cannot allocate",
"insufficient memory", "cuda error", "cublas",
)
)
def _gpu_empty_cache(torch: Any, dt: str) -> None:
if dt == "mps":
torch.mps.empty_cache()
elif dt == "cuda":
torch.cuda.empty_cache()
def _gpu_synchronize(torch: Any, dt: str) -> None:
if dt == "mps":
torch.mps.synchronize()
elif dt == "cuda":
torch.cuda.synchronize()
def _gpu_allocated_gb(torch: Any, dt: str) -> float:
"""Currently-allocated GPU memory (GB) — the resident baseline before inference."""
if dt == "mps":
return torch.mps.driver_allocated_memory() / (1024**3)
if dt == "cuda":
return torch.cuda.memory_allocated() / (1024**3)
return 0.0
def _gpu_peak_gb(torch: Any, dt: str, baseline_gb: float) -> float:
"""Activation peak (GB) above the resident baseline, for calibration."""
if dt == "mps":
return max(0.0, torch.mps.driver_allocated_memory() / (1024**3) - baseline_gb)
if dt == "cuda":
return max(0.0, torch.cuda.max_memory_allocated() / (1024**3) - baseline_gb)
return 0.0
def _apply_speed(pipe: Any, speed: str) -> None:
"""Configure the pipeline for Fast (Lightning LoRA) or Quality (default) mode.
Fast: swap in the Lightning scheduler + enable the Lightning LoRA adapter.
Quality: restore the default scheduler + disable the LoRA adapter entirely.
Both operations are cheap, reversible, and ZeroGPU-safe (no weight fusion/unfusion).
"""
if speed == "Fast":
pipe.scheduler = pipe._qie_lightning_scheduler
# enable_lora() FIRST: a prior Quality run's disable_lora() leaves every adapter
# layer with _disable_adapters=True, and set_adapters() only flips the *active*
# adapter name — it does NOT re-enable the layers. Without this, Lightning is
# silently bypassed on the long-lived MPS process (ZeroGPU hides it: each call
# re-forks a fresh pipeline). enable_lora() is a harmless no-op when already enabled.
pipe.enable_lora()
pipe.set_adapters([models.LORA_ADAPTER_NAME], [1.0])
else:
pipe.scheduler = pipe._qie_default_scheduler
pipe.disable_lora()
_USER_LORA_ADAPTER = "user"
def _delete_user_lora(pipe: Any) -> None:
"""Remove the user LoRA adapter (best-effort). MANDATORY on MPS: it's a single
long-lived process with NO per-call re-fork, so a leftover adapter would silently leak
into the next request. (On ZeroGPU the fork re-forks clean, but cleanup is harmless.)
"""
try:
pipe.delete_adapters(_USER_LORA_ADAPTER)
except Exception:
pass
def _step_callback(progress: Any, total_steps: int) -> Any:
"""Build a diffusers ``callback_on_step_end`` that drives a ``gr.Progress`` with clear
labels ("Generating — step N/M"). Returns None when there's no progress object (CI /
non-UI calls), so the pipeline call is unchanged off the UI path.
Using an explicit step callback (instead of Gradio's ``track_tqdm``) avoids the
confusing "Downloading (incomplete total…) 0/0 B" placeholder Gradio renders for the
pipeline's internal, total-less tqdm bars during the input-encode phase.
"""
if progress is None:
return None
def _cb(_pipe: Any, step: int, _timestep: Any, callback_kwargs: dict) -> dict:
progress((step + 1) / max(1, total_steps), desc=f"Generating — step {step + 1}/{total_steps}")
return callback_kwargs
return _cb
def _run(pipe: Any, params: dict[str, Any], progress: Any = None) -> tuple[Image.Image, dict[str, Any]]:
"""Run inference and return (output_image, metadata).
Validates that at least one image is present, applies the speed mode, resolves
dimensions and seed, then calls the pipeline. On MPS an OOM preflight runs first:
it sizes the request against a live memory budget and auto-degrades down a ladder
(resolution -> refs -> drop CFG double-pass -> Quality->Fast) so inference never
OOMs; the math + any degradation are surfaced in meta. The CUDA/CPU path is
unchanged. ``progress`` (optional gr.Progress) drives a clean step bar.
"""
images: list[Any] = params["images"]
if not images:
raise ValueError("at least one image is required; params['images'] is empty")
import torch # deferred — CI has no torch installed
# Normalize device: pipe.device may be a str or a torch.device object.
device = str(getattr(pipe, "device", "cpu"))
device_type = device.split(":")[0]
# ZeroGPU quirk: after the spaces pack/restore, pipe.device can read "cpu" at the
# START of the call even though execution happens on a real CUDA GPU inside the
# @spaces.GPU fork. Trust the Space environment over the (stale) device property so
# the CUDA activation preflight + never-OOM degrade path actually run on ZeroGPU
# (otherwise the simple/no-budget path is taken and a heavy request can OOM-SIGKILL).
if device_type not in ("mps", "cuda") and models.on_spaces():
device_type = "cuda"
is_gpu = device_type in ("mps", "cuda")
mode = params["mode"]
speed = params["speed"]
steps = int(params["steps"])
true_cfg = float(params["true_cfg"])
prompt = params["prompt"]
negative_prompt = params["negative_prompt"]
seed_in = params["seed"]
seed = random.randint(0, 2**32 - 1) if seed_in < 0 else int(seed_in)
# --- CPU / mocked path (no GPU memory budgeting needed) -----------------------------
if not is_gpu:
_apply_speed(pipe, speed)
w, h = models.fit_dimensions(images[0])
gen = torch.Generator(device).manual_seed(seed)
if progress is not None:
progress(0.0, desc="Encoding inputs…")
out = pipe(
image=images,
prompt=prompt,
negative_prompt=negative_prompt,
true_cfg_scale=true_cfg,
num_inference_steps=steps,
height=h,
width=w,
generator=gen,
callback_on_step_end=_step_callback(progress, steps),
)
meta = {
"mode": mode, "speed": speed, "steps": steps, "true_cfg": true_cfg,
"seed": seed, "width": w, "height": h, "num_inputs": len(images),
}
return out.images[0], meta
# --- GPU path (MPS or CUDA/ZeroGPU): serialized, OOM-preflight + reactive degrade ----
# Same activation-centric never-OOM logic on both: the ~58 GB model is already resident,
# so only the activation must fit the free budget (MPS: free unified RAM capped by the
# working set; CUDA/ZeroGPU: free VRAM via torch.cuda.mem_get_info). On ZeroGPU xlarge
# (96 GB) Edit fits but heavy Compose can exceed -> auto-degrade (the user's mandate).
import gc
import memory
with _GPU_LOCK:
gc.collect()
_gpu_empty_cache(torch, device_type)
base_w, base_h = images[0].size
n_ref0 = max(0, len(images) - 1)
# User LoRA (Quality-only): a local .safetensors path resolved by the handler (off the
# GPU clock). Ignored in Fast — the Lightning 4-step distillation fights a content LoRA.
lora_path = params.get("lora_path")
_lw = params.get("lora_weight")
lora_weight = 0.9 if _lw is None else float(_lw) # explicit None check keeps a valid 0.0
lora_requested = bool(lora_path) and speed == "Quality"
plan = memory.plan_request(
device_type, mode, base_w, base_h, n_ref0, speed, steps, true_cfg, lora_requested
)
if plan["refused"]:
raise RuntimeError(plan["note"])
w, h = plan["width"], plan["height"]
steps, true_cfg, speed, n_ref = plan["steps"], plan["true_cfg"], plan["speed"], plan["n_ref"]
degrades = list(plan["degrades"])
# The user LoRA applies only if the FINAL (post-degrade) speed is Quality — if the
# preflight degraded Quality->Fast, drop it (else record_peak/penalize would mis-key
# the Fast peak into the has_user_lora cache slot). Recomputed AFTER the plan.
lora_active = bool(lora_path) and speed == "Quality"
out = None
last_err: Exception | None = None
# Tracks whether the "user" adapter actually got loaded — drives the finally cleanup
# INDEPENDENTLY of lora_active (which can flip False mid-loop on an OOM degrade to Fast).
# If cleanup keyed off lora_active, a Quality->Fast degrade would leak the loaded adapter.
user_lora_loaded = False
try:
# Load the user LoRA once (adapter "user"), INSIDE the try so the finally below
# always cleans it up — even if load_lora_weights raises (no adapter leak on the
# long-lived MPS process). Re-activated per attempt AFTER _apply_speed (Quality's
# disable_lora() turns all adapters off). delete-before-load clears a stale one.
if lora_active:
_delete_user_lora(pipe)
pipe.load_lora_weights(lora_path, adapter_name=_USER_LORA_ADAPTER)
user_lora_loaded = True
for _attempt in range(8): # bounded reactive degrade — hard never-OOM guarantee
imgs = images[: n_ref + 1]
_apply_speed(pipe, speed)
if lora_active and speed == "Quality":
# _apply_speed's disable_lora() (Quality branch) left every adapter
# layer disabled; re-enable before activating the user adapter, else
# set_adapters() flips the active name but the layers stay bypassed and
# the LoRA has ZERO effect (output identical to no-LoRA). See _apply_speed.
pipe.enable_lora()
pipe.set_adapters([_USER_LORA_ADAPTER], [lora_weight])
gen = torch.Generator("cpu").manual_seed(seed) # CPU generator is safe on MPS + CUDA
baseline_gb = _gpu_allocated_gb(torch, device_type)
if device_type == "cuda":
torch.cuda.reset_peak_memory_stats()
if progress is not None:
progress(0.0, desc="Encoding inputs…")
try:
out = pipe(
image=imgs,
prompt=prompt,
negative_prompt=negative_prompt,
true_cfg_scale=true_cfg,
num_inference_steps=steps,
height=h,
width=w,
generator=gen,
callback_on_step_end=_step_callback(progress, steps),
)
break
except RuntimeError as e:
if not _is_gpu_oom(e):
raise
last_err = e
del gen
gc.collect()
_gpu_synchronize(torch, device_type)
_gpu_empty_cache(torch, device_type)
# Self-correct AFTER cleanup: at the OOM instant the heap is at its sticky
# peak, so the budget would read ~0 -> no-op penalty. A clean heap returns the
# real budget the config overflowed, so the next request degrades preemptively.
memory.penalize(
device_type, h, w, n_ref, true_cfg > 1.0,
memory.activation_budget_gb(device_type), lora_active,
)
nxt = memory.step_down(w, h, n_ref, speed, true_cfg, steps, base_w, base_h, mode)
if nxt is None:
raise RuntimeError(f"GPU OOM at the smallest config and cannot degrade further: {e}") from e
w, h, n_ref = nxt["width"], nxt["height"], nxt["n_ref"]
speed, true_cfg, steps = nxt["speed"], nxt["true_cfg"], nxt["steps"]
# A degrade to Fast drops the (Quality-only) user LoRA: the next attempt's
# _apply_speed switches to Lightning and the guard below skips set_adapters.
# Recompute so record_peak/meta key the Fast peak correctly (NOT the LoRA slot).
lora_active = bool(lora_path) and speed == "Quality"
degrades.append(f"OOM-retry->{w}x{h} {speed} {n_ref + 1}img")
if out is None: # pragma: no cover - defensive
raise RuntimeError(f"GPU inference failed after retries: {last_err}")
peak_gb = _gpu_peak_gb(torch, device_type, baseline_gb)
memory.record_peak(device_type, mode, h, w, n_ref, true_cfg > 1.0, peak_gb, lora_active)
meta = {
"mode": mode, "speed": speed, "steps": steps, "true_cfg": true_cfg,
"seed": seed, "width": w, "height": h, "num_inputs": n_ref + 1,
"preflight": plan["note"], "budget_gb": plan["budget_gb"],
"need_gb": plan["need_gb"], "measured_peak_gb": round(peak_gb, 1),
}
if degrades:
meta["degrades"] = degrades
if lora_active and speed == "Quality":
meta["lora"] = {"weight": lora_weight, "file": lora_path.rsplit("/", 1)[-1]}
return out.images[0], meta
finally:
# Cleanup runs even on error — MANDATORY on MPS (long-lived process, no re-fork):
# a lingering "user" adapter would leak into the next request. Keyed off
# user_lora_loaded (not lora_active) so an OOM degrade-to-Fast still cleans up.
if user_lora_loaded:
_delete_user_lora(pipe)
gc.collect()
_gpu_empty_cache(torch, device_type)
def call_edit(pipe: Any, params: dict[str, Any], progress: Any = None) -> tuple[Image.Image, dict[str, Any]]:
"""Edit mode: single input image + instruction -> edited image.
Expects params["images"] == [target_image].
"""
p = dict(params)
p["mode"] = "edit"
return _run(pipe, p, progress)
def call_compose(pipe: Any, params: dict[str, Any], progress: Any = None) -> tuple[Image.Image, dict[str, Any]]:
"""Compose mode: target image + up to 2 optional reference images -> composed edit.
None slots in params["images"] are dropped before the pipeline call so the
pipeline always receives a contiguous list of 1..3 real images.
"""
p = dict(params)
p["mode"] = "compose"
p["images"] = [img for img in params["images"] if img is not None]
return _run(pipe, p, progress)
DISPATCH: dict[str, Any] = {
"edit": call_edit,
"compose": call_compose,
}