atakan Claude Opus 5 commited on
Commit
e9a64ca
·
1 Parent(s): 31e0df0

fix: Load the Space's model the one way ZeroGPU allows

Browse files

Startup died with "Low-level CUDA init (torch._C._cuda_init) reached. This means
ZeroGPU's PyTorch CUDA emulation mode did not intercept a CUDA operation."

ZeroGPU emulates CUDA at startup and attaches real hardware only inside a
@spaces.GPU call. Its emulation intercepts .to("cuda"), but device_map routes
transformers through caching_allocator_warmup, which calls torch.empty(...,
device="cuda") directly and trips the guard. bitsandbytes requires device_map at
load, so 4-bit quantisation is not available at all while the model is loaded at
startup -- the pre-quantised checkpoint from the previous commit could never
have worked here.

So: no device_map, no quantization_config, bf16 with an explicit .to("cuda").
That is what the last working deploy of this Space did, before the backends were
collapsed; the 4-bit route was a regression from a pattern already known good.
The bitsandbytes path is deleted rather than left switched off, since it cannot
run in the only deployment that exists, and the docstring records why so it does
not get helpfully reintroduced.

The model is Qwen/Qwen3-8B rather than the 14B run locally, because bf16 has to
carry the full weights: ~16GB to download against ~28GB, on every rebuild.

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

Files changed (2) hide show
  1. CLAUDE.md +12 -7
  2. controlai_agent/engine_torch.py +29 -48
