VirusDumb commited on
Commit
0b499a9
·
1 Parent(s): c972d5c

GPU hack fixes

Browse files
Files changed (3) hide show
  1. app.py +9 -4
  2. images.py +32 -9
  3. llm.py +26 -9
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
- intent = route_intent(message, _chat_context(quests, selected_id))
344
- kind = intent.get("intent", "unknown")
 
 
 
 
 
345
 
346
- if kind == "forge" or (kind == "add_tasks" and not quests):
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 — already running at klein's best:
25
- # - precision: full bfloat16 weights (the model's NATIVE dtype, NOT quantized). No Q4/Q8
26
- # tradeoff like the GGUF LLM has this is the highest-quality way to run it.
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 ZeroGPU generation fast.
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
- global _pipe
 
 
 
 
40
  if _pipe is None:
41
- _pipe = Flux2KleinPipeline.from_pretrained(MODEL_ID, torch_dtype=torch.bfloat16)
 
 
 
 
 
 
 
 
42
  return _pipe
43
 
44
 
45
  def _gen(prompt: str, image, seed: int) -> Image.Image:
46
  pipe = _get_pipe()
47
- pipe.to("cuda")
 
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=240)
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=240)
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
- # 128k context. Nemotron Nano 4B supports up to 131072 tokens; ZeroGPU (~48GB) easily holds
30
- # the KV cache for a 4B model at this length. Our prompts are tiny, but this leaves huge
31
- # headroom for long to-do lists and growing quest-log context in the chat router.
32
- N_CTX = 131072
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 Q4_K_M file
116
- n_gpu_layers=-1, # offload all layers to GPU
117
- n_ctx=N_CTX,
118
  verbose=False,
119
  )
120
  return _llm
121
 
122
 
123
- @spaces.GPU(duration=180)
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=60)
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