GPU hack fixes
Browse files
app.py
CHANGED
|
@@ -340,10 +340,15 @@ def chat_send(message, quests, adventure, theme, photo, selected_id, images, bro
|
|
| 340 |
raise gr.Error(_import_error_message())
|
| 341 |
theme = theme if theme in THEMES else "fantasy"
|
| 342 |
|
| 343 |
-
|
| 344 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 345 |
|
| 346 |
-
if kind == "forge"
|
| 347 |
q, a, fid, im, sc, de, st, b2 = _do_forge(message, theme, photo, browser)
|
| 348 |
return ("", q, a, fid, im, sc, de, st, b2)
|
| 349 |
|
|
@@ -527,7 +532,7 @@ with gr.Blocks(title="FrogQuest") as demo:
|
|
| 527 |
)
|
| 528 |
send_btn = gr.Button("SEND ▶", elem_classes=["pix-btn", "primary", "fq-chat-send"])
|
| 529 |
gr.HTML('<p class="fq-chat-hint">e.g. "Finish the report, reply to emails, book dentist" · '
|
| 530 |
-
'"I finished the report" · "Couldn\'t do the gym — too tired"</p>')
|
| 531 |
|
| 532 |
# ---------- wiring ----------
|
| 533 |
photo_image.upload(upload_photo, [photo_image, browser], [photo_state, browser])
|
|
|
|
| 340 |
raise gr.Error(_import_error_message())
|
| 341 |
theme = theme if theme in THEMES else "fantasy"
|
| 342 |
|
| 343 |
+
# Skip the (GPU) intent classifier when there's no log yet — the only sensible action is to
|
| 344 |
+
# forge one. Saves a whole GPU reservation on the most common first message.
|
| 345 |
+
if not quests:
|
| 346 |
+
intent, kind = {}, "forge"
|
| 347 |
+
else:
|
| 348 |
+
intent = route_intent(message, _chat_context(quests, selected_id))
|
| 349 |
+
kind = intent.get("intent", "unknown")
|
| 350 |
|
| 351 |
+
if kind == "forge":
|
| 352 |
q, a, fid, im, sc, de, st, b2 = _do_forge(message, theme, photo, browser)
|
| 353 |
return ("", q, a, fid, im, sc, de, st, b2)
|
| 354 |
|
|
|
|
| 532 |
)
|
| 533 |
send_btn = gr.Button("SEND ▶", elem_classes=["pix-btn", "primary", "fq-chat-send"])
|
| 534 |
gr.HTML('<p class="fq-chat-hint">e.g. "Finish the report, reply to emails, book dentist" · '
|
| 535 |
+
'"I finished the report" · "Couldn\'t do the gym — too tired"</p><p>Where to Start, Where to Break and let Go,<br>Where to change <br>Where to look and to grow </p>')
|
| 536 |
|
| 537 |
# ---------- wiring ----------
|
| 538 |
photo_image.upload(upload_photo, [photo_image, browser], [photo_state, browser])
|
images.py
CHANGED
|
@@ -21,30 +21,53 @@ from diffusers import Flux2KleinPipeline
|
|
| 21 |
from PIL import Image
|
| 22 |
|
| 23 |
MODEL_ID = "black-forest-labs/FLUX.2-klein-4B" # verified: ungated, Apache 2.0
|
| 24 |
-
# Quality config
|
| 25 |
-
# - precision:
|
| 26 |
-
#
|
| 27 |
# - steps: 4 is the model card's recommended value because klein is DISTILLED; raising it
|
| 28 |
# does not improve a distilled model (and can hurt).
|
| 29 |
# - there is no "context length" for a diffusion model.
|
| 30 |
-
# - resolution is the only real speed/quality knob; 768 keeps
|
| 31 |
STEPS = 4
|
| 32 |
GUIDANCE = 4.0
|
| 33 |
MAX_SIDE = 768 # generated-scene resolution (the one quality/speed knob)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 34 |
|
| 35 |
_pipe = None
|
|
|
|
| 36 |
|
| 37 |
|
| 38 |
def _get_pipe():
|
| 39 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 40 |
if _pipe is None:
|
| 41 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 42 |
return _pipe
|
| 43 |
|
| 44 |
|
| 45 |
def _gen(prompt: str, image, seed: int) -> Image.Image:
|
| 46 |
pipe = _get_pipe()
|
| 47 |
-
|
|
|
|
| 48 |
generator = torch.Generator("cuda").manual_seed(int(seed))
|
| 49 |
result = pipe(
|
| 50 |
prompt=prompt,
|
|
@@ -58,7 +81,7 @@ def _gen(prompt: str, image, seed: int) -> Image.Image:
|
|
| 58 |
return result.images[0]
|
| 59 |
|
| 60 |
|
| 61 |
-
@spaces.GPU(duration=
|
| 62 |
def initial_image(user_photo: Image.Image, art_style: str, scene_prompt: str, seed: int) -> Image.Image:
|
| 63 |
"""Generate the quest's initial scene with the user as the hero (photo as reference)."""
|
| 64 |
prompt = (
|
|
@@ -68,7 +91,7 @@ def initial_image(user_photo: Image.Image, art_style: str, scene_prompt: str, se
|
|
| 68 |
return _gen(prompt, image=[user_photo], seed=seed)
|
| 69 |
|
| 70 |
|
| 71 |
-
@spaces.GPU(duration=
|
| 72 |
def edit_image(base_image: Image.Image, edit_instruction: str, art_style: str, seed: int) -> Image.Image:
|
| 73 |
"""NEXT PASS (priority 5): edit the existing image into a success/failure state."""
|
| 74 |
prompt = f"{art_style}. {edit_instruction}"
|
|
|
|
| 21 |
from PIL import Image
|
| 22 |
|
| 23 |
MODEL_ID = "black-forest-labs/FLUX.2-klein-4B" # verified: ungated, Apache 2.0
|
| 24 |
+
# Quality config:
|
| 25 |
+
# - precision: bf16 on GPUs that support it (ZeroGPU/Blackwell = klein's NATIVE dtype, best
|
| 26 |
+
# quality). On a Turing GPU like T4 (no bf16) we fall back to fp16 — see _get_pipe.
|
| 27 |
# - steps: 4 is the model card's recommended value because klein is DISTILLED; raising it
|
| 28 |
# does not improve a distilled model (and can hurt).
|
| 29 |
# - there is no "context length" for a diffusion model.
|
| 30 |
+
# - resolution is the only real speed/quality knob; 768 keeps generation fast.
|
| 31 |
STEPS = 4
|
| 32 |
GUIDANCE = 4.0
|
| 33 |
MAX_SIDE = 768 # generated-scene resolution (the one quality/speed knob)
|
| 34 |
+
LOW_VRAM_GB = 24 # at/below this (e.g. T4 16GB) use fp16 + CPU offload so ~13GB of weights fit
|
| 35 |
+
|
| 36 |
+
# Best-effort: pre-fetch the weights at startup so the first @spaces.GPU call doesn't pay the
|
| 37 |
+
# multi-GB download out of its (metered, on ZeroGPU) duration. No-op offline / on fresh checkout.
|
| 38 |
+
try:
|
| 39 |
+
from huggingface_hub import snapshot_download
|
| 40 |
+
snapshot_download(MODEL_ID)
|
| 41 |
+
except Exception:
|
| 42 |
+
pass
|
| 43 |
|
| 44 |
_pipe = None
|
| 45 |
+
_offloaded = False # True when we used CPU offload (small GPU) instead of a full .to("cuda")
|
| 46 |
|
| 47 |
|
| 48 |
def _get_pipe():
|
| 49 |
+
"""Construct the pipeline lazily INSIDE the GPU call so we can read the REAL device's caps
|
| 50 |
+
and adapt — this is what lets ONE codebase run on both ZeroGPU (Blackwell, 48GB, native
|
| 51 |
+
bf16) and a small standard GPU like T4 (Turing, 16GB, fp16 + CPU offload). Just flip the
|
| 52 |
+
hardware in the Space settings; no code change needed."""
|
| 53 |
+
global _pipe, _offloaded
|
| 54 |
if _pipe is None:
|
| 55 |
+
bf16 = torch.cuda.is_available() and torch.cuda.is_bf16_supported()
|
| 56 |
+
dtype = torch.bfloat16 if bf16 else torch.float16 # T4 (Turing) has no bf16
|
| 57 |
+
_pipe = Flux2KleinPipeline.from_pretrained(MODEL_ID, torch_dtype=dtype)
|
| 58 |
+
vram_gb = (torch.cuda.get_device_properties(0).total_memory / 1e9
|
| 59 |
+
if torch.cuda.is_available() else 0)
|
| 60 |
+
if 0 < vram_gb < LOW_VRAM_GB:
|
| 61 |
+
# Small GPU: stream modules GPU<-CPU per step so the weights fit in ~16GB VRAM.
|
| 62 |
+
_pipe.enable_model_cpu_offload()
|
| 63 |
+
_offloaded = True
|
| 64 |
return _pipe
|
| 65 |
|
| 66 |
|
| 67 |
def _gen(prompt: str, image, seed: int) -> Image.Image:
|
| 68 |
pipe = _get_pipe()
|
| 69 |
+
if not _offloaded:
|
| 70 |
+
pipe.to("cuda") # full-residency path (big GPU); offload manages its own device moves
|
| 71 |
generator = torch.Generator("cuda").manual_seed(int(seed))
|
| 72 |
result = pipe(
|
| 73 |
prompt=prompt,
|
|
|
|
| 81 |
return result.images[0]
|
| 82 |
|
| 83 |
|
| 84 |
+
@spaces.GPU(duration=60)
|
| 85 |
def initial_image(user_photo: Image.Image, art_style: str, scene_prompt: str, seed: int) -> Image.Image:
|
| 86 |
"""Generate the quest's initial scene with the user as the hero (photo as reference)."""
|
| 87 |
prompt = (
|
|
|
|
| 91 |
return _gen(prompt, image=[user_photo], seed=seed)
|
| 92 |
|
| 93 |
|
| 94 |
+
@spaces.GPU(duration=60)
|
| 95 |
def edit_image(base_image: Image.Image, edit_instruction: str, art_style: str, seed: int) -> Image.Image:
|
| 96 |
"""NEXT PASS (priority 5): edit the existing image into a success/failure state."""
|
| 97 |
prompt = f"{art_style}. {edit_instruction}"
|
llm.py
CHANGED
|
@@ -26,10 +26,24 @@ from schema import INTENT_SCHEMA, RESPONSE_SCHEMA # noqa: E402
|
|
| 26 |
GGUF_REPO = "unsloth/NVIDIA-Nemotron-3-Nano-4B-GGUF"
|
| 27 |
GGUF_FILE = "*Q8_0*.gguf"
|
| 28 |
|
| 29 |
-
#
|
| 30 |
-
#
|
| 31 |
-
#
|
| 32 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 33 |
|
| 34 |
SYSTEM_PROMPT = """You are FrogQuest's quest designer. Convert the user's real to-do list into a themed text-adventure quest log and OUTPUT JSON ONLY - no prose, no markdown, no code.
|
| 35 |
|
|
@@ -110,17 +124,20 @@ def _get_llm():
|
|
| 110 |
_preload_cuda_libs()
|
| 111 |
from llama_cpp import Llama
|
| 112 |
|
|
|
|
|
|
|
|
|
|
| 113 |
_llm = Llama.from_pretrained(
|
| 114 |
repo_id=GGUF_REPO,
|
| 115 |
-
filename=GGUF_FILE, # glob -> resolves the exact
|
| 116 |
-
n_gpu_layers=-1, # offload all layers
|
| 117 |
-
n_ctx=
|
| 118 |
verbose=False,
|
| 119 |
)
|
| 120 |
return _llm
|
| 121 |
|
| 122 |
|
| 123 |
-
@spaces.GPU(duration=
|
| 124 |
def generate_quests_raw(todos: str, theme: str) -> dict:
|
| 125 |
"""Return the model's raw JSON object (UNVALIDATED - caller must validate_and_clamp)."""
|
| 126 |
llm = _get_llm()
|
|
@@ -140,7 +157,7 @@ def generate_quests_raw(todos: str, theme: str) -> dict:
|
|
| 140 |
return _extract_json(content)
|
| 141 |
|
| 142 |
|
| 143 |
-
@spaces.GPU(duration=
|
| 144 |
def route_intent(message: str, context: str) -> dict:
|
| 145 |
"""Classify one Frog Master chat message into {intent, target_task?, reason?}.
|
| 146 |
|
|
|
|
| 26 |
GGUF_REPO = "unsloth/NVIDIA-Nemotron-3-Nano-4B-GGUF"
|
| 27 |
GGUF_FILE = "*Q8_0*.gguf"
|
| 28 |
|
| 29 |
+
# Context length is hardware-adaptive (chosen in _get_llm from the REAL device's VRAM). On a big
|
| 30 |
+
# GPU (ZeroGPU, 48GB) we use the full 128k Nemotron supports; on a small standard GPU (e.g. T4
|
| 31 |
+
# 16GB) a 128k KV cache won't fit alongside FLUX, so we drop to 16k — still vastly more than this
|
| 32 |
+
# app's tiny prompts ever need. This is one half of the ZeroGPU<->T4 portability (see images.py).
|
| 33 |
+
N_CTX = 131072 # big GPUs (ZeroGPU)
|
| 34 |
+
N_CTX_SMALL = 16384 # small GPUs (T4 & similar)
|
| 35 |
+
LOW_VRAM_GB = 24 # at/below this, treat the GPU as "small" (T4 = 16GB)
|
| 36 |
+
|
| 37 |
+
# Best-effort: warm the HF cache at startup so the FIRST @spaces.GPU call doesn't spend its
|
| 38 |
+
# (metered, on ZeroGPU) duration downloading ~4GB. No-op if offline or on a fresh local checkout
|
| 39 |
+
# — the model still lazy-loads from cache/network inside the GPU call either way.
|
| 40 |
+
try:
|
| 41 |
+
from huggingface_hub import hf_hub_download, list_repo_files # noqa: E402
|
| 42 |
+
_gguf = next((f for f in list_repo_files(GGUF_REPO) if "Q8_0" in f and f.endswith(".gguf")), None)
|
| 43 |
+
if _gguf:
|
| 44 |
+
hf_hub_download(GGUF_REPO, _gguf)
|
| 45 |
+
except Exception:
|
| 46 |
+
pass
|
| 47 |
|
| 48 |
SYSTEM_PROMPT = """You are FrogQuest's quest designer. Convert the user's real to-do list into a themed text-adventure quest log and OUTPUT JSON ONLY - no prose, no markdown, no code.
|
| 49 |
|
|
|
|
| 124 |
_preload_cuda_libs()
|
| 125 |
from llama_cpp import Llama
|
| 126 |
|
| 127 |
+
vram_gb = (torch.cuda.get_device_properties(0).total_memory / 1e9
|
| 128 |
+
if torch.cuda.is_available() else 0)
|
| 129 |
+
n_ctx = N_CTX if vram_gb >= LOW_VRAM_GB else N_CTX_SMALL
|
| 130 |
_llm = Llama.from_pretrained(
|
| 131 |
repo_id=GGUF_REPO,
|
| 132 |
+
filename=GGUF_FILE, # glob -> resolves the exact Q8_0 file (warmed at import)
|
| 133 |
+
n_gpu_layers=-1, # offload all layers (Q8 4B ~4.3GB fits even on a T4)
|
| 134 |
+
n_ctx=n_ctx,
|
| 135 |
verbose=False,
|
| 136 |
)
|
| 137 |
return _llm
|
| 138 |
|
| 139 |
|
| 140 |
+
@spaces.GPU(duration=70)
|
| 141 |
def generate_quests_raw(todos: str, theme: str) -> dict:
|
| 142 |
"""Return the model's raw JSON object (UNVALIDATED - caller must validate_and_clamp)."""
|
| 143 |
llm = _get_llm()
|
|
|
|
| 157 |
return _extract_json(content)
|
| 158 |
|
| 159 |
|
| 160 |
+
@spaces.GPU(duration=45)
|
| 161 |
def route_intent(message: str, context: str) -> dict:
|
| 162 |
"""Classify one Frog Master chat message into {intent, target_task?, reason?}.
|
| 163 |
|