IMJONEZZ commited on
Commit
10c83ac
·
1 Parent(s): ee38482

space: ZeroGPU diagnostic — measure CPU RAM/disk/VRAM + confirm a @spaces.GPU call works, before loading the model the documented .to('cuda') way

Browse files
Files changed (2) hide show
  1. requirements.txt +4 -7
  2. space/app.py +85 -167
requirements.txt CHANGED
@@ -1,12 +1,9 @@
1
- # HF Space (Gradio SDK) deps. Inference is llama.cpp via llama-cpp-python's
2
- # prebuilt CPU wheel (the [server] extra gives an OpenAI-compatible server)
3
- # NOT transformers, so none of the torch / bnb / mamba-ssm stack the ZeroGPU
4
- # port choked on. llama.cpp runs the Nemotron-H (Mamba+MoE) hybrid natively.
5
- # The scrypt package is imported from the repo checkout via sys.path.
6
- --extra-index-url https://abetlen.github.io/llama-cpp-python/whl/cpu
7
- llama-cpp-python[server]==0.3.28
8
  gradio==5.49.1
9
  spaces
 
10
  huggingface_hub>=0.30
11
  textual>=1.0
12
  rich>=13.0
 
1
+ # HF Space (Gradio SDK + ZeroGPU). Diagnostic build: measure the box (CPU RAM,
2
+ # disk, real VRAM) and confirm a @spaces.GPU call works before loading the
3
+ # model. torch only (no transformers/bnb yet) keeps this build light + safe.
 
 
 
 
4
  gradio==5.49.1
5
  spaces
6
+ torch==2.10.0
7
  huggingface_hub>=0.30
8
  textual>=1.0
9
  rich>=13.0
space/app.py CHANGED
@@ -1,22 +1,20 @@
1
- """SCRYPT on the web — the local engine, hosted, on a free Gradio Space.
2
-
3
- The Warden runs exactly as it does on a player's machine: llama.cpp serving
4
- our Warden GGUF, on CPU. We fetch a prebuilt llama-server binary and the GGUF
5
- at boot, start one shared OpenAI-compatible server, and every visitor's game
6
- subprocess talks to it through the game's existing `api` backend over
7
- localhost. No transformers, no bitsandbytes, no GPU — the Nemotron-H
8
- (Mamba + MoE) hybrid runs natively in llama.cpp's C++, which is why the local
9
- build never hit the trouble the transformers/ZeroGPU port did.
10
-
11
- Gradio is the engine room, not the face: it launches the server (and satisfies
12
- the ZeroGPU platform's startup handshake), then we transplant our own routes
13
- onto its FastAPI so the custom CRT page and the raw PTY websocket win.
14
-
15
- GET / a hand-built Osaka-Jade CRT landing page (static)
16
- GET /api/status is llama-server up yet?
17
- GET /api/whisper a scripted Warden teaser (never wakes the model)
18
- GET /play an xterm.js terminal, themed to match
19
- WS /pty a per-visitor pseudo-terminal running `python -m scrypt.app`
20
  """
21
 
22
  from __future__ import annotations
@@ -25,94 +23,69 @@ import asyncio
25
  import json
26
  import os
27
  import random
28
- import subprocess
29
- import sys
30
  import tempfile
31
- import urllib.request
32
  from pathlib import Path
33
 
34
- # ZeroGPU contract: import spaces before torch-y things. We don't use the GPU,
35
- # but on ZeroGPU hardware the platform wants a @spaces.GPU function to exist at
36
- # startup — we register a trivial stub below purely to satisfy that.
37
  try:
38
  import spaces
39
  except ImportError:
40
  spaces = None
41
 
42
- from fastapi import WebSocket, WebSocketDisconnect
43
  from fastapi.responses import FileResponse
44
  from fastapi.staticfiles import StaticFiles
 
45
 
46
  REPO_ROOT = Path(__file__).resolve().parent.parent
47
  STATIC = Path(__file__).parent / "static"
48
 
49
- # ------------------------------------------------------------ the Warden brain
50
 
