mirror of
https://github.com/unslothai/unsloth.git
synced 2026-08-17 04:43:52 +00:00
* Report the real cause when a save or GGUF export fails
Four related failures where saving reported something other than what
actually went wrong.
Kaggle blamed the disk for every GGUF failure. save_to_gguf is wrapped in a
bare `except Exception` that, whenever IS_KAGGLE_ENVIRONMENT is set, reported
the 20GB disk space limit no matter what failed. An unsupported architecture,
a missing tokenizer and a bad quant method all told the user to free up space.
bert_classification exposed it: ModernBERT for sequence classification has no
llama.cpp converter and cannot produce a GGUF at any size, so the notebook
reported a disk problem that did not exist. The message is now gated on
_gguf_failure_looks_like_disk, which takes either of two independent signals:
the error text or errno naming ENOSPC, or free space under 2GB on the save
directory or the cwd. Either alone is enough, because each can be absent for a
good reason. The check never raises. Both branches now chain with `from e`, so
the original traceback survives either way.
That helper originally landed in the MIDDLE of unsloth_save_pretrained_gguf
instead of after it, and this commit carries the corrected placement. The
function still parsed and still ran, but it now ended right after building the
quantization method list, so it did the 16-bit merge and returned without ever
calling save_to_gguf. Everything below the insertion point became dead code
stranded inside the helper, under its return. save_pretrained_gguf raised
nothing, printed its usual progress, left a populated merge directory behind
and produced no .gguf, which is indistinguishable from success unless you go
looking for the file. It cost a full sweep before it was noticed, and no
ordinary unit test would have caught it since both halves are valid Python and
the import kept working. tests/test_save_entrypoints_reach_converter.py checks
the structural properties directly against the AST instead: the converter call
is present, reachable, and not stranded after a return anywhere in save.py.
A save that failed on an offloaded model said nothing about the offload.
A model too large for its GPU is partially offloaded, and accelerate leaves
those parameters on the meta device with the real data in a weights_map.
Saving then walks into accelerate internals that assume the map is populated,
so the user sees "'NoneType' object is not subscriptable" or "Cannot copy out
of meta tensor; no data!". Qwen3_MoE, Qwen3_5_MoE and Nemotron-3-Nano-30B all
failed to export this way. The hint is appended to the existing error, never
substituted for it, the exception type is unchanged, and it stays empty unless
a meta parameter is really present. It caps at three parameter names. The
helper sits above the @_normalize_tied_weights_keys_for_save decorator so the
decorator still applies to unsloth_save_pretrained_gguf.
SentenceTransformer had a different save_pretrained_merged signature.
FastLanguageModel takes (save_directory, tokenizer = None, save_method =
"merged_16bit", ...) and every notebook and doc calls it positionally, but
FastSentenceTransformer bound the same public name to (self, save_directory,
**kwargs), so that call answered with "takes 2 positional arguments but 4 were
given". Five embedding notebooks hit it. Keyword callers were unaffected,
which is why it went unnoticed. Both closures that bind the name now take
tokenizer and save_method positionally with the same defaults, so every
previous keyword call behaves exactly as before. save_method is honoured
rather than accepted and dropped: the merge_and_unload path can only produce a
16-bit merge, so it refuses anything else, and refuses before writing anything
so no half-finished directory is left behind. The other path forwards to the
FastLanguageModel merge, which understands every method.
* Say when a GGUF conversion was killed for running out of host RAM
The converter is a subprocess and llama.cpp holds tensors in host RAM, so a
large model exceeds what a free Colab or Kaggle VM has. The kernel OOM-killer
takes it and subprocess reports only
Command '[...]' died with <Signals.SIGKILL: 9>
which names no resource at all. The user's first guess is VRAM, and on Kaggle
the existing branch would have called it a disk problem, which is the wrong
advice twice over.
Gemma3N_(4B)-Audio is the case, measured today: it trains, infers and merges
cleanly (15.7GB written, 155GB disk free, freed before the conversion) and the
converter is then SIGKILLed. It happens on the high-RAM T4 as well as the plain
one, so this is not a lane that can be configured around.
SIGKILL alone is the signal: a converter that fails on its own raises and exits
non-zero, so a kill is either the OOM-killer or someone stopping the run by
hand, and both are worth naming. Checked before the Kaggle disk branch so a
SIGKILL on a fullish disk is not misreported, and chained with `from e` so the
original survives.
7 tests, including that an ordinary converter failure and a real disk failure
are both still reported as themselves.
* Honour save_method on the no-modules path and gate the inner Kaggle messages
Three follow-ups to the review of the previous two commits.
The no_modules fallback in the second _save_pretrained_merged accepted a
save_method and then dropped it: the branch strips the Unsloth-specific kwargs
and calls merge_and_unload() unconditionally, having already deleted the
adapter files save_pretrained had just written. So save_pretrained_merged(dir,
tokenizer, "lora") returned merged full weights for a request to write
adapters, silently. That path is reached whenever no modules.json is found,
which is any plain HF encoder checkpoint loaded through FastSentenceTransformer.
It now refuses before writing anything, exactly as the fast-encoder definition
above it already did. The modules.json path is unchanged and still forwards
every method to the FastLanguageModel merge.
save_method spellings now mean the same thing on every model type.
unsloth_save_model lowercases and strips spaces before validating, so
save_method = "MERGED_16BIT" and "merged 16bit" have always worked; the new
signature check compared the raw string and rejected them, which broke keyword
callers the previous commit promised were unaffected. Both closures normalize
first.
save_to_gguf's own missing-output and quantize handlers still added Kaggle's
20GB disk explanation for every failure, and they bake it into the exception
message, so the outer gate cannot recover the real cause afterwards. Both now
take the same _gguf_failure_looks_like_disk check, and the quantize branch
chains with from e like the rest. A broken llama.cpp build with 19GB free is
what that handler used to call a disk problem: see #835, where the reporter was
told about Kaggle's disk limit while not on Kaggle.
Tests: 6 new in test_st_save_merged_signature.py (28) covering the no_modules
refusal, that it refuses before merging or writing, that the forwarding path
still accepts "lora", and the case/space variants on both closures; 3 new in
test_kaggle_gguf_error_message.py (25) asserting no 20GB message is left
ungated anywhere in save.py. Each fails without its fix.
* Add the AGPL-3.0 header to the new test files
* Use this repository's Apache-2.0 header on the new test files
unsloth is Apache-2.0 (LICENSE, and pyproject license = "Apache-2.0"), and 303
of its 314 test files carry no header at all while 10 carry the Apache one. The
new files here went in with an AGPL-3.0 notice attributed to Unsloth Zoo, which
is the wrong licence, the wrong project and against the convention here.
* Recognise a shell-wrapped exit 137 as an OOM kill in the GGUF diagnostics
* Read save.py as UTF-8 in the converter reachability test
* State the free-space premise the non-disk tests are written under
_gguf_failure_looks_like_disk has a second signal independent of the message:
a filesystem under 2GiB free is a disk failure whatever the exception says. It
probes save_directory and then os.getcwd(), so on a host whose working
directory is that full, every 'this is NOT a disk problem' test inverts and
fails for a reason unrelated to the message it asserts about.
Report 99GiB rather than pinning _DISK_HEADROOM_BYTES to 0, so the real
comparison still executes and a nonsensical headroom would still be caught.
* Apply ruff kwarg-spacing formatting
pre-commit.ci's ruff-format-with-kwargs hook reported "files were modified by
this hook" and could not push the result back, so the check stayed red.
Applied locally instead.
Three source-asserting tests had to be made robust rather than re-pinned,
since the reformatting is legitimate and will happen again:
- test_the_flag_is_recorded_beside_the_dtype counted the literal
"self._autocast_enabled = (", which the formatter collapsed onto one line.
Now pairs each _autocast_dtype initialiser with a nearby flag assignment by
position, so it still catches a genuinely missing one.
- test_the_original_error_is_still_reported anchored on a whole f-string
literal, which the formatter merged with the hint that follows it. Anchors
on the message text alone now; either shape satisfies the intent.
- test_the_source_beats_the_version_fallback exposed a real fragility rather
than a test problem: the detector matched the literal "kw_only=True", so any
whitespace in transformers' own source would have read an install that needs
nothing as one that needs patching. Made whitespace-tolerant.
* Apply the repo ruff-format hook (ruff 0.6.9) to files this PR touches
* Fix MLX device type detection under UNSLOTH_ALLOW_CPU for PR #7861
get_device_type() checked the UNSLOTH_ALLOW_CPU escape hatch before the MLX
check, so an Apple Silicon host with MLX reported "cuda" and get_device_count()
then dereferenced torch, which device_type.py never imports on MLX:
unsloth/device_type.py:99: in get_device_count
return torch.cuda.device_count()
E NameError: name 'torch' is not defined
unsloth/__init__.py and unsloth_zoo.device_type both pick MLX first, so the two
also disagreed about the runtime inside one process. Non-MLX hosts are
unaffected: _IS_MLX is False there, so the ordering never applies.
* Pin the CPU-fallback env in the device helper tests for PR #7861
---------
Co-authored-by: danielhanchen <unslothshared@gmail.com>
203 lines
7 KiB
Python
203 lines
7 KiB
Python
import importlib.util
|
|
import sys
|
|
import types
|
|
from pathlib import Path
|
|
|
|
from packaging.version import Version
|
|
|
|
|
|
REPO_ROOT = Path(__file__).resolve().parents[1]
|
|
DEVICE_TYPE_PATH = REPO_ROOT / "unsloth" / "device_type.py"
|
|
CUDA_PROPERTIES = types.SimpleNamespace(
|
|
name = "NVIDIA B200",
|
|
total_memory = 16 * 1024**3,
|
|
major = 10,
|
|
minor = 0,
|
|
)
|
|
|
|
|
|
def _load_device_type(
|
|
monkeypatch,
|
|
torch_module,
|
|
mlx_available = False,
|
|
allow_cpu = False,
|
|
):
|
|
# Always pinned, never inherited. UNSLOTH_ALLOW_CPU short-circuits
|
|
# get_device_type() to "cuda", so a GPU-less host that exports it silently
|
|
# rewrites what the hip and xpu cases are testing.
|
|
if allow_cpu:
|
|
monkeypatch.setenv("UNSLOTH_ALLOW_CPU", "1")
|
|
else:
|
|
monkeypatch.delenv("UNSLOTH_ALLOW_CPU", raising = False)
|
|
|
|
package_name = "_device_helpers_test"
|
|
package = types.ModuleType(package_name)
|
|
package.__path__ = [str(DEVICE_TYPE_PATH.parent)]
|
|
monkeypatch.setitem(sys.modules, package_name, package)
|
|
|
|
bnb_availability = types.ModuleType(f"{package_name}.bnb_availability")
|
|
bnb_availability.native_kernels_ready = lambda *_args, **_kwargs: True
|
|
monkeypatch.setitem(sys.modules, bnb_availability.__name__, bnb_availability)
|
|
|
|
zoo = types.ModuleType("unsloth_zoo")
|
|
zoo.__path__ = []
|
|
zoo_utils = types.ModuleType("unsloth_zoo.utils")
|
|
zoo_utils.Version = Version
|
|
zoo_mlx = types.ModuleType("unsloth_zoo.mlx")
|
|
zoo_mlx.is_mlx_available = lambda: mlx_available
|
|
monkeypatch.setitem(sys.modules, "unsloth_zoo", zoo)
|
|
monkeypatch.setitem(sys.modules, "unsloth_zoo.utils", zoo_utils)
|
|
monkeypatch.setitem(sys.modules, "unsloth_zoo.mlx", zoo_mlx)
|
|
|
|
bitsandbytes = types.ModuleType("bitsandbytes")
|
|
bitsandbytes.__version__ = "0.49.2"
|
|
monkeypatch.setitem(sys.modules, "bitsandbytes", bitsandbytes)
|
|
|
|
if torch_module is None:
|
|
monkeypatch.setitem(sys.modules, "torch", None)
|
|
else:
|
|
monkeypatch.setitem(sys.modules, "torch", torch_module)
|
|
|
|
module_name = f"{package_name}.device_type"
|
|
spec = importlib.util.spec_from_file_location(module_name, DEVICE_TYPE_PATH)
|
|
module = importlib.util.module_from_spec(spec)
|
|
monkeypatch.setitem(sys.modules, module_name, module)
|
|
spec.loader.exec_module(module)
|
|
return module
|
|
|
|
|
|
def _fake_torch(
|
|
*,
|
|
properties,
|
|
hip_version = None,
|
|
xpu_backend = None,
|
|
cuda_available = True,
|
|
):
|
|
torch = types.ModuleType("torch")
|
|
torch.cuda = types.SimpleNamespace(
|
|
is_available = lambda: cuda_available,
|
|
device_count = lambda: 1,
|
|
get_device_properties = lambda _index: properties,
|
|
get_device_name = lambda _index: "",
|
|
empty_cache = lambda: None,
|
|
current_device = lambda: 0,
|
|
)
|
|
torch.version = types.SimpleNamespace(
|
|
cuda = "12.8",
|
|
hip = hip_version,
|
|
xpu = "2026.1",
|
|
)
|
|
if xpu_backend is not None:
|
|
torch.xpu = xpu_backend
|
|
return torch
|
|
|
|
|
|
def test_cuda_import_does_not_require_torch_xpu(monkeypatch):
|
|
torch = _fake_torch(properties = CUDA_PROPERTIES)
|
|
|
|
device_type = _load_device_type(monkeypatch, torch)
|
|
|
|
assert not hasattr(torch, "xpu")
|
|
assert device_type._DEVICE_MODULE is torch.cuda
|
|
|
|
|
|
def test_hip_stats_preserve_arch_name_fallback(monkeypatch):
|
|
properties = types.SimpleNamespace(
|
|
name = "AMD Radeon Graphics",
|
|
total_memory = 8 * 1024**3,
|
|
gcnArchName = "gfx1100:sramecc+:xnack-",
|
|
)
|
|
torch = _fake_torch(properties = properties, hip_version = "6.3")
|
|
device_type = _load_device_type(monkeypatch, torch)
|
|
|
|
name, snippet, max_memory = device_type.get_device_stats()
|
|
|
|
assert name == "AMD gfx1100 GPU. "
|
|
assert snippet == "ROCm Toolkit: 6.3."
|
|
assert max_memory == 8.0
|
|
|
|
|
|
def test_xpu_cache_and_current_device_dispatch(monkeypatch):
|
|
xpu_calls = []
|
|
xpu_backend = types.SimpleNamespace(
|
|
is_available = lambda: True,
|
|
device_count = lambda: 1,
|
|
empty_cache = lambda: xpu_calls.append("empty_cache"),
|
|
current_device = lambda: 3,
|
|
get_device_properties = lambda _index: types.SimpleNamespace(
|
|
name = "Intel Arc",
|
|
total_memory = 8 * 1024**3,
|
|
),
|
|
)
|
|
torch = _fake_torch(
|
|
properties = CUDA_PROPERTIES,
|
|
xpu_backend = xpu_backend,
|
|
cuda_available = False,
|
|
)
|
|
device_type = _load_device_type(monkeypatch, torch)
|
|
|
|
device_type.clean_gpu_cache()
|
|
name, snippet, max_memory = device_type.get_device_stats()
|
|
|
|
assert xpu_calls == ["empty_cache"]
|
|
assert device_type.get_current_device() == 3
|
|
assert (name, snippet, max_memory) == ("Intel Arc. ", "Intel Toolkit: 2026.1.", 8.0)
|
|
|
|
|
|
def test_cpu_fallback_does_not_override_mlx(monkeypatch):
|
|
# UNSLOTH_ALLOW_CPU used to be checked first, so an MLX Mac reported "cuda"
|
|
# and get_device_count() then hit torch, which is never imported there.
|
|
device_type = _load_device_type(
|
|
monkeypatch,
|
|
torch_module = None,
|
|
mlx_available = True,
|
|
allow_cpu = True,
|
|
)
|
|
|
|
assert device_type.DEVICE_TYPE == "mlx"
|
|
assert device_type.DEVICE_COUNT == 1
|
|
|
|
|
|
def test_cpu_fallback_still_reports_cuda_off_mlx(monkeypatch):
|
|
# The GPU hosts' behaviour must be unchanged: no MLX means the CPU fallback wins.
|
|
torch = _fake_torch(properties = CUDA_PROPERTIES, cuda_available = False)
|
|
|
|
device_type = _load_device_type(monkeypatch, torch, allow_cpu = True)
|
|
|
|
assert device_type.DEVICE_TYPE == "cuda"
|
|
assert device_type.DEVICE_COUNT == 1
|
|
|
|
|
|
def test_mlx_helpers_do_not_require_torch(monkeypatch):
|
|
device_type = _load_device_type(
|
|
monkeypatch,
|
|
torch_module = None,
|
|
mlx_available = True,
|
|
)
|
|
device_type.clean_gpu_cache()
|
|
|
|
assert device_type._DEVICE_MODULE is None
|
|
assert device_type.get_current_device() == 0
|
|
|
|
|
|
def test_model_call_sites_use_shared_cache_dispatch():
|
|
llama_source = (REPO_ROOT / "unsloth" / "models" / "llama.py").read_text(encoding = "utf-8")
|
|
vision_source = (REPO_ROOT / "unsloth" / "models" / "vision.py").read_text(encoding = "utf-8")
|
|
gemma_source = (REPO_ROOT / "unsloth" / "models" / "gemma.py").read_text(encoding = "utf-8")
|
|
gemma2_source = (REPO_ROOT / "unsloth" / "models" / "gemma2.py").read_text(encoding = "utf-8")
|
|
granite_source = (REPO_ROOT / "unsloth" / "models" / "granite.py").read_text(encoding = "utf-8")
|
|
|
|
assert "torch.xpu.empty_cache()" not in llama_source
|
|
assert "torch.xpu.empty_cache()" not in vision_source
|
|
assert "torch.cuda.empty_cache()" not in vision_source
|
|
assert "device_context" not in llama_source
|
|
assert "device_context" not in vision_source
|
|
assert 'if DEVICE_TYPE == "xpu":\n vllm_version = ""' in vision_source
|
|
assert "torch.cuda.current_device()" not in gemma_source
|
|
assert gemma_source.count("get_current_device()") >= 3
|
|
assert "torch.cuda.empty_cache()" not in gemma_source
|
|
assert "clean_gpu_cache()" in gemma_source
|
|
assert "torch.cuda.empty_cache()" not in gemma2_source
|
|
assert "clean_gpu_cache()" in gemma2_source
|
|
assert "torch.cuda.empty_cache()" not in granite_source
|
|
assert "clean_gpu_cache()" in granite_source
|