Budlee commited on
Commit
856bab5
·
verified ·
1 Parent(s): 3134861

CPU fallback when ZeroGPU quota exhausted; GPU/CPU chip in topbar

Browse files
Files changed (4) hide show
  1. app.py +44 -4
  2. backend/factory.py +17 -4
  3. backend/real.py +33 -5
  4. frontend/index.html +49 -3
app.py CHANGED
@@ -166,13 +166,53 @@ def world_snapshot() -> dict:
166
  return result
167
 
168
 
169
- @app.api(name="tick_decisions")
170
  @GPU(duration=90)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
171
  def tick_decisions_api() -> dict:
172
- """Drain pending agent decisions inside the GPU boundary."""
 
 
 
173
  _log_req("tick_decisions")
174
- n = TOWNLET.drain_decisions()
175
- result = {"decisions_run": n}
 
 
 
 
 
 
 
 
 
 
176
  _log_resp("tick_decisions", result)
177
  return result
178
 
 
166
  return result
167
 
168
 
 
169
  @GPU(duration=90)
170
+ def _drain_gpu() -> int:
171
+ """The hot path: drain inside a ZeroGPU fork. RealLLMBackend loads each
172
+ fork's Llama with n_gpu_layers=-1.
173
+ """
174
+ return TOWNLET.drain_decisions()
175
+
176
+
177
+ def _drain_cpu() -> int:
178
+ """Quota-exhausted fallback: drain in the parent process with
179
+ n_gpu_layers=0. ZeroGPU isn't touched, so this works even when the
180
+ GPU allocator is empty. ~1-3 tokens/sec on 2 vCPU — slow but the sim
181
+ keeps running. Llama is cached in the parent across drains, so only
182
+ the first call pays the model load.
183
+ """
184
+ import os
185
+ os.environ["TOWNLET_FORCE_CPU"] = "1"
186
+ try:
187
+ return TOWNLET.drain_decisions()
188
+ finally:
189
+ # Keep the env var set for the rest of the process — once we've
190
+ # paid the CPU load cost we want all subsequent drains to reuse
191
+ # the same cached Llama. The next GPU attempt is still cheap to
192
+ # try (~1s for the fork to fail), so the wrapper's try/except
193
+ # remains the source of truth on which mode each call uses.
194
+ pass
195
+
196
+
197
+ @app.api(name="tick_decisions")
198
  def tick_decisions_api() -> dict:
199
+ """Try ZeroGPU first; on quota-exhaustion (or any GPU init error) fall
200
+ back to a CPU drain in the parent process. The sim never stalls; it
201
+ just runs slower when ZeroGPU is empty.
202
+ """
203
  _log_req("tick_decisions")
204
+ try:
205
+ n = _drain_gpu()
206
+ mode = "gpu"
207
+ except RuntimeError as e:
208
+ msg = str(e)
209
+ if "No CUDA GPUs" in msg or "quota" in msg.lower() or "ZeroGPU" in msg:
210
+ print(f"[townlet] GPU unavailable ({msg[:80]}); CPU fallback engaged", flush=True)
211
+ n = _drain_cpu()
212
+ mode = "cpu"
213
+ else:
214
+ raise
215
+ result = {"decisions_run": n, "mode": mode}
216
  _log_resp("tick_decisions", result)
217
  return result
218
 
backend/factory.py CHANGED
@@ -14,6 +14,8 @@ as a placeholder if the endpoint ever gets called.
14
 
15
  from __future__ import annotations
16
 
 
 
17
  from game.models import DEFAULT_MODEL_ID, get_spec
18
 
19
  from . import config
@@ -23,18 +25,29 @@ _llms: dict[str, LLMBackend] = {}
23
  _image: ImageBackend | None = None
24
 
25
 
 
 
 
 
 
 
 
 
 
 
26
  def get_llm(model_id: str = DEFAULT_MODEL_ID) -> LLMBackend:
27
- if model_id not in _llms:
 
28
  get_spec(model_id) # validates id
