mirror of
https://github.com/unslothai/unsloth.git
synced 2026-08-25 08:42:25 +00:00
340 lines
14 KiB
Python
340 lines
14 KiB
Python
# SPDX-License-Identifier: AGPL-3.0-only
|
|
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
|
|
|
"""Import the ML stack on a background thread while the backend finishes booting.
|
|
|
|
torch (plus the sympy/scipy/pandas it drags in) used to be imported by `import
|
|
main`, so the port could not bind until it finished. Deferring it alone would just
|
|
move that cost to the first request; this module pays it concurrently instead.
|
|
|
|
Started from the last line of main.py's lifespan: everything above is on the
|
|
critical path to binding the socket and would contend for the GIL. Uvicorn binds as
|
|
soon as the lifespan returns, so the warm overlaps serving, not boot.
|
|
|
|
Contract:
|
|
* idempotent -- one thread per process, repeat calls are no-ops
|
|
* never fatal -- a failed stage is logged and left cold, retried by whoever needs it
|
|
* no half-initialised state -- stages delegate to the module owning the cache
|
|
(utils.hardware, model_config), which caches under a lock and only on success,
|
|
so a racing request waits rather than sees a partial
|
|
* optional GPU consumers stay cold -- Hub downloads load the Xet/Unsloth Zoo
|
|
integration on demand, and RAG operations load their embedding backend on demand
|
|
|
|
This does NOT make torch-dependent endpoints cheap while it runs: anything reaching
|
|
get_device() blocks until the hardware stage finishes, so `async def` handlers on
|
|
that path must use asyncio.to_thread (see main.py's /api/health).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import importlib
|
|
import importlib.machinery
|
|
import os
|
|
import sys
|
|
import threading
|
|
import time
|
|
from contextlib import contextmanager
|
|
from functools import partial
|
|
from typing import Optional
|
|
|
|
from loggers import get_logger
|
|
|
|
logger = get_logger(__name__)
|
|
|
|
DISABLE_ENV_VAR = "UNSLOTH_STUDIO_DISABLE_TORCH_WARM"
|
|
|
|
_start_lock = threading.Lock()
|
|
_thread: Optional[threading.Thread] = None
|
|
# Detection epoch of the live warm: "already warmed this lifespan" vs. one whose lifespan ended.
|
|
_thread_epoch: Optional[int] = None
|
|
_status: dict = {"started": False, "finished": False, "stages": {}}
|
|
|
|
|
|
def _is_extension_module(name: str) -> bool:
|
|
"""True if sys.modules[name] is a compiled extension, not Python source."""
|
|
module = sys.modules.get(name)
|
|
origin = getattr(getattr(module, "__spec__", None), "origin", None) or getattr(
|
|
module, "__file__", None
|
|
)
|
|
if not isinstance(origin, str):
|
|
return False
|
|
return origin.endswith(tuple(importlib.machinery.EXTENSION_SUFFIXES))
|
|
|
|
|
|
def purge_partial_import(package: str) -> list:
|
|
"""Drop the submodules a failed package import left behind in sys.modules.
|
|
|
|
When ``package/__init__.py`` raises, CPython evicts only the parent and keeps
|
|
every submodule it already executed. The next import re-runs ``__init__`` with
|
|
each ``from .x import y`` served from that cache, so attributes are never
|
|
rebound: the package imports "successfully" but is missing pieces (the
|
|
bitsandbytes case fixed in #7580). The warm makes this reachable -- it imports on
|
|
a thread and swallows the failure, so the retry is somebody else's request.
|
|
|
|
Acts only on that exact signature (parent gone, submodules present), so a
|
|
concurrent, still-running import is left alone. Returns what it removed.
|
|
|
|
Declines when any submodule is a loaded C extension: evicting one re-runs its
|
|
module init, and pybind11 answers a duplicate type registration with
|
|
std::terminate. A torch missing attributes is bad; SIGABRT mid-serve is worse.
|
|
"""
|
|
if package in sys.modules:
|
|
return []
|
|
prefix = package + "."
|
|
stale = [name for name in list(sys.modules) if name.startswith(prefix)]
|
|
# Re-check before touching anything: a retrying importer publishes the parent as soon
|
|
# as its __init__ begins, and popping submodules out from under it produces the very
|
|
# half-initialised package this prevents. Narrows the window; CPython's lock is private.
|
|
if package in sys.modules:
|
|
logger.info(
|
|
"not purging %s: another importer republished it while collecting its "
|
|
"leftovers, so that import owns them now",
|
|
package,
|
|
)
|
|
return []
|
|
compiled = sorted(name for name in stale if _is_extension_module(name))
|
|
if compiled:
|
|
logger.warning(
|
|
"not purging %s: %d of its submodule(s) are loaded C extensions and "
|
|
"re-importing one aborts the process (%s). The next import will reuse "
|
|
"the cached submodules and may be missing attributes.",
|
|
package,
|
|
len(compiled),
|
|
", ".join(compiled[:4]),
|
|
)
|
|
return []
|
|
# Track what actually went: a partway bail must not report a clean slate that never happened.
|
|
removed = []
|
|
for name in stale:
|
|
# Same race, per pop: bail the moment the parent is back.
|
|
if package in sys.modules:
|
|
logger.warning(
|
|
"stopped purging %s partway: another importer republished it. The "
|
|
"submodules already removed will be re-executed by that import.",
|
|
package,
|
|
)
|
|
break
|
|
if sys.modules.pop(name, None) is not None:
|
|
removed.append(name)
|
|
if removed:
|
|
logger.warning(
|
|
"purged %d half-imported %s submodule(s) so the next import re-runs clean: %s",
|
|
len(removed),
|
|
package,
|
|
", ".join(sorted(removed)[:8]),
|
|
)
|
|
return removed
|
|
|
|
|
|
# Stage -> package to purge on failure. inference_backend is absent: it imports nothing.
|
|
_STAGE_PACKAGE = {
|
|
"hardware": "torch",
|
|
"transformers": "transformers",
|
|
"datasets": "datasets",
|
|
}
|
|
|
|
|
|
def _run_stage(name: str, fn) -> None:
|
|
started = time.perf_counter()
|
|
try:
|
|
fn()
|
|
except BaseException as exc: # noqa: BLE001 - a warm failure must be visible, not fatal
|
|
_status["stages"][name] = {"ok": False, "error": repr(exc)}
|
|
# warning, not debug: the stage stays cold and the first request pays for it.
|
|
logger.warning("torch warm stage %r failed: %r", name, exc)
|
|
package = _STAGE_PACKAGE.get(name)
|
|
if package:
|
|
purge_partial_import(package)
|
|
else:
|
|
_status["stages"][name] = {
|
|
"ok": True,
|
|
"seconds": round(time.perf_counter() - started, 3),
|
|
}
|
|
|
|
|
|
def _warm_hardware(epoch: Optional[int] = None) -> None:
|
|
# Requests hit the same call, so they reuse the cache or block on this thread's lock,
|
|
# never race a second detection. The epoch rides along: a shutdown landing between
|
|
# _warm()'s check and _DETECT_LOCK would else publish for the lifespan that just ended.
|
|
from utils.hardware import ensure_hardware_detected
|
|
ensure_hardware_detected(epoch)
|
|
|
|
|
|
def _warm_transformers() -> None:
|
|
from utils.models.model_config import _detection_sets
|
|
_detection_sets()
|
|
|
|
|
|
def _warm_datasets() -> None:
|
|
# `import main` pulled it in; keep the first dataset op as cheap. Ungated: no torch needed.
|
|
importlib.import_module("datasets")
|
|
|
|
|
|
# Keep metadata and framework registries ready without importing optional GPU consumers.
|
|
# Unsloth Zoo is loaded by utils.hf_xet_fallback only when a Hub operation needs it.
|
|
def _warm_inference_backend() -> None:
|
|
# Its constructor reaches hw.get_device(), so whoever builds it first pays for detection
|
|
# -- lazily that is some request, and sync helpers call the getter inline from async
|
|
# handlers. Building it here makes the getter a dict read. After hardware, to reuse it.
|
|
from core.inference import get_inference_backend
|
|
get_inference_backend()
|
|
|
|
|
|
_STAGES = (
|
|
("hardware", _warm_hardware),
|
|
("inference_backend", _warm_inference_backend),
|
|
("transformers", _warm_transformers),
|
|
("datasets", _warm_datasets),
|
|
)
|
|
|
|
|
|
def _warm(epoch: Optional[int] = None) -> None:
|
|
started = time.perf_counter()
|
|
if epoch is None:
|
|
epoch = _detection_epoch()
|
|
# The boundary checks below only catch a shutdown between stages. Inside one, the
|
|
# orchestrator constructor reaches get_device(), which takes no epoch; the scope binds
|
|
# it to this pass so a mid-stage shutdown discards it instead of republishing DEVICE.
|
|
with _owning_epoch(epoch):
|
|
for name, fn in _STAGES:
|
|
if epoch is not None and _detection_epoch() != epoch:
|
|
# Checked before the first stage too: start_background_warm() reads the
|
|
# epoch before start(), so a shutdown in that gap retires this thread
|
|
# while it is still scheduled, with nothing yet run.
|
|
logger.info("torch warm stopped before %s: its lifespan ended", name)
|
|
return
|
|
# Only the real stage takes the epoch; a patched _STAGES entry is called bare.
|
|
_run_stage(name, partial(fn, epoch) if fn is _warm_hardware else fn)
|
|
if epoch is not None and _detection_epoch() != epoch:
|
|
# Shutdown retired this lifespan's detection. Later stages build the
|
|
# orchestrator, which reaches get_device() and would start a fresh
|
|
# detection, republishing DEVICE after teardown cleared it.
|
|
logger.info("torch warm stopped after %s: its lifespan ended", name)
|
|
return
|
|
_status["finished"] = True
|
|
_status["seconds"] = round(time.perf_counter() - started, 3)
|
|
logger.info("torch warm finished in %.1fms", (time.perf_counter() - started) * 1000)
|
|
|
|
|
|
@contextmanager
|
|
def _owning_epoch(epoch: Optional[int]):
|
|
"""hardware.owning_detection_epoch(), a no-op when hardware is not importable: a
|
|
--no-torch host still runs the warm and each stage reports its own absence."""
|
|
try:
|
|
from utils.hardware import hardware as _hw
|
|
scope = _hw.owning_detection_epoch(epoch)
|
|
except Exception:
|
|
yield
|
|
return
|
|
with scope:
|
|
yield
|
|
|
|
|
|
def _detection_epoch() -> Optional[int]:
|
|
"""The current detection epoch, or None if hardware is not importable."""
|
|
try:
|
|
from utils.hardware import hardware as _hw
|
|
return _hw.current_detection_epoch()
|
|
except Exception:
|
|
return None
|
|
|
|
|
|
def _warm_after(previous: threading.Thread, epoch: Optional[int]) -> None:
|
|
"""Wait out a retired warm, then warm for ``epoch``. One importer at a time."""
|
|
previous.join()
|
|
_warm(epoch)
|
|
|
|
|
|
def start_background_warm() -> bool:
|
|
"""Start the warm thread once. Returns True iff this call started it.
|
|
|
|
Runs on every host, torch or not: stage one is hardware detection, which must not
|
|
wait for a request (it feeds /api/health's chat_only).
|
|
|
|
A finished thread from an earlier lifespan does not count as one already running:
|
|
reset_background_warm() declines mid-warm, so a shutdown then leaves the object in
|
|
place, and treating that as "already started" would skip the warm over hardware
|
|
state the same shutdown just cleared.
|
|
"""
|
|
global _thread
|
|
if os.environ.get(DISABLE_ENV_VAR) == "1":
|
|
return False
|
|
global _thread_epoch
|
|
# Epoch read before start(): the child may not run for a while, and a shutdown in that
|
|
# gap retires this lifespan. Reading it in the thread would adopt the post-shutdown one.
|
|
epoch = _detection_epoch()
|
|
with _start_lock:
|
|
target, args = _warm, (epoch,)
|
|
if _thread is not None:
|
|
# A warm holds the latch while its own lifespan is current, so repeat calls
|
|
# are no-ops. Once shutdown retires that epoch the next lifespan warms again.
|
|
if _thread_epoch is not None and epoch == _thread_epoch:
|
|
return False
|
|
if _thread.is_alive():
|
|
# Stale but mid-stage: it stops at the next boundary and nothing retries,
|
|
# so this lifespan would serve cold. Hand off; the successor joins it
|
|
# first, so only one thread imports.
|
|
target, args = _warm_after, (_thread, epoch)
|
|
else:
|
|
_clear_finished_warm_locked()
|
|
_thread = threading.Thread(
|
|
target = target,
|
|
args = args,
|
|
daemon = True,
|
|
name = "torch-warm",
|
|
)
|
|
_thread_epoch = epoch
|
|
_status["started"] = True
|
|
_thread.start()
|
|
return True
|
|
|
|
|
|
def reset_background_warm() -> bool:
|
|
"""Let a later lifespan in this process start a fresh warm. True iff reset.
|
|
|
|
The same app can start twice (repeated ASGI lifespans, an embedded restart) and
|
|
shutdown clears the hardware state the first warm produced; leaving the finished
|
|
thread in place would make the second lifespan skip the warm and hand detection back
|
|
to the first request, the stall this module removes.
|
|
|
|
Declines while the previous warm runs, so two warms never share the same imports.
|
|
Detection self-heals then: shutdown clears DETECTION_COMPLETE and /api/health kicks
|
|
start_background_detection().
|
|
"""
|
|
with _start_lock:
|
|
thread = _thread
|
|
if thread is not None and thread.is_alive():
|
|
return False
|
|
_clear_finished_warm_locked()
|
|
return True
|
|
|
|
|
|
def _clear_finished_warm_locked() -> None:
|
|
"""Drop the finished warm and its status. Caller holds ``_start_lock``."""
|
|
global _thread, _thread_epoch
|
|
_thread = None
|
|
_thread_epoch = None
|
|
_status["started"] = False
|
|
_status["finished"] = False
|
|
_status["stages"] = {}
|
|
_status.pop("seconds", None)
|
|
|
|
|
|
def warm_status() -> dict:
|
|
"""Snapshot of the warm for diagnostics and tests."""
|
|
return {
|
|
"started": _status["started"],
|
|
"finished": _status["finished"],
|
|
"alive": bool(_thread is not None and _thread.is_alive()),
|
|
"stages": dict(_status["stages"]),
|
|
"seconds": _status.get("seconds"),
|
|
}
|
|
|
|
|
|
def join_background_warm(timeout: Optional[float] = None) -> bool:
|
|
"""Wait for the warm thread. Returns True if it is done (or never ran)."""
|
|
thread = _thread
|
|
if thread is None:
|
|
return True
|
|
thread.join(timeout)
|
|
return not thread.is_alive()
|