atakan Claude Opus 5 commited on
Commit
88bdfd7
·
1 Parent(s): 929b04a

fix: Run inference inside @spaces.GPU, where the weights actually exist

Browse files

The Space was up, responsive, and emitting fluent multilingual noise at ~15
minutes a turn. Both symptoms were the same cause. Loading in the import window
got the model created and reported as cuda:0, but that is ZeroGPU's CUDA
*emulation*: it packs the tensors (17.6G, visible in the logs) and only
materialises them on real hardware for the duration of a @spaces.GPU call. Our
forward passes ran from a FastAPI route, so they read unmaterialised tensors --
which does not raise, it just returns garbage slowly.

app.py grows a stream_hook, None locally. app_space.py sets it to _gpu_stream, a
@spaces.GPU(duration=300) generator wrapping one whole agent turn.

The turn is the right unit. A turn is up to MAX_TOOL_STEPS generations plus the
tool calls between them, and they all mutate one KV cache; wrapping each
engine.stream() separately would put that shared state across a process boundary
on every step. One allocation per turn keeps it coherent, at the cost of not
reusing the cache between turns.

_collect() exists because ControlAgent.run() consumes self.stream directly and
would have bypassed the hook, leaving /api/chat running outside the GPU window
while /api/chat/stream ran inside it. It reads traces off the `done` event
rather than accumulating tool_end events, which also keeps the arguments that
run() dropped.

_gpu_probe stays, reduced to returning "ready": a @spaces.GPU function is only
detected when wired to a Gradio event handler, and _gpu_stream is driven by
FastAPI, so it cannot satisfy that itself.

Verified locally with a stand-in hook of the same shape that both /api/chat and
/api/chat/stream route through it, and that the MLX path is untouched when no
hook is installed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

Files changed (3) hide show
  1. CLAUDE.md +12 -0
  2. app.py +41 -6
  3. app_space.py +42 -10
CLAUDE.md CHANGED
@@ -210,6 +210,18 @@ routing transformers through `caching_allocator_warmup` and its direct
210
  unavailable**, which is what forces a model small enough to carry in bf16: `Qwen/Qwen3-8B`, ~16GB
211
  against ~28GB for the 14B run locally.
212
 
 
 
 
 
 
 
 
 
 
 
 
 
213
  **ZeroGPU platform gotchas, each learned by having the Space fail:**
214
  - A `@spaces.GPU` function is only detected if wired to a real Gradio event handler. One called
215
  solely from a FastAPI route fails startup with "No @spaces.GPU function detected". Hence the
 
210
  unavailable**, which is what forces a model small enough to carry in bf16: `Qwen/Qwen3-8B`, ~16GB
211
  against ~28GB for the 14B run locally.
212
 
213
+ **All inference must run inside a `@spaces.GPU` call.** Loading the model in the import window is
214
+ necessary but not sufficient: ZeroGPU creates it under CUDA *emulation*, reports `cuda:0`, and packs
215
+ its tensors — only a `@spaces.GPU` call materialises them on real hardware. Forward passes anywhere
216
+ else do not fail. They read unmaterialised tensors and return fluent-looking multilingual noise at
217
+ roughly fifteen minutes a turn, which is the worst possible failure mode: a demo that is up,
218
+ responsive, and confidently wrong. `app.py` exposes a `stream_hook`, `None` locally; `app_space.py`
219
+ sets it to `_gpu_stream`, a `@spaces.GPU(duration=300)` generator wrapping one whole agent turn.
220
+ Wrapping the *turn* rather than each `engine.stream()` call is deliberate — a turn is several
221
+ generations sharing one KV cache, and splitting them across separate calls would put that shared
222
+ state on the far side of a process boundary each time. `_collect()` exists so `/api/chat` honours the
223
+ hook too; `ControlAgent.run()` consumes `self.stream` directly and would bypass it.
224
+
225
  **ZeroGPU platform gotchas, each learned by having the Space fail:**
226
  - A `@spaces.GPU` function is only detected if wired to a real Gradio event handler. One called
227
  solely from a FastAPI route fails startup with "No @spaces.GPU function detected". Hence the
