unsloth/tests/saving/test_prewarm_base_model_hub_cache.py
Michael Han af93868760
Fix repeated base model downloads across checkpoint exports (#6896)
* Fix repeated base model downloads across checkpoint exports (#6890)

Pre-warm the HF hub cache with the 16bit base weights before
merge_and_overwrite_lora runs. The merge fetches shards with
hf_hub_download(local_dir=...), which never populates the hub cache, so
temporary merge directories (GGUF checkpoint exports) forced a full
re-download of the base model for every checkpoint. The first export now
downloads once into the cache and later exports copy from it.

Skips itself when already cached, offline, on Kaggle/Colab, for local or
nf4/fp4 bases, non-downloading save methods, or low disk. Opt out with
UNSLOTH_PREWARM_HUB_CACHE=0.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Show MB for small base models in the pre-warm download message

* Harden pre-warm: getattr for model config, abspath for relative HF_HUB_CACHE

- Read config._name_or_path via getattr so a model without a config skips
  cleanly instead of taking the outer error path.
- abspath the cache probe so a relative HF_HUB_CACHE walks up to a real root
  rather than "", which would zero the free-space check and skip pre-warm.

Both from PR review; each covered by a test that fails without the fix.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Pre-warm the live-env HF cache so runtime redirects still hit (#6890)

Resolve the hub cache the same way the merge does (unsloth_zoo _active_caches,
live env) instead of huggingface_hub's import-time-frozen constants.HF_HUB_CACHE,
and pass it as cache_dir to the cached probe, disk check and snapshot_download.

Without this, a runtime HF_HOME/HF_HUB_CACHE redirect (unsloth_zoo
redirect_hf_cache_if_readonly on a read-only default cache, or Studio) makes the
pre-warm populate a different directory than the one the merge reads, so the
cache-copy fast path misses and the base re-downloads on every export anyway.

Adds 3 regression tests covering the cache_dir threading and the redirect case.

* Apply ruff-format kwarg spacing to the pre-warm cache-dir changes

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Pre-warm the 16bit sibling for FP8 bases so their merged_16bit exports reuse the cache too

For a merged_16bit export of an FP8 base with an existing 16bit sibling, the merge
swaps to the sibling and downloads that (unsloth_zoo _resolve_fp8_16bit_sibling), so
pre-warming the FP8 repo missed the cache and re-downloaded the sibling every export.
Mirror the swap and pre-warm the sibling. No sibling still caches the FP8 repo for the
in-place dequant path. Adds 2 regression tests.

* Filter pre-warm shards through the safetensors index like the merge does

Repos that ship a leftover shard set the index does not reference (e.g. granite-3.2)
made the disk gate over-count and snapshot_download fetch shards the merge never reads.
Mirror the merge: on the download path, keep only index-referenced shards. Runs after
the already-cached check so the cached fast path stays network-free. Adds 2 tests.

* Tighten pre-warm comments

---------

Co-authored-by: Unsloth <michaelhan@Michaels-MacBook-Pro.local>
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: danielhanchen <danielhanchen@gmail.com>
Co-authored-by: Lee Jackson <130007945+Imagineer99@users.noreply.github.com>
2026-07-06 23:38:29 -07:00

459 lines
18 KiB
Python

# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved.
"""Regression tests for #6890: repeated base-model downloads across checkpoint exports.
merge_and_overwrite_lora downloads missing 16-bit shards with hf_hub_download(local_dir),
which never populates the persistent HF hub cache; a temporary merge directory (Studio
GGUF exports delete it) means every checkpoint export re-downloads the full base model.
_prewarm_base_model_hub_cache snapshot-downloads the base into the hub cache first so
the zoo's cache-copy fast path is hit on later exports.
unsloth.save cannot be imported on GPU-less hosts, so these tests extract the helper's
source via ast and exec it against fakes, mirroring the other GPU-free tests.
"""
from __future__ import annotations
import ast
import json
import os
import types
from pathlib import Path
import pytest
_SAVE_PY = Path(__file__).resolve().parent.parent.parent / "unsloth" / "save.py"
_SOURCE = _SAVE_PY.read_text(encoding = "utf-8")
def _extract_function(name: str) -> str:
tree = ast.parse(_SOURCE)
for node in ast.walk(tree):
if isinstance(node, ast.FunctionDef) and node.name == name:
return ast.get_source_segment(_SOURCE, node)
raise AssertionError(f"{name} not found in unsloth/save.py")
class _FakePeftModel:
def __init__(self, name_or_path = "unsloth/gemma-4-31b-it-bnb-4bit"):
self.config = types.SimpleNamespace(_name_or_path = name_or_path)
class _Recorder:
"""Callable that records calls and returns/raises per configuration."""
def __init__(
self,
result = None,
exc = None,
results_fn = None,
):
self.calls = []
self.result = result
self.exc = exc
self.results_fn = results_fn
def __call__(self, *args, **kwargs):
self.calls.append((args, kwargs))
if self.exc is not None:
raise self.exc
if self.results_fn is not None:
return self.results_fn(*args, **kwargs)
return self.result
def _build_env(
monkeypatch,
tmp_path,
shards = None,
cached = False,
free_bytes = 10**15,
base_source = None,
kaggle = False,
colab = False,
hub_cache = None,
live_hub_cache = "__same__",
fp8_sibling = None,
sibling_source = None,
index_weight_map = None,
):
"""Exec the extracted helper with stubbed collaborators; returns (fn, stubs)."""
shards = (
shards
if shards is not None
else [
("model-00001-of-00002.safetensors", 30 * 1024**3),
("model-00002-of-00002.safetensors", 29 * 1024**3),
]
)
if base_source is None:
base_source = ("unsloth/gemma-4-31b-it", False, None, False, None)
class _FS:
def __init__(self, token = None):
pass
def ls(
self,
repo,
detail = True,
):
return [{"name": f"{repo}/{n}", "size": s} for n, s in shards]
class _LocalMiss(Exception):
pass
hf_hub_download = _Recorder(result = str(tmp_path / "cached"))
if not cached:
hf_hub_download.exc = _LocalMiss("not cached")
# Serve model.safetensors.index.json (the merge's shard filter) while shard cache
# probes still miss, so the index-filter path can be exercised without a network.
if index_weight_map is not None:
_idx_path = tmp_path / "model.safetensors.index.json"
_idx_path.write_text(json.dumps({"weight_map": index_weight_map}))
def _hub_dl(
repo_id = None,
filename = None,
**kw,
):
if filename == "model.safetensors.index.json":
return str(_idx_path)
raise _LocalMiss("not cached")
hf_hub_download.exc = None
hf_hub_download.results_fn = _hub_dl
snapshot_download = _Recorder()
determine_base_model_source = _Recorder(result = base_source)
# For the FP8 -> 16bit sibling swap: return the sibling's (16bit) source when the
# helper re-resolves the sibling, else the original base source.
if fp8_sibling is not None:
_sib_src = sibling_source or (fp8_sibling, False, None, False, None)
determine_base_model_source.results_fn = (
lambda name, token = None: _sib_src if name == fp8_sibling else base_source
)
resolve_fp8_16bit_sibling = _Recorder(result = fp8_sibling)
cache_dir = tmp_path / "hub_cache"
cache_dir.mkdir(exist_ok = True)
hf_module = types.SimpleNamespace(
HfFileSystem = _FS,
hf_hub_download = hf_hub_download,
snapshot_download = snapshot_download,
constants = types.SimpleNamespace(
HF_HUB_CACHE = hub_cache if hub_cache is not None else str(cache_dir)
),
)
zoo_module = types.SimpleNamespace(
determine_base_model_source = determine_base_model_source,
_resolve_fp8_16bit_sibling = resolve_fp8_16bit_sibling,
)
# Stub the live-env cache resolver the pre-warm uses (matches what the merge reads).
_live = (
(hub_cache if hub_cache is not None else str(cache_dir))
if live_hub_cache == "__same__"
else live_hub_cache
)
hf_cache_module = types.SimpleNamespace(_active_caches = lambda: (None, _live, None))
monkeypatch.setitem(__import__("sys").modules, "huggingface_hub", hf_module)
monkeypatch.setitem(__import__("sys").modules, "unsloth_zoo.saving_utils", zoo_module)
monkeypatch.setitem(__import__("sys").modules, "unsloth_zoo.hf_cache", hf_cache_module)
fake_shutil = types.SimpleNamespace(
disk_usage = lambda path: types.SimpleNamespace(free = free_bytes)
)
prints = []
namespace = {
"os": os,
"shutil": fake_shutil,
"PeftModel": _FakePeftModel,
"get_model_name": lambda name, load_in_4bit: name.removesuffix("-bnb-4bit"),
"IS_KAGGLE_ENVIRONMENT": kaggle,
"IS_COLAB_ENVIRONMENT": colab,
"print": lambda *a, **k: prints.append(" ".join(str(x) for x in a)),
}
exec(
compile(_extract_function("_prewarm_base_model_hub_cache"), str(_SAVE_PY), "exec"),
namespace,
)
stubs = types.SimpleNamespace(
snapshot_download = snapshot_download,
hf_hub_download = hf_hub_download,
determine_base_model_source = determine_base_model_source,
resolve_fp8_16bit_sibling = resolve_fp8_16bit_sibling,
prints = prints,
)
return namespace["_prewarm_base_model_hub_cache"], stubs
def test_downloads_base_into_hub_cache(monkeypatch, tmp_path):
monkeypatch.delenv("UNSLOTH_PREWARM_HUB_CACHE", raising = False)
monkeypatch.delenv("HF_HUB_OFFLINE", raising = False)
monkeypatch.delenv("TRANSFORMERS_OFFLINE", raising = False)
fn, stubs = _build_env(monkeypatch, tmp_path)
fn(_FakePeftModel(), save_method = "merged_16bit", token = "tok")
assert len(stubs.snapshot_download.calls) == 1
_, kwargs = stubs.snapshot_download.calls[0]
assert kwargs["repo_id"] == "unsloth/gemma-4-31b-it"
# No local_dir: the whole point is populating the persistent cache.
assert "local_dir" not in kwargs
assert "model-00001-of-00002.safetensors" in kwargs["allow_patterns"]
assert "model.safetensors.index.json" in kwargs["allow_patterns"]
def test_skips_download_when_already_cached(monkeypatch, tmp_path):
fn, stubs = _build_env(monkeypatch, tmp_path, cached = True)
fn(_FakePeftModel(), save_method = "merged_16bit")
assert stubs.snapshot_download.calls == []
# The cached check must not hit the network.
assert all(kwargs.get("local_files_only") for _, kwargs in stubs.hf_hub_download.calls)
def test_skips_when_disk_too_small_for_cache_copy(monkeypatch, tmp_path):
fn, stubs = _build_env(monkeypatch, tmp_path, free_bytes = 60 * 1024**3)
fn(_FakePeftModel(), save_method = "merged_16bit")
assert stubs.snapshot_download.calls == []
@pytest.mark.parametrize("env_value", ["0", "false", "NO", "off"])
def test_env_opt_out(monkeypatch, tmp_path, env_value):
monkeypatch.setenv("UNSLOTH_PREWARM_HUB_CACHE", env_value)
fn, stubs = _build_env(monkeypatch, tmp_path)
fn(_FakePeftModel(), save_method = "merged_16bit")
assert stubs.snapshot_download.calls == []
@pytest.mark.parametrize("var", ["HF_HUB_OFFLINE", "TRANSFORMERS_OFFLINE"])
def test_offline_skips(monkeypatch, tmp_path, var):
monkeypatch.setenv(var, "1")
fn, stubs = _build_env(monkeypatch, tmp_path)
fn(_FakePeftModel(), save_method = "merged_16bit")
assert stubs.snapshot_download.calls == []
def test_kaggle_and_colab_skip(monkeypatch, tmp_path):
for flag in ("kaggle", "colab"):
fn, stubs = _build_env(monkeypatch, tmp_path, **{flag: True})
fn(_FakePeftModel(), save_method = "merged_16bit")
assert stubs.snapshot_download.calls == [], f"{flag} must skip pre-warm"
@pytest.mark.parametrize("save_method", ["merged_4bit", "forced_merged_4bit", "lora"])
def test_non_downloading_save_methods_skip(monkeypatch, tmp_path, save_method):
fn, stubs = _build_env(monkeypatch, tmp_path)
fn(_FakePeftModel(), save_method = save_method)
assert stubs.snapshot_download.calls == []
def test_local_base_model_skips(monkeypatch, tmp_path):
local_dir = tmp_path / "local_base"
local_dir.mkdir()
fn, stubs = _build_env(monkeypatch, tmp_path)
fn(_FakePeftModel(name_or_path = str(local_dir)), save_method = "merged_16bit")
assert stubs.snapshot_download.calls == []
def test_quantized_base_skips(monkeypatch, tmp_path):
fn, stubs = _build_env(
monkeypatch,
tmp_path,
base_source = ("unsloth/gemma-4-31b-it-bnb-4bit", False, None, True, "nf4"),
)
fn(_FakePeftModel(), save_method = "merged_16bit")
assert stubs.snapshot_download.calls == []
def test_non_peft_model_skips(monkeypatch, tmp_path):
fn, stubs = _build_env(monkeypatch, tmp_path)
fn(object(), save_method = "merged_16bit")
assert stubs.determine_base_model_source.calls == []
assert stubs.snapshot_download.calls == []
def test_consolidated_shard_excluded_when_proper_shards_exist(monkeypatch, tmp_path):
fn, stubs = _build_env(
monkeypatch,
tmp_path,
shards = [
("consolidated.safetensors", 14 * 1024**3),
("model-00001-of-00001.safetensors", 14 * 1024**3),
],
)
fn(_FakePeftModel(), save_method = "merged_16bit")
_, kwargs = stubs.snapshot_download.calls[0]
assert "consolidated.safetensors" not in kwargs["allow_patterns"]
assert "model-00001-of-00001.safetensors" in kwargs["allow_patterns"]
def test_consolidated_only_repo_is_kept(monkeypatch, tmp_path):
fn, stubs = _build_env(
monkeypatch,
tmp_path,
shards = [("consolidated.safetensors", 14 * 1024**3)],
)
fn(_FakePeftModel(), save_method = "merged_16bit")
_, kwargs = stubs.snapshot_download.calls[0]
assert "consolidated.safetensors" in kwargs["allow_patterns"]
def test_listing_failure_is_swallowed(monkeypatch, tmp_path):
fn, stubs = _build_env(monkeypatch, tmp_path)
stubs.determine_base_model_source.exc = RuntimeError("HF is down")
fn(_FakePeftModel(), save_method = "merged_16bit") # must not raise
assert stubs.snapshot_download.calls == []
def test_gpt_oss_bf16_mxfp4_swap_skips(monkeypatch, tmp_path):
fn, stubs = _build_env(monkeypatch, tmp_path)
fn(
_FakePeftModel(name_or_path = "unsloth/gpt-oss-20b-BF16"),
save_method = "mxfp4",
)
assert stubs.snapshot_download.calls == []
def test_missing_config_skips_cleanly(monkeypatch, tmp_path):
# A model whose config is None must skip silently, not fall into the outer
# exception handler that prints a misleading "Could not pre-cache" warning.
fn, stubs = _build_env(monkeypatch, tmp_path)
model = _FakePeftModel() # a PeftModel instance so the isinstance guard passes
model.config = None
fn(model, save_method = "merged_16bit")
assert stubs.determine_base_model_source.calls == []
assert stubs.snapshot_download.calls == []
assert not any(
"Could not pre-cache" in p for p in stubs.prints
), "missing config took the error path instead of a clean skip"
def test_relative_hub_cache_does_not_falsely_skip(monkeypatch, tmp_path):
# A relative HF_HUB_CACHE whose leaf does not exist yet must still resolve to a real
# root for the disk probe; without abspath the walk-up hits "" and pre-warm is skipped.
monkeypatch.chdir(tmp_path)
fn, stubs = _build_env(monkeypatch, tmp_path, hub_cache = "relcache/hub")
fn(_FakePeftModel(), save_method = "merged_16bit")
assert len(stubs.snapshot_download.calls) == 1, "relative cache path falsely skipped pre-warm"
def test_generic_save_calls_prewarm_before_merge():
"""unsloth_generic_save must pre-warm the cache before merge_and_overwrite_lora."""
tree = ast.parse(_SOURCE)
fn = next(
node
for node in ast.walk(tree)
if isinstance(node, ast.FunctionDef) and node.name == "unsloth_generic_save"
)
body_src = ast.get_source_segment(_SOURCE, fn)
prewarm_pos = body_src.find("_prewarm_base_model_hub_cache(")
merge_pos = body_src.find("merge_and_overwrite_lora(")
assert prewarm_pos != -1, "unsloth_generic_save no longer pre-warms the hub cache"
assert merge_pos != -1
assert prewarm_pos < merge_pos, "pre-warm must run before the merge downloads shards"
def test_prewarm_downloads_into_live_env_cache(monkeypatch, tmp_path):
# Download must target the live-env cache (what the merge reads), via cache_dir.
fn, stubs = _build_env(monkeypatch, tmp_path, live_hub_cache = "/mnt/persistent/hf/hub")
fn(_FakePeftModel(), save_method = "merged_16bit")
assert stubs.snapshot_download.calls[0][1]["cache_dir"] == "/mnt/persistent/hf/hub"
def test_prewarm_survives_runtime_cache_redirect(monkeypatch, tmp_path):
# Frozen constants (stale dir) vs the merge's runtime-redirected dir: the pre-warm
# must follow the redirect, else the cache-copy fast path misses and #6890 is unfixed.
fn, stubs = _build_env(
monkeypatch,
tmp_path,
hub_cache = "/read-only/original/hub",
live_hub_cache = "/writable/redirect/hub",
)
fn(_FakePeftModel(), save_method = "merged_16bit")
assert stubs.snapshot_download.calls[0][1]["cache_dir"] == "/writable/redirect/hub"
def test_cached_probe_uses_live_env_cache(monkeypatch, tmp_path):
# The already-cached fast path must probe the live-env cache dir too.
fn, stubs = _build_env(
monkeypatch, tmp_path, cached = True, live_hub_cache = "/mnt/persistent/hf/hub"
)
fn(_FakePeftModel(), save_method = "merged_16bit")
assert stubs.hf_hub_download.calls, "cached probe did not run"
assert all(
kw.get("cache_dir") == "/mnt/persistent/hf/hub" for _, kw in stubs.hf_hub_download.calls
)
def test_fp8_base_prewarms_16bit_sibling_not_fp8_repo(monkeypatch, tmp_path):
# A merged_16bit export of an FP8 base with a 16bit sibling merges onto the sibling,
# so the pre-warm must cache the sibling (what the merge downloads), not the FP8 repo.
fn, stubs = _build_env(
monkeypatch,
tmp_path,
base_source = ("unsloth/Model-FP8", False, None, True, "fp8"),
fp8_sibling = "unsloth/Model",
)
fn(_FakePeftModel(name_or_path = "unsloth/Model-FP8"), save_method = "merged_16bit")
assert stubs.resolve_fp8_16bit_sibling.calls, "sibling resolver was not consulted"
assert len(stubs.snapshot_download.calls) == 1
assert stubs.snapshot_download.calls[0][1]["repo_id"] == "unsloth/Model"
def test_fp8_base_without_sibling_still_prewarms_fp8_repo(monkeypatch, tmp_path):
# No sibling: the merge dequants the FP8 base in place, so caching the FP8 repo helps.
fn, stubs = _build_env(
monkeypatch,
tmp_path,
base_source = ("unsloth/Model-FP8", False, None, True, "fp8"),
fp8_sibling = None,
)
fn(_FakePeftModel(name_or_path = "unsloth/Model-FP8"), save_method = "merged_16bit")
assert len(stubs.snapshot_download.calls) == 1
assert stubs.snapshot_download.calls[0][1]["repo_id"] == "unsloth/Model-FP8"
def test_prewarm_filters_shards_through_index(monkeypatch, tmp_path):
# A repo with a leftover shard not referenced by the index: the merge keeps only the
# indexed shards, so the pre-warm must too (else the disk gate over-counts and
# snapshot_download fetches the unused leftover).
fn, stubs = _build_env(
monkeypatch,
tmp_path,
shards = [
("model-00001-of-00002.safetensors", 10 * 1024**3),
("model-00002-of-00002.safetensors", 10 * 1024**3),
("leftover-00001-of-00001.safetensors", 10 * 1024**3),
],
index_weight_map = {
"a.weight": "model-00001-of-00002.safetensors",
"b.weight": "model-00002-of-00002.safetensors",
},
)
fn(_FakePeftModel(), save_method = "merged_16bit")
allow = stubs.snapshot_download.calls[0][1]["allow_patterns"]
assert "leftover-00001-of-00001.safetensors" not in allow
assert "model-00001-of-00002.safetensors" in allow
assert "model-00002-of-00002.safetensors" in allow
def test_prewarm_keeps_all_shards_when_index_matches(monkeypatch, tmp_path):
# No leftover: every listed shard is indexed, so none are dropped.
fn, stubs = _build_env(
monkeypatch,
tmp_path,
shards = [
("model-00001-of-00002.safetensors", 10 * 1024**3),
("model-00002-of-00002.safetensors", 10 * 1024**3),
],
index_weight_map = {
"a.weight": "model-00001-of-00002.safetensors",
"b.weight": "model-00002-of-00002.safetensors",
},
)
fn(_FakePeftModel(), save_method = "merged_16bit")
allow = stubs.snapshot_download.calls[0][1]["allow_patterns"]
assert "model-00001-of-00002.safetensors" in allow
assert "model-00002-of-00002.safetensors" in allow