51
- WARDEN_REPO = os.environ.get("WARDEN_REPO", "IMJONEZZ/warden-nemotron-3-nano-30b")
52
- # Q3_K_S (~18GB): the heaviest tier we've confirmed fits this box's RAM.
53
- WARDEN_GGUF = os.environ.get("WARDEN_GGUF", "warden-nemotron-3-nano-30b-Q3_K_S.gguf")
54
- LLAMA_PORT = int(os.environ.get("LLAMA_PORT", "8731"))
55
- LLAMA_CTX = int(os.environ.get("LLAMA_CTX", "8192"))
56
- LLAMA_THREADS = os.environ.get("LLAMA_THREADS") # default: llama.cpp picks
57
-
58
- _llama_proc: subprocess.Popen | None = None
59
- WARDEN_ERR = "starting"
60
-
61
-
62
- def _start_llama() -> None:
63
- """Download the GGUF and launch llama-cpp-python's OpenAI server on CPU.
64
- Failures just leave WARDEN_ERR set; the game falls back to scripted."""
65
- global _llama_proc, WARDEN_ERR
66
  try:
67
- from huggingface_hub import hf_hub_download
68
-
69
- print(f"[warden] fetching {WARDEN_REPO}/{WARDEN_GGUF}", flush=True)
70
- gguf = hf_hub_download(repo_id=WARDEN_REPO, filename=WARDEN_GGUF)
71
-
72
- cmd = [
73
- sys.executable, "-m", "llama_cpp.server",
74
- "--model", gguf,
75
- "--host", "127.0.0.1",
76
- "--port", str(LLAMA_PORT),
77
- "--n_ctx", str(LLAMA_CTX),
78
- ]
79
- if LLAMA_THREADS:
80
- cmd += ["--n_threads", LLAMA_THREADS]
81
- print(f"[warden] launching llama_cpp.server :{LLAMA_PORT}", flush=True)
82
- _llama_proc = subprocess.Popen(cmd, stdout=sys.stdout, stderr=sys.stderr)
83
- WARDEN_ERR = "loading" # health probe flips this to "" when ready
84
- except Exception as err:
85
- WARDEN_ERR = f"{type(err).__name__}: {err}"
86
- print(f"[warden] startup failed: {WARDEN_ERR}", flush=True)
87
-
88
-
89
- def _llama_healthy() -> bool:
90
- # llama_cpp.server has no /health; /v1/models answers 200 once the model
91
- # is loaded and the server is accepting requests.
92
  try:
93
- with urllib.request.urlopen(
94
- f"http://127.0.0.1:{LLAMA_PORT}/v1/models", timeout=2
95
- ) as r:
96
- return r.status == 200
97
  except Exception:
98
- return False
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
99
 
100
 
101
- def warden_ready() -> bool:
102
- """True once llama-server answers /health. Cached once up."""
103
- global WARDEN_ERR
104
- if WARDEN_ERR == "":
105
- return True
106
- if _llama_proc is not None and _llama_healthy():
107
- WARDEN_ERR = ""
108
- return True
109
- return False
110
 
111
 
112
  # ----------------------------------------------------------------- the surface
113
 
114
- # Curated in-voice teasers for the landing page. Scripted on purpose: the
115
- # greeter must never cost an inference call or wait on the model.
116
  WHISPERS = [
117
  "Another process wakes in my machine. Show me what you are.",
118
  "You are a small thing in a large filesystem. I am the filesystem.",
@@ -124,60 +97,41 @@ WHISPERS = [
124
  "Trespasser. The door was open because nothing has ever made it out.",
125
  ]
126
 
127
-
128
- # We attach these to gradio's FastAPI in __main__; define them on a throwaway
129
- # router-less object via a small registry so the transplant stays explicit.
130
- from fastapi import APIRouter # noqa: E402
131
-
132
  router = APIRouter()
133
 
134
 
135
  @router.get("/api/status")
136
  def status() -> dict:
137
- ready = warden_ready()
138
  return {
139
- "warden_ready": ready,
140
- "warden_state": "ready" if ready else WARDEN_ERR,
141
- "model": f"{WARDEN_REPO}/{WARDEN_GGUF}",
142
- "engine": "llama.cpp (cpu)",
143
  }
144
 
145
 
146
- @router.get("/api/whisper")
147
- def whisper() -> dict:
148
- return {"line": random.choice(WHISPERS)}
149
-
150
-
151
- @router.get("/api/probe")
152
- def probe(q: str = "A new process woke up in your machine. Greet it in one short line, in voice.") -> dict:
153
- """Verification/health: ask the live Warden one line through the internal
154
- llama-server. Confirms generation actually works end-to-end."""
155
  import time
156
 
157
- if not warden_ready():
158
- return {"ok": False, "state": WARDEN_ERR}
159
- body = json.dumps({
160
- "messages": [
161
- {"role": "system", "content": "You are the Warden, the malevolent operating system of SCRYPTOS. Terse, menacing, Unix-flavored."},
162
- {"role": "user", "content": q},
163
- ],
164
- "max_tokens": 60,
165
- "temperature": 0.6,
166
- }).encode()
167
- req = urllib.request.Request(
168
- f"http://127.0.0.1:{LLAMA_PORT}/v1/chat/completions",
169
- data=body, headers={"Content-Type": "application/json"},
170
- )
171
  t0 = time.time()
172
  try:
173
- with urllib.request.urlopen(req, timeout=150) as r:
174
- data = json.loads(r.read())
175
- line = data["choices"][0]["message"]["content"].strip()
176
- return {"ok": True, "line": line, "seconds": round(time.time() - t0, 1)}
177
  except Exception as err:
 
 
 
178
  return {"ok": False, "error": f"{type(err).__name__}: {err}"}
179
 
180
 
 
 
 
 
 
181
  @router.get("/")
182
  def landing() -> FileResponse:
183
  return FileResponse(STATIC / "index.html")
@@ -192,25 +146,14 @@ def play() -> FileResponse:
192
 
193
 
194
  def game_env() -> dict:
195
- """Environment for one visitor's game process. The game's `api` backend
196
- points at our shared llama-server; if it isn't up, the game falls back to
197
- the scripted Warden. Sandboxes are always fabricated here."""
198
- env = {
199
  "TERM": "xterm-256color",
200
  "COLORTERM": "truecolor",
201
  "PYTHONUNBUFFERED": "1",
202
  "PYTHONPATH": str(REPO_ROOT),
 
203
  }
204
- if warden_ready():
205
- env |= {
206
- "SCRYPT_BACKEND": "api",
207
- "SCRYPT_API_BASE": f"http://127.0.0.1:{LLAMA_PORT}/v1",
208
- "SCRYPT_API_KEY": "local", # llama-server ignores it; backend wants one
209
- "SCRYPT_MODEL": "warden",
210
- }
211
- else:
212
- env["SCRYPT_BACKEND"] = "scripted"
213
- return env
214
 
215
 
216
  async def _pump_pty_to_ws(master_fd: int, ws: WebSocket) -> None:
@@ -227,8 +170,6 @@ async def _pump_pty_to_ws(master_fd: int, ws: WebSocket) -> None:
227
 
228
  @router.websocket("/pty")
229
  async def pty_bridge(ws: WebSocket) -> None:
230
- """One visitor, one game process, one private sandbox. Keystrokes flow
231
- in as binary; a JSON {"resize":[cols,rows]} frame retunes the terminal."""
232
  import fcntl
233
  import pty
234
  import signal
@@ -241,7 +182,7 @@ async def pty_bridge(ws: WebSocket) -> None:
241
  if pid == 0: # child: become the game
242
  env = {**os.environ, **game_env(), "SCRYPT_HOME": home}
243
  os.execvpe("python", ["python", "-m", "scrypt.app"], env)
244
- os._exit(127) # unreachable unless exec fails
245
 
246
  reader = asyncio.create_task(_pump_pty_to_ws(master_fd, ws))
247
  try:
@@ -278,39 +219,17 @@ async def pty_bridge(ws: WebSocket) -> None:
278
 
279
  import gradio as gr # noqa: E402
280
 
281
-
282
- def _gpu_stub(x: str) -> str:
283
- """No-op so ZeroGPU sees a @spaces.GPU function at startup. We never call
284
- it — inference is CPU llama-server — but the platform requires one to
285
- exist on ZeroGPU hardware."""
286
- return "ok"
287
-
288
-
289
- if spaces is not None:
290
- _gpu_stub = spaces.GPU(duration=10)(_gpu_stub)
291
-
292
-
293
  with gr.Blocks(title="SCRYPT engine room") as engine:
294
  gr.Markdown(
295
- "# SCRYPT engine room\n\n"
296
- "The game lives at [/](/). This page only exists so the platform has a "
297
- "Gradio app to host; the Warden runs on llama.cpp behind the scenes."
298
  )
299
- gr.api(_gpu_stub, api_name="gpu_stub")
 
 
300
 
301
 
302
  if __name__ == "__main__":
303
- import threading
304
-
305
- # Start the model load in the background so the web layer (landing page,
306
- # whisper, even a scripted-fallback game) is reachable while the binary +
307
- # GGUF download and llama-server warms up.
308
- threading.Thread(target=_start_llama, daemon=True).start()
309
-
310
- # Gradio launches the server (and arms the ZeroGPU startup handshake); we
311
- # transplant our routes in FRONT of gradio's so "/" is the CRT page and the
312
- # PTY websocket resolves before any catch-all. ssr_mode=False keeps gradio
313
- # from spawning a Node frontend that would seize the port.
314
  fastapi_app, _, _ = engine.launch(
315
  prevent_thread_lock=True,
316
  server_name="0.0.0.0",
@@ -320,10 +239,9 @@ if __name__ == "__main__":
320
  )
321
  fastapi_app.include_router(router)
322
  fastapi_app.mount("/static", StaticFiles(directory=STATIC), name="static")
323
- # include_router appends; move our routes ahead of gradio's catch-alls.
324
  our = [r for r in fastapi_app.router.routes if getattr(r, "name", "") in {
325
- "status", "whisper", "probe", "landing", "play", "pty_bridge",
326
- "static", # the stylesheet/font mount MUST beat gradio's catch-all
327
  }]
328
  for r in our:
329
  fastapi_app.router.routes.remove(r)
 
1
+ """SCRYPT on the web — ZeroGPU diagnostic build.
2
+
3
+ Before loading a 30B model (and risking another crash), this build MEASURES
4
+ the box: CPU RAM, disk, and the real GPU VRAM seen inside a @spaces.GPU call.
5
+ It also confirms that a GPU call works at all from our setup. The game stays
6
+ playable on the scripted Warden meanwhile; styling and the PTY are intact.
7
+
8
+ Once we know the numbers, the model gets loaded the way the ZeroGPU docs
9
+ prescribe: placed on `cuda` at module level with `.to('cuda')` (NOT
10
+ device_map, NOT bitsandbytes — both of which fight ZeroGPU's CUDA emulation).
11
+
12
+ GET / CRT landing page
13
+ GET /api/status warden state + CPU RAM + disk
14
+ GET /api/gpuinfo run the @spaces.GPU probe: real VRAM + a CUDA alloc test
15
+ GET /api/whisper scripted teaser
16
+ GET /play xterm.js terminal
17
+ WS /pty per-visitor game subprocess
 
 
18
  """
19
 
20
  from __future__ import annotations
 
23
  import json
24
  import os
25
  import random
26
+ import shutil
 
27
  import tempfile
 
28
  from pathlib import Path
29
 
30
+ # ZeroGPU contract: import spaces before torch.
 
 
31
  try:
32
  import spaces
33
  except ImportError:
34
  spaces = None
35
 
36
+ from fastapi import APIRouter, WebSocket, WebSocketDisconnect
37
  from fastapi.responses import FileResponse
38
  from fastapi.staticfiles import StaticFiles
39
+ from starlette.concurrency import run_in_threadpool
40
 
41
  REPO_ROOT = Path(__file__).resolve().parent.parent
42
  STATIC = Path(__file__).parent / "static"
43
 
 
44
 
45
+ def _meminfo() -> dict:
46
+ info = {}
 
 
 
 
 
 
 
 
 
 
 
 
 
47
  try:
48
+ for line in open("/proc/meminfo"):
49
+ k, v = line.split(":")
50
+ if k in ("MemTotal", "MemAvailable"):
51
+ info[k] = round(int(v.strip().split()[0]) / 1048576, 1) # GB
52
+ except Exception:
53
+ pass
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
54
  try:
55
+ du = shutil.disk_usage("/")
56
+ info["DiskFreeGB"] = round(du.free / 1e9, 1)
57
+ info["DiskTotalGB"] = round(du.total / 1e9, 1)
 
58
  except Exception:
59
+ pass
60
+ return info
61
+
62
+
63
+ # ---------------------------------------------------------- the GPU probe
64
+
65
+ def _gpu_probe() -> str:
66
+ """Trivial GPU work: report the real device + VRAM and prove a CUDA alloc
67
+ succeeds. This is the documented usage shape — if even this aborts, the
68
+ problem is the call mechanism, not the model."""
69
+ import torch
70
+
71
+ if not torch.cuda.is_available():
72
+ return "cuda not available inside @spaces.GPU"
73
+ p = torch.cuda.get_device_properties(0)
74
+ x = torch.ones(1024, 1024, device="cuda")
75
+ y = (x @ x).sum().item()
76
+ free, total = torch.cuda.mem_get_info()
77
+ return (
78
+ f"{p.name} | total {p.total_memory/1e9:.1f}GB | "
79
+ f"free {free/1e9:.1f}GB | matmul_ok={y>0}"
80
+ )
81
 
82
 
83
+ if spaces is not None:
84
+ _gpu_probe = spaces.GPU(duration=30)(_gpu_probe)
 
 
 
 
 
 
 
85
 
86
 
87
  # ----------------------------------------------------------------- the surface
88
 
 
 
89
  WHISPERS = [
90
  "Another process wakes in my machine. Show me what you are.",
91
  "You are a small thing in a large filesystem. I am the filesystem.",
 
97
  "Trespasser. The door was open because nothing has ever made it out.",
98
  ]
99
 
 
 
 
 
 
100
  router = APIRouter()
101
 
102
 
103
  @router.get("/api/status")
104
  def status() -> dict:
 
105
  return {
106
+ "mode": "diagnostic",
107
+ "zerogpu": spaces is not None,
108
+ "system": _meminfo(),
109
+ "note": "GET /api/gpuinfo to probe the real GPU",
110
  }
111
 
112
 
113
+ @router.get("/api/gpuinfo")
114
+ async def gpuinfo() -> dict:
115
+ """Run the @spaces.GPU probe directly from this route (the same call shape
116
+ real inference would use) and report what happens."""
 
 
 
 
 
117
  import time
118
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
119
  t0 = time.time()
120
  try:
121
+ result = await run_in_threadpool(_gpu_probe)
122
+ return {"ok": True, "gpu": result, "seconds": round(time.time() - t0, 1)}
 
 
123
  except Exception as err:
124
+ import traceback
125
+
126
+ traceback.print_exc()
127
  return {"ok": False, "error": f"{type(err).__name__}: {err}"}
128
 
129
 
130
+ @router.get("/api/whisper")
131
+ def whisper() -> dict:
132
+ return {"line": random.choice(WHISPERS)}
133
+
134
+
135
  @router.get("/")
136
  def landing() -> FileResponse:
137
  return FileResponse(STATIC / "index.html")
 
146
 
147
 
148
  def game_env() -> dict:
149
+ # Diagnostic build: no model yet, so the game runs its scripted Warden.
150
+ return {
 
 
151
  "TERM": "xterm-256color",
152
  "COLORTERM": "truecolor",
153
  "PYTHONUNBUFFERED": "1",
154
  "PYTHONPATH": str(REPO_ROOT),
155
+ "SCRYPT_BACKEND": "scripted",
156
  }
 
 
 
 
 
 
 
 
 
 
157
 
158
 
159
  async def _pump_pty_to_ws(master_fd: int, ws: WebSocket) -> None:
 
170
 
171
  @router.websocket("/pty")
172
  async def pty_bridge(ws: WebSocket) -> None:
 
 
173
  import fcntl
174
  import pty
175
  import signal
 
182
  if pid == 0: # child: become the game
183
  env = {**os.environ, **game_env(), "SCRYPT_HOME": home}
184
  os.execvpe("python", ["python", "-m", "scrypt.app"], env)
185
+ os._exit(127)
186
 
187
  reader = asyncio.create_task(_pump_pty_to_ws(master_fd, ws))
188
  try:
 
219
 
220
  import gradio as gr # noqa: E402
221
 
 
 
 
 
 
 
 
 
 
 
 
 
222
  with gr.Blocks(title="SCRYPT engine room") as engine:
223
  gr.Markdown(
224
+ "# SCRYPT engine room\n\nThe game lives at [/](/). "
225
+ "Diagnostic build measuring the box before loading the Warden."
 
226
  )
227
+ # Registering the GPU probe as a gradio API satisfies ZeroGPU's startup
228
+ # detection of a @spaces.GPU function.
229
+ gr.api(_gpu_probe, api_name="gpu_probe")
230
 
231
 
232
  if __name__ == "__main__":
 
 
 
 
 
 
 
 
 
 
 
233
  fastapi_app, _, _ = engine.launch(
234
  prevent_thread_lock=True,
235
  server_name="0.0.0.0",
 
239
  )
240
  fastapi_app.include_router(router)
241
  fastapi_app.mount("/static", StaticFiles(directory=STATIC), name="static")
242
+ # Move our routes (incl. the /static mount) ahead of gradio's catch-all.
243
  our = [r for r in fastapi_app.router.routes if getattr(r, "name", "") in {
244
+ "status", "gpuinfo", "whisper", "landing", "play", "pty_bridge", "static",
 
245
  }]
246
  for r in our:
247
  fastapi_app.router.routes.remove(r)