atakan Claude Opus 5 commited on
Commit
3e5d998
·
1 Parent(s): e9a64ca

fix: Build the Space's agent at import scope, where ZeroGPU is watching

Browse files

Third failure, same RuntimeError, but the traceback finally names the line:
engine_torch.py:102, `self.model.to("cuda")`. So it was never device_map
specifically -- .to("cuda") fails too, and the previous commit's reasoning was
only half right.

ZeroGPU patches torch during the import of the Space's entry module, and only
CUDA operations inside that window are intercepted. app.py builds the model in
FastAPI's lifespan, on a ThreadPoolExecutor worker: after import, on another
thread, outside the window. Any CUDA op there reaches the real
torch._C._cuda_init and raises. That is also why the note from the last working
deploy -- "explicit .to('cuda') works" -- did not carry over: back then the model
was built at import, not in a lifespan.

So where the engine is constructed matters as much as how.
app_space.py::_build_agent_at_import builds it during its own import and assigns
app._agent directly, leaving lifespan's get_agent() a no-op. _fetch_index moves
ahead of it, since the agent touches retrieval as it starts.

device_map remains excluded for its own separate reason (caching_allocator_warmup
calls torch.empty(device="cuda") directly), and bitsandbytes with it, so bf16 and
a model small enough to carry in it stand.

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

Files changed (3) hide show
  1. CLAUDE.md +13 -10
  2. app_space.py +31 -4
  3. controlai_agent/engine_torch.py +11 -8
CLAUDE.md CHANGED
@@ -193,16 +193,19 @@ mlx_lm` several frames from the real cause.
193
  cannot express either of the two things that matter: `DynamicCache.crop()` for prefix reuse across
194
  tool steps, and injecting `</think>` to close an overrunning reasoning block.
195
 
196
- **It loads bf16 and moves the model with an explicit `.to("cuda")`. Do not reintroduce `device_map`
197
- or bitsandbytes.** Both are the obvious thing to reach for and both fail on ZeroGPU with
198
- `RuntimeError: Low-level CUDA init (torch._C._cuda_init) reached`. ZeroGPU emulates CUDA at startup
199
- and attaches real hardware only inside a `@spaces.GPU` call; its emulation intercepts `.to("cuda")`,
200
- but `device_map` routes transformers through `caching_allocator_warmup`, which calls
201
- `torch.empty(..., device="cuda")` directly and trips the guard. bitsandbytes *requires* `device_map`
202
- at load, so **4-bit quantisation is unavailable** while the model is loaded at startup rather than
203
- inside the GPU function. That is the constraint that sets the Space's model: `Qwen/Qwen3-8B` in
204
- bf16, ~16GB to download, against ~28GB for the 14B run locally and a Space rebuild re-downloads
205
- from scratch.
 
 
 
206
 
207
  **ZeroGPU platform gotchas, each learned by having the Space fail:**
208
  - A `@spaces.GPU` function is only detected if wired to a real Gradio event handler. One called
 
193
  cannot express either of the two things that matter: `DynamicCache.crop()` for prefix reuse across
194
  tool steps, and injecting `</think>` to close an overrunning reasoning block.
195
 
196
+ **It loads bf16 with an explicit `.to("cuda")`, and `app_space.py` constructs it at module import
197
+ scope. Do not reintroduce `device_map` or bitsandbytes, and do not move the construction into a
198
+ lifespan or a request.** All of those fail identically with `RuntimeError: Low-level CUDA init
199
+ (torch._C._cuda_init) reached`. ZeroGPU patches torch during the import of the Space's entry module
200
+ and attaches real hardware only inside a `@spaces.GPU` call; only CUDA operations inside that import
201
+ window are intercepted. `app.py` normally builds the model in FastAPI's `lifespan`, on a
202
+ `ThreadPoolExecutor` worker after import, on another thread and `.to("cuda")` there reaches real
203
+ CUDA init and raises. `app_space.py::_build_agent_at_import` builds the agent during import and
204
+ assigns `app._agent`, so `lifespan`'s `get_agent()` is a no-op. `device_map` fails independently, by
205
+ routing transformers through `caching_allocator_warmup` and its direct
206
+ `torch.empty(..., device="cuda")`; bitsandbytes requires `device_map`, so **4-bit quantisation is
207
+ unavailable**, which is what forces a model small enough to carry in bf16: `Qwen/Qwen3-8B`, ~16GB
208
+ against ~28GB for the 14B run locally.
209
 
210
  **ZeroGPU platform gotchas, each learned by having the Space fail:**
211
  - A `@spaces.GPU` function is only detected if wired to a real Gradio event handler. One called
app_space.py CHANGED
@@ -40,7 +40,8 @@ import gradio as gr
40
  import spaces
41
  import uvicorn
42
 
43
- from app import app, get_agent
 
44
 
45
 
46
  def _fetch_index() -> None:
@@ -57,6 +58,34 @@ def _fetch_index() -> None:
57
  print(f"[space] could not fetch the index ({exc}); retrieval disabled")
58
 
59
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
60
  @spaces.GPU(duration=120)
61
  def _gpu_probe(text: str) -> str:
62
  """Satisfies ZeroGPU's startup validation, and warms the model on first use.
@@ -64,8 +93,7 @@ def _gpu_probe(text: str) -> str:
64
  Takes only a plain string. The agent is reached through the module global --
65
  see note 4 above; passing it in is what hangs the whole Space.
66
  """
67
- agent = get_agent()
68
- return agent.run(text).answer if text else "ready"
69
 
70
 
71
  with gr.Blocks() as _gpu_demo:
@@ -76,7 +104,6 @@ with gr.Blocks() as _gpu_demo:
76
 
77
 
78
  def main() -> None:
79
- _fetch_index()
80
  port = int(os.environ.get("PORT", 7860))
81
  # Separate port, non-blocking: see note 3.
82
  _gpu_demo.launch(
 
40
  import spaces
41
  import uvicorn
42
 
43
+ import app as app_module
44
+ from app import app
45
 
46
 
47
  def _fetch_index() -> None:
 
58
  print(f"[space] could not fetch the index ({exc}); retrieval disabled")
59
 
60
 
61
+ def _build_agent_at_import() -> None:
62
+ """Construct the agent *now*, while this module is still being imported.
63
+
64
+ ZeroGPU installs its CUDA emulation by patching torch during the import of
65
+ the Space's entry module, and only operations that happen inside that window
66
+ are intercepted. `app.py` normally builds the model in FastAPI's `lifespan`,
67
+ on a ThreadPoolExecutor worker -- long after import, on another thread, where
68
+ the patching does not apply. `.to("cuda")` there reaches the real
69
+ `torch._C._cuda_init` and raises:
70
+
71
+ RuntimeError: Low-level CUDA init (`torch._C._cuda_init`) reached.
72
+
73
+ Building here and handing the finished agent to `app.py` keeps the load
74
+ inside the window. `lifespan` then finds `_agent` already set and its
75
+ `get_agent()` is a no-op.
76
+ """
77
+ from controlai_agent.agent import ControlAgent
78
+ from controlai_agent.engine_torch import TorchEngine
79
+
80
+ print("[space] building agent at import scope (ZeroGPU CUDA window)")
81
+ app_module._agent = ControlAgent(engine=TorchEngine())
82
+ print("[space] agent ready")
83
+
84
+
85
+ _fetch_index() # the agent prewarms retrieval, so the index must precede it
86
+ _build_agent_at_import()
87
+
88
+
89
  @spaces.GPU(duration=120)
90
  def _gpu_probe(text: str) -> str:
91
  """Satisfies ZeroGPU's startup validation, and warms the model on first use.
 
93
  Takes only a plain string. The agent is reached through the module global --
94
  see note 4 above; passing it in is what hangs the whole Space.
95
  """
96
+ return app_module.get_agent().run(text).answer if text else "ready"
 
97
 
98
 
99
  with gr.Blocks() as _gpu_demo:
 
104
 
105
 
106
  def main() -> None:
 
107
  port = int(os.environ.get("PORT", 7860))
108
  # Separate port, non-blocking: see note 3.
109
  _gpu_demo.launch(
controlai_agent/engine_torch.py CHANGED
@@ -20,19 +20,22 @@ It mirrors `LocalEngine`'s design decisions rather than reaching for
20
  punishes the `[`, `0`, `,` that matrices and JSON are made of.
21
 
22
  **Loading is bf16 and moves to the GPU with an explicit `.to("cuda")`. Do not
23
- reintroduce `device_map` or bitsandbytes here.** Both are the obvious thing to
24
- reach for and both break on ZeroGPU, which is the only place this file runs:
 
25
 
26
  RuntimeError: Low-level CUDA init (`torch._C._cuda_init`) reached. This
27
  means ZeroGPU's PyTorch CUDA emulation mode did not intercept a CUDA
28
  operation in your code.
29
 
30
- ZeroGPU emulates CUDA at startup and attaches real hardware only inside a
31
- `@spaces.GPU` call. Its emulation intercepts `.to("cuda")`, but passing
32
- `device_map` sends transformers through `caching_allocator_warmup`, which calls
33
- `torch.empty(..., device="cuda")` directly and trips the guard. bitsandbytes in
34
- turn *requires* `device_map` at load, so 4-bit quantisation is unavailable here
35
- as long as the model is loaded at startup rather than inside the GPU function.
 
 
36
 
37
  That is why the model is Qwen3-8B rather than the 14B run locally: bf16 8B is
38
  ~16GB to download against ~28GB, and a Space rebuild re-downloads from scratch.
 
20
  punishes the `[`, `0`, `,` that matrices and JSON are made of.
21
 
22
  **Loading is bf16 and moves to the GPU with an explicit `.to("cuda")`. Do not
23
+ reintroduce `device_map` or bitsandbytes, and do not construct this class lazily
24
+ at request time.** All three break on ZeroGPU, which is the only place this file
25
+ runs, and all three fail the same way:
26
 
27
  RuntimeError: Low-level CUDA init (`torch._C._cuda_init`) reached. This
28
  means ZeroGPU's PyTorch CUDA emulation mode did not intercept a CUDA
29
  operation in your code.
30
 
31
+ ZeroGPU patches torch during the import of the Space's entry module and attaches
32
+ real hardware only inside a `@spaces.GPU` call. Only CUDA operations inside that
33
+ import window are intercepted, so **where this object is constructed matters as
34
+ much as how**: `app_space.py` builds it at module scope for exactly that reason.
35
+ `device_map` fails on top of that, because it routes transformers through
36
+ `caching_allocator_warmup`, which calls `torch.empty(..., device="cuda")`
37
+ directly. bitsandbytes in turn *requires* `device_map`, so 4-bit quantisation is
38
+ unavailable here — which is why the model has to be small enough in bf16.
39
 
40
  That is why the model is Qwen3-8B rather than the 14B run locally: bf16 8B is
41
  ~16GB to download against ~28GB, and a Space rebuild re-downloads from scratch.