mirror of
https://github.com/unslothai/unsloth.git
synced 2026-08-17 21:03:59 +00:00
* Survive a torchao that does not match the installed torch
torchao is an optional dependency that unsloth never pins tightly, so the
version a notebook resolves is whatever the index serves that day. Several
recent torchao releases broke `import unsloth` outright, each in a different
place. This collects the fixes.
1. An old torchao aborts LoRA creation that never uses it.
`peft.import_utils.is_torchao_available` returns False when torchao is
absent, but raises when it is installed and older than peft's minimum:
ImportError: Found an incompatible version of torchao. Found version
0.10.0, but only versions above 0.16.0 are supported
peft calls it from `dispatch_torchao` for every LoRA layer, so one stale
optional dependency ends `get_peft_model` for a model that has nothing to
do with torchao. Kaggle ships torchao 0.10.0 preinstalled, so the
difference between pass and fail was one `pip install` line in a notebook.
`fix_peft_stale_torchao_import_error` answers False and warns once, naming
the upgrade. Only the version complaint is caught; any other ImportError
still propagates. Both binding sites are patched, since
`from peft.import_utils import is_torchao_available` copies the original
into peft.tuners.lora.torchao, the actual caller.
2. torchao 0.18 imports torch symbols that torch below 2.10 does not have.
0.17 guarded the import behind `torch_version_at_least("2.10.0")`; 0.18.0
left it at module level:
ImportError: cannot import name 'ScalingType' from 'torch.nn.functional'
It surfaces while importing transformers, so it reaches the user as a bare
Exception naming neither torchao nor torch, and kills `import unsloth`.
`fix_torchao_torch_symbol_skew` supplies placeholders that import cleanly
and refuse to be used: 0.17 simply left these names undefined on old torch,
so anything wanting them already raised, and a stub impersonating a real
enum could hand a float8 path a meaningless value. The symbol list comes
from the whole installed package, not one file:
grep -rh "from torch.nn.functional import" torchao/
which is how scaled_grouped_mm got in. Reading only mx_formats/mx_tensor.py
gives ScalingType and SwizzleType and misses the one on the path of a plain
`import torchao`. scaled_dot_product_attention exists on every supported
torch, and the loop skips anything torch already provides.
3. torchao 0.18 also wants an aten op that torch below 2.8 does not have.
It registers a float8 handler at module scope with
`@implements([aten._grouped_mm.default])`, and the attribute lookup raises
before the decorator is applied. Sibling of the symbol skew but a different
lookup: that one goes through torch.nn.functional, this one through
torch.ops, so supplying the functional names does nothing for it. The
schema is registered and nothing else, as a FRAGMENT into aten, and calling
it raises: torchao only wants somewhere to hang a handler this torch will
never dispatch to, and a plausible-looking grouped matmul would be worse
than the crash it replaces.
4. The in-process fix does not reach vLLM. vLLM inspects model architectures
in a separate process which imports torchao itself, so with
fast_inference = True the run still died, and the user saw only
"Model architectures ['...'] failed to be inspected".
`propagate_torchao_fix_to_subprocesses` stages a sitecustomize on
PYTHONPATH, which children inherit. It chains to any other sitecustomize on
sys.path rather than shadowing it, guarded so a broken one elsewhere cannot
become a startup crash for every subprocess. The logic is inlined rather
than importing unsloth, which would pay the full import cost at the start of
every subprocess on the machine and could recurse. Writes via os.replace so
a concurrent run cannot expose a truncated file, and uses os.pathsep for
Windows.
5. torchao 0.18.0 moved torchao/dtypes/nf4tensor.py to
torchao/quantization/quantize_/workflows/nf4/nf4_tensor.py. torchtune still
imports the old path and xcodec2 imports torchtune, so TTS work died with
ModuleNotFoundError one cell after a green install. `fix_torchao_nf4tensor_move`
appends a meta path finder after the real finders, so an older torchao that
still ships the module always wins, and the loader returns the relocated
module itself rather than a hand-copied stub, so it cannot rot as symbols
are added. Resolution is lazy, so `import unsloth` does not pay for a
torchao import nobody asked for.
Every fix is gated on torchao being installed, the version actually having the
bug, and the torch symbol genuinely being absent. On a healthy pair none of
them do anything, so nothing is masked. The symbol fix is also called from the
MLX branch of __init__.py, which never reaches _gpu_init.
Tests: 84 across five files. They build real package trees and import them in
subprocesses rather than stubbing sys.modules, since appended-versus-inserted
finders and PYTHONPATH inheritance only mean anything to the real import
machinery. 82 pass here; 2 skip because this machine has torchao installed and
a torch that already provides the symbols.
* Stop the in-process torchao fix from disabling the subprocess one
_gpu_init.py calls fix_torchao_torch_symbol_skew() immediately before
propagate_torchao_fix_to_subprocesses(). The first adds a placeholder to
torch.nn.functional for every symbol torch is missing and registers the
aten::_grouped_mm schema; the second then asked hasattr(F, n) and
_torch_op_is_missing("aten", "_grouped_mm") and read both as healthy. So it
returned before staging anything, in exactly the environments it exists for.
Measured on torch 2.9.1: the fix places all three symbols, propagate returns
None, nothing is written and PYTHONPATH is untouched. vLLM's architecture
inspector child therefore still died with the original ImportError, which is
the whole reason for that half of the change.
The gate now asks whether TORCH provides the symbol rather than whether one is
merely present, using the __unsloth_placeholder__ marker the placeholder
already carries, and treats a registered _aten_grouped_mm_library as proof the
op was missing. Reordering the two calls in _gpu_init.py would also work today
but leaves the same landmine for the next reorder.
The decisive symbol test never ran. Its skip predicate was any(hasattr(F, n)
for n in _TORCHAO_TORCH_SYMBOLS), and that tuple contains
scaled_dot_product_attention, which the test above it asserts exists on every
supported torch, so it was unconditionally true. It skipped on every machine,
including this one, which is genuinely missing ScalingType and SwizzleType. Now
gated on the two symbols the reproduced import line actually names, and it
passes.
The nf4 alias is installed on the MLX path too. _gpu_init.py was its only
caller and the _IS_MLX branch never reaches it, so an Apple Silicon user whose
audio work reaches xcodec2 and torchtune still hit the ModuleNotFoundError this
fix exists for. It sits next to the symbol fix already called there.
The alias loader no longer corrupts the relocated module's specification.
create_module returns the real module, and module_from_spec then overwrites its
__spec__ with the old-name one, because importlib/_bootstrap.py assigns
__spec__ unconditionally while guarding every other attribute. find_spec for
the new name reported the old one, and reload ran the alias loader's no-op
exec_module instead of the file. exec_module now restores it.
Tests: 90 pass, 1 skip, over the five torchao files. 4 new, each failing
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.
* tests: stop reading the runner's own torchao as the fixture
CI now installs torchao 0.18.0, which broke two premises.
test_the_real_environment_is_left_alone decided "this machine has the
old layout" from find_spec, but by then the session had imported unsloth
and the alias answers that query, so a new-layout runner looked like an
old-layout one and the assert fired. Ask the filesystem instead, in a
child, like every other case in that file.
test_it_does_nothing_when_torchao_is_absent shadowed torch on PYTHONPATH
but could not un-install torchao, and importlib.metadata is what the hook
reads, so the runner's real torchao made "absent" false. Run that child
in a venv without system packages.
* Keep empty PYTHONPATH components, and stop two probes reading their own placeholders
The rebuild dropped empty components. An empty component is an import
location, not padding: it is what PYTHONPATH="$PYTHONPATH:/opt/lib" leaves
behind when PYTHONPATH was unset, and CPython reads it as the cwd (3.11+
absolutises every component in Modules/getpath.py and abspath("") is the cwd;
3.10 puts the literal "" on sys.path and site.removeduppaths() absolutises
it). Dropping them took an import location away from every descendant. The
opposite case still has to be handled: a set-but-empty PYTHONPATH is ignored
by CPython, so splitting it would ADD the cwd. Hence the 'if current' guard
rather than dropping the filter outright.
Two test probes had the mirror-image bug. conftest.py imports unsloth, so on
the environment these fixes exist for the placeholders are already on
torch.nn.functional before any test runs, and a plain hasattr reads that as a
healthy torch. Use _torch_really_has, and drop the placeholders first in the
two tests that need to observe the 'before' state.
* 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.
* Chain package and pyc form sitecustomize hooks
The generated hook probed each sys.path entry for a sitecustomize.py file,
so an existing sitecustomize installed as a package or shipped as a bare
.pyc was skipped in every child process. Use PathFinder.find_spec instead,
which is what CPython itself would have done.
* Settle deferred compile-mode switches between training steps
* [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
* Fix the torchao ImportError filter and the subprocess fix directory mode for PR #7955
Match peft's stale-version complaint instead of the word "torchao", so a
half-installed torchao ("No module named 'torchao.quantization'") or an
unloadable extension still surfaces rather than being reported as absent.
Tighten an already-existing subprocess fix directory: os.makedirs(exist_ok)
leaves a group- or world-writable directory alone, and that directory holds
the sitecustomize on PYTHONPATH. chmod it back to 0700, re-read, and refuse
it if the bits do not come off.
* Fix the subprocess sitecustomize importing torch at every startup for PR #7955
The generated sitecustomize goes on PYTHONPATH, so it runs in every
Python descendant, and it called _apply() eagerly: with torchao 0.18
installed that imported torch during interpreter startup even in
children that never touch torchao (0.011s to 1.284s for python -c pass
here). Defer it behind a meta_path finder that fires on the torchao
import, which is still before torchao's module body.
* Fix the untrusted pre-existing subprocess hook and the shadowed chained sitecustomize for PR #7955
* Tighten the comments and docstrings added by this PR
Same intent, fewer lines: collapse the multi-paragraph explanations in
import_fixes.py and the new tests to their load-bearing sentences, and drop
the restatements of what the code beside them already says. No code changes.
* Chain by canonical path and stage the subprocess hook through a private temp file
Two symlink holes in the generated sitecustomize for PR #7955.
A symlink alias of the hook directory also on PYTHONPATH compared unequal to
the string spelling the hook was loaded from, so PathFinder handed back this
same file as the sitecustomize to chain to. The two spellings executed one
another until the stack ran out, and every unwound level installed another
import hook: 246 of them in sys.meta_path here, each one running the fix on a
later import torchao, and the real sitecustomize elsewhere on the path never
ran at all. Compare canonical paths, and refuse a spec whose origin resolves
to this hook.
The staging file used a predictable name (sitecustomize.py.<pid>.tmp). In the
upgrade case the directory tightener exists for, a directory left group- or
world-writable by an older run still holds whatever was planted in it, so that
name can already be a symlink to a file another user owns. The write followed
it, the chmod failed silently, and os.replace installed the link itself as
sitecustomize.py, leaving it theirs to rewrite after the directory was secured.
Create the file with a random name under O_CREAT|O_EXCL|O_NOFOLLOW instead, and
remove it if anything fails.
* [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
---------
Co-authored-by: danielhanchen <unslothshared@gmail.com>
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: danielhanchen <unslothai@gmail.com>
170 lines
6.8 KiB
Python
170 lines
6.8 KiB
Python
# Copyright 2023-present Daniel Han-Chen & the Unsloth team. All rights reserved.
|
|
#
|
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
# you may not use this file except in compliance with the License.
|
|
# You may obtain a copy of the License at
|
|
#
|
|
# http://www.apache.org/licenses/LICENSE-2.0
|
|
#
|
|
# Unless required by applicable law or agreed to in writing, software
|
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
# See the License for the specific language governing permissions and
|
|
# limitations under the License.
|
|
|
|
"""An empty PYTHONPATH component is an import location, not padding.
|
|
|
|
`propagate_torchao_fix_to_subprocesses` prepends its directory to PYTHONPATH
|
|
for every descendant process, and rebuilding that value with
|
|
`[p for p in current.split(os.pathsep) if p]` would drop empty components.
|
|
`export PYTHONPATH="$PYTHONPATH:/opt/mylib"` leaves one on the very common
|
|
machine where PYTHONPATH was unset, and CPython reads it as the cwd: 3.11+
|
|
absolutises every component in Modules/getpath.py (abspath("") is the cwd),
|
|
and 3.10 puts the literal "" on sys.path, which site.removeduppaths() then
|
|
makes absolute. Same outcome, different layer.
|
|
|
|
A SET-BUT-EMPTY PYTHONPATH is the opposite case: CPython ignores it entirely,
|
|
so turning "" into a lone "" component would ADD the cwd to every descendant.
|
|
The rebuild keeps that special case.
|
|
"""
|
|
|
|
import importlib.util
|
|
import os
|
|
import subprocess
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
ROOT = Path(__file__).resolve().parents[1]
|
|
sys.path.insert(0, str(ROOT))
|
|
|
|
from unsloth import import_fixes as IF # noqa: E402
|
|
|
|
|
|
def _stage(monkeypatch, tmp_path, pythonpath):
|
|
"""Drive the real function with its gate forced open.
|
|
|
|
The gate returns None on a healthy torch/torchao pair, so without this
|
|
nothing below would run any of the code under test. `find_spec("torchao")`
|
|
is the one part not faked, so skip rather than pass when it is absent.
|
|
"""
|
|
if importlib.util.find_spec("torchao") is None:
|
|
pytest.skip("no torchao here; the function returns before PYTHONPATH")
|
|
monkeypatch.setattr(
|
|
IF, "importlib_version", lambda name: "0.18.0" if name == "torchao" else "0"
|
|
)
|
|
monkeypatch.setattr(IF, "_torch_really_has", lambda F, name: False)
|
|
# Keep the generated sitecustomize in tmp_path, so no staged directory
|
|
# is left behind in the real temp dir.
|
|
monkeypatch.setattr("tempfile.gettempdir", lambda: str(tmp_path))
|
|
# monkeypatch.setenv/delenv restores PYTHONPATH at teardown even though
|
|
# the function writes os.environ directly, so no restore fixture is needed.
|
|
if pythonpath is None:
|
|
monkeypatch.delenv("PYTHONPATH", raising = False)
|
|
else:
|
|
monkeypatch.setenv("PYTHONPATH", pythonpath)
|
|
|
|
directory = IF.propagate_torchao_fix_to_subprocesses()
|
|
assert directory is not None, "gate did not fire; the test proves nothing"
|
|
# Makes the exact-string assertions below sound.
|
|
assert os.pathsep not in directory, directory
|
|
return directory, os.environ.get("PYTHONPATH", "")
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"before",
|
|
[
|
|
os.pathsep + "/opt/lib", # PYTHONPATH=$PYTHONPATH:/opt/lib, unset
|
|
"/opt/lib" + os.pathsep, # PYTHONPATH=/opt/lib:$PYTHONPATH, unset
|
|
"/opt/a" + os.pathsep + os.pathsep + "/opt/b", # interior empty
|
|
os.pathsep, # separator only
|
|
],
|
|
)
|
|
def test_empty_components_survive(monkeypatch, tmp_path, before):
|
|
directory, after = _stage(monkeypatch, tmp_path, before)
|
|
assert after == directory + os.pathsep + before, after
|
|
|
|
|
|
@pytest.mark.parametrize("before", [None, ""])
|
|
def test_an_absent_or_empty_pythonpath_does_not_gain_the_cwd(monkeypatch, tmp_path, before):
|
|
"""CPython ignores a set-but-empty PYTHONPATH, so "" must not be split
|
|
into a lone "" component."""
|
|
directory, after = _stage(monkeypatch, tmp_path, before)
|
|
assert after == directory, after
|
|
|
|
|
|
def test_it_is_still_idempotent(monkeypatch, tmp_path):
|
|
"""Two calls must not stack the directory, nor eat the empty components
|
|
the first preserved."""
|
|
before = os.pathsep + "/opt/lib"
|
|
directory, after = _stage(monkeypatch, tmp_path, before)
|
|
assert IF.propagate_torchao_fix_to_subprocesses() == directory
|
|
assert os.environ["PYTHONPATH"] == after
|
|
|
|
|
|
# ---- the premise and the consequence, with real child processes -----------
|
|
|
|
|
|
def _probe_tree(tmp_path):
|
|
"""cwddir holds a module reachable ONLY through the cwd.
|
|
|
|
The child runs as a script in scriptdir, so sys.path[0] is scriptdir, never
|
|
the cwd. `only_in_cwd` is importable iff a PYTHONPATH component is the cwd.
|
|
"""
|
|
cwddir = tmp_path / "cwddir"
|
|
libdir = tmp_path / "libdir"
|
|
scriptdir = tmp_path / "scriptdir"
|
|
for d in (cwddir, libdir, scriptdir):
|
|
d.mkdir(exist_ok = True)
|
|
(cwddir / "only_in_cwd.py").write_text("MARKER = 1\n", encoding = "utf-8")
|
|
(scriptdir / "probe.py").write_text(
|
|
"import importlib.util as u\nprint(u.find_spec('only_in_cwd') is not None)\n",
|
|
encoding = "utf-8",
|
|
)
|
|
return cwddir, libdir, scriptdir
|
|
|
|
|
|
def _cwd_is_importable(cwddir, scriptdir, pythonpath):
|
|
env = {k: v for k, v in os.environ.items() if k != "PYTHONPATH"}
|
|
if pythonpath is not None:
|
|
env["PYTHONPATH"] = pythonpath
|
|
out = subprocess.run(
|
|
[sys.executable, str(scriptdir / "probe.py")],
|
|
cwd = str(cwddir),
|
|
env = env,
|
|
capture_output = True,
|
|
text = True,
|
|
timeout = 300,
|
|
)
|
|
assert out.returncode == 0, out.stderr
|
|
return out.stdout.strip() == "True"
|
|
|
|
|
|
def test_the_cwd_really_is_importable_from_an_empty_component(tmp_path):
|
|
"""The premise, against this interpreter, with both controls."""
|
|
cwddir, libdir, scriptdir = _probe_tree(tmp_path)
|
|
assert _cwd_is_importable(cwddir, scriptdir, None) is False
|
|
assert _cwd_is_importable(cwddir, scriptdir, str(libdir)) is False
|
|
assert _cwd_is_importable(cwddir, scriptdir, os.pathsep + str(libdir)) is True
|
|
|
|
|
|
def test_the_rewritten_pythonpath_still_reaches_the_cwd(monkeypatch, tmp_path):
|
|
"""The consequence: what a descendant process actually sees afterwards."""
|
|
cwddir, libdir, scriptdir = _probe_tree(tmp_path)
|
|
before = os.pathsep + str(libdir)
|
|
assert _cwd_is_importable(cwddir, scriptdir, before) is True
|
|
_directory, after = _stage(monkeypatch, tmp_path, before)
|
|
assert _cwd_is_importable(cwddir, scriptdir, after) is True, after
|
|
|
|
|
|
def test_a_set_but_empty_pythonpath_still_reaches_nothing_new(monkeypatch, tmp_path):
|
|
"""And the mirror image: no cwd before, no cwd after."""
|
|
cwddir, _libdir, scriptdir = _probe_tree(tmp_path)
|
|
assert _cwd_is_importable(cwddir, scriptdir, "") is False
|
|
_directory, after = _stage(monkeypatch, tmp_path, "")
|
|
assert _cwd_is_importable(cwddir, scriptdir, after) is False, after
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(pytest.main([__file__, "-q"]))
|