app.py CHANGED
@@ -129,6 +129,41 @@ class ChatResponse(BaseModel):
129
  elapsed_seconds: float
130
 
131
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
132
  def _to_wire_events(message: str, history: list[dict[str, str]]):
133
  """Translate agent events into the shape the browser client consumes.
134
 
@@ -137,7 +172,7 @@ def _to_wire_events(message: str, history: list[dict[str, str]]):
137
  stable for the existing UI while the agent's own vocabulary stays clean.
138
  """
139
  thoughts: list[str] = []
140
- for event in get_agent().stream(message, history):
141
  kind = event["type"]
142
  if kind == "text":
143
  yield {"type": "token", "content": event["text"]}
@@ -298,10 +333,10 @@ async def chat(req: ChatRequest) -> ChatResponse:
298
 
299
  def _run():
300
  with inference_lock:
301
- return get_agent().run(message, req.history)
302
 
303
  try:
304
- result = await _on_inference_thread(_run)
305
  except Exception as exc:
306
  print(f"[chat] {type(exc).__name__}: {exc}")
307
  return ChatResponse(
@@ -310,9 +345,9 @@ async def chat(req: ChatRequest) -> ChatResponse:
310
  )
311
 
312
  return ChatResponse(
313
- response=result.answer,
314
- tool_traces=[{"tool": t.name, "status": t.status} for t in result.traces],
315
- plots=result.plots,
316
  elapsed_seconds=round(time.time() - started, 2),
317
  )
318
 
 
129
  elapsed_seconds: float
130
 
131
 
132
+ # Set by app_space.py to a @spaces.GPU-decorated generator. ZeroGPU attaches
133
+ # real hardware only for the duration of such a call, so on the Space the whole
134
+ # turn -- every tool step, every KV-cache mutation -- has to happen inside one.
135
+ # Left None locally, where MLX needs no such thing.
136
+ stream_hook = None
137
+
138
+
139
+ def _stream_events(message: str, history: list[dict[str, str]]):
140
+ """The agent's raw events, through the GPU hook when one is installed."""
141
+ if stream_hook is not None:
142
+ yield from stream_hook(message, history)
143
+ else:
144
+ yield from get_agent().stream(message, history)
145
+
146
+
147
+ def _collect(message: str, history: list[dict[str, str]]):
148
+ """`ControlAgent.run()` over `_stream_events`, so /api/chat honours the hook.
149
+
150
+ ControlAgent.run consumes self.stream directly, which would bypass the hook
151
+ and run inference outside the GPU window.
152
+ """
153
+ answer, plots, sources, stats = "", [], [], {}
154
+ traces: list[Any] = []
155
+ for event in _stream_events(message, history):
156
+ # The `done` event already carries the full traces, arguments included;
157
+ # accumulating tool_end events separately would only lose the arguments.
158
+ if event["type"] == "done":
159
+ answer = event["answer"]
160
+ traces = event["traces"]
161
+ plots = event["plots"]
162
+ sources = event["sources"]
163
+ stats = event["stats"]
164
+ return answer, traces, plots, sources, stats
165
+
166
+
167
  def _to_wire_events(message: str, history: list[dict[str, str]]):
168
  """Translate agent events into the shape the browser client consumes.
169
 
 
172
  stable for the existing UI while the agent's own vocabulary stays clean.
173
  """
174
  thoughts: list[str] = []
175
+ for event in _stream_events(message, history):
176
  kind = event["type"]
177
  if kind == "text":
178
  yield {"type": "token", "content": event["text"]}
 
333
 
334
  def _run():
335
  with inference_lock:
336
+ return _collect(message, req.history)
337
 
338
  try:
339
+ answer, traces, plots, _sources, _stats = await _on_inference_thread(_run)
340
  except Exception as exc:
341
  print(f"[chat] {type(exc).__name__}: {exc}")
342
  return ChatResponse(
 
345
  )
346
 
347
  return ChatResponse(
348
+ response=answer,
349
+ tool_traces=[{"tool": t.get("tool"), "status": t.get("status")} for t in traces],
350
+ plots=plots,
351
  elapsed_seconds=round(time.time() - started, 2),
352
  )
353
 
