bruAristimunha commited on
Commit
c54d8a2
Β·
1 Parent(s): 356d76f

feat: run the eval worker inside the Space (CPU, isolated venv)

Browse files

Make the Space self-contained β€” it now runs the eval engine itself on CPU,
no external machine needed:

- Dockerfile: build an isolated /opt/worker-venv (CPU torch + open-eeg-bench),
kept OUT of the web app's pinned stack to avoid version conflicts; launch the
worker as a background '--watch' loop alongside uvicorn + the frontend, gated
by RUN_EVAL_WORKER (set =0 in Space settings to disable without a rebuild).
Built before the code COPY (cached across code rebuilds) and non-fatal (a
failed worker-deps install never breaks the web app).
- run_eval_worker.py: import-light (only huggingface_hub + pure app modules, so
it runs in the minimal venv), default --device cpu, add process_queue() and a
--watch/--interval service loop.
- app/services/__init__.py: stop eagerly importing the heavy submodules so the
pure results_mapping can be imported without dragging in datasets/transformers/
aiohttp. All call sites use direct submodule imports, so nothing breaks.

Verified: the worker runs from a venv containing only huggingface_hub+pandas+
pyarrow (no backend stack, no oeb); web app + 16 tests still green.

Dockerfile CHANGED
@@ -26,6 +26,19 @@ COPY backend/pyproject.toml backend/poetry.lock* ./
26
  RUN poetry config virtualenvs.create false \
27
  && poetry install --no-interaction --no-ansi --no-root --only main
28
 
 
 
 
 
 
 
 
 
 
 
 
 
 
29
  # Copy backend code
30
  COPY backend/ .
31
 
@@ -52,11 +65,14 @@ ENV HF_HOME=/app/.cache \
52
  HF_DATASETS_CACHE=/app/.cache \
53
  INTERNAL_API_PORT=7861 \
54
  PORT=7860 \
55
- NODE_ENV=production
 
56
 
57
  # Note: HF_TOKEN should be provided at runtime, not build time
58
  USER user
59
  EXPOSE 7860
60
 
61
- # Start both servers with wait-for
62
- CMD ["sh", "-c", "uvicorn app.asgi:app --host 0.0.0.0 --port 7861 & while ! nc -z localhost 7861; do sleep 1; done && cd frontend && npm run serve"]
 
 
 
26
  RUN poetry config virtualenvs.create false \
27
  && poetry install --no-interaction --no-ansi --no-root --only main
28
 
