unsloth/tests/conftest.py
Daniel Han af2fa437a2
Give each xdist worker its own torch.compile cache directory (#9134)
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.
2026-08-17 18:48:11 -07:00

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()