Ox1 Cursor commited on
Commit
4c7e762
·
1 Parent(s): 25d768b

fix(deploy): switch to CPU inference for ZeroGPU compatibility

Browse files

ZeroGPU's CUDA emulation is PyTorch-specific — llama-cpp-python links
directly against libcudart.so.12 which does not exist in the container.

- Remove --extra-index-url cu124 (installs CPU-only llama-cpp-python)
- Set python_version to 3.12 (ZeroGPU only supports 3.10/3.12)
- Detect SPACE_ID to force n_gpu_layers=0 on HF Spaces
- Remove @spaces.GPU from text-only LLM functions
- Keep @spaces.GPU on detection functions (PyTorch YOLO benefits)
- Add libopenblas-dev for CPU BLAS acceleration
- Remove Python 3.13 unraisablehook patch (no longer needed)
- Update README with local GPU install instructions

All hackathon badges preserved: Off the Grid, Llama Champion, Tiny Titan.
See docs/18-zerogpu-cpu-inference.md for full ADR.

Co-authored-by: Cursor <cursoragent@cursor.com>

Files changed (5) hide show
  1. README.md +9 -5
  2. app.py +0 -23
  3. packages.txt +1 -0
  4. requirements.txt +0 -1
  5. src/model_loader.py +15 -7
README.md CHANGED
@@ -5,7 +5,7 @@ colorFrom: green
5
  colorTo: blue
6
  sdk: gradio
7
  sdk_version: 6.17.3
8
- python_version: "3.13"
9
  app_file: app.py
10
  pinned: false
11
  license: mit
@@ -61,16 +61,20 @@ Total parameters: **4 billion** — fits comfortably on a T4 GPU with Q4 quantiz
61
 
62
  ### On HuggingFace Spaces
63
 
64
- The app runs on a T4 GPU Space. On first use:
65
- 1. Click **"Obtener Dataset"** to load a sample wardrobe from a HuggingFace dataset (takes ~2-5 minutes as it processes each garment through the VLM).
66
  2. Or upload your own clothes photos in the **Captura** tab.
67
  3. Explore combinations in **Combina** and ask questions in **Pregunta**.
68
 
69
- ### Local Development
70
 
71
  ```bash
72
  cd packages/wardrobe-us
73
  python -m venv .venv && source .venv/bin/activate
 
 
 
 
74
  pip install -r requirements.txt
75
 
76
  # Custom minimal frontend (default — gr.Server + Alpine.js):
@@ -80,7 +84,7 @@ python app.py
80
  python app.py --default
81
  ```
82
 
83
- Requires a CUDA GPU with at least 8GB VRAM. Set `HF_TOKEN` in `.env` for model downloads.
84
 
85
  The default mode (`--ui`) uses `gradio.Server` with a vanilla HTML/CSS/JS + Alpine.js interface designed for non-technical users. Pass `--default` for the full Gradio Blocks UI with all advanced features (manual bounding box annotation, detection backend settings, etc.).
86
 
 
5
  colorTo: blue
6
  sdk: gradio
7
  sdk_version: 6.17.3
8
+ python_version: "3.12"
9
  app_file: app.py
10
  pinned: false
11
  license: mit
 
61
 
62
  ### On HuggingFace Spaces
63
 
64
+ The app runs on ZeroGPU with CPU-based LLM inference (llama.cpp). On first use:
65
+ 1. Click **"Obtener Dataset"** to load a sample wardrobe from a HuggingFace dataset (takes ~5-10 minutes on CPU).
66
  2. Or upload your own clothes photos in the **Captura** tab.
67
  3. Explore combinations in **Combina** and ask questions in **Pregunta**.
68
 
69
+ ### Local Development (GPU accelerated)
70
 
71
  ```bash
72
  cd packages/wardrobe-us
73
  python -m venv .venv && source .venv/bin/activate
74
+
75
+ # Install with CUDA 12.4 GPU acceleration (recommended):
76
+ pip install llama-cpp-python \
77
+ --extra-index-url https://abetlen.github.io/llama-cpp-python/whl/cu124
78
  pip install -r requirements.txt
79
 
80
  # Custom minimal frontend (default — gr.Server + Alpine.js):
 
84
  python app.py --default
85
  ```
86
 
87
+ Requires a CUDA GPU with at least 8GB VRAM for GPU mode. Without CUDA, the app falls back to CPU inference automatically. Set `HF_TOKEN` in `.env` for model downloads.
88
 
89
  The default mode (`--ui`) uses `gradio.Server` with a vanilla HTML/CSS/JS + Alpine.js interface designed for non-technical users. Pass `--default` for the full Gradio Blocks UI with all advanced features (manual bounding box annotation, detection backend settings, etc.).
90
 
app.py CHANGED
@@ -9,24 +9,8 @@ Built for the Build Small Hackathon (HuggingFace x Gradio, June 2026).
9
  import io
10
  import logging
11
  import os
12
- import sys
13
  from pathlib import Path
14
 
15
- # Python 3.13 raises spurious ValueError during event-loop GC when httpx
16
- # (used internally by Gradio) creates and discards temporary loops.
17
- # Suppress the noise — it has no functional impact.
18
- if sys.version_info >= (3, 13):
19
- _original_unraisablehook = sys.unraisablehook
20
-
21
- def _quiet_unraisablehook(unraisable):
22
- if isinstance(unraisable.exc_value, ValueError) and "file descriptor" in str(
23
- unraisable.exc_value
24
- ):
25
- return
26
- _original_unraisablehook(unraisable)
27
-
28
- sys.unraisablehook = _quiet_unraisablehook
29
-
30
  from dotenv import load_dotenv
31
 
32
  load_dotenv(Path(__file__).resolve().parent / ".env")
@@ -146,7 +130,6 @@ def auto_detect(annotation, mode, progress=gr.Progress(track_tqdm=False)):
146
  return _format_results(added)
147
 
148
 
149
- @spaces.GPU(duration=120)
150
  def process_manual_selection(annotation, progress=gr.Progress(track_tqdm=False)):
151
  """Process manually drawn bounding boxes from the annotator.
152
 
@@ -453,7 +436,6 @@ def clear_all():
453
  # Chat handlers
454
  # ---------------------------------------------------------------------------
455
 
456
- @spaces.GPU(duration=90)
457
  def chat_respond(message, history):
458
  """Handle chat messages with streaming responses."""
459
  if not message:
@@ -496,7 +478,6 @@ def _get_combo_display(combo: dict | None) -> tuple[str | None, str | None, str,
496
  return top_img, bottom_img, top_text, bottom_text
497
 
498
 
499
- @spaces.GPU(duration=60)
500
  def init_combinations(state, context):
501
  """Initialize or refresh the combination queue, optionally ranked by context."""
502
  combos = generate_combinations()
@@ -852,7 +833,6 @@ def _build_custom_server():
852
  return {"garments": catalog, "count": len(catalog)}
853
 
854
  @server.api(name="add_photo")
855
- @spaces.GPU(duration=120)
856
  def api_add_photo(image_path) -> dict:
857
  if isinstance(image_path, dict):
858
  image_path = image_path.get("path") or image_path.get("url", "")
@@ -865,7 +845,6 @@ def _build_custom_server():
865
  return {"garments": added, "count": len(added)}
866
 
867
  @server.api(name="get_combinations")
868
- @spaces.GPU(duration=60)
869
  def api_get_combinations(context: str = "") -> dict:
870
  combos = generate_combinations()
871
  if not combos:
@@ -890,14 +869,12 @@ def _build_custom_server():
890
  return {"status": "ok", "liked": liked}
891
 
892
  @server.api(name="ask_question")
893
- @spaces.GPU(duration=90)
894
  def api_ask_question(question: str) -> str:
895
  if not question or not question.strip():
896
  return "Please ask a question about your wardrobe."
897
  return ask(question.strip())
898
 
899
  @server.api(name="load_dataset")
900
- @spaces.GPU(duration=300)
901
  def api_load_dataset(dataset_key: str) -> dict:
902
  from datasets import load_dataset as hf_load
903
 
 
9
  import io
10
  import logging
11
  import os
 
12
  from pathlib import Path
13
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
14
  from dotenv import load_dotenv
15
 
16
  load_dotenv(Path(__file__).resolve().parent / ".env")
 
130
  return _format_results(added)
131
 
132
 
 
133
  def process_manual_selection(annotation, progress=gr.Progress(track_tqdm=False)):
134
  """Process manually drawn bounding boxes from the annotator.
135
 
 
436
  # Chat handlers
437
  # ---------------------------------------------------------------------------
438
 
 
439
  def chat_respond(message, history):
440
  """Handle chat messages with streaming responses."""
441
  if not message:
 
478
  return top_img, bottom_img, top_text, bottom_text
479
 
480
 
 
481
  def init_combinations(state, context):
482
  """Initialize or refresh the combination queue, optionally ranked by context."""
483
  combos = generate_combinations()
 
833
  return {"garments": catalog, "count": len(catalog)}
834
 
835
  @server.api(name="add_photo")
 
836
  def api_add_photo(image_path) -> dict:
837
  if isinstance(image_path, dict):
838
  image_path = image_path.get("path") or image_path.get("url", "")
 
845
  return {"garments": added, "count": len(added)}
846
 
847
  @server.api(name="get_combinations")
 
848
  def api_get_combinations(context: str = "") -> dict:
849
  combos = generate_combinations()
850
  if not combos:
 
869
  return {"status": "ok", "liked": liked}
870
 
871
  @server.api(name="ask_question")
 
872
  def api_ask_question(question: str) -> str:
873
  if not question or not question.strip():
874
  return "Please ask a question about your wardrobe."
875
  return ask(question.strip())
876
 
877
  @server.api(name="load_dataset")
 
878
  def api_load_dataset(dataset_key: str) -> dict:
879
  from datasets import load_dataset as hf_load
880
 
packages.txt CHANGED
@@ -1,2 +1,3 @@
1
  cmake
2
  build-essential
 
 
1
  cmake
2
  build-essential
3
+ libopenblas-dev
requirements.txt CHANGED
@@ -1,4 +1,3 @@
1
- --extra-index-url https://abetlen.github.io/llama-cpp-python/whl/cu124
2
  spaces
3
  gradio==6.17.3
4
  llama-cpp-python>=0.3.28
 
 
1
  spaces
2
  gradio==6.17.3
3
  llama-cpp-python>=0.3.28
src/model_loader.py CHANGED
@@ -1,10 +1,10 @@
1
- """Singleton model loader with VRAM management.
2
 
3
- Handles loading/unloading of GGUF models. Only one model is kept in
4
- memory at a time to fit within available VRAM.
5
-
6
- Supports ZeroGPU (dynamic GPU allocation on HF Spaces) by detecting
7
- CUDA availability at load time rather than import time.
8
  """
9
 
10
  import gc
@@ -96,7 +96,15 @@ class _ModelManager:
96
 
97
  @staticmethod
98
  def _detect_gpu_layers() -> int:
99
- """Return -1 (all layers on GPU) if CUDA is available, else 0 (CPU)."""
 
 
 
 
 
 
 
 
100
  if os.environ.get("CUDA_VISIBLE_DEVICES") == "":
101
  return 0
102
  try:
 
1
+ """Singleton model loader for GGUF models.
2
 
3
+ Keeps one model loaded at a time. Supports dual-mode deployment:
4
+ - HF Spaces (ZeroGPU): CPU-only inference (n_gpu_layers=0) because
5
+ ZeroGPU's CUDA emulation is PyTorch-specific and llama.cpp links
6
+ against the system CUDA runtime which is not available.
7
+ - Local: Full GPU offload via CUDA when libcudart.so.12 is present.
8
  """
9
 
10
  import gc
 
96
 
97
  @staticmethod
98
  def _detect_gpu_layers() -> int:
99
+ """Detect whether to offload layers to GPU.
100
+
101
+ On HF Spaces the CUDA runtime is unavailable to llama.cpp
102
+ (ZeroGPU only exposes CUDA through PyTorch emulation).
103
+ Locally, probe for libcudart.so.12 to confirm real CUDA.
104
+ """
105
+ if os.environ.get("SPACE_ID"):
106
+ logger.info("Running on HF Spaces — using CPU inference")
107
+ return 0
108
  if os.environ.get("CUDA_VISIBLE_DEVICES") == "":
109
  return 0
110
  try: