Load model on cuda at module level (canonical ZeroGPU pattern)
Browse files- README.md +7 -6
- 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`
|
| 47 |
-
|
| 48 |
-
|
| 49 |
|
| 50 |
## 🖥️ Hardware
|
| 51 |
|
| 52 |
-
Runs on **ZeroGPU** (
|
| 53 |
-
|
| 54 |
-
|
|
|
|
| 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.
|
| 4 |
-
|
| 5 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 19 |
-
|
| 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 |
-
|
|
|
|
| 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 |
-
|
| 64 |
-
|
| 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(
|
| 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
|
| 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":
|
| 151 |
-
"detail": {
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|