unity4ar commited on
Commit
a475083
·
verified ·
1 Parent(s): 7036a02

Load model on cuda at module level (canonical ZeroGPU pattern)

Browse files
Files changed (2) hide show
  1. README.md +7 -6
  2. llm/zerogpu_backend.py +50 -64
README.md CHANGED
@@ -43,15 +43,16 @@ in a custom `gr.Server` HTML/JS board interface (no stock Gradio components).
43
 
44
  - **Model:** [`openbmb/MiniCPM4.1-8B`](https://huggingface.co/openbmb/MiniCPM4.1-8B)
45
  (text, bf16 transformers) — ~8B params, well under the 32B cap.
46
- - **Inference:** in-process Hugging Face `transformers`. The model lives in CPU
47
- RAM and is moved to cuda inside a `@spaces.GPU`-decorated function, so the
48
- H200 is only held while the model is actually generating.
49
 
50
  ## 🖥️ Hardware
51
 
52
- Runs on **ZeroGPU** (H200, free 40 min/day for org members). Each generation is
53
- capped at `PHANTOM_GRID_ZEROGPU_DURATION` seconds (default 90). No voice path
54
- on this Space (`PHANTOM_GRID_WITNESS_CHAT_TTS=0`).
 
55
 
56
  ## 🎥 Demo video
57
 
 
43
 
44
  - **Model:** [`openbmb/MiniCPM4.1-8B`](https://huggingface.co/openbmb/MiniCPM4.1-8B)
45
  (text, bf16 transformers) — ~8B params, well under the 32B cap.
46
+ - **Inference:** in-process Hugging Face `transformers`, placed on `cuda` at
47
+ module load (using ZeroGPU's PyTorch CUDA emulation), with the real GPU
48
+ attached only inside a `@spaces.GPU`-decorated `generate()` call.
49
 
50
  ## 🖥️ Hardware
51
 
52
+ Runs on **ZeroGPU** (NVIDIA RTX Pro 6000 Blackwell, `large` / 48 GB VRAM; 40
53
+ min/day for Team org members). Each generation is capped at
54
+ `PHANTOM_GRID_ZEROGPU_DURATION` seconds (default 90). No voice path
55
+ (`PHANTOM_GRID_WITNESS_CHAT_TTS=0`).
56
 
57
  ## 🎥 Demo video
58
 
llm/zerogpu_backend.py CHANGED
@@ -1,56 +1,60 @@
1
  """In-process transformers backend for HF Spaces ZeroGPU.
2
 
3
- Loaded only when PHANTOM_GRID_LLM_PROVIDER=zerogpu_transformers. Model weights
4
- live in CPU RAM and are moved to cuda inside the @spaces.GPU function, which is
5
- how ZeroGPU's per-call GPU acquisition expects PyTorch models to behave.
 
 
 
 
 
 
 
6
  """
7
  from __future__ import annotations
8
 
9
- import json
10
  import os
11
- import re
12
- import threading
13
  from typing import Any
14
 
15
  _MODEL_ID = os.getenv("PHANTOM_GRID_ZEROGPU_MODEL_ID", "openbmb/MiniCPM4.1-8B")
16
  _DEFAULT_DURATION = int(os.getenv("PHANTOM_GRID_ZEROGPU_DURATION", "90"))
17
 
18
- _lock = threading.Lock()
19
- _model = None
20
- _tokenizer = None
21
- _loaded_to_cuda = False
22
-
23
-
24
- def _ensure_loaded() -> None:
25
- """Load the model + tokenizer onto CPU. Called once at module init."""
26
- global _model, _tokenizer
27
- if _model is not None and _tokenizer is not None:
28
- return
29
- import torch
30
- from transformers import AutoModelForCausalLM, AutoTokenizer
31
-
32
- with _lock:
33
- if _tokenizer is None:
34
- _tokenizer = AutoTokenizer.from_pretrained(_MODEL_ID, trust_remote_code=True)
35
- if _model is None:
36
- _model = AutoModelForCausalLM.from_pretrained(
37
- _MODEL_ID,
38
- torch_dtype=torch.bfloat16,
39
- trust_remote_code=True,
40
- low_cpu_mem_usage=True,
41
- )
42
- _model.eval()
43
-
44
-
45
  try:
46
  import spaces # type: ignore
47
-
48
  _spaces_gpu = spaces.GPU
49
- except ImportError: # pragma: no cover — local dev without `spaces` installed
 
50
  def _spaces_gpu(*_args, **_kwargs):
51
  def _wrap(fn):
52
  return fn
53
  return _wrap
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
54
 
55
 
56
  @_spaces_gpu(duration=_DEFAULT_DURATION)
@@ -60,26 +64,18 @@ def _generate_on_gpu(
60
  temperature: float,
61
  json_mode: bool,
62
  ) -> str:
63
- """Runs on the H200 only when ZeroGPU has granted the slot."""
64
- import torch
65
-
66
- global _loaded_to_cuda
67
- _ensure_loaded()
68
- assert _model is not None and _tokenizer is not None
69
-
70
- if not _loaded_to_cuda:
71
- _model.to("cuda")
72
- _loaded_to_cuda = True
73
 
74
  prompt = _tokenizer.apply_chat_template(
75
  messages,
76
  tokenize=False,
77
  add_generation_prompt=True,
78
  )
79
- inputs = _tokenizer(prompt, return_tensors="pt").to("cuda")
80
 
81
  do_sample = temperature > 0
82
- gen_kwargs = {
83
  "max_new_tokens": max_new_tokens,
84
  "do_sample": do_sample,
85
  "pad_token_id": _tokenizer.eos_token_id,
@@ -93,14 +89,12 @@ def _generate_on_gpu(
93
 
94
  new_tokens = outputs[0][inputs["input_ids"].shape[1]:]
95
  text = _tokenizer.decode(new_tokens, skip_special_tokens=True).strip()
96
-
97
  if json_mode:
98
  text = _extract_json(text)
99
  return text
100
 
101
 
102
  def _extract_json(text: str) -> str:
103
- """Strip code fences and isolate the first JSON object."""
104
  cleaned = text.strip()
105
  if cleaned.startswith("```"):
106
  cleaned = cleaned.strip("`")
@@ -138,26 +132,18 @@ def chat_completion(
138
  max_tokens: int = 512,
139
  json_mode: bool = False,
140
  ) -> str:
141
- """Synchronous, drop-in replacement for an OpenAI chat-completions POST."""
142
  messages = _maybe_inject_no_think([dict(m) for m in messages])
143
  return _generate_on_gpu(messages, max_tokens, temperature, json_mode)
144
 
145
 
146
  def health() -> dict[str, Any]:
147
- """Cheap probe — does NOT touch the GPU. Reflects whether the module imported."""
148
  return {
149
  "reachable": True,
150
- "ready": _tokenizer is not None,
151
- "detail": {"model_id": _MODEL_ID, "loaded_to_cuda": _loaded_to_cuda},
 
 
 
 
152
  }
153
-
154
-
155
- # Kick off the CPU-side load eagerly so the first user turn doesn't pay for it.
156
- # Wrapped in try/except so an import failure (e.g. during local dev without the
157
- # huge model cache) doesn't crash app startup — the health probe will report it.
158
- try:
159
- _ensure_loaded()
160
- except Exception as exc: # pragma: no cover
161
- _LOAD_ERROR = f"{exc.__class__.__name__}: {exc}"
162
- else:
163
- _LOAD_ERROR = None
 
1
  """In-process transformers backend for HF Spaces ZeroGPU.
2
 
3
+ Loaded only when PHANTOM_GRID_LLM_PROVIDER=zerogpu_transformers. Follows the
4
+ canonical ZeroGPU pattern documented at
5
+ https://huggingface.co/docs/hub/en/spaces-zerogpu :
6
+
7
+ "Models must be placed on `cuda` at the root module level. A PyTorch CUDA
8
+ emulation mode is enabled outside @spaces.GPU functions, allowing CUDA
9
+ operations without a real GPU. Inside @spaces.GPU, real CUDA is used."
10
+
11
+ So we load directly to `cuda` here; ZeroGPU's emulation handles it at import,
12
+ and the real device is attached only while the @spaces.GPU function runs.
13
  """
14
  from __future__ import annotations
15
 
 
16
  import os
 
 
17
  from typing import Any
18
 
19
  _MODEL_ID = os.getenv("PHANTOM_GRID_ZEROGPU_MODEL_ID", "openbmb/MiniCPM4.1-8B")
20
  _DEFAULT_DURATION = int(os.getenv("PHANTOM_GRID_ZEROGPU_DURATION", "90"))
21
 
22
+ # ZeroGPU's `spaces` package patches torch's CUDA at import time. We must import
23
+ # it BEFORE torch so the emulation is in place when we then load to 'cuda'.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
24
  try:
25
  import spaces # type: ignore
 
26
  _spaces_gpu = spaces.GPU
27
+ _ON_ZEROGPU = True
28
+ except ImportError: # local dev without the spaces package
29
  def _spaces_gpu(*_args, **_kwargs):
30
  def _wrap(fn):
31
  return fn
32
  return _wrap
33
+ _ON_ZEROGPU = False
34
+
35
+ import torch # noqa: E402 — must come after `import spaces`
36
+ from transformers import AutoModelForCausalLM, AutoTokenizer # noqa: E402
37
+
38
+ _LOAD_ERROR: str | None = None
39
+ _model = None
40
+ _tokenizer = None
41
+
42
+ try:
43
+ _tokenizer = AutoTokenizer.from_pretrained(_MODEL_ID, trust_remote_code=True)
44
+ # On ZeroGPU, `device_map="cuda"` lands on the emulated device at import
45
+ # and is the real GPU inside @spaces.GPU. Off-platform (no `spaces`
46
+ # package installed), fall back to CPU so the module is still importable.
47
+ _device_map = "cuda" if _ON_ZEROGPU else "cpu"
48
+ _model = AutoModelForCausalLM.from_pretrained(
49
+ _MODEL_ID,
50
+ torch_dtype=torch.bfloat16,
51
+ trust_remote_code=True,
52
+ device_map=_device_map,
53
+ low_cpu_mem_usage=True,
54
+ )
55
+ _model.eval()
56
+ except Exception as exc: # pragma: no cover
57
+ _LOAD_ERROR = f"{exc.__class__.__name__}: {exc}"
58
 
59
 
60
  @_spaces_gpu(duration=_DEFAULT_DURATION)
 
64
  temperature: float,
65
  json_mode: bool,
66
  ) -> str:
67
+ if _model is None or _tokenizer is None:
68
+ raise RuntimeError(_LOAD_ERROR or "zerogpu backend not initialized")
 
 
 
 
 
 
 
 
69
 
70
  prompt = _tokenizer.apply_chat_template(
71
  messages,
72
  tokenize=False,
73
  add_generation_prompt=True,
74
  )
75
+ inputs = _tokenizer(prompt, return_tensors="pt").to(_model.device)
76
 
77
  do_sample = temperature > 0
78
+ gen_kwargs: dict[str, Any] = {
79
  "max_new_tokens": max_new_tokens,
80
  "do_sample": do_sample,
81
  "pad_token_id": _tokenizer.eos_token_id,
 
89
 
90
  new_tokens = outputs[0][inputs["input_ids"].shape[1]:]
91
  text = _tokenizer.decode(new_tokens, skip_special_tokens=True).strip()
 
92
  if json_mode:
93
  text = _extract_json(text)
94
  return text
95
 
96
 
97
  def _extract_json(text: str) -> str:
 
98
  cleaned = text.strip()
99
  if cleaned.startswith("```"):
100
  cleaned = cleaned.strip("`")
 
132
  max_tokens: int = 512,
133
  json_mode: bool = False,
134
  ) -> str:
135
+ """Synchronous drop-in for an OpenAI chat-completions POST."""
136
  messages = _maybe_inject_no_think([dict(m) for m in messages])
137
  return _generate_on_gpu(messages, max_tokens, temperature, json_mode)
138
 
139
 
140
  def health() -> dict[str, Any]:
 
141
  return {
142
  "reachable": True,
143
+ "ready": _model is not None and _LOAD_ERROR is None,
144
+ "detail": {
145
+ "model_id": _MODEL_ID,
146
+ "on_zerogpu": _ON_ZEROGPU,
147
+ "load_error": _LOAD_ERROR,
148
+ },
149
  }