File size: 13,977 Bytes
d4d7197 6010c50 d4d7197 4a62dee d4d7197 6010c50 d4d7197 6010c50 4a62dee 3c0eecd 4a62dee 3c0eecd 4a62dee d4d7197 6010c50 d4d7197 6010c50 d4d7197 4a62dee 3c0eecd 4a62dee 3c0eecd 6010c50 4a62dee 6010c50 d4d7197 6010c50 d4d7197 6010c50 d4d7197 6010c50 d4d7197 6010c50 d4d7197 6010c50 d4d7197 6010c50 d4d7197 6010c50 d4d7197 6010c50 d4d7197 6010c50 d4d7197 6010c50 d4d7197 6010c50 d4d7197 6010c50 d4d7197 6010c50 d4d7197 6010c50 d4d7197 | 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 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 | """Zero-GPU Hugging Face Space β Gradio entry point.
A single UI that trains **SFT / DPO / KTO**, with the number of optimizer steps
derived dynamically from the uploaded trace (see below). The training call is
decorated with ``@spaces.GPU`` so a ZeroGPU Space attaches an A10G on demand
(and trains on GPU); on a CPU / free-CPU Space that decorator is a no-op and the
same code trains on CPU.
Queue
-----
Requests are **queued**, not run inline, so a client timeout cannot lose them.
"Submit" caches the request in an in-RAM queue and returns immediately (no GPU),
so the request is safe even if the caller times out. "Train" drains the queue
under ``@spaces.GPU`` β running **all SFT jobs, then all DPO, then all KTO**,
FIFO within a method β and reports each job's result. Set ``QUEUE_PERSIST=true``
to also mirror the queue to the bucket so it survives a Space restart.
Dynamic gradient accumulation
-----------------------------
The number of optimizer steps a job runs is derived from how many examples it
contains and the deployment's ``GRAD_ACCUM``: ``steps = ceil(n / G)``. A
single trace -> 1 step; 10 traces with ``GRAD_ACCUM=4`` -> 3 steps (4 + 4 + 2).
The caller just uploads a trace β it never picks "steps per call".
One bucket + one model per deployment
--------------------------------------
``MODEL_ID`` / ``BUCKET_ID`` (and the LoRA hyper-params) are set once via the
Space's environment variables, not re-entered per request. The base model is
loaded once and kept **resident in RAM/VRAM** across calls (auto-sharded across
all GPUs when more than one is available) β much more efficient than re-loading
per request. Only weights + optimizer/scheduler state are checkpointed to the
bucket; the uploaded trace (training data) is never persisted.
Set as Space secrets/variables: ``HF_TOKEN``, ``MODEL_ID``, ``BUCKET_ID``
(+ optionally ``LORA_*``, ``GRAD_ACCUM``, ``LEARNING_RATE``, ``MAX_LENGTH``,
``QUEUE_PERSIST``).
"""
import json
import os
import re
import threading
import gradio as gr
import spaces # noqa: F401 (provides spaces.GPU; auto-wraps Gradio fns too)
# NOTE: the module is named ``job_queue`` (not ``queue``) on purpose β a local
# ``queue.py`` would shadow Python's stdlib ``queue`` module and break
# huggingface_hub's ``from queue import Queue``.
from job_queue import get_queue
from train import (
TrainConfig,
current_progress,
resolve_bucket_id,
resolve_learning_rate,
resolve_lora_alpha,
resolve_lora_dropout,
resolve_lora_r,
resolve_max_length,
resolve_model_id,
resolve_target_modules,
run_one_call,
)
METHODS = ["sft", "dpo", "kto"]
EXAMPLES = {
"sft": [
{"messages": [
{"role": "user", "content": "What is 2+2?"},
{"role": "assistant", "content": "4"},
]},
{"messages": [
{"role": "user", "content": "Capital of France?"},
{"role": "assistant", "content": "Paris"},
]},
],
"dpo": [
{
"prompt": "Translate 'hello' to French.",
"chosen": "Bonjour",
"rejected": "Hello",
},
{
"prompt": "What is 2+2?",
"chosen": "4",
"rejected": "3",
},
],
"kto": [
{"prompt": "Be kind. ", "completion": "Here is a helpful answer.", "desirable": True},
{"prompt": "Be kind. ", "completion": "I refuse to help.", "desirable": False},
],
}
# Example data is shown as JSONL (one JSON object per line) for the upload box.
EXAMPLE_JSONL = {
"sft": json.dumps(EXAMPLES["sft"], indent=2),
"dpo": json.dumps(EXAMPLES["dpo"], indent=2),
"kto": json.dumps(EXAMPLES["kto"], indent=2),
}
# The queue is configured once at startup from the deployment's env.
_q = get_queue()
# A lock so the (fast) submit callback and the (slow, GPU) drain callback β
# which Gradio runs in separate threads β don't touch the queue concurrently.
_QUEUE_LOCK = threading.Lock()
def _parse_upload(text: str) -> list[dict]:
"""Accept either a JSON array or JSONL (one object per line)."""
text = (text or "").strip()
if not text:
return []
# try strict JSON first (array or single object)
try:
data = json.loads(text)
if isinstance(data, list):
return data
if isinstance(data, dict):
return [data]
except json.JSONDecodeError:
pass
# JSONL / loose JSON: tolerate trailing commas and split lines
rows = []
for line in text.splitlines():
line = line.strip().rstrip(",")
if not line or line.startswith("{") is False:
continue
try:
rows.append(json.loads(line))
except json.JSONDecodeError:
try: # tolerate trailing commas
rows.append(json.loads(re.sub(r",\s*([}\]])", r"\1", line)))
except json.JSONDecodeError:
continue
return rows
def build_app() -> gr.Blocks:
with gr.Blocks(title="Zero-GPU Training Space") as demo:
gr.Markdown(
"# π§ Zero-GPU Training Space\n"
"Train **SFT / DPO / KTO** β the number of optimizer steps is "
"**derived from the trace** (dynamic gradient accumulation), not "
"chosen per request. Requests are **queued** so a client timeout can "
"never lose them: *Submit* caches them, *Train* runs them in "
"**method priority** (SFT β DPO β KTO) on **GPU if the ZeroGPU "
"decorator attaches one, otherwise CPU**. Weights + optimizer state "
"are checkpointed to a Hugging Face Storage Bucket; the trace "
"itself is **never stored**, so you can shut the Space down and "
"resume later without restarting the optimizer from scratch."
)
with gr.Row():
with gr.Column(scale=1):
gr.Markdown("### 1 Β· Configuration")
gr.Markdown(
"#### Deployment (set once via env, not per request)\n"
f"- **Model:** `{resolve_model_id(TrainConfig())}` "
f"(env `MODEL_ID`) \n"
f"- **Bucket:** `{resolve_bucket_id(TrainConfig()) or '(none β set BUCKET_ID)'} ` "
f"(env `BUCKET_ID`) \n"
f"- **LoRA:** rank `{resolve_lora_r(TrainConfig())}`, "
f"alpha `{resolve_lora_alpha(TrainConfig())}`, "
f"dropout `{resolve_lora_dropout(TrainConfig())}`, "
f"targets `{', '.join(resolve_target_modules(TrainConfig()))}` "
f"(env `LORA_R` / `LORA_ALPHA` / `LORA_DROPOUT` / "
f"`LORA_TARGET_MODULES`) \n"
f"- **Learning rate:** `{resolve_learning_rate(TrainConfig())}` "
f"(env `LEARNING_RATE`) Β· **Max length:** "
f"`{resolve_max_length(TrainConfig())}` (env `MAX_LENGTH`) \n"
f"- **Gradient accumulation:** "
f"`{__import__('train').resolve_grad_accum(TrainConfig())}` "
f"(env `GRAD_ACCUM`) β steps = ceil(n_examples / G)\n"
"The base model is loaded once and kept **resident in "
"RAM/VRAM** (auto-sharded across all GPUs when more than one "
"is available); only the LoRA adapter + optimizer state are "
"re-applied per call. All hyper-params come from env β "
"restart the Space to change them."
)
method = gr.Dropdown(
label="Method", choices=METHODS, value="sft",
)
resume = gr.Checkbox(value=True, label="Resume from latest checkpoint")
with gr.Column(scale=1):
gr.Markdown("### 2 Β· Upload trace")
gr.Markdown(
"Paste **JSONL** (one JSON object per line) or a JSON array. "
"The number of optimizer steps is **derived from the number "
"of rows** (ceil(n / GRAD_ACCUM)) β 1 row β 1 step, 10 rows "
"with GRAD_ACCUM=4 β 3 steps. The data is parsed in-memory "
"and **discarded after the run** β never persisted."
)
trace = gr.Textbox(
label="Trace (JSONL / JSON)",
lines=12,
placeholder='{...}\n{...}',
)
load_example = gr.Button("Load example for method", variant="secondary")
with gr.Row():
submit = gr.Button("β Submit to queue", variant="secondary", scale=1)
train = gr.Button("β‘ Train queue (SFTβDPOβKTO)", variant="primary", scale=2)
refresh = gr.Button("β³ Refresh status", variant="secondary", scale=1)
gr.Markdown("### 3 Β· Queue & Progress")
queue_status = gr.JSON(label="Queue status")
status = gr.JSON(label="Training status")
log = gr.Textbox(label="Log", lines=14, interactive=False)
# --- wiring ----------------------------------------------------- #
load_example.click(
lambda m: EXAMPLE_JSONL[m], inputs=[method], outputs=[trace]
)
# Submit: parse + cache the request in the queue, then return the queue
# status. Fast β no GPU, no training β so a client timeout cannot lose
# the request (it is already cached before the call returns).
submit.click(
_submit,
inputs=[method, trace],
outputs=[log, queue_status, trace],
)
# Train: drain the queue on the GPU, in method-priority order.
train.click(
_train,
inputs=[resume],
outputs=[log, status, queue_status],
)
refresh.click(
lambda: _status(),
outputs=[status, queue_status],
)
# show the initial queue status
demo.load(lambda: _queue_status(), outputs=[queue_status])
return demo
def _status() -> tuple[dict, dict]:
"""Combined (training status, queue status) for the refresh button."""
try:
st = current_progress(resolve_bucket_id(TrainConfig()))
except Exception as exc: # noqa: BLE001
st = {"error": str(exc)}
return st, _queue_status()
def _queue_status() -> dict:
return _q.status()
def _submit(method, trace_text):
"""Cache a training request in the queue and return the queue status.
Fast (no GPU, no training). Because the request is queued *before* this
call returns, a client-side timeout after this point cannot lose it β the
request is held in the queue for the GPU worker to run later.
"""
rows = _parse_upload(trace_text)
if not rows:
return (
"No valid rows parsed β nothing was queued. Check the JSONL/JSON "
"format.",
_queue_status(),
trace_text,
)
job = _q.submit(method, rows)
line = (
f"Queued **{job.method}** job `{job.job_id}` with "
f"{len(rows)} example(s). Press **Train** to run the queue "
f"(SFT β DPO β KTO). Client timeout is safe β the request is cached."
)
return line, _queue_status(), trace_text
@spaces.GPU
def _train(resume):
"""Generator: drain the queue on the GPU, in method-priority order.
``@spaces.GPU`` attaches an A10G for the duration of the call on a ZeroGPU
Space (so ``torch.cuda.is_available()`` is True and we train on GPU); on a
CPU / free-CPU Space it is a no-op and we train on CPU. It drains *all*
pending jobs (all SFT, then all DPO, then all KTO), yielding a live log
after each job so progress is visible even though a single call may run
several training steps.
The bucket, model, and LoRA hyper-params are all fixed for the whole
deployment via env. Training data is never persisted β only weights +
optimizer/scheduler state are.
"""
log_lines: list[str] = []
def _emit(extra=""):
log_lines.append(extra)
_emit("Starting training (draining the queue in method-priority order)β¦")
_emit(f"Queue: {_queue_status()['order']}")
# Drain the queue for the whole GPU call (so the GPU is used for every
# queued job, not just the first). Method priority + FIFO is enforced by
# the queue; each job is removed as it is popped.
with _QUEUE_LOCK:
jobs = list(_q.drain())
if not jobs:
yield (
"\n".join(log_lines) + "\n\nQueue is empty β nothing to train.",
_status(),
_queue_status(),
)
return
last_status = None
for job in jobs:
n_examples = len(job.rows)
log_lines.append(
f"\n=== {job.method} Β· {job.job_id} Β· {n_examples} example(s) ==="
)
cfg = TrainConfig(
method=job.method,
# bucket/model/LoRA/LR/max-length/grad_accum are resolved from the
# deployment env inside run_one_call.
resume=bool(resume),
)
try:
result = run_one_call(
job.rows,
cfg,
log=lambda m: log_lines.append(str(m)),
)
out = result.as_dict()
log_lines.append(
f"\n=== RESULT ({job.method}Β·{job.job_id}) ===\n"
f"{json.dumps(out, indent=2)}"
)
# the queue status changes as jobs are popped; refresh it
last_status = _status()
except Exception as exc: # noqa: BLE001
log_lines.append(f"\nERROR ({job.method}Β·{job.job_id}): {exc}")
last_status = {"error": str(exc)}
yield "\n".join(log_lines), last_status or {}, _queue_status()
app = build_app()
if __name__ == "__main__":
app.launch(server_name="0.0.0.0", server_port=7860)
|