fix: gate transformers judge on real GPU; ZeroGPU-correct loading; deploy to BladeSzaSza ZeroGPU Space
#8
by BladeSzaSza - opened
- formscout/config.py +24 -3
- formscout/serving/transformers_vlm.py +19 -10
- scripts/hf_upload.sh +27 -7
- tests/test_judge_backend.py +12 -2
formscout/config.py
CHANGED
|
@@ -147,14 +147,35 @@ LLAMA_CPP_PORT_EMBED = 8081
|
|
| 147 |
# βββ Judge backend selection ββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 148 |
# "llama_cpp" β local llama-server (default for local dev; works perfectly)
|
| 149 |
# "transformers"β in-process Qwen3-VL via transformers, GPU on HF Spaces (ZeroGPU)
|
| 150 |
-
# "auto" β transformers on a
|
| 151 |
JUDGE_BACKEND = os.environ.get("FORMSCOUT_JUDGE_BACKEND", "auto")
|
| 152 |
JUDGE_HF_MODEL = os.environ.get("FORMSCOUT_JUDGE_HF_MODEL", "Qwen/Qwen3-VL-8B-Instruct")
|
| 153 |
ON_HF_SPACE = bool(os.environ.get("SPACE_ID"))
|
| 154 |
|
| 155 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 156 |
def resolve_judge_backend() -> str:
|
| 157 |
-
"""Resolve the effective judge backend from JUDGE_BACKEND + environment.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 158 |
if JUDGE_BACKEND in ("llama_cpp", "transformers"):
|
| 159 |
return JUDGE_BACKEND
|
| 160 |
-
return "transformers" if ON_HF_SPACE else "llama_cpp"
|
|
|
|
| 147 |
# βββ Judge backend selection ββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 148 |
# "llama_cpp" β local llama-server (default for local dev; works perfectly)
|
| 149 |
# "transformers"β in-process Qwen3-VL via transformers, GPU on HF Spaces (ZeroGPU)
|
| 150 |
+
# "auto" β transformers ONLY on a GPU/ZeroGPU Space, else llama_cpp
|
| 151 |
JUDGE_BACKEND = os.environ.get("FORMSCOUT_JUDGE_BACKEND", "auto")
|
| 152 |
JUDGE_HF_MODEL = os.environ.get("FORMSCOUT_JUDGE_HF_MODEL", "Qwen/Qwen3-VL-8B-Instruct")
|
| 153 |
ON_HF_SPACE = bool(os.environ.get("SPACE_ID"))
|
| 154 |
|
| 155 |
|
| 156 |
+
def has_gpu() -> bool:
|
| 157 |
+
"""True on a ZeroGPU Space (env flag) or when CUDA is actually present.
|
| 158 |
+
|
| 159 |
+
ZeroGPU exposes no CUDA outside @spaces.GPU, so it is detected via the
|
| 160 |
+
SPACES_ZERO_GPU env flag; ordinary GPU Spaces report via torch.cuda.
|
| 161 |
+
"""
|
| 162 |
+
if os.environ.get("SPACES_ZERO_GPU") or os.environ.get("ZERO_GPU"):
|
| 163 |
+
return True
|
| 164 |
+
try:
|
| 165 |
+
import torch
|
| 166 |
+
return bool(torch.cuda.is_available())
|
| 167 |
+
except Exception:
|
| 168 |
+
return False
|
| 169 |
+
|
| 170 |
+
|
| 171 |
def resolve_judge_backend() -> str:
|
| 172 |
+
"""Resolve the effective judge backend from JUDGE_BACKEND + environment.
|
| 173 |
+
|
| 174 |
+
`auto` only engages the heavy in-process transformers model when a GPU is
|
| 175 |
+
actually available β a CPU-only Space stays on llama_cpp (which is then
|
| 176 |
+
unreachable, so the Judge falls back to the fast rubric instead of trying to
|
| 177 |
+
run a 17 GB model on CPU).
|
| 178 |
+
"""
|
| 179 |
if JUDGE_BACKEND in ("llama_cpp", "transformers"):
|
| 180 |
return JUDGE_BACKEND
|
| 181 |
+
return "transformers" if (ON_HF_SPACE and has_gpu()) else "llama_cpp"
|
formscout/serving/transformers_vlm.py
CHANGED
|
@@ -39,19 +39,27 @@ except Exception: # pragma: no cover
|
|
| 39 |
return fn
|
| 40 |
|
| 41 |
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
"""Load (cached) and run Qwen3-VL; returns the raw decoded string."""
|
| 46 |
-
import torch
|
| 47 |
-
from transformers import AutoModelForImageTextToText, AutoProcessor
|
| 48 |
-
|
| 49 |
if "model" not in _CACHE:
|
|
|
|
|
|
|
| 50 |
_CACHE["processor"] = AutoProcessor.from_pretrained(model_id)
|
| 51 |
_CACHE["model"] = AutoModelForImageTextToText.from_pretrained(
|
| 52 |
-
model_id, torch_dtype=
|
| 53 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 54 |
processor, model = _CACHE["processor"], _CACHE["model"]
|
|
|
|
| 55 |
|
| 56 |
content = [{"type": "image", "image": im} for im in pil_images]
|
| 57 |
content.append({"type": "text", "text": prompt})
|
|
@@ -60,7 +68,7 @@ def _generate(model_id: str, prompt: str, pil_images: list, max_tokens: int,
|
|
| 60 |
inputs = processor.apply_chat_template(
|
| 61 |
messages, tokenize=True, add_generation_prompt=True,
|
| 62 |
return_tensors="pt", return_dict=True,
|
| 63 |
-
).to(
|
| 64 |
|
| 65 |
with torch.no_grad():
|
| 66 |
out = model.generate(
|
|
@@ -90,7 +98,8 @@ class TransformersVLMClient:
|
|
| 90 |
stop: list[str] | None = None) -> dict:
|
| 91 |
try:
|
| 92 |
pil_images = self._decode_images(images)
|
| 93 |
-
|
|
|
|
| 94 |
return LlamaCppClient._parse_json_reply(text)
|
| 95 |
except Exception as e: # pragma: no cover - needs GPU + model
|
| 96 |
logger.warning("transformers VLM failed (%s) β falling back to rubric", e)
|
|
|
|
| 39 |
return fn
|
| 40 |
|
| 41 |
|
| 42 |
+
def _ensure_loaded(model_id: str): # pragma: no cover - downloads ~16 GB
|
| 43 |
+
"""Load processor + model to CPU once (cached). Kept OUT of the GPU window so
|
| 44 |
+
the 17 GB download/load does not eat ZeroGPU time."""
|
|
|
|
|
|
|
|
|
|
|
|
|
| 45 |
if "model" not in _CACHE:
|
| 46 |
+
import torch
|
| 47 |
+
from transformers import AutoModelForImageTextToText, AutoProcessor
|
| 48 |
_CACHE["processor"] = AutoProcessor.from_pretrained(model_id)
|
| 49 |
_CACHE["model"] = AutoModelForImageTextToText.from_pretrained(
|
| 50 |
+
model_id, torch_dtype=torch.bfloat16,
|
| 51 |
)
|
| 52 |
+
return _CACHE["processor"], _CACHE["model"]
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
@_gpu
|
| 56 |
+
def _generate(prompt: str, pil_images: list, max_tokens: int,
|
| 57 |
+
temperature: float) -> str: # pragma: no cover - needs GPU + model
|
| 58 |
+
"""Move the cached model to CUDA and run Qwen3-VL (ZeroGPU window)."""
|
| 59 |
+
import torch
|
| 60 |
+
|
| 61 |
processor, model = _CACHE["processor"], _CACHE["model"]
|
| 62 |
+
model.to("cuda")
|
| 63 |
|
| 64 |
content = [{"type": "image", "image": im} for im in pil_images]
|
| 65 |
content.append({"type": "text", "text": prompt})
|
|
|
|
| 68 |
inputs = processor.apply_chat_template(
|
| 69 |
messages, tokenize=True, add_generation_prompt=True,
|
| 70 |
return_tensors="pt", return_dict=True,
|
| 71 |
+
).to("cuda")
|
| 72 |
|
| 73 |
with torch.no_grad():
|
| 74 |
out = model.generate(
|
|
|
|
| 98 |
stop: list[str] | None = None) -> dict:
|
| 99 |
try:
|
| 100 |
pil_images = self._decode_images(images)
|
| 101 |
+
_ensure_loaded(self.model_id) # CPU load (no GPU time)
|
| 102 |
+
text = _generate(prompt, pil_images, max_tokens, temperature)
|
| 103 |
return LlamaCppClient._parse_json_reply(text)
|
| 104 |
except Exception as e: # pragma: no cover - needs GPU + model
|
| 105 |
logger.warning("transformers VLM failed (%s) β falling back to rubric", e)
|
scripts/hf_upload.sh
CHANGED
|
@@ -23,6 +23,7 @@ cd "$(dirname "$0")/.."
|
|
| 23 |
|
| 24 |
MODEL_REPO="silas-therapy/small-functional-movement-screening"
|
| 25 |
SPACE_REPO="spaces/silas-therapy/small-functional-movement-screening"
|
|
|
|
| 26 |
MSG="${1:-$(git log -1 --pretty=%s)}"
|
| 27 |
LARGE_THRESHOLD="${FORMSCOUT_HF_LARGE_THRESHOLD:-500}"
|
| 28 |
|
|
@@ -76,22 +77,41 @@ if (( N_FILES == 0 )); then
|
|
| 76 |
exit 1
|
| 77 |
fi
|
| 78 |
|
|
|
|
|
|
|
|
|
|
| 79 |
upload_repo() {
|
| 80 |
local repo="$1"
|
|
|
|
| 81 |
if (( N_FILES > LARGE_THRESHOLD )); then
|
| 82 |
echo "ββ $repo: $N_FILES files > $LARGE_THRESHOLD, using upload-large-folder"
|
| 83 |
echo " (resumable; commits directly to main β no PR, no custom message)"
|
| 84 |
hf upload-large-folder "$repo" . "${EXCLUDES[@]}"
|
|
|
|
|
|
|
|
|
|
| 85 |
else
|
| 86 |
-
echo "ββ uploading to: $repo"
|
| 87 |
-
hf upload "$repo" . .
|
| 88 |
-
"${EXCLUDES[@]}" \
|
| 89 |
-
--create-pr \
|
| 90 |
-
--commit-message="$MSG"
|
| 91 |
fi
|
| 92 |
}
|
| 93 |
|
| 94 |
-
|
| 95 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 96 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 97 |
echo "β done"
|
|
|
|
| 23 |
|
| 24 |
MODEL_REPO="silas-therapy/small-functional-movement-screening"
|
| 25 |
SPACE_REPO="spaces/silas-therapy/small-functional-movement-screening"
|
| 26 |
+
SPACE_BLADESZASZA_REPO="spaces/bladeszasza/small-functional-movement-screening"
|
| 27 |
MSG="${1:-$(git log -1 --pretty=%s)}"
|
| 28 |
LARGE_THRESHOLD="${FORMSCOUT_HF_LARGE_THRESHOLD:-500}"
|
| 29 |
|
|
|
|
| 77 |
exit 1
|
| 78 |
fi
|
| 79 |
|
| 80 |
+
# upload_repo <repo> [pr|direct]
|
| 81 |
+
# pr β open a PR (shared org repos; review before merge)
|
| 82 |
+
# direct β commit straight to main (repos you own; deploys immediately)
|
| 83 |
upload_repo() {
|
| 84 |
local repo="$1"
|
| 85 |
+
local mode="${2:-pr}"
|
| 86 |
if (( N_FILES > LARGE_THRESHOLD )); then
|
| 87 |
echo "ββ $repo: $N_FILES files > $LARGE_THRESHOLD, using upload-large-folder"
|
| 88 |
echo " (resumable; commits directly to main β no PR, no custom message)"
|
| 89 |
hf upload-large-folder "$repo" . "${EXCLUDES[@]}"
|
| 90 |
+
elif [[ "$mode" == "direct" ]]; then
|
| 91 |
+
echo "ββ uploading (direct β main) to: $repo"
|
| 92 |
+
hf upload "$repo" . . "${EXCLUDES[@]}" --commit-message="$MSG"
|
| 93 |
else
|
| 94 |
+
echo "ββ uploading (PR) to: $repo"
|
| 95 |
+
hf upload "$repo" . . "${EXCLUDES[@]}" --create-pr --commit-message="$MSG"
|
|
|
|
|
|
|
|
|
|
| 96 |
fi
|
| 97 |
}
|
| 98 |
|
| 99 |
+
# Ensure the personal ZeroGPU Space exists. Tries zero-a10g (needs Pro/ZeroGPU);
|
| 100 |
+
# falls back to cpu-basic so the upload still has a target (set ZeroGPU in
|
| 101 |
+
# Settings afterward). Idempotent via --exist-ok.
|
| 102 |
+
ensure_blade_space() {
|
| 103 |
+
local id="bladeszasza/$REPO_NAME"
|
| 104 |
+
if hf repos create "$id" --type space --space-sdk gradio --flavor zero-a10g --exist-ok 2>/dev/null; then
|
| 105 |
+
echo "ββ Space ready (ZeroGPU / zero-a10g): $id"
|
| 106 |
+
else
|
| 107 |
+
echo "ββ zero-a10g unavailable (Pro/ZeroGPU quota?) β creating cpu-basic; switch to ZeroGPU in Settings"
|
| 108 |
+
hf repos create "$id" --type space --space-sdk gradio --exist-ok || true
|
| 109 |
+
fi
|
| 110 |
+
}
|
| 111 |
|
| 112 |
+
# Shared org repos β PRs; personal ZeroGPU Space β created + direct deploy.
|
| 113 |
+
upload_repo "$MODEL_REPO" pr
|
| 114 |
+
upload_repo "$SPACE_REPO" pr
|
| 115 |
+
ensure_blade_space
|
| 116 |
+
upload_repo "$SPACE_BLADESZASZA_REPO" direct
|
| 117 |
echo "β done"
|
tests/test_judge_backend.py
CHANGED
|
@@ -21,9 +21,19 @@ def test_resolve_backend_default_local(monkeypatch):
|
|
| 21 |
assert cfg.resolve_judge_backend() == "llama_cpp"
|
| 22 |
|
| 23 |
|
| 24 |
-
def
|
| 25 |
-
cfg = _reload_config(monkeypatch, FORMSCOUT_JUDGE_BACKEND="auto",
|
|
|
|
| 26 |
assert cfg.resolve_judge_backend() == "transformers"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 27 |
|
| 28 |
|
| 29 |
def test_resolve_backend_explicit(monkeypatch):
|
|
|
|
| 21 |
assert cfg.resolve_judge_backend() == "llama_cpp"
|
| 22 |
|
| 23 |
|
| 24 |
+
def test_resolve_backend_auto_on_zero_gpu_space(monkeypatch):
|
| 25 |
+
cfg = _reload_config(monkeypatch, FORMSCOUT_JUDGE_BACKEND="auto",
|
| 26 |
+
SPACE_ID="me/space", SPACES_ZERO_GPU="true")
|
| 27 |
assert cfg.resolve_judge_backend() == "transformers"
|
| 28 |
+
importlib.reload(config)
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def test_resolve_backend_auto_on_cpu_space_stays_llama(monkeypatch):
|
| 32 |
+
# A CPU-only Space must NOT load the 17 GB transformers model.
|
| 33 |
+
cfg = _reload_config(monkeypatch, FORMSCOUT_JUDGE_BACKEND="auto",
|
| 34 |
+
SPACE_ID="me/space", SPACES_ZERO_GPU=None, ZERO_GPU=None)
|
| 35 |
+
assert cfg.resolve_judge_backend() == "llama_cpp"
|
| 36 |
+
importlib.reload(config)
|
| 37 |
|
| 38 |
|
| 39 |
def test_resolve_backend_explicit(monkeypatch):
|