Spaces:
Running on Zero
Running on Zero
File size: 6,692 Bytes
6010c50 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 | """Timeout-resilient, method-priority job queue for the training Space.
Why a queue
-----------
Training calls are gated by ``@spaces.GPU``: the GPU is only attached while a
call runs. If several training requests arrive at once — or a client times out
mid-training — the requests must not be lost. They are held in this queue and
processed later, in method-priority order, as the GPU becomes available.
Ordering (the requested behaviour)
---------------------------------
* **Method priority:** all **SFT** jobs run first, then all **DPO**, then all
**KTO**. If 5 SFT and 6 DPO requests arrive, every SFT job is drained
before any DPO job starts.
* **FIFO within a method:** earlier requests of the same method run first.
* **Timeout-resilient:** the queue lives in RAM (and, optionally, mirrored to
the bucket), so a *client-side* timeout does not drop a request — the worker
keeps it queued and trains it when the GPU is next available.
Persistence
----------
The raw training *data* is not persisted to the bucket by default (per the
Space's "no data persistence" design). The queue is held in RAM, which
survives a client timeout (the Space process keeps running while a client
waits/abandons). Set ``QUEUE_PERSIST=true`` to also mirror queued jobs to the
bucket so they survive a Space *restart* too.
"""
from __future__ import annotations
import itertools
import time
from dataclasses import dataclass, field
from typing import Optional
# Method priority: lower runs first. SFT first, then DPO, then KTO. Unknown
# methods sort last.
METHOD_PRIORITY = {"sft": 0, "dpo": 1, "kto": 2}
# A monotonic submission counter, used for stable FIFO ordering within a method.
_seq = itertools.count(1)
# A module-level lock: the RAM queue is shared between the (fast) submit
# callback and the (slow, GPU) worker callback, which Gradio runs in separate
# threads.
import threading
_lock = threading.Lock()
@dataclass
class Job:
"""A single queued training request: a method + the raw uploaded rows."""
method: str
rows: list
submitted_at: float = field(default_factory=time.time)
seq: int = 0
job_id: str = ""
def __post_init__(self) -> None:
self.seq = next(_seq)
self.job_id = f"j{self.seq:06d}"
self.method = self.method.lower()
def as_dict(self) -> dict:
return {
"job_id": self.job_id,
"method": self.method,
"submitted_at": self.submitted_at,
"n_examples": len(self.rows),
}
class JobQueue:
"""An in-RAM, method-priority, FIFO-within-method job queue.
Optional bucket persistence (``QUEUE_PERSIST=true`` + a ``BUCKET_ID``)
mirrors the queue to the bucket so it survives a Space restart.
"""
def __init__(self, bucket_id: str = "", persist: bool = False):
self._bucket_id = bucket_id
self._persist = bool(persist and bucket_id)
self._jobs: list[Job] = []
# -- submission ------------------------------------------------------- #
def submit(self, method: str, rows: list) -> Job:
"""Enqueue a request and return its handle. Fast — no GPU, no
training; this is what makes a client timeout harmless (the request is
already cached before the call returns)."""
job = Job(method=method.lower(), rows=list(rows))
with _lock:
self._jobs.append(job)
if self._persist:
self._mirror(job, add=True)
return job
# -- draining --------------------------------------------------------- #
def pop(self) -> Optional[Job]:
"""Pop and return the highest-priority pending job (method priority,
then FIFO), or ``None`` if the queue is empty."""
with _lock:
if not self._jobs:
return None
job = self._sorted()[0]
self._jobs.remove(job)
if self._persist:
self._mirror(job, add=False)
return job
def drain(self, max_jobs: int = 0):
"""Yield pending jobs in execution order (method priority, then FIFO),
up to ``max_jobs`` (0 = no cap). Each yielded job is removed from the
queue as it is popped, so the queue is drained as the worker runs."""
n = 0
while True:
job = self.pop()
if job is None:
break
yield job
n += 1
if max_jobs and n >= max_jobs:
return
# -- inspection ------------------------------------------------------- #
def pending_count(self) -> int:
with _lock:
return len(self._jobs)
def status(self) -> dict:
with _lock:
order = self._sorted()
counts: dict = {}
for j in self._jobs:
counts[j.method] = counts.get(j.method, 0) + 1
return {
"pending": len(self._jobs),
"by_method": counts,
"persisted": self._persist,
"next": (f"{order[0].method} · {order[0].job_id}" if order else None),
"order": [f"{j.method}·{j.job_id}" for j in order[:12]],
}
# -- helpers ---------------------------------------------------------- #
def _sorted(self) -> list[Job]:
"""Order the current queue by method priority, then FIFO (seq)."""
return sorted(
self._jobs,
key=lambda j: (METHOD_PRIORITY.get(j.method, 99), j.seq),
)
def _mirror(self, job: Job, add: bool) -> None:
"""Best-effort bucket mirror (opt-in). Never raises into the caller."""
try:
from store import delete_job, save_job
if add:
save_job(self._bucket_id, job)
else:
delete_job(self._bucket_id, job.job_id)
except Exception: # pragma: no cover
pass
# A module-level queue instance shared by the Space. It is (re)configured
# from the env on first use via ``init_queue``.
_QUEUE: Optional[JobQueue] = None
def init_queue(bucket_id: str = "", persist: bool = False) -> JobQueue:
"""(Re)create the module-level queue. Called once at Space startup."""
global _QUEUE
_QUEUE = JobQueue(bucket_id=bucket_id, persist=persist)
return _QUEUE
def get_queue() -> JobQueue:
"""Return the module-level queue, creating it lazily if needed."""
global _QUEUE
if _QUEUE is None:
import os
bucket_id = (os.environ.get("BUCKET_ID") or "").strip()
persist = (os.environ.get("QUEUE_PERSIST") or "").lower() in ("1", "true", "yes")
_QUEUE = JobQueue(bucket_id=bucket_id, persist=persist)
return _QUEUE
|