29
  if config.USE_REAL_MODELS:
30
  from .real import RealLLMBackend
31
 
32
- _llms[model_id] = RealLLMBackend(model_id)
33
  else:
34
  from .mock import MockLLMBackend
35
 
36
- _llms[model_id] = MockLLMBackend(model_id)
37
- return _llms[model_id]
38
 
39
 
40
  def get_image() -> ImageBackend:
 
14
 
15
  from __future__ import annotations
16
 
17
+ import os
18
+
19
  from game.models import DEFAULT_MODEL_ID, get_spec
20
 
21
  from . import config
 
25
  _image: ImageBackend | None = None
26
 
27
 
28
+ def _cache_key(model_id: str) -> str:
29
+ """GPU and CPU Llama instances are loaded with different `n_gpu_layers`
30
+ and can't be reused across modes. Cache them under separate keys so the
31
+ CPU-fallback path (parent process, TOWNLET_FORCE_CPU=1) and the GPU
32
+ path (forked workers, no env var) don't collide.
33
+ """
34
+ mode = "cpu" if os.getenv("TOWNLET_FORCE_CPU") == "1" else "gpu"
35
+ return f"{model_id}::{mode}"
36
+
37
+
38
  def get_llm(model_id: str = DEFAULT_MODEL_ID) -> LLMBackend:
39
+ key = _cache_key(model_id)
40
+ if key not in _llms:
41
  get_spec(model_id) # validates id
42
  if config.USE_REAL_MODELS:
43
  from .real import RealLLMBackend
44
 
45
+ _llms[key] = RealLLMBackend(model_id)
46
  else:
47
  from .mock import MockLLMBackend
48
 
49
+ _llms[key] = MockLLMBackend(model_id)
50
+ return _llms[key]
51
 
52
 
53
  def get_image() -> ImageBackend:
backend/real.py CHANGED
@@ -17,6 +17,7 @@ Model card references (per CLAUDE.md vendor-citation rule):
17
 
18
  from __future__ import annotations
19
 
 
20
  from typing import TYPE_CHECKING
21
 
22
  from game.models import get_spec
@@ -27,6 +28,13 @@ if TYPE_CHECKING:
27
  from llama_cpp import Llama
28
 
29
 
 
 
 
 
 
 
 
30
  class RealLLMBackend:
31
  """A small GGUF LLM via llama-cpp-python, keyed by `model_id` in the roster.
32
 
@@ -54,19 +62,39 @@ class RealLLMBackend:
54
  # n_ctx: smolagents' CodeAgent system prompt is large (tool defs
55
  # + examples). Combined with our composite perception prompt this
56
  # blows past 4096 tokens. Nemotron 3 4B trained at 1M ctx;
57
- # 16384 is safe headroom on Spaces; smaller locally.
58
  # n_gpu_layers: -1 (all on GPU) is what Zero-GPU needs. On a
59
  # Mac (Intel especially) the Metal integrated GPU times out on
60
  # batches this size — kIOAccelCommandBufferCallbackErrorTimeout
61
  # surfaces as `llama_decode -3`. Fall back to CPU there.
 
 
 
 
62
  on_spaces = config.IS_SPACES
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
63
  self._llm = Llama.from_pretrained(
64
  repo_id=self._spec.repo_id,
65
  filename=self._spec.gguf_filename,
66
- n_gpu_layers=-1 if on_spaces else 0,
67
- n_ctx=16384 if on_spaces else 8192,
68
- n_batch=16384 if on_spaces else 512,
69
- flash_attn=on_spaces,
70
  verbose=False,
71
  )
72
  return self._llm
 
17
 
18
  from __future__ import annotations
19
 
20
+ import os
21
  from typing import TYPE_CHECKING
22
 
23
  from game.models import get_spec
 
28
  from llama_cpp import Llama
29
 
30
 
31
+ def force_cpu_mode() -> bool:
32
+ """When set, RealLLMBackend loads with `n_gpu_layers=0` and reduced
33
+ context — usable on the Space's 2 vCPU when ZeroGPU quota is gone.
34
+ Toggled by app.py's tick_decisions fallback path."""
35
+ return os.getenv("TOWNLET_FORCE_CPU") == "1"
36
+
37
+
38
  class RealLLMBackend:
