* 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>
569 lines
18 KiB
Python
569 lines
18 KiB
Python
# SPDX-License-Identifier: AGPL-3.0-only
|
||
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||
|
||
"""
|
||
Hardware detection — run once at startup, read everywhere.
|
||
|
||
Usage:
|
||
# At FastAPI lifespan startup:
|
||
from utils.hardware import detect_hardware
|
||
detect_hardware()
|
||
|
||
# Anywhere else:
|
||
from utils.hardware import DEVICE, DeviceType, is_apple_silicon
|
||
if DEVICE == DeviceType.CUDA:
|
||
import torch
|
||
...
|
||
"""
|
||
|
||
import platform
|
||
import structlog
|
||
from loggers import get_logger
|
||
from enum import Enum
|
||
from typing import Optional, Dict, Any
|
||
|
||
logger = get_logger(__name__)
|
||
|
||
|
||
# ========== Device Enum ==========
|
||
|
||
|
||
class DeviceType(str, Enum):
|
||
"""Supported compute backends. Inherits from str so it serializes cleanly in JSON."""
|
||
|
||
CUDA = "cuda"
|
||
MLX = "mlx"
|
||
CPU = "cpu"
|
||
|
||
|
||
# ========== Global State (set once by detect_hardware) ==========
|
||
|
||
DEVICE: Optional[DeviceType] = None
|
||
CHAT_ONLY: bool = True # No CUDA GPU -> GGUF chat only (Mac, CPU-only, etc.)
|
||
|
||
|
||
# ========== Detection ==========
|
||
|
||
|
||
def is_apple_silicon() -> bool:
|
||
"""Check if running on Apple Silicon hardware (pure platform check, no ML imports)."""
|
||
return platform.system() == "Darwin" and platform.machine() == "arm64"
|
||
|
||
|
||
def _has_torch() -> bool:
|
||
"""Check if PyTorch is importable."""
|
||
try:
|
||
import torch
|
||
|
||
return True
|
||
except ImportError:
|
||
return False
|
||
|
||
|
||
def _has_mlx() -> bool:
|
||
"""Check if MLX is importable."""
|
||
try:
|
||
import mlx.core
|
||
|
||
return True
|
||
except ImportError:
|
||
return False
|
||
|
||
|
||
def detect_hardware() -> DeviceType:
|
||
"""
|
||
Detect the best available compute device and set the module-level DEVICE global.
|
||
|
||
Should be called exactly once during FastAPI lifespan startup.
|
||
Safe to call multiple times (idempotent).
|
||
|
||
Detection order:
|
||
1. CUDA (NVIDIA GPU, requires torch)
|
||
2. MLX (Apple Silicon via MLX framework)
|
||
3. CPU (fallback)
|
||
"""
|
||
global DEVICE, CHAT_ONLY
|
||
CHAT_ONLY = True # reset -- only CUDA sets it to False
|
||
|
||
# --- CUDA: try PyTorch ---
|
||
if _has_torch():
|
||
import torch
|
||
|
||
if torch.cuda.is_available():
|
||
DEVICE = DeviceType.CUDA
|
||
CHAT_ONLY = False
|
||
device_name = torch.cuda.get_device_properties(0).name
|
||
print(f"Hardware detected: CUDA — {device_name}")
|
||
return DEVICE
|
||
|
||
# --- MLX: Apple Silicon ---
|
||
if is_apple_silicon() and _has_mlx():
|
||
DEVICE = DeviceType.MLX
|
||
chip = platform.processor() or platform.machine()
|
||
print(f"Hardware detected: MLX — Apple Silicon ({chip})")
|
||
return DEVICE
|
||
|
||
# --- Fallback ---
|
||
DEVICE = DeviceType.CPU
|
||
print("Hardware detected: CPU (no GPU backend available)")
|
||
return DEVICE
|
||
|
||
|
||
# ========== Convenience helpers ==========
|
||
|
||
|
||
def get_device() -> DeviceType:
|
||
"""
|
||
Return the detected device. Auto-detects if detect_hardware() hasn't been called yet.
|
||
Prefer calling detect_hardware() explicitly at startup instead.
|
||
"""
|
||
global DEVICE
|
||
if DEVICE is None:
|
||
detect_hardware()
|
||
return DEVICE
|
||
|
||
|
||
def clear_gpu_cache():
|
||
"""
|
||
Clear GPU memory cache for the current device.
|
||
Safe to call on any platform — no-ops gracefully.
|
||
"""
|
||
import gc
|
||
|
||
gc.collect()
|
||
|
||
device = get_device()
|
||
|
||
if device == DeviceType.CUDA:
|
||
import torch
|
||
|
||
torch.cuda.synchronize()
|
||
torch.cuda.empty_cache()
|
||
torch.cuda.ipc_collect()
|
||
elif device == DeviceType.MLX:
|
||
# MLX manages memory automatically; no explicit cache clear needed.
|
||
# mlx.core has no empty_cache equivalent — gc.collect() above is enough.
|
||
pass
|
||
|
||
|
||
def get_gpu_memory_info() -> Dict[str, Any]:
|
||
"""
|
||
Get GPU memory information.
|
||
Supports CUDA (NVIDIA), MLX (Apple Silicon), and CPU-only environments.
|
||
"""
|
||
device = get_device()
|
||
|
||
# ---- CUDA path ----
|
||
if device == DeviceType.CUDA:
|
||
try:
|
||
import torch
|
||
|
||
idx = torch.cuda.current_device()
|
||
props = torch.cuda.get_device_properties(idx)
|
||
|
||
total = props.total_memory
|
||
allocated = torch.cuda.memory_allocated(idx)
|
||
reserved = torch.cuda.memory_reserved(idx)
|
||
|
||
return {
|
||
"available": True,
|
||
"backend": device.value,
|
||
"device": idx,
|
||
"device_name": props.name,
|
||
"total_gb": total / (1024**3),
|
||
"allocated_gb": allocated / (1024**3),
|
||
"reserved_gb": reserved / (1024**3),
|
||
"free_gb": (total - allocated) / (1024**3),
|
||
"utilization_pct": (allocated / total) * 100,
|
||
}
|
||
except Exception as e:
|
||
logger.error(f"Error getting CUDA GPU info: {e}")
|
||
return {"available": False, "backend": device.value, "error": str(e)}
|
||
|
||
# ---- MLX path (Apple Silicon) ----
|
||
if device == DeviceType.MLX:
|
||
try:
|
||
import mlx.core as mx
|
||
import psutil
|
||
|
||
# MLX uses unified memory — report system memory as the pool
|
||
total = psutil.virtual_memory().total
|
||
# MLX doesn't expose per-process GPU allocation; report 0 as allocated
|
||
allocated = 0
|
||
|
||
return {
|
||
"available": True,
|
||
"backend": device.value,
|
||
"device": 0,
|
||
"device_name": f"Apple Silicon ({platform.processor() or platform.machine()})",
|
||
"total_gb": total / (1024**3),
|
||
"allocated_gb": allocated / (1024**3),
|
||
"reserved_gb": 0,
|
||
"free_gb": (total - allocated) / (1024**3),
|
||
"utilization_pct": (allocated / total) * 100 if total else 0,
|
||
}
|
||
except Exception as e:
|
||
logger.error(f"Error getting MLX GPU info: {e}")
|
||
return {"available": False, "backend": device.value, "error": str(e)}
|
||
|
||
# ---- CPU-only ----
|
||
return {"available": False, "backend": "cpu"}
|
||
|
||
|
||
def log_gpu_memory(context: str):
|
||
"""Log GPU memory usage with context."""
|
||
memory_info = get_gpu_memory_info()
|
||
if memory_info.get("available"):
|
||
backend = memory_info.get("backend", "unknown").upper()
|
||
device_name = memory_info.get("device_name", "")
|
||
label = f"{backend}" + (f" ({device_name})" if device_name else "")
|
||
logger.info(
|
||
f"GPU Memory [{context}] {label}: "
|
||
f"{memory_info['allocated_gb']:.2f}GB/{memory_info['total_gb']:.2f}GB "
|
||
f"({memory_info['utilization_pct']:.1f}% used, "
|
||
f"{memory_info['free_gb']:.2f}GB free)"
|
||
)
|
||
else:
|
||
logger.info(f"GPU Memory [{context}]: No GPU available (CPU-only)")
|
||
|
||
|
||
# ========== GPU Summary & Package Versions ==========
|
||
|
||
|
||
def get_gpu_summary() -> Dict[str, Any]:
|
||
"""
|
||
Return a compact summary of the primary GPU.
|
||
|
||
Returns dict with keys:
|
||
gpu_name – e.g. "NVIDIA L4" (or None)
|
||
vram_total_gb – e.g. 22.17 (or None)
|
||
"""
|
||
mem = get_gpu_memory_info()
|
||
if mem.get("available"):
|
||
return {
|
||
"gpu_name": mem.get("device_name"),
|
||
"vram_total_gb": round(mem.get("total_gb", 0), 2),
|
||
"vram_free_gb": round(mem.get("free_gb", 0), 2),
|
||
}
|
||
return {"gpu_name": None, "vram_total_gb": None, "vram_free_gb": None}
|
||
|
||
|
||
def get_package_versions() -> Dict[str, Optional[str]]:
|
||
"""
|
||
Return the installed versions of key ML packages.
|
||
|
||
Uses importlib.metadata (stdlib) so no subprocess is needed.
|
||
CUDA version comes from torch.version.cuda.
|
||
|
||
Returns dict with keys: unsloth, torch, transformers, cuda.
|
||
Missing packages yield None.
|
||
"""
|
||
from importlib.metadata import version as pkg_version, PackageNotFoundError
|
||
|
||
packages = ("unsloth", "torch", "transformers")
|
||
versions: Dict[str, Optional[str]] = {}
|
||
|
||
for name in packages:
|
||
try:
|
||
versions[name] = pkg_version(name)
|
||
except PackageNotFoundError:
|
||
versions[name] = None
|
||
|
||
# CUDA toolkit version bundled with torch
|
||
try:
|
||
import torch
|
||
|
||
versions["cuda"] = getattr(torch.version, "cuda", None)
|
||
except Exception:
|
||
versions["cuda"] = None
|
||
|
||
return versions
|
||
|
||
|
||
# ========== Live GPU Utilization (nvidia-smi) ==========
|
||
|
||
|
||
def get_gpu_utilization() -> Dict[str, Any]:
|
||
"""
|
||
Return a live snapshot of GPU utilization via ``nvidia-smi``.
|
||
|
||
Designed to be polled by the frontend during training (not streaming).
|
||
Uses ``nvidia-smi --query-gpu`` which is the most accurate source for
|
||
utilization %, temperature, and power draw – stats that PyTorch does
|
||
not expose.
|
||
|
||
Returns dict with keys:
|
||
available – bool, whether stats could be retrieved
|
||
gpu_utilization_pct – GPU core utilization %
|
||
temperature_c – GPU temperature in °C
|
||
vram_used_gb – VRAM currently used (GiB)
|
||
vram_total_gb – VRAM total (GiB)
|
||
vram_utilization_pct – VRAM used / total * 100
|
||
power_draw_w – current power draw (W)
|
||
power_limit_w – power limit (W)
|
||
power_utilization_pct – power draw / limit * 100
|
||
"""
|
||
device = get_device()
|
||
|
||
if device != DeviceType.CUDA:
|
||
return {"available": False, "backend": device.value}
|
||
|
||
def _parse_smi_value(raw: str):
|
||
"""Parse a single nvidia-smi CSV value. Returns float or None for [N/A]."""
|
||
raw = raw.strip()
|
||
if not raw or raw == "[N/A]":
|
||
return None
|
||
try:
|
||
return float(raw)
|
||
except (ValueError, TypeError):
|
||
return None
|
||
|
||
# ── nvidia-smi (most complete source) ───────────────────────
|
||
smi_data = {}
|
||
try:
|
||
import subprocess
|
||
|
||
result = subprocess.run(
|
||
[
|
||
"nvidia-smi",
|
||
"--query-gpu=utilization.gpu,temperature.gpu,"
|
||
"memory.used,memory.total,power.draw,power.limit",
|
||
"--format=csv,noheader,nounits",
|
||
],
|
||
capture_output = True,
|
||
text = True,
|
||
timeout = 5,
|
||
)
|
||
|
||
if result.returncode == 0 and result.stdout.strip():
|
||
# nvidia-smi outputs one line per GPU; take GPU 0
|
||
first_line = result.stdout.strip().splitlines()[0]
|
||
parts = [p.strip() for p in first_line.split(",")]
|
||
if len(parts) >= 6:
|
||
smi_data = {
|
||
"gpu_util": _parse_smi_value(parts[0]),
|
||
"temp": _parse_smi_value(parts[1]),
|
||
"vram_used_mb": _parse_smi_value(parts[2]),
|
||
"vram_total_mb": _parse_smi_value(parts[3]),
|
||
"power_draw": _parse_smi_value(parts[4]),
|
||
"power_limit": _parse_smi_value(parts[5]),
|
||
}
|
||
|
||
except FileNotFoundError:
|
||
logger.debug("nvidia-smi not found, falling back to torch.cuda")
|
||
except Exception as e:
|
||
logger.warning(f"nvidia-smi query failed: {e}")
|
||
|
||
# ── Backfill VRAM from torch.cuda if nvidia-smi returned [N/A] ──
|
||
vram_used_mb = smi_data.get("vram_used_mb")
|
||
vram_total_mb = smi_data.get("vram_total_mb")
|
||
|
||
if vram_used_mb is None or vram_total_mb is None:
|
||
try:
|
||
import torch
|
||
|
||
idx = torch.cuda.current_device()
|
||
props = torch.cuda.get_device_properties(idx)
|
||
if vram_total_mb is None:
|
||
vram_total_mb = props.total_memory / (1024**2) # bytes → MiB
|
||
if vram_used_mb is None:
|
||
vram_used_mb = torch.cuda.memory_allocated(idx) / (1024**2)
|
||
except Exception as e:
|
||
logger.debug(f"torch.cuda VRAM backfill failed: {e}")
|
||
|
||
# ── Build response ──────────────────────────────────────────
|
||
gpu_util = smi_data.get("gpu_util")
|
||
temp = smi_data.get("temp")
|
||
power_draw = smi_data.get("power_draw")
|
||
power_limit = smi_data.get("power_limit")
|
||
|
||
vram_used_gb = round(vram_used_mb / 1024, 2) if vram_used_mb is not None else None
|
||
vram_total_gb = (
|
||
round(vram_total_mb / 1024, 2) if vram_total_mb is not None else None
|
||
)
|
||
vram_pct = (
|
||
round((vram_used_mb / vram_total_mb) * 100, 1)
|
||
if vram_used_mb is not None and vram_total_mb and vram_total_mb > 0
|
||
else None
|
||
)
|
||
power_pct = (
|
||
round((power_draw / power_limit) * 100, 1)
|
||
if power_draw is not None and power_limit and power_limit > 0
|
||
else None
|
||
)
|
||
|
||
# If we got at least something useful, report available
|
||
has_any = any(v is not None for v in [gpu_util, temp, vram_used_gb, power_draw])
|
||
if not has_any:
|
||
return {"available": False, "backend": device.value}
|
||
|
||
return {
|
||
"available": True,
|
||
"backend": device.value,
|
||
"gpu_utilization_pct": gpu_util,
|
||
"temperature_c": temp,
|
||
"vram_used_gb": vram_used_gb,
|
||
"vram_total_gb": vram_total_gb,
|
||
"vram_utilization_pct": vram_pct,
|
||
"power_draw_w": power_draw,
|
||
"power_limit_w": power_limit,
|
||
"power_utilization_pct": power_pct,
|
||
}
|
||
|
||
|
||
# ========== Multi-GPU Detection & Safe num_proc ==========
|
||
|
||
_physical_gpu_count: Optional[int] = None
|
||
_visible_gpu_count: Optional[int] = None
|
||
|
||
|
||
def get_physical_gpu_count() -> int:
|
||
"""
|
||
Return the number of physical NVIDIA GPUs on the machine.
|
||
|
||
Uses ``nvidia-smi -L`` which is NOT affected by CUDA_VISIBLE_DEVICES,
|
||
so it always reflects the true hardware count.
|
||
Result is cached after the first call.
|
||
"""
|
||
global _physical_gpu_count
|
||
if _physical_gpu_count is not None:
|
||
return _physical_gpu_count
|
||
|
||
try:
|
||
import subprocess
|
||
|
||
result = subprocess.run(
|
||
["nvidia-smi", "-L"],
|
||
capture_output = True,
|
||
text = True,
|
||
timeout = 5,
|
||
)
|
||
if result.returncode == 0 and result.stdout.strip():
|
||
_physical_gpu_count = len(result.stdout.strip().splitlines())
|
||
else:
|
||
_physical_gpu_count = 1
|
||
except Exception:
|
||
_physical_gpu_count = 1
|
||
|
||
return _physical_gpu_count
|
||
|
||
|
||
def get_visible_gpu_count() -> int:
|
||
"""
|
||
Return the number of GPUs visible to this process.
|
||
|
||
Respects ``CUDA_VISIBLE_DEVICES`` -- if set, only those GPUs count.
|
||
Falls back to physical count if the env var is unset or torch is
|
||
unavailable. Result is cached after the first call.
|
||
"""
|
||
global _visible_gpu_count
|
||
if _visible_gpu_count is not None:
|
||
return _visible_gpu_count
|
||
|
||
import os
|
||
|
||
cuda_visible = os.environ.get("CUDA_VISIBLE_DEVICES")
|
||
if cuda_visible is not None:
|
||
# "" means zero GPUs, "0" means 1, "0,1,2" means 3
|
||
cuda_visible = cuda_visible.strip()
|
||
if cuda_visible == "" or cuda_visible == "-1":
|
||
_visible_gpu_count = 0
|
||
else:
|
||
_visible_gpu_count = len([x for x in cuda_visible.split(",") if x.strip()])
|
||
return _visible_gpu_count
|
||
|
||
# CUDA_VISIBLE_DEVICES not set -- try torch, fall back to physical count
|
||
try:
|
||
import torch
|
||
|
||
_visible_gpu_count = torch.cuda.device_count()
|
||
except Exception:
|
||
_visible_gpu_count = get_physical_gpu_count()
|
||
|
||
return _visible_gpu_count
|
||
|
||
|
||
def safe_num_proc(desired: Optional[int] = None) -> int:
|
||
"""
|
||
Return a safe ``num_proc`` for ``dataset.map()`` calls.
|
||
|
||
On Windows, always returns 1 because Python uses ``spawn`` instead of
|
||
``fork`` for multiprocessing -- the overhead of re-importing torch,
|
||
transformers, unsloth etc. per worker is typically slower than
|
||
single-process for normal dataset sizes.
|
||
|
||
On multi-GPU machines (where multiple GPUs are *visible* to this
|
||
process) the NVIDIA driver spawns extra background threads, making
|
||
``os.fork()`` prone to deadlocks when many workers are created.
|
||
This helper caps ``num_proc`` to 4 on such machines.
|
||
|
||
When ``CUDA_VISIBLE_DEVICES`` restricts to a single GPU, the cap
|
||
does not apply.
|
||
|
||
Args:
|
||
desired: The num_proc you *want*. If None, auto-computes from
|
||
``os.cpu_count()``.
|
||
|
||
Returns:
|
||
A safe integer ≥ 1.
|
||
"""
|
||
import os
|
||
import sys
|
||
|
||
# Windows and macOS use 'spawn' for multiprocessing -- the overhead of
|
||
# re-importing torch/transformers/unsloth per worker is typically slower
|
||
# than single-process.
|
||
if sys.platform in ("win32", "darwin"):
|
||
return 1
|
||
|
||
if desired is None or not isinstance(desired, int):
|
||
desired = max(1, (os.cpu_count() or 1) // 3)
|
||
|
||
visible = get_visible_gpu_count()
|
||
if visible > 1:
|
||
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 max(1, desired)
|
||
|
||
|
||
def safe_thread_num_proc(desired: Optional[int] = None) -> int:
|
||
"""
|
||
Return a safe worker count for ``ThreadPoolExecutor`` calls.
|
||
|
||
Unlike ``safe_num_proc()``, this does NOT cap to 1 on macOS/Windows.
|
||
Threads share the parent process address space and are unaffected by
|
||
the ``spawn`` vs ``fork`` distinction.
|
||
|
||
Args:
|
||
desired: The thread count you *want*. If None, auto-computes
|
||
from ``os.cpu_count()``.
|
||
|
||
Returns:
|
||
A safe integer >= 1.
|
||
"""
|
||
import os
|
||
|
||
if desired is None or not isinstance(desired, int):
|
||
desired = max(1, (os.cpu_count() or 1) // 3)
|
||
|
||
return max(1, desired)
|
||
|
||
|
||
def dataset_map_num_proc(desired: Optional[int] = None) -> Optional[int]:
|
||
"""
|
||
Return a safe ``num_proc`` for ``Dataset.map()`` and ``Dataset.filter()``.
|
||
|
||
Returns ``None`` on spawn-based platforms (Windows, macOS) because
|
||
``datasets`` treats ``num_proc=1`` as multiprocessing (creates ``Pool(1)``).
|
||
Only ``num_proc=None`` guarantees in-process execution.
|
||
"""
|
||
import sys
|
||
|
||
if sys.platform in ("win32", "darwin"):
|
||
return None
|
||
return safe_num_proc(desired)
|