mirror of
https://github.com/unslothai/unsloth.git
synced 2026-07-09 15:58:41 +00:00
* Studio: keep transformers off sys.modules until the training worker activates the sidecar
The training worker (core/training/worker.py:run_training_process) decides the per-worker
Xet env flip during preflight by importing utils/hf_xet_fallback.py, which eagerly imported
unsloth_zoo at module load. unsloth_zoo's __init__ imports transformers, so the default
transformers 4.57.x was cached in sys.modules before activate_transformers_for_subprocess
prepended the 5.x sidecar to sys.path. Since activation only edits sys.path, the already
cached module won, and 5.x models failed to load their tokenizer or config:
- Qwen3.5 / GLM-4.7 (tokenizer_class TokenizersBackend): "Tokenizer class TokenizersBackend
does not exist or is not currently imported."
- gemma-4: "... is not supported yet in transformers==4.57.6."
Fix: load the shared unsloth_zoo backend lazily (only when a heavy download helper is first
used, which is after activation). child_should_disable_xet and the DEFAULT_* constants are
defined locally so importing the shim stays light. The download wrappers, the DownloadStallError
class, start_watchdog and get_hf_download_state resolve the shared backend on first use, and the
degraded no-unsloth_zoo fallback is preserved.
Tests:
- test_hf_xet_fallback.py: existing suite kept green via the restored _shared_* seam; the
GPU-init retry test now triggers the lazy load explicitly; new guard asserts importing
child_should_disable_xet does not import transformers/unsloth_zoo.
- test_training_worker_import_discipline.py: new invariant test that the worker preflight
imports leave transformers unimported, so this class of regression cannot return silently.
Runs in studio-backend-ci (CPU only, no network/GPU/weights).
* [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
* Studio: CPU-only guard that activation switches transformers to the model's sidecar version
Adds test_worker_activates_correct_transformers.py: runs the real worker preflight
(from utils.hf_xet_fallback import child_should_disable_xet) plus the real tier
detection and activate_transformers_for_subprocess for a transformers-5.x model
(Qwen3.5, tier 530), then asserts the in-process transformers actually switched to
the 5.x sidecar. A stale pre-activation import leaves 4.57.x pinned and fails the
assertion, which is exactly the TokenizersBackend regression (#6951).
Self-contained CUDA spoof (mirrors tests/_zoo_aggressive_cuda_spoof.py) forces
unsloth_zoo down its full, transformers-importing init path on a GPU-less runner;
without it unsloth_zoo degrades and never preloads transformers, masking the bug.
A one-line stub sidecar stands in for the 5.x venv, so no GPU, network, weights, or
real sidecar are needed. Passes on this fix, fails on buggy main.
* Studio: load the repo's canonical CUDA spoof in the correct-version guard
Load tests/_zoo_aggressive_cuda_spoof.py (the committed spoof the consolidated CI
already relies on) as the single source of truth so the guard matches CI and stays
robust on a CPU-only torch wheel, where a partial hand-rolled spoof could miss a
torch.cuda call and let the unsloth_zoo import raise (masking the bug). Falls back to
a minimal inline spoof for a standalone studio checkout. Verified: passes on this fix,
fails on buggy main, and the fallback path passes when the spoof file is absent.
* Studio: declare the lazily-resolved xet names so ruff F822 stays green
DownloadStallError, start_watchdog and get_hf_download_state are provided via the
module __getattr__ (PEP 562), so ruff F822 flagged them as undefined names in __all__
and the Source-lint / pre-commit checks went red. Add annotation-only declarations
(no value bound, so __getattr__ still resolves them lazily to the shared unsloth_zoo
backend) to mark them defined for the linter while keeping F822 active for the rest
of __all__.
* Studio: tighten comments on the sidecar-activation fix and its tests
* Studio: mirror the new MLX-dispatch preflight import in the import-discipline guard
The worker preflight now also runs 'from core.training.training import
is_apple_silicon_training_platform, should_use_mlx_training_backend' before it
activates the transformers sidecar. Add that import (guarded) to the guard's
preflight snippet so the invariant test stays a faithful mirror: a future change
that makes core.training.training pull transformers/unsloth_zoo eagerly would then
be caught too. Verified clean on the current tree (no leak).
---------
Co-authored-by: danielhanchen <unslothai@gmail.com>
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: Lee Jackson <130007945+Imagineer99@users.noreply.github.com>
369 lines
14 KiB
Python
369 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
|
|
|
|
"""Tests for the Studio shim over the shared unsloth_zoo Xet -> HTTP fallback.
|
|
|
|
The transport-policy matrix is tested once in unsloth_zoo; here we assert only the
|
|
Studio seam: re-exporting the shared API and injecting the marker-aware
|
|
prepare_cache_for_transport on the HTTP retry. CPU-only, no network, no real subprocess.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import sys
|
|
import types as _types
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
_BACKEND_DIR = str(Path(__file__).resolve().parent.parent)
|
|
if _BACKEND_DIR not in sys.path:
|
|
sys.path.insert(0, _BACKEND_DIR)
|
|
|
|
# Stub heavy/unavailable deps before importing the module under test. Use real structlog when present;
|
|
# a bare stub would break later modules that log at import time.
|
|
_loggers_stub = _types.ModuleType("loggers")
|
|
_loggers_stub.get_logger = lambda name: __import__("logging").getLogger(name)
|
|
sys.modules.setdefault("loggers", _loggers_stub)
|
|
try:
|
|
import structlog # noqa: F401
|
|
except ImportError:
|
|
sys.modules["structlog"] = _types.ModuleType("structlog")
|
|
|
|
import huggingface_hub
|
|
|
|
try:
|
|
import unsloth_zoo.hf_xet_fallback as _shared_mod
|
|
shared = _shared_mod
|
|
except Exception: # noqa: BLE001 - still collect degraded-path tests when unsloth_zoo is unavailable
|
|
shared = None
|
|
|
|
import utils.hf_xet_fallback as xf
|
|
|
|
|
|
DL_REPO, FILE = "ztest/xet-dl", "model-Q4_K_XL.gguf"
|
|
|
|
|
|
def _requires_shared():
|
|
if shared is None:
|
|
pytest.skip("unsloth_zoo.hf_xet_fallback is not installed in this environment")
|
|
|
|
|
|
def test_shim_reexports_shared_api():
|
|
_requires_shared()
|
|
assert xf.DownloadStallError is shared.DownloadStallError
|
|
for name in (
|
|
"start_watchdog",
|
|
"get_hf_download_state",
|
|
"child_should_disable_xet",
|
|
"hf_hub_download_with_xet_fallback",
|
|
"snapshot_download_with_xet_fallback",
|
|
):
|
|
assert hasattr(xf, name), f"shim missing {name}"
|
|
|
|
|
|
def test_child_should_disable_xet_truth_table():
|
|
assert xf.child_should_disable_xet({"disable_xet": True}) is True
|
|
assert xf.child_should_disable_xet({"disable_xet": False}) is False
|
|
assert xf.child_should_disable_xet({}) is False
|
|
|
|
|
|
def test_shim_injects_studio_prepare_on_http_retry(monkeypatch):
|
|
"""A Xet stall retries over HTTP and the shim runs Studio's marker-aware
|
|
``prepare_cache_for_transport(..., 'http')`` before the retry."""
|
|
_requires_shared()
|
|
for var in ("UNSLOTH_DISABLE_XET", "UNSLOTH_STABLE_DOWNLOADS", "HF_HUB_DISABLE_XET"):
|
|
monkeypatch.delenv(var, raising = False)
|
|
monkeypatch.setattr(huggingface_hub, "try_to_load_from_cache", lambda *a, **k: None)
|
|
|
|
seen_disable_xet = []
|
|
|
|
def fake_attempt(
|
|
repo_id,
|
|
*,
|
|
kind,
|
|
params,
|
|
token,
|
|
repo_type,
|
|
disable_xet,
|
|
cancel_event,
|
|
stall_timeout,
|
|
interval,
|
|
grace_period,
|
|
on_status,
|
|
force_download = False,
|
|
):
|
|
seen_disable_xet.append(disable_xet)
|
|
return ("ok", "/cache/model.gguf") if disable_xet else ("stall", None)
|
|
|
|
monkeypatch.setattr(shared, "_run_download_attempt", fake_attempt)
|
|
|
|
prepared = []
|
|
monkeypatch.setattr(
|
|
"hub.utils.download_registry.prepare_cache_for_transport",
|
|
lambda repo_type, repo_id, mode, *a, **k: prepared.append((repo_type, repo_id, mode)),
|
|
)
|
|
|
|
out = xf.hf_hub_download_with_xet_fallback(DL_REPO, FILE, None)
|
|
assert out == "/cache/model.gguf"
|
|
assert seen_disable_xet == [False, True] # Xet first, then HTTP
|
|
assert prepared == [("model", DL_REPO, "http")], "shim must run Studio's marker-aware prep"
|
|
|
|
|
|
def test_shim_snapshot_injects_studio_prepare(monkeypatch):
|
|
"""The snapshot wrapper forwards Studio's marker-aware prep, like the file wrapper."""
|
|
captured = {}
|
|
|
|
def fake_snapshot(repo_id, **kwargs):
|
|
captured["repo_id"] = repo_id
|
|
captured["prepare_for_http_fn"] = kwargs.get("prepare_for_http_fn")
|
|
return "/tmp/snap-dir"
|
|
|
|
monkeypatch.setattr(xf, "_shared_snapshot_download_with_xet_fallback", fake_snapshot)
|
|
out = xf.snapshot_download_with_xet_fallback("org/model")
|
|
assert out == "/tmp/snap-dir"
|
|
assert captured["repo_id"] == "org/model"
|
|
assert captured["prepare_for_http_fn"] is xf._studio_prepare_for_http
|
|
|
|
|
|
def test_degrades_gracefully_without_shared_helper(monkeypatch):
|
|
"""On an older unsloth_zoo lacking the shared helper, the shim still imports (Studio
|
|
boots) and exposes stub API doing plain HF downloads with the watchdog disabled."""
|
|
import importlib
|
|
|
|
class _BlockShared:
|
|
def find_spec(
|
|
self,
|
|
name,
|
|
path = None,
|
|
target = None,
|
|
):
|
|
if name == "unsloth_zoo.hf_xet_fallback":
|
|
raise ModuleNotFoundError(f"No module named '{name}'", name = name)
|
|
return None
|
|
|
|
finder = _BlockShared()
|
|
saved_shared = sys.modules.pop("unsloth_zoo.hf_xet_fallback", None)
|
|
saved_shim = sys.modules.pop("utils.hf_xet_fallback", None)
|
|
sys.meta_path.insert(0, finder)
|
|
try:
|
|
degraded = importlib.import_module("utils.hf_xet_fallback")
|
|
|
|
# Boots without raising and mirrors the shared API surface.
|
|
assert issubclass(degraded.DownloadStallError, RuntimeError)
|
|
assert degraded.child_should_disable_xet({"disable_xet": True}) is True
|
|
assert degraded.get_hf_download_state(["x"]) is None # unmeasurable
|
|
event = degraded.start_watchdog(repo_ids = ["x"], on_stall = lambda m: None)
|
|
assert hasattr(event, "set") and not event.is_set() # never fires
|
|
|
|
# Degraded mode still emits heartbeats so the inactivity deadline is not tripped.
|
|
import time as _time
|
|
|
|
beats = []
|
|
hb_stop = degraded.start_watchdog(
|
|
repo_ids = ["x"],
|
|
on_stall = lambda m: None,
|
|
on_heartbeat = beats.append,
|
|
interval = 0.02,
|
|
)
|
|
try:
|
|
deadline = _time.monotonic() + 2.0
|
|
while not beats and _time.monotonic() < deadline:
|
|
_time.sleep(0.02)
|
|
assert beats, "degraded watchdog emitted no heartbeat"
|
|
finally:
|
|
hb_stop.set()
|
|
|
|
# Downloads fall back to plain huggingface_hub (no watchdog, no crash).
|
|
called = {}
|
|
|
|
def _fake_snapshot(repo_id, **kwargs):
|
|
called["repo_id"] = repo_id
|
|
return "/snap-dir"
|
|
|
|
monkeypatch.setattr(huggingface_hub, "snapshot_download", _fake_snapshot)
|
|
assert degraded.snapshot_download_with_xet_fallback("org/model") == "/snap-dir"
|
|
assert called["repo_id"] == "org/model"
|
|
|
|
# Cancellation still holds: an already-set cancel_event aborts before the HF download.
|
|
import threading as _threading
|
|
|
|
cancelled = _threading.Event()
|
|
cancelled.set()
|
|
called.clear()
|
|
with pytest.raises(RuntimeError, match = "Cancelled"):
|
|
degraded.snapshot_download_with_xet_fallback("org/model", cancel_event = cancelled)
|
|
assert "repo_id" not in called, "degraded download ran despite cancellation"
|
|
finally:
|
|
sys.meta_path.remove(finder)
|
|
sys.modules.pop("utils.hf_xet_fallback", None)
|
|
if saved_shared is not None:
|
|
sys.modules["unsloth_zoo.hf_xet_fallback"] = saved_shared
|
|
if saved_shim is not None:
|
|
sys.modules["utils.hf_xet_fallback"] = saved_shim
|
|
|
|
|
|
def test_degrades_when_unsloth_zoo_entirely_absent():
|
|
"""When unsloth_zoo is absent entirely, the import raises
|
|
ModuleNotFoundError(name='unsloth_zoo') (top-level package). Guard that the shim still
|
|
degrades and does not re-raise, breaking every Studio import that pulls it in."""
|
|
import importlib
|
|
|
|
class _BlockZoo:
|
|
def find_spec(
|
|
self,
|
|
name,
|
|
path = None,
|
|
target = None,
|
|
):
|
|
# Whole package absent, so ModuleNotFoundError.name is the top-level 'unsloth_zoo'.
|
|
if name == "unsloth_zoo" or name.startswith("unsloth_zoo."):
|
|
raise ModuleNotFoundError("No module named 'unsloth_zoo'", name = "unsloth_zoo")
|
|
return None
|
|
|
|
finder = _BlockZoo()
|
|
saved = {
|
|
k: v
|
|
for k, v in list(sys.modules.items())
|
|
if k == "unsloth_zoo" or k.startswith("unsloth_zoo.")
|
|
}
|
|
for k in saved:
|
|
del sys.modules[k]
|
|
saved_shim = sys.modules.pop("utils.hf_xet_fallback", None)
|
|
sys.meta_path.insert(0, finder)
|
|
try:
|
|
degraded = importlib.import_module("utils.hf_xet_fallback")
|
|
# Boots without raising and exposes the stub API.
|
|
assert issubclass(degraded.DownloadStallError, RuntimeError)
|
|
assert degraded.get_hf_download_state(["x"]) is None
|
|
event = degraded.start_watchdog(repo_ids = ["x"], on_stall = lambda m: None)
|
|
assert hasattr(event, "set") and not event.is_set()
|
|
finally:
|
|
sys.meta_path.remove(finder)
|
|
sys.modules.pop("utils.hf_xet_fallback", None)
|
|
sys.modules.update(saved)
|
|
if saved_shim is not None:
|
|
sys.modules["utils.hf_xet_fallback"] = saved_shim
|
|
|
|
|
|
def test_degrades_when_shared_helper_import_raises_importerror():
|
|
"""unsloth_zoo can be installed yet fail to import when torch is missing (llama.cpp/GGUF-only
|
|
Studio), raising ImportError not ModuleNotFoundError. The shim must degrade for that too."""
|
|
import importlib
|
|
|
|
class _BlockWithImportError:
|
|
def find_spec(
|
|
self,
|
|
name,
|
|
path = None,
|
|
target = None,
|
|
):
|
|
if name == "unsloth_zoo.hf_xet_fallback":
|
|
# Mirror a torch-less install: a plain ImportError with no .name.
|
|
raise ImportError("Unsloth: Pytorch is not installed.")
|
|
return None
|
|
|
|
finder = _BlockWithImportError()
|
|
saved_shared = sys.modules.pop("unsloth_zoo.hf_xet_fallback", None)
|
|
saved_zoo = sys.modules.pop("unsloth_zoo", None)
|
|
saved_shim = sys.modules.pop("utils.hf_xet_fallback", None)
|
|
sys.meta_path.insert(0, finder)
|
|
try:
|
|
degraded = importlib.import_module("utils.hf_xet_fallback")
|
|
assert issubclass(degraded.DownloadStallError, RuntimeError)
|
|
assert degraded.get_hf_download_state(["x"]) is None
|
|
event = degraded.start_watchdog(repo_ids = ["x"], on_stall = lambda m: None)
|
|
assert hasattr(event, "set") and not event.is_set()
|
|
finally:
|
|
sys.meta_path.remove(finder)
|
|
sys.modules.pop("utils.hf_xet_fallback", None)
|
|
if saved_shared is not None:
|
|
sys.modules["unsloth_zoo.hf_xet_fallback"] = saved_shared
|
|
if saved_zoo is not None:
|
|
sys.modules["unsloth_zoo"] = saved_zoo
|
|
if saved_shim is not None:
|
|
sys.modules["utils.hf_xet_fallback"] = saved_shim
|
|
|
|
|
|
def test_retries_under_light_gpu_init_when_import_fails(monkeypatch):
|
|
"""GPU detection in unsloth_zoo's __init__ raises NotImplementedError on a GPU-less host. The shim
|
|
retries under UNSLOTH_ZOO_DISABLE_GPU_INIT=1, restores the env, and degrades if the retry fails.
|
|
The backend loads lazily (first use of a heavy helper), so this triggers the load explicitly
|
|
before asserting the retry/degrade behavior."""
|
|
import importlib
|
|
import os
|
|
|
|
monkeypatch.delenv("UNSLOTH_ZOO_DISABLE_GPU_INIT", raising = False)
|
|
seen_env = []
|
|
|
|
class _GpuGatedBlocker:
|
|
def find_spec(
|
|
self,
|
|
name,
|
|
path = None,
|
|
target = None,
|
|
):
|
|
# Crash is in unsloth_zoo's __init__, so intercept "unsloth_zoo" itself (the parent).
|
|
if name == "unsloth_zoo":
|
|
# Record the env each attempt sees; raise the no-GPU error both times so the shim
|
|
# degrades.
|
|
seen_env.append(os.environ.get("UNSLOTH_ZOO_DISABLE_GPU_INIT"))
|
|
raise NotImplementedError("Unsloth cannot find any torch accelerator")
|
|
return None
|
|
|
|
finder = _GpuGatedBlocker()
|
|
saved = {
|
|
k: v
|
|
for k, v in list(sys.modules.items())
|
|
if k == "unsloth_zoo" or k.startswith("unsloth_zoo.")
|
|
}
|
|
for k in saved:
|
|
del sys.modules[k]
|
|
saved_shim = sys.modules.pop("utils.hf_xet_fallback", None)
|
|
sys.meta_path.insert(0, finder)
|
|
try:
|
|
degraded = importlib.import_module("utils.hf_xet_fallback")
|
|
# Import is light (lazy backend); unsloth_zoo not loaded yet.
|
|
assert seen_env == [], seen_env
|
|
# First use of a heavy helper triggers the load (attempt without the light env, then a retry
|
|
# with it set); accessing DownloadStallError drives it via __getattr__.
|
|
stall_error = degraded.DownloadStallError
|
|
assert seen_env == [None, "1"], seen_env
|
|
# Both attempts raised -> Studio still boots in degraded mode.
|
|
assert issubclass(stall_error, RuntimeError)
|
|
# The env override must not leak past the load.
|
|
assert os.environ.get("UNSLOTH_ZOO_DISABLE_GPU_INIT") is None
|
|
finally:
|
|
sys.meta_path.remove(finder)
|
|
sys.modules.pop("utils.hf_xet_fallback", None)
|
|
sys.modules.update(saved)
|
|
if saved_shim is not None:
|
|
sys.modules["utils.hf_xet_fallback"] = saved_shim
|
|
|
|
|
|
def test_importing_child_should_disable_xet_stays_light(monkeypatch):
|
|
"""Regression guard for the stale-transformers-sidecar bug: importing the shim (and
|
|
``child_should_disable_xet``) must NOT pull in ``transformers``/``unsloth_zoo``. The worker calls
|
|
this at startup to decide the Xet env flip BEFORE activating the sidecar; an eager import here
|
|
would cache the default transformers 4.57.x in sys.modules, defeating the sidecar sys.path prepend
|
|
and breaking 5.x models (Qwen3.5/GLM/gemma-4)."""
|
|
import importlib
|
|
|
|
for name in [
|
|
m
|
|
for m in list(sys.modules)
|
|
if m == "transformers"
|
|
or m.startswith("transformers.")
|
|
or m == "unsloth_zoo"
|
|
or m.startswith("unsloth_zoo.")
|
|
or m == "utils.hf_xet_fallback"
|
|
]:
|
|
monkeypatch.delitem(sys.modules, name, raising = False)
|
|
|
|
mod = importlib.import_module("utils.hf_xet_fallback")
|
|
# The lightweight decision works without the heavy backend.
|
|
assert mod.child_should_disable_xet({"disable_xet": True}) is True
|
|
assert mod.child_should_disable_xet({}) is False
|
|
# And nothing heavy was imported as a side effect.
|
|
assert "transformers" not in sys.modules, "importing the shim must not import transformers"
|
|
assert "unsloth_zoo" not in sys.modules, "importing the shim must not import unsloth_zoo"
|