mirror of https://github.com/razor-ai/soup.git
335 lines
11 KiB
Python
335 lines
11 KiB
Python
"""HuggingFace auto-push callback and resume helpers (v0.29.0 Part B).
|
|
|
|
Hooks into the HF Trainer's ``on_save`` event so every checkpoint saved to
|
|
disk is pushed to the Hub as a ``checkpoint-<step>`` branch. Also provides
|
|
``prepare_hf_resume`` which downloads the latest checkpoint branch back to
|
|
the local ``output_dir`` so a fresh run can pick up where the previous
|
|
crashed.
|
|
|
|
Network errors are logged and swallowed — we never crash training because
|
|
the Hub is unreachable.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
import re
|
|
from pathlib import Path
|
|
from typing import Optional
|
|
|
|
from soup_cli.utils.hf import get_hf_api, resolve_endpoint, resolve_token, validate_repo_id
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
_CHECKPOINT_BRANCH_RE = re.compile(r"^checkpoint-(\d+)$")
|
|
|
|
# Files worth shipping in an auto-pushed checkpoint. Keeps stray .env /
|
|
# source files / caches out of auto-pushed revisions if ``output_dir``
|
|
# is ever misconfigured to overlap with the project root.
|
|
_CHECKPOINT_ALLOW_PATTERNS = [
|
|
"*.safetensors",
|
|
"*.bin",
|
|
"*.pt",
|
|
"*.json",
|
|
"tokenizer*",
|
|
"special_tokens_map.json",
|
|
"generation_config.json",
|
|
"trainer_state.json",
|
|
"training_args.bin",
|
|
"README.md",
|
|
]
|
|
|
|
|
|
class HFPushCallback:
|
|
"""TrainerCallback-shaped auto-pusher.
|
|
|
|
We don't inherit from :class:`transformers.TrainerCallback` here to keep
|
|
the module importable without transformers (tests mock it anyway). The
|
|
``train`` command attaches an instance via ``trainer.add_callback`` so
|
|
duck-typing is sufficient.
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
repo_id: str,
|
|
token: Optional[str] = None,
|
|
endpoint: Optional[str] = None,
|
|
output_dir: str = "",
|
|
private: bool = False,
|
|
) -> None:
|
|
validate_repo_id(repo_id)
|
|
self.repo_id = repo_id
|
|
self.token = token
|
|
self.endpoint = endpoint
|
|
# ``output_dir`` is a fallback used only when HF Trainer's
|
|
# ``TrainingArguments.output_dir`` is missing (e.g. tests that
|
|
# construct a bare SimpleNamespace). Under real training the value
|
|
# comes from ``args.output_dir``.
|
|
self.output_dir = output_dir
|
|
self.private = private
|
|
self._repo_created = False
|
|
self._repo_failed = False # short-circuits retries after hard failure
|
|
|
|
# --- TrainerCallback protocol ---
|
|
|
|
def on_train_begin(self, args, state, control, **kwargs) -> None:
|
|
# Eagerly create the repo so the first checkpoint upload is not
|
|
# delayed. Swallow errors so we don't crash training; the failure
|
|
# is retried once on the first on_save, then short-circuited.
|
|
self._ensure_repo()
|
|
|
|
def on_save(self, args, state, control, **kwargs) -> None:
|
|
"""Upload the checkpoint directory written at ``global_step``."""
|
|
step = int(getattr(state, "global_step", 0) or 0)
|
|
if step <= 0:
|
|
return
|
|
|
|
out_dir = getattr(args, "output_dir", None) or self.output_dir
|
|
if not out_dir:
|
|
return
|
|
|
|
ckpt_path = Path(out_dir) / f"checkpoint-{step}"
|
|
if not ckpt_path.is_dir():
|
|
logger.debug("HFPushCallback: checkpoint dir missing: %s", ckpt_path)
|
|
return
|
|
|
|
self._upload_checkpoint(ckpt_path, step)
|
|
|
|
# --- Helpers ---
|
|
|
|
def _ensure_repo(self) -> bool:
|
|
"""Create the repo if needed. Returns True if ready for uploads."""
|
|
if self._repo_created:
|
|
return True
|
|
if self._repo_failed:
|
|
return False
|
|
try:
|
|
api = get_hf_api(token=self.token, endpoint=self.endpoint)
|
|
api.create_repo(repo_id=self.repo_id, private=self.private, exist_ok=True)
|
|
self._repo_created = True
|
|
return True
|
|
except Exception as exc:
|
|
logger.warning(
|
|
"HFPushCallback: create_repo failed (%s); auto-push disabled", exc,
|
|
)
|
|
self._repo_failed = True
|
|
return False
|
|
|
|
def _upload_checkpoint(self, ckpt_path: Path, step: int) -> None:
|
|
if not self._ensure_repo():
|
|
return
|
|
try:
|
|
api = get_hf_api(token=self.token, endpoint=self.endpoint)
|
|
|
|
revision = f"checkpoint-{step}"
|
|
try:
|
|
api.create_branch(
|
|
repo_id=self.repo_id,
|
|
branch=revision,
|
|
exist_ok=True,
|
|
)
|
|
except Exception as exc:
|
|
# create_branch is best-effort — older hub versions lack it.
|
|
logger.debug("create_branch failed (continuing): %s", exc)
|
|
|
|
api.upload_folder(
|
|
folder_path=str(ckpt_path),
|
|
repo_id=self.repo_id,
|
|
revision=revision,
|
|
commit_message=f"Soup auto-push checkpoint-{step}",
|
|
allow_patterns=_CHECKPOINT_ALLOW_PATTERNS,
|
|
)
|
|
except Exception as exc:
|
|
logger.warning("HFPushCallback: upload failed at step %d: %s", step, exc)
|
|
|
|
|
|
def resolve_latest_checkpoint_revision(
|
|
repo_id: str,
|
|
token: Optional[str] = None,
|
|
endpoint: Optional[str] = None,
|
|
) -> Optional[str]:
|
|
"""Return the ``checkpoint-<N>`` revision with the largest ``N``, or None.
|
|
|
|
Swallows API errors — a missing repo or network failure returns None so
|
|
callers can fall back to training from scratch.
|
|
"""
|
|
try:
|
|
api = get_hf_api(token=token, endpoint=endpoint)
|
|
refs = api.list_repo_refs(repo_id=repo_id)
|
|
except Exception as exc:
|
|
logger.debug("list_repo_refs failed (%s); no resume revision", exc)
|
|
return None
|
|
|
|
branches = getattr(refs, "branches", None) or []
|
|
best_step = -1
|
|
best_name: Optional[str] = None
|
|
for branch in branches:
|
|
name = getattr(branch, "name", None)
|
|
if not isinstance(name, str):
|
|
continue
|
|
match = _CHECKPOINT_BRANCH_RE.match(name)
|
|
if not match:
|
|
continue
|
|
step = int(match.group(1))
|
|
if step > best_step:
|
|
best_step = step
|
|
best_name = name
|
|
return best_name
|
|
|
|
|
|
def _find_highest_local_checkpoint(output_dir: str) -> Optional[int]:
|
|
"""Return the highest ``checkpoint-<N>`` step under ``output_dir``, or None.
|
|
|
|
Skips non-directories and malformed names. Returns None if ``output_dir``
|
|
does not exist or contains no checkpoints.
|
|
"""
|
|
base = Path(output_dir)
|
|
if not base.is_dir():
|
|
return None
|
|
best: Optional[int] = None
|
|
try:
|
|
children = list(base.iterdir())
|
|
except OSError:
|
|
return None
|
|
for entry in children:
|
|
if not entry.is_dir():
|
|
continue
|
|
match = _CHECKPOINT_BRANCH_RE.match(entry.name)
|
|
if not match:
|
|
continue
|
|
step = int(match.group(1))
|
|
if best is None or step > best:
|
|
best = step
|
|
return best
|
|
|
|
|
|
def _download_checkpoint(
|
|
repo_id: str,
|
|
revision: str,
|
|
local_dir: str,
|
|
token: Optional[str],
|
|
endpoint: Optional[str],
|
|
) -> str:
|
|
"""Download a revision into ``local_dir``.
|
|
|
|
``local_dir_use_symlinks=False`` forces direct copies — defence against
|
|
older ``huggingface_hub`` versions that could symlink the shared cache
|
|
into ``local_dir`` and thus let a crafted repo (or future SDK bug) place
|
|
symlinks pointing at arbitrary filesystem locations.
|
|
"""
|
|
try:
|
|
from huggingface_hub import snapshot_download
|
|
except ImportError as exc:
|
|
raise ImportError(
|
|
"huggingface_hub is required for --hf-resume. Install huggingface-hub."
|
|
) from exc
|
|
|
|
Path(local_dir).mkdir(parents=True, exist_ok=True)
|
|
try:
|
|
resolved = snapshot_download(
|
|
repo_id=repo_id,
|
|
revision=revision,
|
|
local_dir=local_dir,
|
|
token=token,
|
|
endpoint=endpoint,
|
|
local_dir_use_symlinks=False,
|
|
)
|
|
except TypeError:
|
|
# Older huggingface_hub versions reject local_dir_use_symlinks.
|
|
resolved = snapshot_download(
|
|
repo_id=repo_id,
|
|
revision=revision,
|
|
local_dir=local_dir,
|
|
token=token,
|
|
endpoint=endpoint,
|
|
)
|
|
return resolved or local_dir
|
|
|
|
|
|
def prepare_hf_resume(
|
|
repo_id: str,
|
|
output_dir: str,
|
|
token: Optional[str] = None,
|
|
endpoint: Optional[str] = None,
|
|
) -> Optional[str]:
|
|
"""Pull the latest checkpoint branch from HF into ``output_dir``.
|
|
|
|
Returns the local path of the checkpoint directory, or None if there's
|
|
nothing to resume from.
|
|
|
|
The ``output_dir`` must stay under the current working directory — an
|
|
attacker-controlled ``cfg.output`` (e.g. ``../../../tmp``) would
|
|
otherwise place downloaded files outside the project tree.
|
|
"""
|
|
from soup_cli.utils.paths import is_under_cwd
|
|
|
|
validate_repo_id(repo_id)
|
|
if not is_under_cwd(output_dir):
|
|
raise ValueError(
|
|
"output_dir must stay under the current working directory "
|
|
f"for --hf-resume; got: {output_dir!r}"
|
|
)
|
|
revision = resolve_latest_checkpoint_revision(repo_id, token=token, endpoint=endpoint)
|
|
if revision is None:
|
|
return None
|
|
|
|
# Prefer local newer (#50): if local has checkpoint-N >= remote's,
|
|
# skip the download and return the local path. Saves bandwidth and
|
|
# avoids overwriting a fresher local checkpoint with stale Hub state.
|
|
remote_match = _CHECKPOINT_BRANCH_RE.match(revision)
|
|
remote_step = int(remote_match.group(1)) if remote_match else -1
|
|
local_step = _find_highest_local_checkpoint(output_dir)
|
|
if local_step is not None and local_step >= remote_step:
|
|
local_revision = f"checkpoint-{local_step}"
|
|
local_path = Path(output_dir) / local_revision
|
|
logger.info(
|
|
"HF resume: local %s >= remote %s; skipping download",
|
|
local_revision,
|
|
revision,
|
|
)
|
|
return str(local_path)
|
|
|
|
# Mirror HF Trainer's on-disk layout: output_dir/<revision>
|
|
local_dir = str(Path(output_dir) / revision)
|
|
try:
|
|
return _download_checkpoint(
|
|
repo_id=repo_id,
|
|
revision=revision,
|
|
local_dir=local_dir,
|
|
token=token,
|
|
endpoint=endpoint,
|
|
)
|
|
except Exception as exc:
|
|
logger.warning("HF resume download failed (%s); skipping auto-resume", exc)
|
|
return None
|
|
|
|
|
|
def build_push_callback(
|
|
repo_id: str,
|
|
output_dir: str,
|
|
explicit_token: Optional[str] = None,
|
|
private: bool = False,
|
|
) -> Optional[HFPushCallback]:
|
|
"""Factory that resolves token/endpoint and builds the callback.
|
|
|
|
Returns None when no HF token is available — the caller logs and skips
|
|
auto-push silently.
|
|
"""
|
|
token = resolve_token(explicit=explicit_token)
|
|
if token is None:
|
|
return None
|
|
|
|
try:
|
|
endpoint = resolve_endpoint()
|
|
except ValueError as exc:
|
|
logger.warning("HF_ENDPOINT invalid (%s); skipping auto-push", exc)
|
|
return None
|
|
|
|
return HFPushCallback(
|
|
repo_id=repo_id,
|
|
token=token,
|
|
endpoint=endpoint,
|
|
output_dir=output_dir,
|
|
private=private,
|
|
)
|