Spaces:
Running on Zero
fix: Load the Space's model the one way ZeroGPU allows
Browse filesStartup 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>
- CLAUDE.md +12 -7
- controlai_agent/engine_torch.py +29 -48
|
@@ -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.
|
| 195 |
-
|
| 196 |
-
|
| 197 |
-
|
| 198 |
-
`
|
| 199 |
-
|
| 200 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
|
@@ -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
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
#
|
| 54 |
-
|
| 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 |
-
#
|
| 83 |
-
#
|
| 84 |
-
#
|
|
|
|
| 85 |
cuda = torch.cuda.is_available()
|
| 86 |
-
|
| 87 |
-
**dtype_kwarg(torch.bfloat16 if cuda else torch.float32)
|
| 88 |
-
|
| 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,
|
| 116 |
-
#
|
| 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)
|