fix(studio): prevent ModuleNotFoundError in dataset.map() on Windows (#4473)

* fix(studio): prevent ModuleNotFoundError in dataset.map() on Windows

On Windows, dataset.map() uses "spawn", which requires workers to
import compiled modules from disk. Previously, clear_unsloth_compiled_cache()
deleted the entire directory, causing workers to crash when looking for
UnslothSFTTrainer.py.

Changes:
1. Added `preserve_patterns` to cache cleanup to keep `Unsloth*Trainer.py`
   on Windows while clearing model-specific files.
2. Added the cache directory to PYTHONPATH for spawn workers.
Linux/macOS behavior is unchanged.

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

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

* Fix spawn-platform coverage, CWD path mismatch, and race condition for PR #4473

- Extend platform guard from win32-only to include macOS (also uses spawn
  since Python 3.8, same ModuleNotFoundError would occur)
- Replace fragile CWD-based PYTHONPATH registration with centralized
  register_compiled_cache_on_path() that uses the same __file__-relative
  _CACHE_DIRS already used by cache_cleanup -- fixes path mismatch when
  studio is launched from a directory other than the repo root
- Move PYTHONPATH registration to the top of _train_worker(), before any
  dataset.map() call (previously it ran late in config assembly, after
  dataset formatting which also calls dataset.map())
- Update inference.py model-unload to preserve trainer files on spawn
  platforms, preventing a race where unloading a model via inference tab
  would delete UnslothSFTTrainer.py while training workers are importing it

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

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

* Fix cache-dir precedence reversal in register_compiled_cache_on_path()

Iterating _CACHE_DIRS in forward order while calling insert(0) each time
reverses the declared priority: later entries shadow earlier ones. When
multiple compiled-cache directories exist, spawned workers could import a
stale trainer from the wrong cache.

Fix: iterate in reverse so that the highest-priority entry (first in
_CACHE_DIRS) is inserted last and ends up at position 0 in sys.path and
PYTHONPATH.

* fix: harden worker-count helpers against cpu_count=None and desired<=0

- safe_num_proc: guard os.cpu_count() with `or 1`, clamp multi-GPU
  path with max(1, min(4, desired)), clamp return with max(1, desired)
- safe_thread_num_proc: same os.cpu_count() guard and return clamp
- Add regression tests (31 L1 unit + 10 sandbox edge-case tests)

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

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

* remove regression tests from PR

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: Daniel Han <danielhanchen@users.noreply.github.com>
Co-authored-by: Daniel Han <danielhanchen@gmail.com>
This commit is contained in:
NuoFang 2026-03-22 21:11:24 +08:00 committed by GitHub
commit 4cedeba8c2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 93 additions and 10 deletions

View file

@ -549,10 +549,18 @@ class InferenceBackend:
# Clear GPU memory cache
clear_gpu_cache()
# Remove stale compiled cache so the next model gets a fresh one
# Remove stale compiled cache so the next model gets a fresh one.
# On spawn-based platforms, preserve trainer files so that any
# concurrent training dataset.map() workers can still import them.
import sys as _sys
from utils.cache_cleanup import clear_unsloth_compiled_cache
clear_unsloth_compiled_cache()
_preserve = (
["Unsloth*Trainer.py"]
if _sys.platform in ("win32", "darwin")
else None
)
clear_unsloth_compiled_cache(preserve_patterns = _preserve)
logger.info(f"Model '{model_name}' successfully unloaded.")
return True

View file

@ -529,7 +529,10 @@ class UnslothTrainer:
# Remove stale compiled cache so the new model gets a fresh one
from utils.cache_cleanup import clear_unsloth_compiled_cache
clear_unsloth_compiled_cache()
_preserve = (
["Unsloth*Trainer.py"] if sys.platform in ("win32", "darwin") else None
)
clear_unsloth_compiled_cache(preserve_patterns = _preserve)
# Detect audio model type dynamically (config.json + tokenizer)
self._audio_type = detect_audio_type(model_name, hf_token)
# audio_vlm is detected as an audio_type now, handle it separately
@ -2718,6 +2721,15 @@ class UnslothTrainer:
def _train_worker(self, dataset: Dataset, **training_args):
"""Worker function for training (runs in separate thread)"""
try:
# On spawn-based platforms (Windows, macOS), register all known
# compiled-cache directories on sys.path and PYTHONPATH before any
# dataset.map() call so spawned workers can import dynamically
# compiled modules such as UnslothSFTTrainer.
if sys.platform in ("win32", "darwin"):
from utils.cache_cleanup import register_compiled_cache_on_path
register_compiled_cache_on_path()
# Store training parameters for metrics calculation
self.batch_size = training_args.get("batch_size", 2)
self.max_seq_length = training_args.get("max_seq_length", 2048)

View file

@ -6,13 +6,16 @@ Utility for cleaning up the Unsloth compiled cache directory.
The unsloth_compiled_cache is created by unsloth_zoo/compiler.py during
FastModel.from_pretrained() and contains model-type-specific compiled Python
files. It should be cleared between model loads to avoid stale artefacts.
files. It should be selectively cleared between model loads to avoid stale
artefacts, while preserving model-agnostic components (like Trainers) needed
by spawned subprocesses.
"""
import shutil
import structlog
from loggers import get_logger
from pathlib import Path
from typing import List, Optional
logger = get_logger(__name__)
@ -27,9 +30,69 @@ _CACHE_DIRS = [
]
def clear_unsloth_compiled_cache() -> None:
"""Remove every known unsloth_compiled_cache directory (idempotent)."""
def get_existing_cache_dirs() -> List[Path]:
"""Return known compiled-cache directories that currently exist on disk."""
return [d for d in _CACHE_DIRS if d.exists()]
def register_compiled_cache_on_path() -> None:
"""Add all existing compiled-cache directories to sys.path and PYTHONPATH.
This ensures spawned workers (on platforms using the 'spawn' start method,
i.e. Windows and macOS) can import dynamically compiled modules such as
UnslothSFTTrainer.
"""
import os
import sys
pypath = os.environ.get("PYTHONPATH", "")
pypath_entries = [p for p in pypath.split(os.pathsep) if p]
# Iterate in reverse so that earlier _CACHE_DIRS entries (higher priority)
# are inserted last and therefore end up first in sys.path / PYTHONPATH.
for cache_dir in reversed(get_existing_cache_dirs()):
resolved = str(cache_dir.resolve())
if resolved not in sys.path:
sys.path.insert(0, resolved)
if resolved not in pypath_entries:
pypath_entries.insert(0, resolved)
os.environ["PYTHONPATH"] = os.pathsep.join(pypath_entries)
def clear_unsloth_compiled_cache(preserve_patterns: Optional[List[str]] = None) -> None:
"""
Remove compiled files from the cache directory (idempotent).
Args:
preserve_patterns: A list of glob patterns for files to keep
(e.g., ["Unsloth*Trainer.py"]). If None or empty,
the entire cache directory is deleted (legacy behavior).
"""
for cache_dir in _CACHE_DIRS:
if cache_dir.exists():
if not cache_dir.exists():
continue
if preserve_patterns:
logger.info(
f"Cleaning unsloth compiled cache (preserving {preserve_patterns}): "
f"{cache_dir}"
)
for item in cache_dir.iterdir():
if item.is_file():
# Check if the file matches any of the patterns we want to keep
preserve = any(item.match(pattern) for pattern in preserve_patterns)
if not preserve:
try:
item.unlink()
except OSError as e:
logger.debug(f"Could not delete {item}: {e}")
elif item.is_dir():
# Always clear __pycache__ and other subdirectories
shutil.rmtree(item, ignore_errors = True)
else:
# Legacy behavior: nuke the entire directory
logger.info(f"Removing unsloth compiled cache: {cache_dir}")
shutil.rmtree(cache_dir, ignore_errors = True)

View file

@ -521,14 +521,14 @@ def safe_num_proc(desired: Optional[int] = None) -> int:
visible = get_visible_gpu_count()
if visible > 1:
capped = min(4, desired)
capped = max(1, min(4, desired))
logger.info(
f"Multi-GPU detected ({visible} visible GPUs) -- "
f"capping num_proc {desired} -> {capped} to avoid fork deadlocks"
)
return capped
return desired
return max(1, desired)
def safe_thread_num_proc(desired: Optional[int] = None) -> int:
@ -551,7 +551,7 @@ def safe_thread_num_proc(desired: Optional[int] = None) -> int:
if desired is None or not isinstance(desired, int):
desired = max(1, (os.cpu_count() or 1) // 3)
return desired
return max(1, desired)
def dataset_map_num_proc(desired: Optional[int] = None) -> Optional[int]: