Spaces:
Running on Zero
Running on Zero
Download train.py from flozi00/zero-gpu-training-space: direct link, hf CLI and curl.
- Browser
- Download file 31.9 kB
-
https://huggingface.co/spaces/flozi00/zero-gpu-training-space/resolve/main/train.py
- Command line
-
hf download hf://spaces/flozi00/zero-gpu-training-space/train.py
-
curl -L -o train.py https://huggingface.co/spaces/flozi00/zero-gpu-training-space/resolve/main/train.py
31.9 kB
| """Hugging Face Space training core: SFT / DPO / KTO, one step at a time, | |
| resumable. | |
| Design goals | |
| ------------ | |
| * **One bucket + one model per deployment (env, not per request).** The | |
| ``BUCKET_ID`` and ``MODEL_ID`` environment variables configure the whole | |
| Space — they are *not* re-entered on every request. Loading the base model | |
| once and keeping it resident avoids re-downloading / re-loading it on every | |
| call. | |
| * **Model kept resident in RAM/VRAM.** The base model is loaded once and kept | |
| in a module-level cache across calls (the expensive part — download + load — | |
| happens a single time). The LoRA adapter weights are tiny and are reset from | |
| the resumed checkpoint on each call, so reuse is safe: the frozen base | |
| weights never change, only the adapter + optimizer state do. | |
| * **GPU-when-available, CPU otherwise.** The device is chosen from | |
| ``torch.cuda.is_available()``. On a ZeroGPU Space ``@spaces.GPU`` attaches an | |
| A10G for the call (so we train on GPU); off ZeroGPU the decorator is a no-op | |
| and we train on CPU. Because ZeroGPU's GPU is *transient* (it detaches | |
| between calls), the resident cache is only used when the device is | |
| persistent (a dedicated-GPU or CPU Space); on ZeroGPU the model is loaded per | |
| call from the local HF cache instead. | |
| * **No data persistence.** The uploaded "trace" is parsed into an in-memory | |
| ``datasets`` object for the duration of the call and discarded afterwards. | |
| Only weights + optimizer/scheduler state are ever written to the bucket. | |
| * **Dynamic gradient accumulation.** The number of optimizer steps a call runs | |
| is derived from the number of uploaded examples and the deployment's | |
| gradient-accumulation size ``G`` (``GRAD_ACCUM`` env): | |
| ``steps = ceil(num_examples / G)`` (SFT/DPO). A single trace -> 1 step; 10 | |
| traces with ``G=4`` -> 3 steps (4 + 4 + 2). The caller no longer chooses a | |
| fixed "steps per call" — it just uploads the trace. | |
| * **Incremental, resumable training.** Each call advances training by the | |
| dynamic number of steps above. The HF ``Trainer`` checkpoint | |
| (model/adapter weights + ``optimizer.pt`` + ``scheduler.pt`` + ``rng`` + | |
| ``trainer_state.json``) is persisted to an HF Storage Bucket, so the next | |
| call resumes exactly where it left off — the optimizer does **not** restart | |
| from scratch. Shut the Space down, come back later, keep training. | |
| Method data schemas | |
| ------------------- | |
| SFT (``trl.SFTTrainer``) — prompt/completion or plain text: | |
| {"messages": [{"role": "user", "content": "..."}, | |
| {"role": "assistant", "content": "..."}]} | |
| or {"text": "a single string"} | |
| DPO (``trl.DPOTrainer``) — chosen/rejected pairs: | |
| {"prompt": "...", "chosen": "...", "rejected": "..."} | |
| (messages-format ``prompt``/``chosen``/``rejected`` also accepted) | |
| KTO (``trl.KTOTrainer``) — binary desirable/undesirable feedback: | |
| {"prompt": "...", "completion": "...", "desirable": true|false} | |
| """ | |
| from __future__ import annotations | |
| import json | |
| import os | |
| import tempfile | |
| from dataclasses import dataclass, field | |
| from pathlib import Path | |
| from typing import Any | |
| import torch | |
| from datasets import Dataset | |
| from huggingface_hub import snapshot_download | |
| from peft import LoraConfig, get_peft_model | |
| from transformers import AutoModelForCausalLM, AutoTokenizer | |
| # `spaces` lets us detect whether we are running on a ZeroGPU Space. On | |
| # ZeroGPU the GPU is *transient* (attached only for the duration of a | |
| # ``@spaces.GPU`` call), so a model cannot be kept resident across calls — it | |
| # would be freed and any cached reference would dangle. We therefore only keep | |
| # the model resident when the device is *persistent* (a dedicated-GPU or CPU | |
| # Space). The import is optional; the platform ships its own `spaces` and, on | |
| # any non-ZeroGPU Space, `spaces.Config.zero_gpu` is simply False. | |
| try: | |
| from spaces.config import Config as _SpacesConfig | |
| _ZERO_GPU = bool(getattr(_SpacesConfig, "zero_gpu", False)) | |
| except Exception: # pragma: no cover | |
| _ZERO_GPU = False | |
| from store import ( | |
| checkpoint_paths, | |
| pull_checkpoint, | |
| push_checkpoint, | |
| save_state_index, | |
| ) | |
| HERE = Path(__file__).resolve().parent | |
| LOCAL_CACHE = HERE / ".cache" | |
| # Small, CPU-fine base model used for defaults / smoke tests. | |
| DEFAULT_MODEL_ID = "HuggingFaceTB/SmolLM2-135M" | |
| # Injected when a base model's tokenizer has no chat template, so SFT with | |
| # chat-format ("messages") traces works on *any* base model, not just -Instruct | |
| # ones. A simple, valid template transformers can apply. | |
| DEFAULT_CHAT_TEMPLATE = ( | |
| "{%- for message in messages -%}\n" | |
| "{% if message['role'] == 'user' %}User: {{ message['content'] }}\n" | |
| "{% elif message['role'] == 'assistant' %}Assistant: {{ message['content'] }}\n" | |
| "{% elif message['role'] == 'system' %}System: {{ message['content'] }}\n" | |
| "{% endif %}{%- endfor -%}" | |
| ) | |
| # --------------------------------------------------------------------------- # | |
| # Configuration | |
| # --------------------------------------------------------------------------- # | |
| class TrainConfig: | |
| # One model + one bucket per deployment. These come from the Space's | |
| # environment variables (MODEL_ID, BUCKET_ID) and are NOT re-entered per | |
| # request. `resolve_*` fills them from the env when left empty, so the | |
| # whole deployment is configured once. | |
| model_id: str = "" # e.g. "HuggingFaceTB/SmolLM2-135M" — from MODEL_ID env | |
| method: str = "sft" # sft | dpo | kto | |
| # LoRA — keeps CPU training tractable on a ZeroGPU container. | |
| lora_r: int = 8 | |
| lora_alpha: int = 16 | |
| lora_dropout: float = 0.0 | |
| target_modules: tuple = ("q_proj", "k_proj", "v_proj", "o_proj") | |
| # Optimizer / training. | |
| learning_rate: float = 1e-4 | |
| # Constant LR keeps "one step at a time" stable across resumable calls; | |
| # a decaying schedule would require a fixed horizon and is not the goal here. | |
| lr_scheduler_type: str = "constant" | |
| optim: str = "adamw_torch" | |
| weight_decay: float = 0.0 | |
| max_length: int = 256 | |
| # Bucket + run. | |
| bucket_id: str = "" # e.g. "username/my-bucket" — from BUCKET_ID env | |
| resume: bool = True # resume from latest checkpoint if one exists | |
| # Gradient accumulation: how many micro-batches to accumulate per optimizer | |
| # step. The number of optimizer steps this call runs is derived from the | |
| # number of uploaded examples: steps_this_call = ceil(num_examples / | |
| # grad_accum). 10 examples with GRAD_ACCUM=4 -> 3 steps (4+4+2). | |
| # 1 example -> 1 step (single trace, single step). | |
| grad_accum: int = 4 | |
| save_total_limit: int = 3 # keep only the N most-recent checkpoints | |
| # --------------------------------------------------------------------------- # | |
| # Result | |
| # --------------------------------------------------------------------------- # | |
| class TrainResult: | |
| method: str | |
| model_id: str | |
| global_step: int | |
| loss: float | None = None | |
| checkpoint_id: str = "" | |
| resumed_from: str | None = None | |
| num_examples: int = 0 | |
| grad_accum: int = 1 | |
| steps_run: int = 0 # optimizer steps this call advanced | |
| notes: list = field(default_factory=list) | |
| elapsed: float = 0.0 | |
| def as_dict(self) -> dict: | |
| return { | |
| "method": self.method, | |
| "model_id": self.model_id, | |
| "global_step": self.global_step, | |
| "loss": round(self.loss, 6) if self.loss is not None else None, | |
| "checkpoint_id": self.checkpoint_id, | |
| "resumed_from": self.resumed_from, | |
| "num_examples": self.num_examples, | |
| "grad_accum": self.grad_accum, | |
| "steps_run": self.steps_run, | |
| "notes": self.notes, | |
| "elapsed_sec": round(self.elapsed, 2), | |
| } | |
| # --------------------------------------------------------------------------- # | |
| # Trace parsing (the ONLY place the uploaded data is touched) | |
| # --------------------------------------------------------------------------- # | |
| def parse_trace(rows: list[dict], method: str) -> Dataset: | |
| """Turn raw uploaded rows into a ``datasets.Dataset`` for the method. | |
| Rows come in as a list of dicts (one JSON object per line, or a small | |
| JSON array). Anything that doesn't parse is dropped (logged, not fatal). | |
| """ | |
| m = method.lower() | |
| if m == "sft": | |
| return _parse_sft(rows) | |
| if m == "dpo": | |
| return _parse_dpo(rows) | |
| if m == "kto": | |
| return _parse_kto(rows) | |
| raise ValueError(f"Unknown method: {method!r}") | |
| def _parse_sft(rows: list[dict]) -> Dataset: | |
| # Prefer chat format; fall back to a single "text" field. | |
| conv = [] | |
| for r in rows: | |
| if "messages" in r and isinstance(r["messages"], list): | |
| conv.append({"messages": r["messages"]}) | |
| if conv: | |
| return Dataset.from_list(conv) | |
| texts = [] | |
| for r in rows: | |
| if "text" in r: | |
| texts.append({"text": r["text"]}) | |
| elif "completion" in r and "prompt" in r: | |
| # prompt/completion -> single text for SFT | |
| texts.append({"text": f"{r['prompt']}{r['completion']}"}) | |
| if texts: | |
| return Dataset.from_list(texts) | |
| raise ValueError("SFT trace needs rows with 'messages' (chat) or 'text'.") | |
| def _parse_dpo(rows: list[dict]) -> Dataset: | |
| out = [] | |
| for r in rows: | |
| if "chosen" in r and "rejected" in r: | |
| out.append( | |
| { | |
| "prompt": r.get("prompt", ""), | |
| "chosen": r["chosen"], | |
| "rejected": r["rejected"], | |
| } | |
| ) | |
| if not out: | |
| raise ValueError("DPO trace needs rows with 'chosen' and 'rejected'.") | |
| return Dataset.from_list(out) | |
| def _parse_kto(rows: list[dict]) -> Dataset: | |
| # trl's KTOTrainer expects a boolean ``label`` column (True = desirable); | |
| # the trace uses the friendlier ``desirable`` name, so map it through. | |
| out = [] | |
| for r in rows: | |
| completion = r.get("completion") | |
| desirable = r.get("desirable", r.get("label")) | |
| if completion is not None and desirable is not None: | |
| out.append( | |
| { | |
| "prompt": r.get("prompt", ""), | |
| "completion": completion, | |
| "label": bool(desirable), | |
| } | |
| ) | |
| if not out: | |
| raise ValueError("KTO trace needs rows with 'completion' and 'desirable'.") | |
| return Dataset.from_list(out) | |
| # --------------------------------------------------------------------------- # | |
| # Env + resident model cache | |
| # --------------------------------------------------------------------------- # | |
| def _resolve_token() -> str | None: | |
| # On a Space the token is set as a secret (HF_TOKEN / HUGGING_FACE_HUB_TOKEN). | |
| return ( | |
| os.environ.get("HF_TOKEN") | |
| or os.environ.get("HUGGING_FACE_HUB_TOKEN") | |
| or os.environ.get("HF_HUB_TOKEN") | |
| ) | |
| def resolve_model_id(cfg: TrainConfig) -> str: | |
| """The model for this deployment, from MODEL_ID env (or the passed | |
| config). One model per deployment — not re-chosen per request.""" | |
| return (cfg.model_id or os.environ.get("MODEL_ID") or DEFAULT_MODEL_ID).strip() | |
| def resolve_bucket_id(cfg: TrainConfig) -> str: | |
| """The bucket for this deployment, from BUCKET_ID env (or the passed | |
| config). One bucket per deployment — not re-chosen per request.""" | |
| return (cfg.bucket_id or os.environ.get("BUCKET_ID") or "").strip() | |
| def resolve_lora_r(cfg: TrainConfig) -> int: | |
| """LoRA rank for this deployment, from LORA_R env (or the config default).""" | |
| raw = os.environ.get("LORA_R") | |
| return int(raw) if raw else cfg.lora_r | |
| def resolve_lora_alpha(cfg: TrainConfig) -> int: | |
| """LoRA alpha for this deployment, from LORA_ALPHA env (or the config | |
| default).""" | |
| raw = os.environ.get("LORA_ALPHA") | |
| return int(raw) if raw else cfg.lora_alpha | |
| def resolve_lora_dropout(cfg: TrainConfig) -> float: | |
| """LoRA dropout for this deployment, from LORA_DROPOUT env (or the config | |
| default).""" | |
| raw = os.environ.get("LORA_DROPOUT") | |
| return float(raw) if raw else cfg.lora_dropout | |
| def resolve_target_modules(cfg: TrainConfig): | |
| """Target modules for this deployment, from LORA_TARGET_MODULES env | |
| (comma-separated, e.g. ``q_proj,k_proj,v_proj,o_proj``) or the config | |
| default tuple.""" | |
| raw = os.environ.get("LORA_TARGET_MODULES") | |
| if raw: | |
| return tuple(p.strip() for p in raw.split(",") if p.strip()) | |
| return tuple(cfg.target_modules) | |
| def resolve_learning_rate(cfg: TrainConfig) -> float: | |
| """Learning rate for this deployment, from LEARNING_RATE env (or the config | |
| default).""" | |
| raw = os.environ.get("LEARNING_RATE") | |
| return float(raw) if raw else cfg.learning_rate | |
| def resolve_max_length(cfg: TrainConfig) -> int: | |
| """Max sequence length for this deployment, from MAX_LENGTH env (or the | |
| config default).""" | |
| raw = os.environ.get("MAX_LENGTH") | |
| return int(raw) if raw else cfg.max_length | |
| def resolve_grad_accum(cfg: TrainConfig) -> int: | |
| """Gradient-accumulation size for this deployment, from GRAD_ACCUM env | |
| (or the config default). The number of optimizer steps a call runs is | |
| derived from the number of uploaded examples: ceil(num_examples / | |
| grad_accum). 10 examples with GRAD_ACCUM=4 -> 3 steps (4+4+2); 1 example | |
| -> 1 step.""" | |
| raw = os.environ.get("GRAD_ACCUM") | |
| val = int(raw) if raw else cfg.grad_accum | |
| return max(1, val) | |
| def _device_name() -> str: | |
| return "cuda" if torch.cuda.is_available() else "cpu" | |
| def _device_map(): | |
| """Where to load the model: auto-shard across every available GPU, else a | |
| single GPU, else CPU. | |
| ``device_map="auto"`` lets accelerate spread a large model across all | |
| visible GPUs (e.g. a 27B base model across 4 GPUs) so the *same code path* | |
| trains a 27B model on 4 GPUs as a 1B model on 1 GPU — no manual setup. | |
| With 0/1 GPUs accelerate returns a single device (no sharding, so the | |
| Trainer's single-device path is used). The model only spans multiple | |
| devices when >1 GPU is present. | |
| """ | |
| if torch.cuda.is_available(): | |
| return "auto" # accelerate distributes across all visible GPUs | |
| return "cpu" | |
| def _dtype_for_shard(model_id: str, is_sharded: bool): | |
| """Load a multi-GPU-sharded model in float16 so the per-GPU footprint | |
| roughly halves (a 27B model = ~54GB in fp32 vs ~27GB in fp16, splitting | |
| across 4 GPUs becomes trivially affordable). Single-device / CPU models | |
| stay float32 for accuracy. LoRA training is robust to a fp16 base.""" | |
| if is_sharded and torch.cuda.is_available(): | |
| return torch.float16 | |
| return torch.float32 | |
| # A module-level cache of the loaded base model. Populated once on the first | |
| # call and reused on every subsequent call, so the expensive download + load | |
| # happens a single time per deployment. The base (frozen) weights never | |
| # change between calls; only the LoRA adapter + optimizer state do, and those | |
| # are reset from the resumed checkpoint on each call — so reuse is safe. | |
| _MODEL_CACHE: dict = {} | |
| def _model_cache_key(model_id: str) -> str: | |
| return model_id | |
| def _load_model_fresh(model_id: str, cfg: TrainConfig): | |
| """Download + load the base model and wrap it in a fresh LoRA adapter. | |
| The device is chosen automatically: ``device_map="auto"`` shards the model | |
| across every visible GPU (so a 27B model on 4 GPUs loads the same way a 1B | |
| model loads on 1 GPU), otherwise it uses a single GPU or CPU. A | |
| multi-GPU-sharded model is loaded in fp16 to halve the per-GPU footprint; | |
| single-device / CPU models stay fp32 for accuracy. | |
| """ | |
| num_gpus = torch.cuda.device_count() if torch.cuda.is_available() else 0 | |
| is_sharded = num_gpus > 1 | |
| device_map = _device_map() | |
| torch_dtype = _dtype_for_shard(model_id, is_sharded) | |
| tok = AutoTokenizer.from_pretrained(model_id, token=_resolve_token()) | |
| model = AutoModelForCausalLM.from_pretrained( | |
| model_id, | |
| token=_resolve_token(), | |
| torch_dtype=torch_dtype, | |
| device_map=device_map, | |
| ) | |
| if tok.pad_token is None: | |
| tok.pad_token = tok.eos_token | |
| # A base (non-instruct) model may have no chat template; SFT with chat | |
| # traces needs one. Inject a simple default so any base model works. | |
| if getattr(tok, "chat_template", None) is None: | |
| tok.chat_template = DEFAULT_CHAT_TEMPLATE | |
| lora = LoraConfig( | |
| r=cfg.lora_r, | |
| lora_alpha=cfg.lora_alpha, | |
| lora_dropout=cfg.lora_dropout, | |
| target_modules=list(cfg.target_modules), | |
| bias="none", | |
| task_type="CAUSAL_LM", | |
| ) | |
| model = get_peft_model(model, lora) | |
| model.print_trainable_parameters() | |
| if is_sharded: | |
| # Log the sharding so the user can see how the model was spread. | |
| devices = sorted({str(d) for d in set(model.hf_device_map.values())}) | |
| print(f"[train] model {model_id} sharded across {len(devices)} device(s): " | |
| f"{', '.join(devices)} ({num_gpus} GPU, {torch_dtype})") | |
| return model, tok | |
| def _model_is_cached(model_id: str) -> bool: | |
| return model_id in _MODEL_CACHE and _MODEL_CACHE[model_id] is not None | |
| def get_model_and_tokenizer(model_id: str, cfg: TrainConfig): | |
| """Return a model (PEFT-wrapped) + tokenizer for the deployment. | |
| When the device is *persistent* (not ZeroGPU) the loaded base model is | |
| cached in a module-level dict and reused across calls — the whole point: | |
| the base model stays resident in RAM/VRAM instead of being re-downloaded / | |
| re-loaded on every request. The frozen base weights never change between | |
| calls; only the LoRA adapter + optimizer state do, and those are reset | |
| from the resumed checkpoint on each call, so reuse is safe. | |
| On ZeroGPU the GPU is *transient* (detached between calls), so we skip the | |
| cache and load per-call from the local HF cache (which still avoids a | |
| network re-download — only the load is repeated). | |
| Note: LoRA hyper-params (rank / alpha) are fixed at first-load time. To | |
| change them mid-deployment, restart the Space (which re-runs the cache). | |
| """ | |
| # ZeroGPU's GPU is transient: never keep a cached device ref across calls. | |
| if _ZERO_GPU: | |
| return _load_model_fresh(model_id, cfg) | |
| cached = _MODEL_CACHE.get(model_id) | |
| if cached is not None: | |
| return cached | |
| model, tok = _load_model_fresh(model_id, cfg) | |
| _MODEL_CACHE[model_id] = (model, tok) | |
| return model, tok | |
| def _build_config(cfg: TrainConfig, output_dir: Path, num_examples: int, | |
| current_step: int): | |
| """Build a TRL training config for a single call. | |
| **Dynamic gradient accumulation.** The number of optimizer steps this call | |
| runs is derived from how many examples the uploaded trace contains and the | |
| deployment's gradient-accumulation size ``G`` (``GRAD_ACCUM`` env), *not* | |
| from a fixed "steps per call": | |
| steps_this_call = ceil(num_examples / (per_device_batch * accum)) | |
| with ``accum = ceil(G / per_device_batch)``. With ``G=4`` this is exactly | |
| the requested behaviour: a single trace -> 1 step; 10 traces -> 3 steps | |
| (4 + 4 + 2). SFT/DPO use a per-device batch of 1 (so one optimizer step == | |
| ``G`` examples, matching the example precisely); KTO uses a per-device | |
| batch of 2 (its KL term is undefined for a single example), so its per-step | |
| example count is ``2 * ceil(G/2)``. | |
| ``max_steps`` is the *absolute* target (``current_step + steps_this_call``); | |
| with ``resume_from_checkpoint`` the Trainer runs the remaining steps, so the | |
| call continues exactly where the last call left off. | |
| """ | |
| import math | |
| use_gpu = torch.cuda.is_available() | |
| use_cpu = not use_gpu | |
| m = cfg.method.lower() | |
| # KTO needs an actual batch > 1 (its KL term is undefined for a single | |
| # example); SFT/DPO use a per-device batch of 1. | |
| per_device_batch = 2 if m == "kto" else 1 | |
| accum = max(1, math.ceil(cfg.grad_accum / per_device_batch)) | |
| examples_per_step = per_device_batch * accum | |
| steps_this_call = max(1, math.ceil(num_examples / examples_per_step)) if num_examples else 1 | |
| target = current_step + steps_this_call | |
| common = dict( | |
| output_dir=str(output_dir), | |
| per_device_train_batch_size=per_device_batch, | |
| gradient_accumulation_steps=accum, | |
| max_steps=target, # absolute target; resume continues from here | |
| learning_rate=cfg.learning_rate, | |
| lr_scheduler_type=cfg.lr_scheduler_type, | |
| optim=cfg.optim, | |
| weight_decay=cfg.weight_decay, | |
| max_grad_norm=1.0, | |
| logging_strategy="steps", | |
| logging_steps=1, | |
| save_strategy="steps", | |
| save_steps=1, | |
| save_total_limit=cfg.save_total_limit, | |
| save_only_model=False, # MUST keep optimizer.pt / scheduler.pt / rng | |
| use_cpu=use_cpu, # GPU when available, else CPU | |
| report_to="none", | |
| dataloader_num_workers=0, | |
| remove_unused_columns=False, | |
| ignore_data_skip=True, # robust resume for tiny/arbitrary datasets | |
| ) | |
| if m == "sft": | |
| from trl import SFTConfig | |
| args = SFTConfig(**common, max_length=cfg.max_length) | |
| elif m == "dpo": | |
| from trl import DPOConfig | |
| args = DPOConfig(**common, max_length=cfg.max_length) | |
| elif m == "kto": | |
| from trl import KTOConfig | |
| args = KTOConfig(**common, max_length=cfg.max_length) | |
| else: | |
| raise ValueError(f"Unknown method {cfg.method!r}") | |
| return args, steps_this_call | |
| def _build_trainer(method: str, model, tok, dataset, args): | |
| if method == "sft": | |
| from trl import SFTTrainer | |
| return SFTTrainer(model=model, args=args, train_dataset=dataset, processing_class=tok) | |
| if method == "dpo": | |
| from trl import DPOTrainer | |
| # ref_model=None + a PEFT model => trl uses the adapter-disabled model | |
| # as the reference (standard LoRA-DPO pattern). | |
| return DPOTrainer(model=model, args=args, train_dataset=dataset, processing_class=tok) | |
| if method == "kto": | |
| from trl import KTOTrainer | |
| return KTOTrainer(model=model, args=args, train_dataset=dataset, processing_class=tok) | |
| raise ValueError(f"Unknown method {method!r}") | |
| # --------------------------------------------------------------------------- # | |
| # The main entry point | |
| # --------------------------------------------------------------------------- # | |
| def _steps_for_call(cfg: TrainConfig, num_examples: int) -> int: | |
| """Dynamic optimizer-step count for a single call. | |
| ``steps = ceil(num_examples / (per_device_batch * accum))`` with | |
| ``accum = ceil(cfg.grad_accum / per_device_batch)``. SFT/DPO use a | |
| per-device batch of 1, so ``steps = ceil(num_examples / grad_accum)`` — | |
| exactly the requested behaviour (10 examples, grad_accum=4 -> 3 steps; | |
| 1 example -> 1 step). KTO uses a per-device batch of 2. A call with no | |
| examples still runs 1 step (keeps the optimizer warm / makes the call | |
| idempotent).""" | |
| import math | |
| m = cfg.method.lower() | |
| per_device_batch = 2 if m == "kto" else 1 | |
| accum = max(1, math.ceil(cfg.grad_accum / per_device_batch)) | |
| examples_per_step = per_device_batch * accum | |
| if not num_examples: | |
| return 1 | |
| return max(1, math.ceil(num_examples / examples_per_step)) | |
| def run_one_call( | |
| rows: list[dict], | |
| cfg: TrainConfig, | |
| *, | |
| log=print, | |
| ) -> TrainResult: | |
| """Parse the uploaded trace, run ``steps_per_call`` optimizer steps for the | |
| chosen method (resuming from the latest checkpoint if one exists), then | |
| persist the resulting weights + optimizer state to the bucket. | |
| The uploaded ``rows`` (the training data) are never written anywhere — | |
| only the trained weights and optimizer/scheduler state are persisted. | |
| """ | |
| import time | |
| start = time.time() | |
| # One bucket + one model per deployment: resolve from the env when the | |
| # caller did not pin them (the UI no longer asks for them per request). | |
| cfg.model_id = resolve_model_id(cfg) | |
| cfg.bucket_id = resolve_bucket_id(cfg) | |
| # LoRA hyper-params are also fixed per deployment via env (one model + one | |
| # bucket + one LoRA config per deployment). They are resolved here so the | |
| # resident model cache (keyed by model_id) wraps the *deployment's* LoRA | |
| # config at first-load, and every later call reuses the same cached model. | |
| cfg.lora_r = resolve_lora_r(cfg) | |
| cfg.lora_alpha = resolve_lora_alpha(cfg) | |
| cfg.lora_dropout = resolve_lora_dropout(cfg) | |
| cfg.target_modules = resolve_target_modules(cfg) | |
| cfg.learning_rate = resolve_learning_rate(cfg) | |
| cfg.max_length = resolve_max_length(cfg) | |
| cfg.grad_accum = resolve_grad_accum(cfg) | |
| m = cfg.method.lower() | |
| dataset = parse_trace(rows, m) | |
| num_examples = len(dataset) | |
| log(f"[train] model={cfg.model_id} bucket={cfg.bucket_id or '(none)'} " | |
| f"parsed {num_examples} {m} examples from trace " | |
| f"(grad_accum={cfg.grad_accum})") | |
| notes: list[str] = [] | |
| # --- resolve resume checkpoint -------------------------------------- # | |
| local_cache = LOCAL_CACHE | |
| local_cache.mkdir(parents=True, exist_ok=True) | |
| resume_dir = None | |
| resumed_from = None | |
| if cfg.resume: | |
| resume_dir, resumed_from = _resolve_resume( | |
| cfg, local_cache, notes | |
| ) | |
| if resume_dir: | |
| log(f"[train] resuming from {resumed_from}") | |
| current_step = _read_global_step(resume_dir) if resume_dir else 0 | |
| # --- dynamic gradient accumulation: how many steps this call runs -- # | |
| steps_this_call = _steps_for_call(cfg, num_examples) | |
| target = current_step + steps_this_call | |
| output_dir = local_cache / f"run-{target}" | |
| log( | |
| f"[train] {num_examples} example(s) / grad_accum={cfg.grad_accum} " | |
| f"-> {steps_this_call} step(s): {current_step} -> {target}" | |
| ) | |
| args, steps_this_call = _build_config(cfg, output_dir, num_examples, current_step) | |
| # --- build model + trainer (resident cache keeps the base model in | |
| # RAM/VRAM across calls; the adapter+optimizer state is reset from the | |
| # resumed checkpoint below) ------------------------- # | |
| model, tok = get_model_and_tokenizer(cfg.model_id, cfg) | |
| trainer = _build_trainer(m, model, tok, dataset, args) | |
| # --- train (resume continues from current_step to target) ---------- # | |
| train_args = {} | |
| if resume_dir: | |
| train_args["resume_from_checkpoint"] = resume_dir | |
| log(f"[train] training steps {current_step} -> {target}") | |
| train_res = trainer.train(**train_args) | |
| # --- read back the new global step + loss -------------------------- # | |
| new_step = current_step | |
| loss = None | |
| try: | |
| st = trainer.state | |
| new_step = int(getattr(st, "global_step", current_step) or current_step) | |
| except Exception: | |
| pass | |
| logs = getattr(train_res, "log", None) or [] | |
| if logs: | |
| for entry in logs: | |
| if "loss" in entry: | |
| loss = float(entry["loss"]) | |
| if loss is None: | |
| try: | |
| metrics = getattr(train_res, "metrics", None) or {} | |
| train_metrics = metrics.get("train", {}) | |
| if "train_loss" in train_metrics: | |
| loss = float(train_metrics["train_loss"]) | |
| except Exception: | |
| pass | |
| # --- persist the latest checkpoint to the bucket ------------------- # | |
| checkpoint_id = f"checkpoint-{new_step}" | |
| saved_dir = _find_checkpoint_dir(output_dir, new_step) or output_dir | |
| if cfg.bucket_id: | |
| try: | |
| push_checkpoint(cfg.bucket_id, saved_dir, checkpoint_id) | |
| save_state_index( | |
| cfg.bucket_id, | |
| { | |
| "global_step": new_step, | |
| "method": m, | |
| "model_id": cfg.model_id, | |
| "loss": loss, | |
| }, | |
| ) | |
| log(f"[train] pushed {checkpoint_id} to bucket {cfg.bucket_id}") | |
| except Exception as exc: | |
| notes.append(f"push failed: {exc}") | |
| log(f"[train] PUSH FAILED: {exc}") | |
| else: | |
| notes.append("no bucket_id set — weights NOT persisted") | |
| result = TrainResult( | |
| method=m, | |
| model_id=cfg.model_id, | |
| global_step=new_step, | |
| loss=loss, | |
| checkpoint_id=checkpoint_id, | |
| resumed_from=resumed_from, | |
| num_examples=num_examples, | |
| grad_accum=cfg.grad_accum, | |
| steps_run=new_step - current_step, | |
| notes=notes, | |
| elapsed=time.time() - start, | |
| ) | |
| log(f"[train] done: {result.as_dict()}") | |
| return result | |
| # --------------------------------------------------------------------------- # | |
| # helpers | |
| # --------------------------------------------------------------------------- # | |
| def _resolve_resume(cfg: TrainConfig, local_cache: Path, notes: list) -> tuple[str | None, str | None]: | |
| """Decide which checkpoint to resume from. | |
| 1. Prefer a fresh local checkpoint from a previous call (same session) — | |
| so back-to-back calls resume even without a bucket. | |
| 2. Otherwise pull the latest checkpoint from the bucket. | |
| """ | |
| # 1) fresh local checkpoint from a prior call | |
| local = _find_any_checkpoint(local_cache) | |
| if local is not None: | |
| return str(local), local.name | |
| # 2) latest from the bucket | |
| if cfg.bucket_id: | |
| try: | |
| latest = _latest_checkpoint_prefix(cfg.bucket_id) | |
| except Exception as exc: # pragma: no cover | |
| latest = None | |
| notes.append(f"could not list bucket: {exc}") | |
| if latest: | |
| local_dir = local_cache / f"pull-{latest}" | |
| if pull_checkpoint(cfg.bucket_id, latest, local_dir): | |
| return str(local_dir), latest | |
| notes.append(f"could not download {latest}") | |
| return None, None | |
| def _find_any_checkpoint(root: Path): | |
| """Return the local ``.../checkpoint-N`` dir with the highest N under | |
| ``root`` (searches run-*/checkpoint-* and top-level checkpoint-*).""" | |
| best = None | |
| for cand in list(root.glob("checkpoint-*")) + list(root.glob("run-*/checkpoint-*")): | |
| s = _dir_step(cand) | |
| if best is None or s > _dir_step(best): | |
| best = cand | |
| return best | |
| def _latest_checkpoint_prefix(bucket_id: str) -> str | None: | |
| try: | |
| from store import list_latest_checkpoint | |
| return list_latest_checkpoint(bucket_id) | |
| except Exception: | |
| return None | |
| def _read_global_step(checkpoint_dir: str | None) -> int: | |
| if not checkpoint_dir: | |
| return 0 | |
| p = Path(checkpoint_dir) / "trainer_state.json" | |
| try: | |
| data = json.loads(p.read_text()) | |
| return int(data.get("global_step", 0)) | |
| except Exception: | |
| return 0 | |
| def _find_checkpoint_dir(output_dir: Path, step: int) -> Path | None: | |
| cand = output_dir / f"checkpoint-{step}" | |
| if cand.exists(): | |
| return cand | |
| # the trainer may have saved under a different name; take the max one | |
| best = None | |
| for d in output_dir.glob("checkpoint-*"): | |
| try: | |
| s = int(d.name.split("-")[-1]) | |
| except Exception: | |
| continue | |
| if best is None or s > int(_dir_step(best)): | |
| best = d | |
| return best | |
| def _dir_step(d: Path) -> int: | |
| try: | |
| return int(d.name.split("-")[-1]) | |
| except Exception: | |
| return -1 | |
| def current_progress(bucket_id: str) -> dict: | |
| """Lightweight status: latest checkpoint + global step from the index.""" | |
| try: | |
| from store import load_state_index | |
| idx = load_state_index(bucket_id) | |
| except Exception: | |
| idx = {} | |
| latest = None | |
| try: | |
| latest = _latest_checkpoint_prefix(bucket_id) | |
| except Exception: | |
| pass | |
| return { | |
| "global_step": idx.get("global_step", 0), | |
| "latest_checkpoint": latest, | |
| "checkpoints": checkpoint_paths(bucket_id), | |
| } | |