29
+ # Isolated venv for the eval worker (heavy ML deps: torch + braindecode from git),
30
+ # kept OUT of the web app's pinned stack to avoid version conflicts. Built before
31
+ # the code COPY so it stays cached across code-only rebuilds. Non-fatal: if the
32
+ # install fails, the web app still builds and the worker is simply disabled.
33
+ RUN apt-get update && apt-get install -y --no-install-recommends git \
34
+ && python -m venv /opt/worker-venv \
35
+ && /opt/worker-venv/bin/pip install --no-cache-dir -U pip \
36
+ && (/opt/worker-venv/bin/pip install --no-cache-dir torch --index-url https://download.pytorch.org/whl/cpu \
37
+ && /opt/worker-venv/bin/pip install --no-cache-dir huggingface_hub pandas pyarrow open-eeg-bench \
38
+ || echo "WARN: eval-worker deps failed to install; worker disabled at runtime") \
39
+ && chown -R user:user /opt/worker-venv \
40
+ && rm -rf /var/lib/apt/lists/*
41
+
42
  # Copy backend code
43
  COPY backend/ .
44
 
 
65
  HF_DATASETS_CACHE=/app/.cache \
66
  INTERNAL_API_PORT=7861 \
67
  PORT=7860 \
68
+ NODE_ENV=production \
69
+ RUN_EVAL_WORKER=1
70
 
71
  # Note: HF_TOKEN should be provided at runtime, not build time
72
  USER user
73
  EXPOSE 7860
74
 
75
+ # Start the API, the (optional) CPU eval worker, and the frontend. The worker
76
+ # runs in its own venv as a background --watch loop. Disable it without a rebuild
77
+ # by setting RUN_EVAL_WORKER=0 in the Space settings, then restarting.
78
+ CMD ["sh", "-c", "uvicorn app.asgi:app --host 0.0.0.0 --port 7861 & if [ \"$RUN_EVAL_WORKER\" = \"1\" ]; then PYTHONPATH=/app /opt/worker-venv/bin/python -m scripts.run_eval_worker --watch --limit 1 --interval 300 --device cpu & fi; while ! nc -z localhost 7861; do sleep 1; done && cd frontend && npm run serve"]
backend/app/services/__init__.py CHANGED
@@ -1,3 +1,8 @@
1
- from . import hf_service, leaderboard, votes, models
2
 
3
- __all__ = ["hf_service", "leaderboard", "votes", "models"]
 
 
 
 
 
 
1
+ """Service layer.
2
 
3
+ Submodules are imported directly (``from app.services.models import ...``) rather
4
+ than eagerly here. This keeps light, dependency-free modules like
5
+ ``results_mapping`` importable without dragging in the whole backend stack
6
+ (datasets, transformers, aiohttp, ...), so the eval worker can import it inside a
7
+ minimal isolated venv.
8
+ """
backend/scripts/run_eval_worker.py CHANGED
@@ -1,14 +1,20 @@
1
  """OpenEEGBench eval worker β€” the engine behind the arena.
2
 
3
- This is NOT run by the Space (a CPU web server). Run it on a machine with a GPU
4
- (or SLURM access) that has ``open-eeg-bench`` installed and an ``HF_TOKEN`` with
5
- write access to the ``braindecode`` org:
 
 
 
 
6
 
7
  pip install -r scripts/worker-requirements.txt # open-eeg-bench (+ torch, braindecode)
8
  export HF_TOKEN=hf_xxx
9
- python -m scripts.run_eval_worker --dry-run # list pending, do nothing
10
- python -m scripts.run_eval_worker --device cuda # process the queue once
11
- python -m scripts.run_eval_worker --slurm --infra-folder ./oeb-cache --device cuda
 
 
12
 
13
  For each PENDING request it:
14
  1. marks the request RUNNING (in braindecode/requests),
@@ -27,10 +33,18 @@ import logging
27
  from datetime import datetime, timezone
28
  from pathlib import Path
29
 
30
- from app.config.base import HF_TOKEN
31
- from app.config.hf_config import API as hf_api, QUEUE_REPO, AGGREGATED_REPO
 
 
 
 
32
  from app.services.results_mapping import map_oeb_results_to_contents
33
 
 
 
 
 
34
  logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
35
  logger = logging.getLogger("eval_worker")
36
 
@@ -133,22 +147,44 @@ def publish(rows):
133
  return len(combined)
134
 
135
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
136
  def main():
 
 
137
  ap = argparse.ArgumentParser(
138
  description="Run queued OpenEEGBench submissions and publish results."
139
  )
140
  ap.add_argument("--dry-run", action="store_true", help="List pending submissions and exit.")
141
- ap.add_argument("--device", default="cuda", help="Torch device for benchmark() (default: cuda).")
142
- ap.add_argument("--limit", type=int, default=None, help="Max submissions to process this run.")
 
 
143
  ap.add_argument("--infra-folder", default=None, help="oeb cache/results folder (enables caching + SLURM).")
144
  ap.add_argument("--slurm", action="store_true", help="Submit experiments via SLURM (oeb infra cluster=slurm).")
145
  args = ap.parse_args()
146
 
147
- pending = load_pending(args.limit)
148
- logger.info("Found %d pending submission(s).", len(pending))
149
-
150
  if args.dry_run:
151
- for _, e in pending:
152
  logger.info("PENDING %s β€” %s", e.get("model_name"), (e.get("benchmark_kwargs") or {}).get("model_cls"))
153
  return
154
 
@@ -159,20 +195,19 @@ def main():
159
  infra["cluster"] = "slurm"
160
  infra = infra or None
161
 
162
- for path_in_repo, entry in pending:
163
- name = entry.get("model_name", "?")
164
- logger.info("RUNNING %s", name)
165
- set_status(path_in_repo, entry, "RUNNING", started_time=_now())
166
- try:
167
- rows = run_benchmark(entry, args.device, infra)
168
- if not rows:
169
- raise RuntimeError("benchmark produced no completed results")
170
- total = publish(rows)
171
- set_status(path_in_repo, entry, "FINISHED", finished_time=_now(), n_rows=len(rows))
172
- logger.info("FINISHED %s β€” published %d row(s); contents now has %d.", name, len(rows), total)
173
- except Exception as e: # noqa: BLE001 β€” record the failure on the request and move on
174
- logger.exception("FAILED %s", name)
175
- set_status(path_in_repo, entry, "FAILED", finished_time=_now(), error=str(e)[:500])
176
 
177
 
178
  if __name__ == "__main__":
 
1
  """OpenEEGBench eval worker β€” the engine behind the arena.
2
 
3
+ The eval engine. By default the **Space runs this itself** in a background
4
+ process (see the Dockerfile): a CPU ``--watch`` loop in an *isolated venv*
5
+ (open-eeg-bench + its heavy deps, kept out of the web app's pinned stack) that
6
+ polls the queue and publishes results. **A GPU is optional** β€” linear/ridge
7
+ probing and inference run fine on CPU (the default); heavier strategies like
8
+ ``full_finetune`` are just slower. It can also run on any other machine that has
9
+ ``open-eeg-bench`` installed and an ``HF_TOKEN`` with ``braindecode`` write:
10
 
11
  pip install -r scripts/worker-requirements.txt # open-eeg-bench (+ torch, braindecode)
12
  export HF_TOKEN=hf_xxx
13
+ python -m scripts.run_eval_worker --dry-run # list pending, do nothing
14
+ python -m scripts.run_eval_worker --limit 1 # process the queue once, CPU
15
+ python -m scripts.run_eval_worker --watch --interval 300 # run as a service (what the Space does)
16
+ python -m scripts.run_eval_worker --device cuda # ... use a GPU if you have one
17
+ python -m scripts.run_eval_worker --slurm --infra-folder ./oeb-cache # ... or fan out via SLURM
18
 
19
  For each PENDING request it:
20
  1. marks the request RUNNING (in braindecode/requests),
 
33
  from datetime import datetime, timezone
34
  from pathlib import Path
35
 
36
+ from huggingface_hub import HfApi
37
+
38
+ # Import only import-light, dependency-free app modules so this worker can run in
39
+ # its own isolated venv (open-eeg-bench + its heavy deps) without pulling the web
40
+ # app's pinned stack. base.py is pure stdlib; results_mapping is pure Python.
41
+ from app.config.base import HF_TOKEN, HF_ORGANIZATION
42
  from app.services.results_mapping import map_oeb_results_to_contents
43
 
44
+ QUEUE_REPO = f"{HF_ORGANIZATION}/requests"
45
+ AGGREGATED_REPO = f"{HF_ORGANIZATION}/contents"
46
+ hf_api = HfApi(token=HF_TOKEN)
47
+
48
  logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
49
  logger = logging.getLogger("eval_worker")
50
 
 
147
  return len(combined)
148
 
149
 
150
+ def process_queue(device="cpu", limit=None, infra=None):
151
+ """Run every PENDING submission once. Returns how many were processed."""
152
+ pending = load_pending(limit)
153
+ logger.info("Found %d pending submission(s).", len(pending))
154
+ for path_in_repo, entry in pending:
155
+ name = entry.get("model_name", "?")
156
+ logger.info("RUNNING %s", name)
157
+ set_status(path_in_repo, entry, "RUNNING", started_time=_now())
158
+ try:
159
+ rows = run_benchmark(entry, device, infra)
160
+ if not rows:
161
+ raise RuntimeError("benchmark produced no completed results")
162
+ total = publish(rows)
163
+ set_status(path_in_repo, entry, "FINISHED", finished_time=_now(), n_rows=len(rows))
164
+ logger.info("FINISHED %s β€” published %d row(s); contents now has %d.", name, len(rows), total)
165
+ except Exception as e: # noqa: BLE001 β€” record the failure on the request and move on
166
+ logger.exception("FAILED %s", name)
167
+ set_status(path_in_repo, entry, "FAILED", finished_time=_now(), error=str(e)[:500])
168
+ return len(pending)
169
+
170
+
171
  def main():
172
+ import time
173
+
174
  ap = argparse.ArgumentParser(
175
  description="Run queued OpenEEGBench submissions and publish results."
176
  )
177
  ap.add_argument("--dry-run", action="store_true", help="List pending submissions and exit.")
178
+ ap.add_argument("--watch", action="store_true", help="Keep polling the queue (run as a long-lived service).")
179
+ ap.add_argument("--interval", type=int, default=300, help="Seconds between polls in --watch mode (default 300).")
180
+ ap.add_argument("--device", default="cpu", help="Torch device for benchmark() (default: cpu; use cuda if a GPU is available).")
181
+ ap.add_argument("--limit", type=int, default=None, help="Max submissions to process per cycle.")
182
  ap.add_argument("--infra-folder", default=None, help="oeb cache/results folder (enables caching + SLURM).")
183
  ap.add_argument("--slurm", action="store_true", help="Submit experiments via SLURM (oeb infra cluster=slurm).")
184
  args = ap.parse_args()
185
 
 
 
 
186
  if args.dry_run:
187
+ for _, e in load_pending(args.limit):
188
  logger.info("PENDING %s β€” %s", e.get("model_name"), (e.get("benchmark_kwargs") or {}).get("model_cls"))
189
  return
190
 
 
195
  infra["cluster"] = "slurm"
196
  infra = infra or None
197
 
198
+ if args.watch:
199
+ logger.info(
200
+ "Worker watching %s every %ds (device=%s, limit=%s).",
201
+ QUEUE_REPO, args.interval, args.device, args.limit,
202
+ )
203
+ while True:
204
+ try:
205
+ process_queue(args.device, args.limit, infra)
206
+ except Exception: # noqa: BLE001 β€” never let one cycle kill the worker
207
+ logger.exception("worker cycle error")
208
+ time.sleep(args.interval)
209
+ else:
210
+ process_queue(args.device, args.limit, infra)
 
211
 
212
 
213
  if __name__ == "__main__":
backend/scripts/worker-requirements.txt CHANGED
@@ -1,6 +1,6 @@
1
  # Extra dependency for the eval worker (scripts/run_eval_worker.py).
2
- # The worker runs OUTSIDE the Space, on a GPU/SLURM machine. Install it on top
3
- # of the backend deps (the worker imports app.config.* / app.services.*):
4
  #
5
  # cd backend && pip install -e . -r scripts/worker-requirements.txt
6
  # # or: poetry install && pip install -r scripts/worker-requirements.txt
 
1
  # Extra dependency for the eval worker (scripts/run_eval_worker.py).
2
+ # The worker runs OUTSIDE the Space, on ANY machine (CPU is fine; GPU optional).
3
+ # Install it on top of the backend deps (the worker imports app.config.* / app.services.*):
4
  #
5
  # cd backend && pip install -e . -r scripts/worker-requirements.txt
6
  # # or: poetry install && pip install -r scripts/worker-requirements.txt