Fsezai33 commited on
Commit
9f871fc
·
verified ·
1 Parent(s): 3461432

Create dynamic ZeroGPU LLM playground

Browse files
Files changed (4) hide show
  1. README.md +35 -7
  2. app.py +233 -0
  3. model_manager.py +264 -0
  4. requirements.txt +4 -0
README.md CHANGED
@@ -1,13 +1,41 @@
1
  ---
2
- title: Llm Zero Gpu Playground
3
- emoji: 🐨
4
- colorFrom: yellow
5
- colorTo: purple
6
  sdk: gradio
7
  sdk_version: 6.25.0
8
- python_version: '3.12'
9
  app_file: app.py
10
- pinned: false
 
 
11
  ---
12
 
13
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
+ title: Dynamic LLM ZeroGPU Playground
3
+ emoji: 🧪
4
+ colorFrom: blue
5
+ colorTo: indigo
6
  sdk: gradio
7
  sdk_version: 6.25.0
 
8
  app_file: app.py
9
+ short_description: Load and test HF causal LLMs on ZeroGPU
10
+ python_version: "3.10"
11
+ startup_duration_timeout: 1h
12
  ---
13
 
14
+ # Dynamic LLM ZeroGPU Playground
15
+
16
+ A small, general-purpose playground for testing standard Hugging Face causal
17
+ language models by model ID.
18
+
19
+ ## How it works
20
+
21
+ 1. Enter a model ID such as `Qwen/Qwen2.5-0.5B-Instruct`.
22
+ 2. Click **Download** to fetch the snapshot into the Space's CPU-side cache.
23
+ 3. Click **Load** to put that model on the ZeroGPU worker.
24
+ 4. Chat with the model, or use **Unload** before switching models.
25
+ 5. Use **Delete from disk** to unload first, then remove all cached revisions for the selected ID.
26
+
27
+ Only one model is kept active by the runtime. Switching models releases the
28
+ previous model with `del`, `gc.collect()`, and `torch.cuda.empty_cache()` before
29
+ the new model is loaded. Chat templates are used whenever the tokenizer
30
+ provides `apply_chat_template()`.
31
+
32
+ This MVP intentionally targets standard `transformers` +
33
+ `AutoModelForCausalLM` checkpoints. The loader is isolated in
34
+ `TransformersCausalLMRuntime`, so AWQ, GPTQ, or FP8 backends can be added later.
35
+
36
+ ## Notes
37
+
38
+ - Model downloads happen on CPU and are never triggered by the chat handler.
39
+ - A model must be downloaded before it can be loaded or used.
40
+ - Large models may exceed ZeroGPU memory or take a long time to load.
41
+ - Remote model code is disabled in this first version for safety and stability.
app.py ADDED
@@ -0,0 +1,233 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Dynamic Hugging Face causal-LLM playground for ZeroGPU Spaces."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import os
7
+ from typing import Any
8
+
9
+ os.environ.setdefault("PYTORCH_CUDA_ALLOC_CONF", "expandable_segments:True")
10
+
11
+ # ZeroGPU must be imported before torch or any library that may touch CUDA.
12
+ import spaces
13
+ import torch
14
+ import gradio as gr
15
+
16
+ from model_manager import ModelCache, TransformersCausalLMRuntime, validate_model_id
17
+
18
+
19
+ logging.basicConfig(level=logging.INFO)
20
+ LOGGER = logging.getLogger(__name__)
21
+
22
+ DEFAULT_MODEL_ID = "Qwen/Qwen2.5-0.5B-Instruct"
23
+ cache = ModelCache()
24
+ runtime = TransformersCausalLMRuntime(cache)
25
+
26
+
27
+ def _short_error(prefix: str, exc: Exception) -> str:
28
+ LOGGER.exception("%s", prefix)
29
+ detail = str(exc).strip().splitlines()[0] if str(exc).strip() else exc.__class__.__name__
30
+ return f"Error: {prefix} {detail[:300]}"
31
+
32
+
33
+ def _safe_generation_settings(
34
+ max_new_tokens: Any,
35
+ temperature: Any,
36
+ top_p: Any,
37
+ ) -> tuple[int, float, float]:
38
+ tokens = max(1, min(2048, int(max_new_tokens or 256)))
39
+ temp = max(0.0, min(2.0, float(temperature or 0.7)))
40
+ nucleus = max(0.05, min(1.0, float(top_p or 0.95)))
41
+ return tokens, temp, nucleus
42
+
43
+
44
+ def download_model(model_id: str) -> tuple[str, str]:
45
+ """Download a model snapshot on CPU into the playground cache."""
46
+
47
+ try:
48
+ model_id = validate_model_id(model_id)
49
+ snapshot_path = cache.download(model_id)
50
+ return (
51
+ f"Downloaded on CPU: `{model_id}`",
52
+ f"Disk cache: ready ({snapshot_path.name}).",
53
+ )
54
+ except Exception as exc:
55
+ message = _short_error("Could not download the model:", exc)
56
+ return message, cache.describe(model_id)
57
+
58
+
59
+ @spaces.GPU(duration=420)
60
+ def load_model_on_gpu(model_id: str) -> tuple[str, str, str]:
61
+ """Load one downloaded Transformers causal LM on the ZeroGPU worker."""
62
+
63
+ try:
64
+ model_id = validate_model_id(model_id)
65
+ cache.cached_snapshot(model_id) # local-only check; never downloads here
66
+ active = runtime.ensure_loaded(model_id)
67
+ return (
68
+ f"Loaded on ZeroGPU: `{active}`",
69
+ active,
70
+ cache.describe(active),
71
+ )
72
+ except Exception as exc:
73
+ return _short_error("Could not load the model:", exc), "No model loaded", cache.describe(model_id)
74
+
75
+
76
+ @spaces.GPU(duration=180)
77
+ def chat_with_model(
78
+ message: str,
79
+ history: list[Any] | None,
80
+ model_id: str,
81
+ system_prompt: str,
82
+ max_new_tokens: Any,
83
+ temperature: Any,
84
+ top_p: Any,
85
+ ) -> str:
86
+ """Generate a reply using the selected cached Hugging Face model."""
87
+
88
+ try:
89
+ model_id = validate_model_id(model_id)
90
+ cache.cached_snapshot(model_id) # a chat never performs a download
91
+ tokens, temp, nucleus = _safe_generation_settings(
92
+ max_new_tokens, temperature, top_p
93
+ )
94
+ return runtime.generate(
95
+ model_id=model_id,
96
+ message=message,
97
+ history=history,
98
+ system_prompt=system_prompt,
99
+ max_new_tokens=tokens,
100
+ temperature=temp,
101
+ top_p=nucleus,
102
+ )
103
+ except Exception as exc:
104
+ return _short_error("Could not generate a reply:", exc)
105
+
106
+
107
+ @spaces.GPU(duration=30)
108
+ def unload_model_on_gpu(model_id: str) -> tuple[str, str, str]:
109
+ """Unload the active model and release RAM/VRAM on the ZeroGPU worker."""
110
+
111
+ try:
112
+ runtime.unload()
113
+ return "Unloaded; RAM/VRAM cleanup requested.", "No model loaded", cache.describe(model_id)
114
+ except Exception as exc:
115
+ return _short_error("Could not unload the model:", exc), "Unknown", cache.describe(model_id)
116
+
117
+
118
+ def delete_model_from_disk(model_id: str) -> tuple[str, str, str]:
119
+ """Delete every cached revision of the selected model from disk."""
120
+
121
+ try:
122
+ model_id = validate_model_id(model_id)
123
+ deleted = cache.delete(model_id)
124
+ if deleted:
125
+ status = f"Deleted from disk: `{model_id}`"
126
+ else:
127
+ status = f"No cached files found for `{model_id}`"
128
+ return status, "No model loaded", cache.describe(model_id)
129
+ except Exception as exc:
130
+ return _short_error("Could not delete the model cache:", exc), "Unknown", cache.describe(model_id)
131
+
132
+
133
+ CSS = """
134
+ #app-container { max-width: 1180px; margin: 0 auto; }
135
+ .dark .gradio-container { color: var(--body-text-color); }
136
+ """
137
+
138
+
139
+ with gr.Blocks(title="Dynamic LLM ZeroGPU Playground", css=CSS) as demo:
140
+ gr.Markdown(
141
+ """
142
+ # Dynamic LLM ZeroGPU Playground
143
+
144
+ Download standard `transformers` causal language models, load one model
145
+ at a time on ZeroGPU, and test it through a Gradio chat interface.
146
+ Downloading and cache management stay on CPU; model loading and
147
+ inference use the GPU only when requested.
148
+ """
149
+ )
150
+
151
+ with gr.Row():
152
+ model_id = gr.Textbox(
153
+ label="Hugging Face model ID",
154
+ value=DEFAULT_MODEL_ID,
155
+ placeholder="namespace/model-name",
156
+ scale=4,
157
+ )
158
+ download_button = gr.Button("Download", variant="secondary", scale=1)
159
+ load_button = gr.Button("Load", variant="primary", scale=1)
160
+ unload_button = gr.Button("Unload", variant="secondary", scale=1)
161
+ delete_button = gr.Button("Delete from disk", variant="stop", scale=1)
162
+
163
+ with gr.Row():
164
+ current_model = gr.Textbox(
165
+ label="Active model",
166
+ value="No model loaded",
167
+ interactive=False,
168
+ scale=1,
169
+ )
170
+ cache_status = gr.Markdown("Disk cache: no model selected.", scale=1)
171
+
172
+ status = gr.Markdown("Status: enter a model ID, then click Download.")
173
+
174
+ with gr.Accordion("Generation settings", open=True):
175
+ system_prompt = gr.Textbox(
176
+ label="System prompt",
177
+ value="You are a helpful assistant.",
178
+ lines=2,
179
+ )
180
+ with gr.Row():
181
+ max_new_tokens = gr.Slider(
182
+ label="Max new tokens", minimum=1, maximum=2048, value=256, step=1
183
+ )
184
+ temperature = gr.Slider(
185
+ label="Temperature", minimum=0, maximum=2, value=0.7, step=0.05
186
+ )
187
+ top_p = gr.Slider(label="Top-p", minimum=0.05, maximum=1, value=0.95, step=0.05)
188
+
189
+ chatbot = gr.Chatbot(height=520)
190
+ gr.ChatInterface(
191
+ fn=chat_with_model,
192
+ chatbot=chatbot,
193
+ additional_inputs=[model_id, system_prompt, max_new_tokens, temperature, top_p],
194
+ textbox=gr.Textbox(placeholder="Write a message…", container=False),
195
+ api_name="chat",
196
+ )
197
+
198
+ download_button.click(
199
+ fn=download_model,
200
+ inputs=[model_id],
201
+ outputs=[status, cache_status],
202
+ api_name="download",
203
+ )
204
+ load_button.click(
205
+ fn=load_model_on_gpu,
206
+ inputs=[model_id],
207
+ outputs=[status, current_model, cache_status],
208
+ api_name="load",
209
+ )
210
+ unload_button.click(
211
+ fn=unload_model_on_gpu,
212
+ inputs=[model_id],
213
+ outputs=[status, current_model, cache_status],
214
+ api_name="unload",
215
+ )
216
+ delete_event = delete_button.click(
217
+ # Release a possibly active GPU copy before removing its CPU cache.
218
+ # The actual deletion remains a CPU-only operation in the next step.
219
+ fn=unload_model_on_gpu,
220
+ inputs=[model_id],
221
+ outputs=[status, current_model, cache_status],
222
+ )
223
+ delete_event.then(
224
+ fn=delete_model_from_disk,
225
+ inputs=[model_id],
226
+ outputs=[status, current_model, cache_status],
227
+ api_name="delete_from_disk",
228
+ )
229
+
230
+
231
+ if __name__ == "__main__":
232
+ demo.queue(default_concurrency_limit=1)
233
+ demo.launch(mcp_server=True)
model_manager.py ADDED
@@ -0,0 +1,264 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """CPU cache helpers and the single-model Transformers runtime.
2
+
3
+ The runtime deliberately keeps model loading behind the GPU handlers in
4
+ ``app.py``. That makes model IDs dynamic while still ensuring that no model
5
+ weights are loaded during Space startup.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import gc
11
+ import logging
12
+ import os
13
+ import re
14
+ import threading
15
+ from pathlib import Path
16
+ from typing import Any
17
+
18
+ import spaces
19
+ import torch
20
+ from huggingface_hub import scan_cache_dir, snapshot_download
21
+ from transformers import AutoModelForCausalLM, AutoTokenizer
22
+
23
+
24
+ LOGGER = logging.getLogger(__name__)
25
+ MODEL_ID_PATTERN = re.compile(r"^[^/\s]+/[^/\s]+$")
26
+
27
+
28
+ def validate_model_id(model_id: str) -> str:
29
+ """Validate and normalize a Hugging Face model repository ID."""
30
+
31
+ normalized = (model_id or "").strip()
32
+ if not MODEL_ID_PATTERN.fullmatch(normalized):
33
+ raise ValueError("Model ID must look like namespace/model-name.")
34
+ return normalized
35
+
36
+
37
+ class ModelCache:
38
+ """A dedicated Hugging Face cache for downloaded model snapshots."""
39
+
40
+ def __init__(self, cache_dir: str | None = None) -> None:
41
+ default_dir = Path.home() / ".cache" / "huggingface" / "llm-playground"
42
+ self.root = Path(cache_dir or os.getenv("PLAYGROUND_CACHE_DIR", default_dir))
43
+ self.root.mkdir(parents=True, exist_ok=True)
44
+
45
+ def download(self, model_id: str) -> Path:
46
+ """Download a complete model snapshot without initializing a model."""
47
+
48
+ model_id = validate_model_id(model_id)
49
+ snapshot_path = snapshot_download(
50
+ repo_id=model_id,
51
+ repo_type="model",
52
+ cache_dir=str(self.root),
53
+ )
54
+ return Path(snapshot_path)
55
+
56
+ def cached_snapshot(self, model_id: str) -> Path:
57
+ """Return a locally cached snapshot, raising if it is not complete."""
58
+
59
+ model_id = validate_model_id(model_id)
60
+ try:
61
+ snapshot_path = snapshot_download(
62
+ repo_id=model_id,
63
+ repo_type="model",
64
+ cache_dir=str(self.root),
65
+ local_files_only=True,
66
+ )
67
+ except Exception as exc: # hub versions expose different local-cache errors
68
+ raise FileNotFoundError(
69
+ f"{model_id} is not downloaded yet. Click Download first."
70
+ ) from exc
71
+ return Path(snapshot_path)
72
+
73
+ def describe(self, model_id: str) -> str:
74
+ """Return a short cache status for the UI."""
75
+
76
+ model_id = (model_id or "").strip()
77
+ if not model_id:
78
+ return "Disk cache: no model selected."
79
+ try:
80
+ path = self.cached_snapshot(model_id)
81
+ except (ValueError, FileNotFoundError):
82
+ return f"Disk cache: {model_id} is not downloaded."
83
+ return f"Disk cache: ready ({path.name})."
84
+
85
+ def delete(self, model_id: str) -> bool:
86
+ """Delete every cached revision of one model from this cache."""
87
+
88
+ model_id = validate_model_id(model_id)
89
+ cache_info = scan_cache_dir(cache_dir=str(self.root))
90
+ revisions = []
91
+ for repo in cache_info.repos:
92
+ if repo.repo_id == model_id:
93
+ revisions.extend(revision.commit_hash for revision in repo.revisions)
94
+
95
+ if not revisions:
96
+ return False
97
+
98
+ # The cache manager removes snapshots, refs, and blobs that are no
99
+ # longer shared by another cached revision.
100
+ cache_info.delete_revisions(*revisions).execute()
101
+ return True
102
+
103
+
104
+ class TransformersCausalLMRuntime:
105
+ """Single active standard Transformers causal language model."""
106
+
107
+ def __init__(self, cache: ModelCache) -> None:
108
+ self.cache = cache
109
+ self._model: Any | None = None
110
+ self._tokenizer: Any | None = None
111
+ self._model_id: str | None = None
112
+ self._lock = threading.RLock()
113
+
114
+ @property
115
+ def active_model_id(self) -> str | None:
116
+ return self._model_id
117
+
118
+ def unload(self) -> None:
119
+ """Release the active model and clear CUDA's allocator cache."""
120
+
121
+ with self._lock:
122
+ old_model = self._model
123
+ self._model = None
124
+ self._tokenizer = None
125
+ self._model_id = None
126
+
127
+ if old_model is not None:
128
+ del old_model
129
+ gc.collect()
130
+ if torch.cuda.is_available():
131
+ torch.cuda.empty_cache()
132
+
133
+ def ensure_loaded(self, model_id: str) -> str:
134
+ """Load one cached model on CUDA, replacing any previously active model."""
135
+
136
+ model_id = validate_model_id(model_id)
137
+ with self._lock:
138
+ if self._model is not None and self._model_id == model_id:
139
+ return model_id
140
+
141
+ # Switching models always releases the old object before reading
142
+ # the new checkpoint, keeping the one-model invariant explicit.
143
+ self.unload()
144
+ snapshot_path = self.cache.cached_snapshot(model_id)
145
+
146
+ tokenizer = AutoTokenizer.from_pretrained(
147
+ str(snapshot_path),
148
+ local_files_only=True,
149
+ use_fast=True,
150
+ trust_remote_code=False,
151
+ )
152
+ model = AutoModelForCausalLM.from_pretrained(
153
+ str(snapshot_path),
154
+ local_files_only=True,
155
+ torch_dtype=torch.bfloat16,
156
+ low_cpu_mem_usage=True,
157
+ trust_remote_code=False,
158
+ )
159
+ model = model.to("cuda").eval()
160
+
161
+ if tokenizer.pad_token_id is None and tokenizer.eos_token_id is not None:
162
+ tokenizer.pad_token = tokenizer.eos_token
163
+ if getattr(model.config, "pad_token_id", None) is None:
164
+ model.config.pad_token_id = tokenizer.pad_token_id
165
+
166
+ self._tokenizer = tokenizer
167
+ self._model = model
168
+ self._model_id = model_id
169
+ return model_id
170
+
171
+ @staticmethod
172
+ def _history_to_messages(history: list[Any] | None) -> list[dict[str, str]]:
173
+ messages: list[dict[str, str]] = []
174
+ for item in history or []:
175
+ if isinstance(item, dict):
176
+ role = str(item.get("role", ""))
177
+ content = item.get("content", "")
178
+ if role in {"user", "assistant"} and isinstance(content, str):
179
+ messages.append({"role": role, "content": content})
180
+ elif isinstance(item, (list, tuple)) and len(item) == 2:
181
+ user_text, assistant_text = item
182
+ if isinstance(user_text, str) and user_text:
183
+ messages.append({"role": "user", "content": user_text})
184
+ if isinstance(assistant_text, str) and assistant_text:
185
+ messages.append({"role": "assistant", "content": assistant_text})
186
+ return messages
187
+
188
+ @staticmethod
189
+ def _plain_prompt(messages: list[dict[str, str]]) -> str:
190
+ lines = [f"{message['role'].capitalize()}: {message['content']}" for message in messages]
191
+ return "\n".join(lines) + "\nAssistant:"
192
+
193
+ def _tokenize(self, messages: list[dict[str, str]]) -> Any:
194
+ assert self._tokenizer is not None
195
+ tokenizer = self._tokenizer
196
+
197
+ if hasattr(tokenizer, "apply_chat_template"):
198
+ try:
199
+ return tokenizer.apply_chat_template(
200
+ messages,
201
+ add_generation_prompt=True,
202
+ tokenize=True,
203
+ return_tensors="pt",
204
+ return_dict=True,
205
+ )
206
+ except TypeError:
207
+ try:
208
+ return tokenizer.apply_chat_template(
209
+ messages,
210
+ add_generation_prompt=True,
211
+ tokenize=True,
212
+ return_tensors="pt",
213
+ )
214
+ except Exception:
215
+ LOGGER.debug("Chat template without return_dict failed", exc_info=True)
216
+ except Exception:
217
+ LOGGER.debug("Chat template failed; using plain prompt", exc_info=True)
218
+
219
+ return tokenizer(self._plain_prompt(messages), return_tensors="pt")
220
+
221
+ def generate(
222
+ self,
223
+ model_id: str,
224
+ message: str,
225
+ history: list[Any] | None,
226
+ system_prompt: str,
227
+ max_new_tokens: int,
228
+ temperature: float,
229
+ top_p: float,
230
+ ) -> str:
231
+ """Generate one answer from the active cached Transformers model."""
232
+
233
+ with self._lock:
234
+ self.ensure_loaded(model_id)
235
+ assert self._model is not None
236
+ assert self._tokenizer is not None
237
+
238
+ messages: list[dict[str, str]] = []
239
+ if (system_prompt or "").strip():
240
+ messages.append({"role": "system", "content": system_prompt.strip()})
241
+ messages.extend(self._history_to_messages(history))
242
+ messages.append({"role": "user", "content": (message or "").strip()})
243
+
244
+ encoded = self._tokenize(messages)
245
+ encoded = {
246
+ key: value.to("cuda")
247
+ for key, value in encoded.items()
248
+ if torch.is_tensor(value)
249
+ }
250
+ input_length = int(encoded["input_ids"].shape[-1])
251
+
252
+ generation_kwargs: dict[str, Any] = {
253
+ "max_new_tokens": max_new_tokens,
254
+ "do_sample": temperature > 0,
255
+ }
256
+ if temperature > 0:
257
+ generation_kwargs.update({"temperature": temperature, "top_p": top_p})
258
+
259
+ with torch.inference_mode():
260
+ generated = self._model.generate(**encoded, **generation_kwargs)
261
+
262
+ new_tokens = generated[0, input_length:]
263
+ answer = self._tokenizer.decode(new_tokens, skip_special_tokens=True).strip()
264
+ return answer or "The model returned an empty response."
requirements.txt ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ transformers>=4.45.0,<5
2
+ accelerate>=0.34.0
3
+ safetensors>=0.4.3
4
+ sentencepiece>=0.2.0