qwen-image-editor / backend.py
techfreakworm's picture
feat(zerogpu): get all modes working on ZeroGPU
22b5c43 unverified
Raw
History Blame Contribute Delete
9.93 kB
"""QwenImageEditBackend β€” wraps the diffusers pipeline; applies @spaces.GPU on HF Spaces."""
from __future__ import annotations
import os
from typing import Any
# Spaces import is optional β€” running locally we don't have it.
try:
import spaces # type: ignore
except ImportError:
spaces = None # type: ignore[assignment]
import modes
_ON_SPACES = bool(os.environ.get("SPACES_ZERO_GPU"))
def _identity(fn):
return fn
def duration_for(mode: str, params: dict[str, Any]) -> int:
"""ZeroGPU @spaces.GPU duration budget in seconds.
On the ``xlarge`` ZeroGPU tier (the ~58 GB model needs 96 GB) the request is
DOUBLED for the per-call ceiling check. Confirmed live: duration=180 (360 s
requested) is rejected ("ZeroGPU illegal duration"), while duration=54
(108 s requested) is ACCEPTED but the task is "GPU task aborted" β€” the
per-call execution (model materialization + inference) exceeds a 54 s budget.
So we want the highest legal budget: 145 s (290 s requested, just under the
360 s that was rejected). If even this aborts, the 58 GB bf16 model is too
slow per-call for xlarge and needs fp8 (to reach the 1x ``large`` tier).
Override via the ``QIE_GPU_DURATION`` Space variable to retune the budget
without a code redeploy (a Space restart picks it up).
"""
override = os.environ.get("QIE_GPU_DURATION")
if override:
try:
return int(override)
except ValueError:
pass
return 145
def _duration_arg(*args: Any, **kwargs: Any) -> int:
"""Locate the params dict among the decorated method's call args.
spaces passes generate()'s args (which include ``self``) to this callable, so
we scan for the params dict rather than relying on a fixed positional index.
"""
params = next((a for a in args if isinstance(a, dict)), {})
return duration_for("", params)
# ZeroGPU GPU size: "xlarge" (96 GB, full RTX Pro 6000 Blackwell, 2x quota) is required
# because the ~58 GB bf16 model does NOT fit the default "large" (48 GB) tier. xlarge keeps
# the whole model resident with NO cpu_offload, eliminating the slow per-call materialization
# that exceeded the proxy-TTL on the 48 GB tier (the historical "image produced but never
# delivered" failure). Override via QIE_GPU_SIZE.
_GPU_SIZE = os.environ.get("QIE_GPU_SIZE", "xlarge")
_GPU = spaces.GPU(duration=_duration_arg, size=_GPU_SIZE) if (spaces is not None and _ON_SPACES) else _identity
def _build_pipeline() -> Any:
"""Build and configure a QwenImageEditPlusPipeline.
Heavy imports (torch, diffusers) are deferred here so that importing
backend.py in CI (no torch installed) is always safe.
Pipeline is pre-loaded with the Lightning LoRA adapter and starts in
Fast mode (Lightning scheduler + adapter enabled at weight 1.0).
Switching to Quality mode is handled by modes._apply_speed per request.
"""
import torch
from diffusers import (
FlowMatchEulerDiscreteScheduler,
QwenImageEditPlusPipeline,
QwenImageTransformer2DModel,
)
import models
# On ZeroGPU the build runs under CPU-emulation where auto_device() can misreport
# (cuda.is_available() may be False at module scope), yet the real per-call device is
# CUDA. Trust the Space env so the dtype/VAE/placement decisions below are consistent
# with how the model actually runs. Off-Spaces, autodetect (cuda > mps > cpu).
device = "cuda" if models.on_spaces() else models.auto_device()
# LOAD gate (MPS): confirm the ~58 GB resident weights fit BEFORE materializing
# shards, so a low-memory box refuses cleanly instead of OOM / swap-thrashing
# mid-load. A resident-weights overflow cannot be fixed by lowering resolution.
if device == "mps":
import memory
ok, need, budget = memory.load_gate(device)
if not ok:
raise RuntimeError(
f"Insufficient memory to load Qwen-Image-Edit-2511 on MPS: need ~{need:.0f} GB, "
f"budget ~{budget:.0f} GB (available {memory.available_gb():.0f} GB - reserve). "
f"Free ~{need - budget:.0f} GB then retry. Top consumers: {memory.top_consumers()}"
)
# Precision: default to full bf16 everywhere (the ~58 GB model β€” transformer 40.9 +
# text_encoder 16.6 + vae 0.25 β€” fits the MPS 128 GB unified pool AND the ZeroGPU
# `xlarge` 96 GB tier with the whole model resident, no offload). fp8 weight-only
# quantization (torchao, CUDA-only) is now OPT-IN via QIE_USE_FP8=1: it shrinks the
# transformer ~40->~20 GB to fit the smaller `large` 48 GB tier, but the torchao/
# diffusers fp8 stack proved unstable, so bf16 on xlarge is the default/working path.
use_fp8 = device == "cuda" and os.environ.get("QIE_USE_FP8", "0") == "1"
if use_fp8:
from diffusers import TorchAoConfig
from torchao.quantization import Float8WeightOnlyConfig
transformer = QwenImageTransformer2DModel.from_pretrained(
models.MODEL_ID,
subfolder="transformer",
quantization_config=TorchAoConfig(Float8WeightOnlyConfig()),
torch_dtype=torch.bfloat16,
)
pipe = QwenImageEditPlusPipeline.from_pretrained(
models.MODEL_ID,
transformer=transformer,
torch_dtype=torch.bfloat16,
)
else:
# Default bf16: load the WHOLE pipeline directly (no separately-loaded transformer).
# On ZeroGPU a separately-constructed component is a suspected disruptor of the
# snapshot/restore CUDA placement (observed: all components landed on CPU in the
# fork). Direct load mirrors the official Qwen Space and lets `spaces` track every
# component for the cuda snapshot.
pipe = QwenImageEditPlusPipeline.from_pretrained(
models.MODEL_ID,
torch_dtype=torch.bfloat16,
)
# Stash the bundled default scheduler for Quality mode.
pipe._qie_default_scheduler = pipe.scheduler
# Build and stash the Lightning scheduler for Fast mode.
pipe._qie_lightning_scheduler = FlowMatchEulerDiscreteScheduler.from_config(models.LIGHTNING_SCHEDULER_CONFIG)
# Load (but do NOT fuse) the Lightning LoRA so we can toggle it per-request.
pipe.load_lora_weights(
models.LORA_REPO,
weight_name=models.LORA_FILE,
adapter_name=models.LORA_ADAPTER_NAME,
)
# Device placement β€” on_spaces takes priority; cpu-offload for small local GPUs.
# NOTE: on Apple Silicon's UNIFIED memory, enable_model_cpu_offload saves no
# physical RAM (CPU+GPU share one 128 GB pool), so MPS goes straight to .to(mps)
# (models.should_cpu_offload already returns False for mps).
if models.on_spaces():
# ZeroGPU: leave the model on CPU at module level and move it to cuda INSIDE the
# @spaces.GPU fork (generate()). A module-level pipe.to("cuda") is snapshotted, and on
# per-call restore the params land back on CPU while a ~36 GB orphaned cuda copy stays
# resident β†’ the in-fork .to then makes a 2nd copy β†’ ~94 GB duplication (no room for
# activation). Building on CPU = no cuda orphan β†’ one clean ~58 GB copy in the fork.
pass
elif models.should_cpu_offload(device):
pipe.enable_model_cpu_offload()
else:
pipe.to(device)
# VAE tiling + slicing cap the decode memory spike β€” enabled on MPS (unified memory is
# tight). Not needed on CUDA/ZeroGPU: the 96 GB xlarge tier decodes 1024^2 untiled with
# ample headroom (measured activation peak ~4 GB).
if device == "mps":
for _fn in ("enable_vae_tiling", "enable_vae_slicing"):
if hasattr(pipe, _fn):
getattr(pipe, _fn)()
# Start in Fast state: Lightning scheduler + LoRA adapter at full weight.
pipe.scheduler = pipe._qie_lightning_scheduler
pipe.set_adapters([models.LORA_ADAPTER_NAME], [1.0])
return pipe
class QwenImageEditBackend:
"""One-process backend wrapping the QwenImageEditPlusPipeline."""
def __init__(self) -> None:
self.pipeline = _build_pipeline()
@_GPU
def generate(self, mode: str, params: dict[str, Any], progress: Any = None) -> tuple[Any, dict[str, Any]]:
"""Route the request to the appropriate mode handler.
Raises ValueError for unrecognised modes so callers get a clear error
rather than an opaque AttributeError / KeyError. ``progress`` (optional
gr.Progress) is forwarded to the handler to drive a clean step bar.
"""
# ZeroGPU placement: the model is built on CPU (a module-level .to("cuda") gets
# snapshotted and restores the params back to CPU while leaving a duplicate cuda copy
# resident). Moving to cuda HERE β€” inside the @spaces.GPU fork, where a real GPU is
# attached β€” gives one clean ~55 GB resident copy with ~37 GB free for activation.
# Idempotent; Spaces-only (local MPS/CPU placement is set at build time).
if _ON_SPACES:
self.pipeline.to("cuda")
handler = modes.DISPATCH.get(mode)
if handler is None:
raise ValueError(f"unknown mode: {mode!r}; expected one of {list(modes.DISPATCH)}")
return handler(self.pipeline, params, progress)
def generate_with_retry(
backend_instance: QwenImageEditBackend,
mode: str,
params: dict[str, Any],
progress: Any = None,
) -> tuple[Any, dict[str, Any]]:
"""Call backend_instance.generate; on ZeroGPU GPU-task-abort, retry exactly once.
Any other exception propagates immediately without a retry.
"""
try:
return backend_instance.generate(mode, params, progress)
except Exception as e:
if "gpu task aborted" in str(e).lower():
return backend_instance.generate(mode, params, progress)
raise