mirror of
https://github.com/unslothai/unsloth.git
synced 2026-08-23 15:53:46 +00:00
Inductor's on-disk caches default to one directory per USER, not per process, so the four workers under pytest -n 4 share /tmp/torchinductor_<user> and its fxgraph, aotautograd and Triton subtrees. The upstream recipe is explicit that a common TORCHINDUCTOR_CACHE_DIR is what makes processes SHARE compiled artifacts, so a different value per worker is how they are kept apart. Sharing is not obviously wrong, since the entries are content-addressed. It is still a write-write interaction between processes that nothing here controls, and a cache is exactly the sort of thing that turns a deterministic suite into an intermittent one. This removes the interaction for the price of some recompilation. What it buys, measured rather than assumed, so nobody has to guess later: a full unsloth_zoo run against an empty dedicated cache directory took 578s and left ZERO entries in it. That suite never populates the on-disk cache, so for that step this is neither a saving nor a cost. It is insurance for the suites that do compile, applied wherever tests run in parallel rather than only where a problem has already been seen. It has to run before torch is imported, so it is a module-level import in the two conftests rather than a fixture. TRITON_CACHE_DIR is set explicitly rather than left to follow, because it only derives from the inductor directory when unset and an environment exporting it would keep all four workers on one Triton cache. An explicit TORCHINDUCTOR_CACHE_DIR is split underneath rather than replaced, so a CI path chosen on purpose is respected. A single-process run is left alone entirely. Verified under real xdist, not only in unit tests: four workers report four distinct directories, and the parallel suites still pass.
194 lines
7.3 KiB
Python
194 lines
7.3 KiB
Python
# SPDX-License-Identifier: AGPL-3.0-only
|
|
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved.
|
|
|
|
"""GPU-free test harness.
|
|
|
|
unsloth_zoo.device_type calls get_device_type() at import time and raises
|
|
NotImplementedError on CI runners with no CUDA/XPU/HIP. Pre-load it under a
|
|
mocked torch.cuda.is_available()==True so its @cache permanently captures
|
|
"cuda"; on a real accelerator the pre-load is skipped.
|
|
|
|
Mirrors the conftest harness in unslothai/unsloth-zoo PR #624.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
# --- torch.compile cache isolation -------------------------------------------------
|
|
# Must run before torch is imported anywhere below, so it is here rather than in a
|
|
# fixture. See tests/_shared/compile_cache_isolation.py for what it does and why.
|
|
import importlib.util as _ilu # noqa: E402
|
|
import pathlib as _pathlib # noqa: E402
|
|
|
|
_iso = _pathlib.Path(__file__).resolve()
|
|
for _up in _iso.parents:
|
|
_candidate = _up / "tests" / "_shared" / "compile_cache_isolation.py"
|
|
if _candidate.is_file():
|
|
_spec = _ilu.spec_from_file_location("_unsloth_compile_cache_isolation", _candidate)
|
|
_mod = _ilu.module_from_spec(_spec)
|
|
_spec.loader.exec_module(_mod) # sets the env vars on import
|
|
break
|
|
# -----------------------------------------------------------------------------------
|
|
|
|
import importlib.util
|
|
import os
|
|
import sys
|
|
import types
|
|
|
|
|
|
def _has_real_accelerator() -> bool:
|
|
try:
|
|
import torch
|
|
except Exception:
|
|
return False
|
|
for probe in (
|
|
lambda: hasattr(torch, "cuda") and torch.cuda.is_available(),
|
|
lambda: hasattr(torch, "xpu") and torch.xpu.is_available(),
|
|
lambda: hasattr(torch, "accelerator") and torch.accelerator.is_available(),
|
|
):
|
|
try:
|
|
if probe():
|
|
return True
|
|
except Exception:
|
|
pass
|
|
return False
|
|
|
|
|
|
def _preload_device_type(package: str, prereqs: tuple[str, ...] = ()) -> bool:
|
|
"""Pre-load <package>.device_type under a mocked is_available()==True so its
|
|
@cache captures "cuda"; prereqs are submodules to load first (e.g. 'utils').
|
|
Returns False if anything is unimportable, so the caller falls back to a stub."""
|
|
target = f"{package}.device_type"
|
|
if target in sys.modules:
|
|
return True
|
|
pkg_spec = importlib.util.find_spec(package)
|
|
if pkg_spec is None or not pkg_spec.submodule_search_locations:
|
|
return False
|
|
pkg_path = pkg_spec.submodule_search_locations[0]
|
|
|
|
skeleton_already = package in sys.modules
|
|
if not skeleton_already:
|
|
skel = types.ModuleType(package)
|
|
skel.__path__ = [pkg_path]
|
|
skel.__spec__ = pkg_spec
|
|
skel.__package__ = package
|
|
sys.modules[package] = skel
|
|
|
|
try:
|
|
for prereq in prereqs:
|
|
full = f"{package}.{prereq}"
|
|
if full in sys.modules:
|
|
continue
|
|
prereq_path = os.path.join(pkg_path, f"{prereq}.py")
|
|
prereq_spec = importlib.util.spec_from_file_location(full, prereq_path)
|
|
prereq_mod = importlib.util.module_from_spec(prereq_spec)
|
|
sys.modules[full] = prereq_mod
|
|
prereq_spec.loader.exec_module(prereq_mod)
|
|
|
|
device_type_path = os.path.join(pkg_path, "device_type.py")
|
|
dt_spec = importlib.util.spec_from_file_location(target, device_type_path)
|
|
dt_mod = importlib.util.module_from_spec(dt_spec)
|
|
sys.modules[target] = dt_mod
|
|
|
|
import torch
|
|
|
|
_orig_is_avail = torch.cuda.is_available
|
|
torch.cuda.is_available = lambda: True # type: ignore[assignment]
|
|
try:
|
|
dt_spec.loader.exec_module(dt_mod)
|
|
finally:
|
|
torch.cuda.is_available = _orig_is_avail
|
|
except Exception:
|
|
sys.modules.pop(target, None)
|
|
return False
|
|
finally:
|
|
if not skeleton_already:
|
|
sys.modules.pop(package, None)
|
|
|
|
return True
|
|
|
|
|
|
def _patch_torch_cuda_for_import() -> None:
|
|
"""Stub the torch.cuda.* probes fired at import time once DEVICE_TYPE is
|
|
forced to "cuda"; returning plausible Ampere values lets the import finish
|
|
(real-tensor tests still run on CPU)."""
|
|
try:
|
|
import torch.cuda.memory as _cuda_memory # type: ignore
|
|
|
|
# (free, total). Zero free is an exhausted card, which callers that size
|
|
# against it treat as fatal.
|
|
_cuda_memory.mem_get_info = lambda *a, **k: (60 * 1024**3, 80 * 1024**3)
|
|
except Exception:
|
|
pass
|
|
try:
|
|
import torch
|
|
torch.cuda.get_device_capability = lambda *a, **k: (8, 0)
|
|
torch.cuda.is_bf16_supported = lambda *a, **k: True
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
def _install_device_type_stub(name: str) -> None:
|
|
stub = types.ModuleType(name)
|
|
stub.DEVICE_TYPE = "cuda"
|
|
stub.DEVICE_TYPE_TORCH = "cuda"
|
|
stub.DEVICE_COUNT = 1
|
|
stub.ALLOW_PREQUANTIZED_MODELS = False
|
|
stub.is_hip = lambda: False
|
|
stub.get_device_type = lambda: "cuda"
|
|
stub.get_device_count = lambda: 1
|
|
stub.device_synchronize = lambda *a, **k: None
|
|
stub.device_empty_cache = lambda *a, **k: None
|
|
stub.device_is_bf16_supported = lambda *a, **k: False
|
|
sys.modules[name] = stub
|
|
|
|
|
|
def _preimport_bitsandbytes() -> None:
|
|
"""Bind bitsandbytes against the real torch before the CUDA spoof below.
|
|
|
|
`bitsandbytes/__init__.py` runs `if torch.cuda.is_available(): from .backends.cuda
|
|
import ops`, and that module reads `torch._C._cuda_getCurrentRawStream`, which a
|
|
CPU-only torch build does not expose. `_preload_device_type` patches
|
|
`torch.cuda.is_available` to return True, so a bitsandbytes import landing inside
|
|
that window takes the CUDA branch and dies with AttributeError.
|
|
|
|
Python then drops `bitsandbytes` from sys.modules but leaves `bitsandbytes.functional`
|
|
and the rest of its submodules cached, so the next import re-executes __init__ against
|
|
those cached submodules, re-binds nothing, and hands back a module with no
|
|
`.functional`. `unsloth/kernels/utils.py` reads `bnb.functional.get_ptr` at module
|
|
scope, so every later `import unsloth` in that process dies with
|
|
"module 'bitsandbytes' has no attribute 'functional'".
|
|
|
|
Importing first, outside the window, keeps bitsandbytes on its CPU backend and fully
|
|
usable. Must stay ahead of the `_preload_device_type` calls below.
|
|
"""
|
|
try:
|
|
import bitsandbytes # noqa: F401
|
|
except Exception:
|
|
# A genuinely absent or broken wheel is unsloth's own degradation path.
|
|
pass
|
|
|
|
|
|
if not _has_real_accelerator():
|
|
_preimport_bitsandbytes()
|
|
if not _preload_device_type("unsloth_zoo", prereqs = ("utils",)):
|
|
_install_device_type_stub("unsloth_zoo.device_type")
|
|
if not _preload_device_type("unsloth"):
|
|
_install_device_type_stub("unsloth.device_type")
|
|
_patch_torch_cuda_for_import()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Apply upstream-drift fixes (vllm/triton/peft) by triggering ``import unsloth``
|
|
# (they run at import time in unsloth/import_fixes.py). The harness above lets
|
|
# the import survive CPU-only runners; the ImportError is swallowed otherwise.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _apply_upstream_import_fixes_for_tests() -> None:
|
|
try:
|
|
import unsloth # noqa: F401 # runs unsloth/import_fixes.py
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
_apply_upstream_import_fixes_for_tests()
|