39
  """A small GGUF LLM via llama-cpp-python, keyed by `model_id` in the roster.
40
 
 
62
  # n_ctx: smolagents' CodeAgent system prompt is large (tool defs
63
  # + examples). Combined with our composite perception prompt this
64
  # blows past 4096 tokens. Nemotron 3 4B trained at 1M ctx;
65
+ # 16384 is safe headroom on Spaces; smaller locally / on CPU.
66
  # n_gpu_layers: -1 (all on GPU) is what Zero-GPU needs. On a
67
  # Mac (Intel especially) the Metal integrated GPU times out on
68
  # batches this size — kIOAccelCommandBufferCallbackErrorTimeout
69
  # surfaces as `llama_decode -3`. Fall back to CPU there.
70
+ # When TOWNLET_FORCE_CPU=1 (the quota-exhausted fallback path
71
+ # set by app.py's tick_decisions wrapper) we force 0 GPU
72
+ # layers and shrink the context — slower per token but the
73
+ # sim keeps running without the GPU.
74
  on_spaces = config.IS_SPACES
75
+ cpu_only = force_cpu_mode()
76
+ if cpu_only:
77
+ gpu_layers = 0
78
+ n_ctx = 4096
79
+ n_batch = 256
80
+ use_flash = False
81
+ elif on_spaces:
82
+ gpu_layers = -1
83
+ n_ctx = 16384
84
+ n_batch = 16384
85
+ use_flash = True
86
+ else:
87
+ gpu_layers = 0
88
+ n_ctx = 8192
89
+ n_batch = 512
90
+ use_flash = False
91
  self._llm = Llama.from_pretrained(
92
  repo_id=self._spec.repo_id,
93
  filename=self._spec.gguf_filename,
94
+ n_gpu_layers=gpu_layers,
95
+ n_ctx=n_ctx,
96
+ n_batch=n_batch,
97
+ flash_attn=use_flash,
98
  verbose=False,
99
  )
100
  return self._llm
frontend/index.html CHANGED
@@ -146,6 +146,16 @@
146
  .tl-sent-negative .tl-sent-pip { background: var(--red); }
147
  .tl-sent-positive { color: var(--green); }
148
  .tl-sent-positive .tl-sent-pip { background: var(--green); }
 
 
 
 
 
 
 
 
 
 
149
 
150
  /* ====== middle band ====== */
151
  .tl-mid { flex: 1; display: flex; gap: 14px; padding: 14px; min-height: 0; }
@@ -701,6 +711,10 @@
701
  <span class="tl-sentiment tl-sent-neutral" id="sent-chip">
702
  <span class="tl-sent-pip"></span><span id="sent-text">neutral</span>
703
  </span>
 
 
 
 
704
  </div>
705
  </div>
706
 
@@ -1223,6 +1237,24 @@
1223
  const elInfoModal = document.getElementById("info-modal");
1224
  const elInfoClose = document.getElementById("info-close");
1225
  const elInfoDotText = document.getElementById("info-dot-text");
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1226
  const elReset = document.getElementById("reset-btn");
1227
  const elPanelEmpty = document.getElementById("panel-empty");
1228
  const elPanelBody = document.getElementById("panel-body");
@@ -1873,12 +1905,26 @@
1873
  // previous to resolve, which clamps the effective cadence to the
1874
  // call duration itself.
1875
  let tickInFlight = false;
 
1876
  setInterval(async () => {
1877
  if (tickInFlight) return;
1878
  tickInFlight = true;
1879
- try { await client.predict("/tick_decisions", {}); }
1880
- catch (e) { console.warn("tick_decisions failed", e); }
1881
- finally { tickInFlight = false; }
 
 
 
 
 
 
 
 
 
 
 
 
 
1882
  }, 5000);
1883
  refresh();
1884
  }
 
146
  .tl-sent-negative .tl-sent-pip { background: var(--red); }
147
  .tl-sent-positive { color: var(--green); }
148
  .tl-sent-positive .tl-sent-pip { background: var(--green); }
149
+ .tl-mode {
150
+ display: inline-flex; align-items: center; gap: 6px;
151
+ padding: 2px 9px; border-radius: 8px;
152
+ border: 1.5px solid currentColor;
153
+ font-size: 11px; letter-spacing: 0.08em; text-transform: uppercase;
154
+ }
155
+ .tl-mode-pip { width: 7px; height: 7px; border-radius: 50%; background: currentColor; }
156
+ .tl-mode-unknown { color: var(--ink-soft); }
157
+ .tl-mode-gpu { color: var(--green); }
158
+ .tl-mode-cpu { color: var(--red); }
159
 
160
  /* ====== middle band ====== */
161
  .tl-mid { flex: 1; display: flex; gap: 14px; padding: 14px; min-height: 0; }
 
711
  <span class="tl-sentiment tl-sent-neutral" id="sent-chip">
712
  <span class="tl-sent-pip"></span><span id="sent-text">neutral</span>
713
  </span>
714
+ <span class="tl-meta-dot">·</span>
715
+ <span class="tl-mode tl-mode-unknown" id="mode-chip" title="Which compute the last tick used. CPU appears when ZeroGPU quota is empty — the sim keeps running, just slower.">
716
+ <span class="tl-mode-pip"></span><span id="mode-text">—</span>
717
+ </span>
718
  </div>
719
  </div>
720
 
 
1237
  const elInfoModal = document.getElementById("info-modal");
1238
  const elInfoClose = document.getElementById("info-close");
1239
  const elInfoDotText = document.getElementById("info-dot-text");
1240
+ const elModeChip = document.getElementById("mode-chip");
1241
+ const elModeText = document.getElementById("mode-text");
1242
+
1243
+ function setModeChip(mode) {
1244
+ // mode is "gpu", "cpu", or null/undefined for unknown.
1245
+ if (!elModeChip || !elModeText) return;
1246
+ elModeChip.classList.remove("tl-mode-unknown", "tl-mode-gpu", "tl-mode-cpu");
1247
+ if (mode === "gpu") {
1248
+ elModeChip.classList.add("tl-mode-gpu");
1249
+ elModeText.textContent = "GPU";
1250
+ } else if (mode === "cpu") {
1251
+ elModeChip.classList.add("tl-mode-cpu");
1252
+ elModeText.textContent = "CPU";
1253
+ } else {
1254
+ elModeChip.classList.add("tl-mode-unknown");
1255
+ elModeText.textContent = "—";
1256
+ }
1257
+ }
1258
  const elReset = document.getElementById("reset-btn");
1259
  const elPanelEmpty = document.getElementById("panel-empty");
1260
  const elPanelBody = document.getElementById("panel-body");
 
1905
  // previous to resolve, which clamps the effective cadence to the
1906
  // call duration itself.
1907
  let tickInFlight = false;
1908
+ let lastMode = null;
1909
  setInterval(async () => {
1910
  if (tickInFlight) return;
1911
  tickInFlight = true;
1912
+ try {
1913
+ const res = await client.predict("/tick_decisions", {});
1914
+ const payload = res?.data?.[0];
1915
+ const mode = payload?.mode;
1916
+ if (mode) {
1917
+ setModeChip(mode);
1918
+ if (mode !== lastMode) {
1919
+ console.log("[townlet] tick mode:", mode, mode === "cpu" ? "(ZeroGPU quota empty — CPU fallback active)" : "");
1920
+ lastMode = mode;
1921
+ }
1922
+ }
1923
+ } catch (e) {
1924
+ console.warn("tick_decisions failed", e);
1925
+ } finally {
1926
+ tickInFlight = false;
1927
+ }
1928
  }, 5000);
1929
  refresh();
1930
  }