CLAUDE.md CHANGED
@@ -191,13 +191,18 @@ mlx_lm` several frames from the real cause.
191
 
192
  `engine_torch.py` mirrors `engine.py` rather than calling `model.generate`, because `generate`
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. The default checkpoint is
195
- `unsloth/Qwen3-14B-bnb-4bit` — already NF4, so ~9GB downloads instead of the ~28GB of bf16
196
- `Qwen/Qwen3-14B` and there is no quantisation step at load, which matters because a Space rebuild
197
- re-downloads from scratch. `_already_quantised()` detects that and skips passing a second
198
- `BitsAndBytesConfig`, which transformers raises on rather than merging. `device_map={"": 0}`
199
- **never `"auto"`**, which inspects free VRAM at load time, before ZeroGPU has attached hardware,
200
- and silently offloads to CPU.
 
 
 
 
 
201
 
202
  **ZeroGPU platform gotchas, each learned by having the Space fail:**
203
  - A `@spaces.GPU` function is only detected if wired to a real Gradio event handler. One called
 
191
 
192
  `engine_torch.py` mirrors `engine.py` rather than calling `model.generate`, because `generate`
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
controlai_agent/engine_torch.py CHANGED
@@ -19,11 +19,23 @@ It mirrors `LocalEngine`'s design decisions rather than reaching for
19
  applied for the reason spelled out in `engine.py`: a flat repetition penalty
20
  punishes the `[`, `0`, `,` that matrices and JSON are made of.
21
 
22
- Loading is 4-bit NF4 via bitsandbytes: Qwen3-14B is ~28GB in bf16 and ~9GB
23
- quantised. `device_map={"": 0}` pins every layer to GPU 0 explicitly. Do not use
24
- `device_map="auto"` here -- it inspects free VRAM at load time, which on ZeroGPU
25
- happens *before* real hardware is attached to the process, and silently offloads
26
- layers to CPU.
 
 
 
 
 
 
 
 
 
 
 
 
27
  """
28
 
29
  from __future__ import annotations
@@ -50,11 +62,8 @@ def dtype_kwarg(dtype: Any) -> dict[str, Any]:
50
  key = "dtype" if (major, minor) >= (4, 56) else "torch_dtype"
51
  return {key: dtype}
52
 
53
- # The MLX default is a 4-bit MLX conversion, which transformers cannot read.
54
- # This is the same weights in a format it can, already quantised to NF4: ~9GB to
55
- # download rather than the ~28GB of the bf16 Qwen/Qwen3-14B, and no quantisation
56
- # step at load. That matters because a Space rebuild re-downloads from scratch.
57
- DEFAULT_TORCH_MODEL = os.environ.get("CONTROLAI_MODEL_TORCH", "unsloth/Qwen3-14B-bnb-4bit")
58
 
59
 
60
  class TorchEngine:
@@ -66,7 +75,6 @@ class TorchEngine:
66
  adapter_path: str | None = None,
67
  sampling: SamplingConfig | None = None,
68
  max_cache_tokens: int = 32768,
69
- load_in_4bit: bool = True,
70
  ) -> None:
71
  import torch
72
  from transformers import AutoModelForCausalLM, AutoTokenizer
@@ -79,41 +87,23 @@ class TorchEngine:
79
  t0 = time.time()
80
  self.tokenizer = AutoTokenizer.from_pretrained(model_id)
81
 
82
- # `{"": 0}` pins every layer to GPU 0 explicitly -- never "auto", see the
83
- # module docstring. The CPU branch exists only so the decode loop can be
84
- # exercised on a small model off a GPU box; it is far too slow to serve.
 
85
  cuda = torch.cuda.is_available()
86
- kwargs: dict[str, Any] = {
87
- **dtype_kwarg(torch.bfloat16 if cuda else torch.float32),
88
- "device_map": {"": 0} if cuda else "cpu",
89
- }
90
- if load_in_4bit and not cuda:
91
- load_in_4bit = False # bitsandbytes is CUDA-only
92
- if load_in_4bit and self._already_quantised(model_id):
93
- # A pre-quantised checkpoint carries its own quantization_config;
94
- # passing a second one makes transformers raise rather than merge.
95
- load_in_4bit = False
96
- print(f"[torch] {model_id} ships quantised, using its own config")
97
- if load_in_4bit:
98
- from transformers import BitsAndBytesConfig
99
-
100
- kwargs["quantization_config"] = BitsAndBytesConfig(
101
- load_in_4bit=True,
102
- bnb_4bit_quant_type="nf4",
103
- bnb_4bit_compute_dtype=torch.bfloat16,
104
- # Quantising the quantisation constants too; ~0.4GB saved on a
105
- # 14B model for no measurable quality cost.
106
- bnb_4bit_use_double_quant=True,
107
- )
108
- self.model = AutoModelForCausalLM.from_pretrained(model_id, **kwargs)
109
  if adapter_path:
110
  from peft import PeftModel
111
 
112
  self.model = PeftModel.from_pretrained(self.model, adapter_path)
 
113
  self.model.eval()
114
  self.load_seconds = time.time() - t0
115
- # Verifiable in the Space logs: if this says cpu, device_map silently
116
- # offloaded and every request will be minutes rather than seconds.
117
  print(f"[torch] {model_id} on {next(self.model.parameters()).device} "
118
  f"in {self.load_seconds:.1f}s")
119
 
@@ -129,15 +119,6 @@ class TorchEngine:
129
 
130
  # ------------------------------------------------------------------ setup
131
 
132
- @staticmethod
133
- def _already_quantised(model_id: str) -> bool:
134
- try:
135
- from transformers import AutoConfig
136
-
137
- return getattr(AutoConfig.from_pretrained(model_id), "quantization_config", None) is not None
138
- except Exception: # noqa: BLE001 - fall back to quantising ourselves
139
- return False
140
-
141
  def _token_ids(self, text: str) -> list[int]:
142
  try:
143
  return self.tokenizer.encode(text, add_special_tokens=False)
 
19
  applied for the reason spelled out in `engine.py`: a flat repetition penalty
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.
39
  """
40
 
41
  from __future__ import annotations
 
62
  key = "dtype" if (major, minor) >= (4, 56) else "torch_dtype"
63
  return {key: dtype}
64
 
65
+ # Smaller than the 14B run locally, deliberately: see the module docstring.
66
+ DEFAULT_TORCH_MODEL = os.environ.get("CONTROLAI_MODEL_TORCH", "Qwen/Qwen3-8B")
 
 
 
67
 
68
 
69
  class TorchEngine:
 
75
  adapter_path: str | None = None,
76
  sampling: SamplingConfig | None = None,
77
  max_cache_tokens: int = 32768,
 
78
  ) -> None:
79
  import torch
80
  from transformers import AutoModelForCausalLM, AutoTokenizer
 
87
  t0 = time.time()
88
  self.tokenizer = AutoTokenizer.from_pretrained(model_id)
89
 
90
+ # No device_map and no quantization_config -- see the module docstring.
91
+ # Load to CPU, then move with .to(), which ZeroGPU's emulation intercepts.
92
+ # The CPU branch exists so the decode loop can be exercised on a small
93
+ # model off a GPU box; it is far too slow to actually serve.
94
  cuda = torch.cuda.is_available()
95
+ self.model = AutoModelForCausalLM.from_pretrained(
96
+ model_id, **dtype_kwarg(torch.bfloat16 if cuda else torch.float32)
97
+ )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
98
  if adapter_path:
99
  from peft import PeftModel
100
 
101
  self.model = PeftModel.from_pretrained(self.model, adapter_path)
102
+ self.model = self.model.to("cuda" if cuda else "cpu")
103
  self.model.eval()
104
  self.load_seconds = time.time() - t0
105
+ # Verifiable in the Space logs: if this says cpu on the Space, the .to()
106
+ # did not take and every request will be minutes rather than seconds.
107
  print(f"[torch] {model_id} on {next(self.model.parameters()).device} "
108
  f"in {self.load_seconds:.1f}s")
109
 
 
119
 
120
  # ------------------------------------------------------------------ setup
121
 
 
 
 
 
 
 
 
 
 
122
  def _token_ids(self, text: str) -> list[int]:
123
  try:
124
  return self.tokenizer.encode(text, add_special_tokens=False)