z_image / app.py
mrfakename's picture
fix zero-gpu error (#90)
9dc2680
Raw
History Blame Contribute Delete
6.39 kB
import os
import tempfile
import spaces
import torch
import gradio as gr
from diffusers import DiffusionPipeline
# Load the pipeline once at startup. The Space is a ZeroGPU space, so the
# model weights stay resident and `@spaces.GPU` allocates a worker per call.
print("Loading Z-Image-Turbo pipeline...")
pipe = DiffusionPipeline.from_pretrained(
"Tongyi-MAI/Z-Image-Turbo",
torch_dtype=torch.bfloat16,
low_cpu_mem_usage=False,
)
pipe.to("cuda")
print("Pipeline loaded!")
def _save_image(image) -> dict:
"""Mirror of `gradio.workflow._save_tmp`: serialize a PIL.Image as a JSON
pointer the canvas can render. `Workflow.launch()` already adds the
tempdir to `allowed_paths`, so the /gradio_api/file=… URL resolves."""
path = os.path.join(
tempfile.gettempdir(), f"zimage_{os.urandom(8).hex()}.png"
)
image.save(path)
return {
"path": path,
"url": f"/gradio_api/file={path}",
"orig_name": "zimage.png",
"mime_type": "image/png",
}
def _estimate_duration(prompt, height, width, num_inference_steps, seed, randomize_seed) -> int:
"""Rough wall-clock estimate (seconds) for one Z-Image-Turbo call.
ZeroGPU's default per-call duration is 60s. Requesting less than you need
*raises* queue priority (shorter tasks get scheduled sooner) and — crucially
for a busy shared Space — frees the GPU slot for the next visitor far
sooner than holding it for a full minute, so far fewer users hit the
Space's "reached its GPU limit" rejection. Scaled by pixel count and steps;
clamped to a small floor/ceiling so a runaway slider can't starve the queue
or under-budget a big call.
Signature mirrors the GPU function exactly because @spaces.GPU passes the
decorated function's inputs straight through to the duration callable.
See: https://huggingface.co/docs/hub/en/spaces-zerogpu#duration-management
"""
pixels = max(int(height), 1) * max(int(width), 1)
# ~0.4s/step at 1024^2, linear-ish in pixels. Big calls still need headroom.
per_step = 0.4 * (pixels / (1024 * 1024))
seconds = int(num_inference_steps) * per_step
return max(20, min(int(seconds) + 15, 120))
def _friendly_gpu_error(err: Exception) -> str:
"""Turn ZeroGPU's terse allocator rejections into a clear, honest message.
'Space app has reached its GPU limit' is a *Space-level* capacity rejection
(the shared ZeroGPU pool is saturated), not a per-user quota wall — it
reproduces regardless of inputs, account tier, or sign-in state. Don't make
an upgrade claim whose truth we can't pin down, so the message is neutral:
shared GPU at capacity, retry shortly.
"""
msg = (str(err) or "").lower()
capacity_hints = (
"gpu limit", "reached its gpu limit", "gpu quota", "out of quota",
"quota", "no gpu", "could not allocate", "gpu is busy", "too many",
"concurrent",
)
if any(h in msg for h in capacity_hints):
return (
"⛔ This demo's shared GPU is at capacity right now — it's not a "
"problem with your prompt or your account. The GPU pool is fully "
"booked by other users at the moment. Please wait a minute and "
"retry; demand clears between bursts."
)
if "out of memory" in msg or "oom" in msg or "cuda" in msg:
return (
"💥 Image generation ran out of GPU memory. Try a smaller "
"Height/Width or fewer Inference Steps, then retry."
)
return (
"⚠️ Image generation failed. Please try again in a moment — if it "
"keeps happening, simplify your prompt or lower the resolution."
)
@spaces.GPU(duration=_estimate_duration)
def _generate_image_gpu(
prompt: str,
height: int,
width: int,
num_inference_steps: int,
seed: int,
randomize_seed: bool,
):
"""The GPU-decorated worker. Runs only under a ZeroGPU allocation; the
allocator raises *before* this body if no GPU can be granted, which is why
the rewording lives in the plain `generate_image` wrapper below, not here.
"""
if not prompt or not prompt.strip():
raise gr.Error("Please enter a prompt.")
if randomize_seed:
seed = torch.randint(0, 2**32 - 1, (1,)).item()
generator = torch.Generator("cuda").manual_seed(int(seed))
image = pipe(
prompt=prompt,
height=int(height),
width=int(width),
num_inference_steps=int(num_inference_steps),
guidance_scale=0.0,
generator=generator,
).images[0]
return _save_image(image), int(seed)
def generate_image(
prompt: str,
height: int,
width: int,
num_inference_steps: int,
seed: int,
randomize_seed: bool,
):
"""Workflow-facing wrapper around the GPU worker.
Bound to the canvas as a `fn` operator node — the workflow calls this
Python function directly server-side, so the entire pipeline (frontend +
ZeroGPU) lives in a single Space. This non-GPU wrapper catches rejections
from the `@spaces.GPU` allocator (which fire before the worker body runs)
and rewords them into a clear, honest user-facing message.
Returns (image_dict, seed_used). The image is serialized to a /gradio_api
file URL so JSON serialization across the fn bridge succeeds; the
executor's `fromGradioOutput` turns the dict back into an image port value.
"""
try:
return _generate_image_gpu(
prompt, height, width, num_inference_steps, seed, randomize_seed
)
except gr.Error:
# Already a user-facing validation message (e.g. empty prompt) — pass
# it through unchanged.
raise
except Exception as e:
# Allocator rejection (GPU limit / quota / OOM / etc.) — reword.
raise gr.Error(_friendly_gpu_error(e)) from e
# The workflow (workflow.json) wires `generate_image` as a `fn` operator:
# Prompt, Height, Width, Inference Steps, Seed, Randomize Seed ─▶
# generate_image (fn operator, kind="fn") ─▶ Output Image, Seed Used
#
# On a Space with `hf_oauth: true`, visiting the canvas runs this function
# under a ZeroGPU worker using each visitor's own HF token.
demo = gr.Workflow(
graph="workflow.json",
bind={"generate_image": generate_image},
)
if __name__ == "__main__":
demo.launch()