app_space.py CHANGED
@@ -28,6 +28,7 @@ it the retriever has no corpus and every answer falls back to model knowledge.
28
  from __future__ import annotations
29
 
30
  import os
 
31
 
32
  # Assigned, not setdefault: this module is the CUDA entry point by definition,
33
  # and a stale Space variable must not be able to select something else. One did
@@ -87,21 +88,52 @@ def _build_agent_at_import() -> None:
87
  # "[agent] retrieval failed". Force it onto the GPU here too. Embedding one
88
  # throwaway string is what actually triggers the load.
89
  get_embedder().encode_query("warmup")
90
- print("[space] agent and embedder ready")
91
-
92
-
93
- _fetch_index() # the agent prewarms retrieval, so the index must precede it
94
- _build_agent_at_import()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
95
 
96
 
97
- @spaces.GPU(duration=120)
98
  def _gpu_probe(text: str) -> str:
99
- """Satisfies ZeroGPU's startup validation, and warms the model on first use.
100
 
101
- Takes only a plain string. The agent is reached through the module global --
102
- see note 4 above; passing it in is what hangs the whole Space.
 
 
103
  """
104
- return app_module.get_agent().run(text).answer if text else "ready"
 
 
 
 
 
 
 
105
 
106
 
107
  with gr.Blocks() as _gpu_demo:
 
28
  from __future__ import annotations
29
 
30
  import os
31
+ from typing import Iterator
32
 
33
  # Assigned, not setdefault: this module is the CUDA entry point by definition,
34
  # and a stale Space variable must not be able to select something else. One did
 
88
  # "[agent] retrieval failed". Force it onto the GPU here too. Embedding one
89
  # throwaway string is what actually triggers the load.
90
  get_embedder().encode_query("warmup")
91
+ # Route every turn through the GPU-decorated generator above.
92
+ app_module.stream_hook = _gpu_stream
93
+ print("[space] agent and embedder ready, GPU stream hook installed")
94
+
95
+
96
+ # One allocation per user turn, not per engine call. A turn is up to
97
+ # MAX_TOOL_STEPS generations plus the tool executions between them, and they all
98
+ # mutate the same KV cache; splitting them across separate @spaces.GPU calls
99
+ # would put that shared state on the far side of a process boundary each time.
100
+ # 300s is the ceiling for a turn that actually calls two tools.
101
+ @spaces.GPU(duration=300)
102
+ def _gpu_stream(message: str, history: list) -> Iterator[dict]:
103
+ """Run one whole agent turn with real hardware attached.
104
+
105
+ Everything about ControlAI's inference has to happen inside a call like this
106
+ one. ZeroGPU creates the model under CUDA *emulation* at import and packs its
107
+ tensors; only a @spaces.GPU call materialises them on a real device. Running
108
+ the forward passes anywhere else does not fail loudly -- it reads
109
+ unmaterialised tensors and returns fluent-looking multilingual noise at a few
110
+ minutes per turn, which is exactly what the Space did before this existed.
111
+
112
+ `message` and `history` are plain data. The agent is reached through the
113
+ module global and never passed in: ZeroGPU marshals arguments across a
114
+ process boundary and would try to share the model's CUDA tensors, hanging
115
+ with no output at all.
116
+ """
117
+ yield from app_module.get_agent().stream(message, history)
118
 
119
 
120
+ @spaces.GPU(duration=60)
121
  def _gpu_probe(text: str) -> str:
122
+ """Satisfies ZeroGPU's startup validation.
123
 
124
+ A @spaces.GPU function is only detected if it is wired to a real Gradio event
125
+ handler, and _gpu_stream is driven by FastAPI rather than by Gradio, so it
126
+ cannot serve that purpose itself. This one exists to be wired to the hidden
127
+ button below.
128
  """
129
+ return "ready"
130
+
131
+
132
+ # Both run at import, in this order: the index first (the agent touches retrieval
133
+ # as it starts), then the agent -- which installs _gpu_stream, so it has to come
134
+ # after that function exists.
135
+ _fetch_index()
136
+ _build_agent_at_import()
137
 
138
 
139
  with gr.Blocks() as _gpu_demo: