marinarosa commited on
Commit
8e0eeee
·
1 Parent(s): 479248b

Enable CUDA llama-cpp in ZeroGPU vision workers

Browse files

Vision was still running on CPU because requirements.txt pinned the CPU-only
llama-cpp-python wheel. ZeroGPU workers had n_gpu_layers=-1 but no CUDA
backend to honor it, which explains ~62s image decode times.

Switch the Space runtime to the cu124 prebuilt wheel so @spaces.GPU workers
can offload the GGUF. Keep the main Gradio process on n_gpu_layers=0 for
text replies; lazy import still defers model load until first use. Route
single-image extract() through the GPU worker as well, and reload the
backend when switching between CPU and GPU load modes in the same instance.

Adds unit tests for _n_gpu_layers and GPU routing of extract().

requirements.txt CHANGED
@@ -1,10 +1,12 @@
1
  # Hugging Face Spaces installs this file with pip (Spaces do not use uv).
2
  # Keep it in sync with pyproject.toml: `make lock-requirements`.
3
  # Runtime dependencies only; dev tools (ruff, mypy, pytest) live in pyproject.
4
- # CPU wheel: CUDA wheels need libcudart at import time, which ZeroGPU lacks.
 
 
5
  --prefer-binary
6
  --only-binary llama-cpp-python
7
- --extra-index-url https://abetlen.github.io/llama-cpp-python/whl/cpu
8
  llama-cpp-python>=0.3.26
9
  spaces
10
  huggingface_hub
 
1
  # Hugging Face Spaces installs this file with pip (Spaces do not use uv).
2
  # Keep it in sync with pyproject.toml: `make lock-requirements`.
3
  # Runtime dependencies only; dev tools (ruff, mypy, pytest) live in pyproject.
4
+ # CUDA wheel: vision runs in @spaces.GPU workers with n_gpu_layers=-1. The main
5
+ # Gradio process still uses n_gpu_layers=0 for text; lazy import avoids loading
6
+ # the model until first use. HF ZeroGPU images ship libcudart for both paths.
7
  --prefer-binary
8
  --only-binary llama-cpp-python
9
+ --extra-index-url https://abetlen.github.io/llama-cpp-python/whl/cu124
10
  llama-cpp-python>=0.3.26
11
  spaces
12
  huggingface_hub
src/vivamais/adapters/models/llama_cpp.py CHANGED
@@ -108,7 +108,7 @@ def _image_mime(data: bytes) -> str:
108
 
109
 
110
  def _import_llama() -> tuple[Any, Any]:
111
- """Import llama_cpp lazily so Space startup does not load CUDA libs."""
112
  from llama_cpp import Llama
113
  from llama_cpp.llama_chat_format import MiniCPMv26ChatHandler
114
 
@@ -116,7 +116,7 @@ def _import_llama() -> tuple[Any, Any]:
116
 
117
 
118
  def _n_gpu_layers(*, use_gpu: bool = False) -> int:
119
- """CPU in the Gradio process; offload all layers inside ZeroGPU workers."""
120
  env_val = os.environ.get("VIVAMAIS_N_GPU_LAYERS")
121
  if env_val is not None:
122
  return int(env_val)
@@ -139,6 +139,7 @@ class LlamaCppBackend:
139
  self._n_ctx = n_ctx or (int(env_ctx) if env_ctx else 4096)
140
  self._verbose = verbose
141
  self._llama: Any | None = None
 
142
  self._last_raw_output: str = ""
143
 
144
  def __del__(self) -> None:
@@ -165,8 +166,11 @@ class LlamaCppBackend:
165
  )
166
 
167
  def _ensure_loaded(self, use_gpu: bool = False) -> None:
168
- if self._llama is not None:
169
  return
 
 
 
170
  model_path = _resolve_path(
171
  self._model_path,
172
  "VIVAMAIS_MODEL_PATH",
@@ -181,13 +185,17 @@ class LlamaCppBackend:
181
  )
182
  Llama, MiniCPMv26ChatHandler = _import_llama()
183
  handler = MiniCPMv26ChatHandler(clip_model_path=mmproj_path, verbose=self._verbose)
 
184
  self._llama = Llama(
185
  model_path=model_path,
186
  n_ctx=self._n_ctx,
187
- n_gpu_layers=_n_gpu_layers(use_gpu=use_gpu),
188
  verbose=self._verbose,
189
  chat_handler=handler,
190
  )
 
 
 
191
 
192
  def _build_messages(self, image: bytes, prompt: str) -> list[dict[str, Any]]:
193
  image_b64 = base64.b64encode(image).decode("utf-8")
@@ -241,7 +249,13 @@ class LlamaCppBackend:
241
  self._last_raw_output = text
242
  return text
243
 
244
- def extract(self, image: bytes, schema_hint: str) -> dict[str, Any]:
 
 
 
 
 
 
245
  if schema_hint == "ticket":
246
  prompt = TICKET_PROMPT
247
  elif schema_hint == "document":
@@ -249,7 +263,10 @@ class LlamaCppBackend:
249
  else:
250
  prompt = RECEIPT_PROMPT
251
  messages = self._build_messages(image, prompt)
252
- text = self._completion(messages, max_tokens=384, temperature=0.0)
 
 
 
253
  return _parse_json(text, schema_hint)
254
 
255
  def extract_all(
@@ -315,7 +332,12 @@ def _gpu_extract_impl(image: bytes) -> tuple[dict[str, Any], dict[str, Any]]:
315
  return _worker_backend_instance().extract_all(image, use_gpu=True)
316
 
317
 
 
 
 
 
318
  _gpu_extract_one: Callable[[bytes], tuple[dict[str, Any], dict[str, Any]]] | None = None
 
319
 
320
  if spaces is not None:
321
 
@@ -323,6 +345,10 @@ if spaces is not None:
323
  def _gpu_extract_one(image: bytes) -> tuple[dict[str, Any], dict[str, Any]]:
324
  return _gpu_extract_impl(image)
325
 
 
 
 
 
326
 
327
  def _run_gpu_images(
328
  images: list[bytes],
@@ -344,6 +370,8 @@ class LlamaCppVisionModel:
344
  self._backend = backend
345
 
346
  def extract(self, image: bytes, schema_hint: str) -> dict[str, Any]:
 
 
347
  return self._backend.extract(image, schema_hint)
348
 
349
  def extract_all(self, image: bytes) -> tuple[dict[str, Any], dict[str, Any]]:
 
108
 
109
 
110
  def _import_llama() -> tuple[Any, Any]:
111
+ """Import llama_cpp lazily so Space startup does not load the GGUF."""
112
  from llama_cpp import Llama
113
  from llama_cpp.llama_chat_format import MiniCPMv26ChatHandler
114
 
 
116
 
117
 
118
  def _n_gpu_layers(*, use_gpu: bool = False) -> int:
119
+ """CPU in the Gradio process; full offload inside ZeroGPU workers."""
120
  env_val = os.environ.get("VIVAMAIS_N_GPU_LAYERS")
121
  if env_val is not None:
122
  return int(env_val)
 
139
  self._n_ctx = n_ctx or (int(env_ctx) if env_ctx else 4096)
140
  self._verbose = verbose
141
  self._llama: Any | None = None
142
+ self._loaded_use_gpu: bool | None = None
143
  self._last_raw_output: str = ""
144
 
145
  def __del__(self) -> None:
 
166
  )
167
 
168
  def _ensure_loaded(self, use_gpu: bool = False) -> None:
169
+ if self._llama is not None and self._loaded_use_gpu == use_gpu:
170
  return
171
+ if self._llama is not None:
172
+ del self._llama
173
+ self._llama = None
174
  model_path = _resolve_path(
175
  self._model_path,
176
  "VIVAMAIS_MODEL_PATH",
 
185
  )
186
  Llama, MiniCPMv26ChatHandler = _import_llama()
187
  handler = MiniCPMv26ChatHandler(clip_model_path=mmproj_path, verbose=self._verbose)
188
+ gpu_layers = _n_gpu_layers(use_gpu=use_gpu)
189
  self._llama = Llama(
190
  model_path=model_path,
191
  n_ctx=self._n_ctx,
192
+ n_gpu_layers=gpu_layers,
193
  verbose=self._verbose,
194
  chat_handler=handler,
195
  )
196
+ self._loaded_use_gpu = use_gpu
197
+ if self._verbose:
198
+ print(f"llamacpp loaded n_gpu_layers={gpu_layers} use_gpu={use_gpu}", flush=True)
199
 
200
  def _build_messages(self, image: bytes, prompt: str) -> list[dict[str, Any]]:
201
  image_b64 = base64.b64encode(image).decode("utf-8")
 
249
  self._last_raw_output = text
250
  return text
251
 
252
+ def extract(
253
+ self,
254
+ image: bytes,
255
+ schema_hint: str,
256
+ *,
257
+ use_gpu: bool = False,
258
+ ) -> dict[str, Any]:
259
  if schema_hint == "ticket":
260
  prompt = TICKET_PROMPT
261
  elif schema_hint == "document":
 
263
  else:
264
  prompt = RECEIPT_PROMPT
265
  messages = self._build_messages(image, prompt)
266
+ if use_gpu:
267
+ text = self._completion_gpu(messages, max_tokens=384, temperature=0.0)
268
+ else:
269
+ text = self._completion(messages, max_tokens=384, temperature=0.0)
270
  return _parse_json(text, schema_hint)
271
 
272
  def extract_all(
 
332
  return _worker_backend_instance().extract_all(image, use_gpu=True)
333
 
334
 
335
+ def _gpu_extract_schema_impl(image: bytes, schema_hint: str) -> dict[str, Any]:
336
+ return _worker_backend_instance().extract(image, schema_hint, use_gpu=True)
337
+
338
+
339
  _gpu_extract_one: Callable[[bytes], tuple[dict[str, Any], dict[str, Any]]] | None = None
340
+ _gpu_extract_schema: Callable[[bytes, str], dict[str, Any]] | None = None
341
 
342
  if spaces is not None:
343
 
 
345
  def _gpu_extract_one(image: bytes) -> tuple[dict[str, Any], dict[str, Any]]:
346
  return _gpu_extract_impl(image)
347
 
348
+ @spaces.GPU(duration=_single_image_duration) # type: ignore[misc]
349
+ def _gpu_extract_schema(image: bytes, schema_hint: str) -> dict[str, Any]:
350
+ return _gpu_extract_schema_impl(image, schema_hint)
351
+
352
 
353
  def _run_gpu_images(
354
  images: list[bytes],
 
370
  self._backend = backend
371
 
372
  def extract(self, image: bytes, schema_hint: str) -> dict[str, Any]:
373
+ if spaces is not None and _gpu_extract_schema is not None:
374
+ return _gpu_extract_schema(image, schema_hint)
375
  return self._backend.extract(image, schema_hint)
376
 
377
  def extract_all(self, image: bytes) -> tuple[dict[str, Any], dict[str, Any]]:
tests/unit/adapters/test_llama_cpp.py CHANGED
@@ -15,6 +15,7 @@ from vivamais.adapters.models.llama_cpp import (
15
  VARIANT,
16
  LlamaCppBackend,
17
  LlamaCppVisionModel,
 
18
  _parse_json,
19
  _resolve_path,
20
  _single_image_duration,
@@ -66,6 +67,19 @@ class TestSingleImageDuration:
66
  )
67
 
68
 
 
 
 
 
 
 
 
 
 
 
 
 
 
69
  class TestLlamaCppVisionModelBatchProcess:
70
  def _make_mock_backend(self) -> MagicMock:
71
  backend = MagicMock()
@@ -102,6 +116,17 @@ class TestLlamaCppVisionModelBatchProcess:
102
  gpu_fn.assert_any_call(b"img1")
103
  gpu_fn.assert_any_call(b"img2")
104
 
 
 
 
 
 
 
 
 
 
 
 
105
  def test_stream_process_yields_per_image_on_gpu(self) -> None:
106
  backend = self._make_mock_backend()
107
  model = LlamaCppVisionModel(backend)
 
15
  VARIANT,
16
  LlamaCppBackend,
17
  LlamaCppVisionModel,
18
+ _n_gpu_layers,
19
  _parse_json,
20
  _resolve_path,
21
  _single_image_duration,
 
67
  )
68
 
69
 
70
+ class TestNGpuLayers:
71
+ def test_cpu_path_uses_zero_layers(self) -> None:
72
+ assert _n_gpu_layers(use_gpu=False) == 0
73
+
74
+ def test_gpu_worker_offloads_all_layers(self) -> None:
75
+ assert _n_gpu_layers(use_gpu=True) == -1
76
+
77
+ def test_env_override(self) -> None:
78
+ with patch.dict(os.environ, {"VIVAMAIS_N_GPU_LAYERS": "32"}, clear=False):
79
+ assert _n_gpu_layers(use_gpu=True) == 32
80
+ assert _n_gpu_layers(use_gpu=False) == 32
81
+
82
+
83
  class TestLlamaCppVisionModelBatchProcess:
84
  def _make_mock_backend(self) -> MagicMock:
85
  backend = MagicMock()
 
116
  gpu_fn.assert_any_call(b"img1")
117
  gpu_fn.assert_any_call(b"img2")
118
 
119
+ def test_extract_routes_through_gpu_when_spaces_available(self) -> None:
120
+ backend = self._make_mock_backend()
121
+ model = LlamaCppVisionModel(backend)
122
+ gpu_fn = MagicMock(return_value={"passenger": "Maria"})
123
+ with patch("vivamais.adapters.models.llama_cpp.spaces", MagicMock()):
124
+ with patch("vivamais.adapters.models.llama_cpp._gpu_extract_schema", gpu_fn):
125
+ result = model.extract(b"img", "document")
126
+ assert result == {"passenger": "Maria"}
127
+ gpu_fn.assert_called_once_with(b"img", "document")
128
+ backend.extract.assert_not_called()
129
+
130
  def test_stream_process_yields_per_image_on_gpu(self) -> None:
131
  backend = self._make_mock_backend()
132
  model = LlamaCppVisionModel(backend)