Spaces:
Running on Zero
Running on Zero
Commit ·
8e0eeee
1
Parent(s): 479248b
Enable CUDA llama-cpp in ZeroGPU vision workers
Browse filesVision 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 |
-
#
|
|
|
|
|
|
|
| 5 |
--prefer-binary
|
| 6 |
--only-binary llama-cpp-python
|
| 7 |
-
--extra-index-url https://abetlen.github.io/llama-cpp-python/whl/
|
| 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
|
| 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
|
| 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=
|
| 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(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
| 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)
|