merge: nightly into feature/data-reciper-enchansments
resolve setup.sh conflict by keeping nightly installer flow and preserving local data-designer plugin install via install_python_stack.py
This commit is contained in:
commit
acf7cce4a8
17 changed files with 1961 additions and 135 deletions
|
|
@ -418,9 +418,8 @@ class ExportBackend:
|
|||
pre_existing_ggufs = set(glob.glob(os.path.join(cwd, "*.gguf")))
|
||||
|
||||
# Pass absolute path — no os.chdir needed.
|
||||
# unsloth saves intermediate HF model files into model_save_path,
|
||||
# while check_llama_cpp("llama.cpp") resolves against cwd (repo root)
|
||||
# where setup.sh already built llama.cpp with quantizer.
|
||||
# unsloth saves intermediate HF model files into model_save_path.
|
||||
# unsloth-zoo's check_llama_cpp() uses ~/.unsloth/llama.cpp by default.
|
||||
model_save_path = os.path.join(abs_save_dir, "model")
|
||||
self.current_model.save_pretrained_gguf(
|
||||
model_save_path,
|
||||
|
|
|
|||
|
|
@ -41,6 +41,8 @@ class LlamaCppBackend:
|
|||
self._is_vision: bool = False
|
||||
self._healthy = False
|
||||
self._lock = threading.Lock()
|
||||
self._stdout_lines: list[str] = []
|
||||
self._stdout_thread: Optional[threading.Thread] = None
|
||||
|
||||
atexit.register(self._cleanup)
|
||||
|
||||
|
|
@ -74,33 +76,83 @@ class LlamaCppBackend:
|
|||
Locate the llama-server binary.
|
||||
|
||||
Search order:
|
||||
1. LLAMA_SERVER_PATH environment variable
|
||||
2. ./llama.cpp/build/bin/llama-server (built by setup.sh in-tree)
|
||||
3. llama-server on PATH (system install)
|
||||
4. ./bin/llama-server (legacy: extracted binary)
|
||||
1. LLAMA_SERVER_PATH environment variable (direct path to binary)
|
||||
1b. UNSLOTH_LLAMA_CPP_PATH env var (custom llama.cpp install dir)
|
||||
2. ~/.unsloth/llama.cpp/llama-server (make build, root dir)
|
||||
3. ~/.unsloth/llama.cpp/build/bin/llama-server (cmake build, Linux)
|
||||
4. ~/.unsloth/llama.cpp/build/bin/Release/llama-server.exe (cmake build, Windows)
|
||||
5. ./llama.cpp/llama-server (legacy: make build, root dir)
|
||||
6. ./llama.cpp/build/bin/llama-server (legacy: cmake in-tree build)
|
||||
7. llama-server on PATH (system install)
|
||||
8. ./bin/llama-server (legacy: extracted binary)
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
|
||||
# 1. Env var
|
||||
binary_name = "llama-server.exe" if sys.platform == "win32" else "llama-server"
|
||||
|
||||
# 1. Env var — direct path to binary
|
||||
env_path = os.environ.get("LLAMA_SERVER_PATH")
|
||||
if env_path and Path(env_path).is_file():
|
||||
return env_path
|
||||
|
||||
# Project root: llama_cpp.py → inference/ → core/ → backend/ → studio/ → root
|
||||
project_root = Path(__file__).resolve().parents[4]
|
||||
# 1b. UNSLOTH_LLAMA_CPP_PATH — custom llama.cpp install directory
|
||||
custom_llama_cpp = os.environ.get("UNSLOTH_LLAMA_CPP_PATH")
|
||||
if custom_llama_cpp:
|
||||
custom_dir = Path(custom_llama_cpp)
|
||||
# Root dir (make builds)
|
||||
root_bin = custom_dir / binary_name
|
||||
if root_bin.is_file():
|
||||
return str(root_bin)
|
||||
# build/bin/ (cmake builds on Linux)
|
||||
cmake_bin = custom_dir / "build" / "bin" / binary_name
|
||||
if cmake_bin.is_file():
|
||||
return str(cmake_bin)
|
||||
# build/bin/Release/ (cmake builds on Windows)
|
||||
if sys.platform == "win32":
|
||||
win_bin = custom_dir / "build" / "bin" / "Release" / binary_name
|
||||
if win_bin.is_file():
|
||||
return str(win_bin)
|
||||
|
||||
# 2. In-tree llama.cpp build (setup.sh builds here)
|
||||
build_path = project_root / "llama.cpp" / "build" / "bin" / "llama-server"
|
||||
# 2–4. ~/.unsloth/llama.cpp (primary — setup.sh / setup.ps1 build here)
|
||||
unsloth_home = Path.home() / ".unsloth" / "llama.cpp"
|
||||
# Root dir (make builds copy binaries here)
|
||||
home_root = unsloth_home / binary_name
|
||||
if home_root.is_file():
|
||||
return str(home_root)
|
||||
# build/bin/ (cmake builds on Linux)
|
||||
home_linux = unsloth_home / "build" / "bin" / binary_name
|
||||
if home_linux.is_file():
|
||||
return str(home_linux)
|
||||
|
||||
# 3. Windows MSVC build has Release subdir
|
||||
if sys.platform == "win32":
|
||||
home_win = unsloth_home / "build" / "bin" / "Release" / binary_name
|
||||
if home_win.is_file():
|
||||
return str(home_win)
|
||||
|
||||
# 5–6. Legacy: in-tree build (older setup.sh / setup.ps1 versions)
|
||||
project_root = Path(__file__).resolve().parents[4]
|
||||
# Root dir (make builds)
|
||||
root_path = project_root / "llama.cpp" / binary_name
|
||||
if root_path.is_file():
|
||||
return str(root_path)
|
||||
# build/bin/ (cmake builds)
|
||||
build_path = project_root / "llama.cpp" / "build" / "bin" / binary_name
|
||||
if build_path.is_file():
|
||||
return str(build_path)
|
||||
if sys.platform == "win32":
|
||||
win_path = project_root / "llama.cpp" / "build" / "bin" / "Release" / binary_name
|
||||
if win_path.is_file():
|
||||
return str(win_path)
|
||||
|
||||
# 3. System PATH
|
||||
# 7. System PATH
|
||||
system_path = shutil.which("llama-server")
|
||||
if system_path:
|
||||
return system_path
|
||||
|
||||
# 4. Legacy: extracted to bin/
|
||||
bin_path = project_root / "bin" / "llama-server"
|
||||
# 8. Legacy: extracted to bin/
|
||||
bin_path = project_root / "bin" / binary_name
|
||||
if bin_path.is_file():
|
||||
return str(bin_path)
|
||||
|
||||
|
|
@ -115,6 +167,26 @@ class LlamaCppBackend:
|
|||
s.bind(("", 0))
|
||||
return s.getsockname()[1]
|
||||
|
||||
# ── Stdout drain (prevents pipe deadlock on Windows) ─────────
|
||||
|
||||
def _drain_stdout(self):
|
||||
"""
|
||||
Read lines from the subprocess stdout in a background thread.
|
||||
|
||||
This prevents a pipe-buffer deadlock on Windows where the default
|
||||
pipe buffer is only ~4 KB. Without draining, llama-server blocks
|
||||
on writes and never becomes healthy.
|
||||
"""
|
||||
try:
|
||||
for line in self._process.stdout:
|
||||
line = line.rstrip()
|
||||
if line:
|
||||
self._stdout_lines.append(line)
|
||||
logger.info(f"[llama-server] {line}")
|
||||
except (ValueError, OSError):
|
||||
# Pipe closed — process is terminating
|
||||
pass
|
||||
|
||||
# ── Lifecycle ─────────────────────────────────────────────────
|
||||
|
||||
def load_model(
|
||||
|
|
@ -122,6 +194,8 @@ class LlamaCppBackend:
|
|||
*,
|
||||
# Local mode: pass a path to a .gguf file
|
||||
gguf_path: Optional[str] = None,
|
||||
# Vision projection (mmproj) for local vision models
|
||||
mmproj_path: Optional[str] = None,
|
||||
# HF mode: let llama-server download via -hf "repo:quant"
|
||||
hf_repo: Optional[str] = None,
|
||||
hf_variant: Optional[str] = None,
|
||||
|
|
@ -160,16 +234,58 @@ class LlamaCppBackend:
|
|||
|
||||
# Build command based on mode
|
||||
if hf_repo:
|
||||
hf_spec = f"{hf_repo}:{hf_variant}" if hf_variant else hf_repo
|
||||
# Download the GGUF file ourselves using huggingface_hub
|
||||
# (llama-server's -hf flag requires HTTPS/curl which may not
|
||||
# be available, e.g. Windows builds with -DLLAMA_CURL=OFF)
|
||||
try:
|
||||
from huggingface_hub import hf_hub_download
|
||||
except ImportError:
|
||||
raise RuntimeError(
|
||||
"huggingface_hub is required for HF model loading. "
|
||||
"Install it with: pip install huggingface_hub"
|
||||
)
|
||||
|
||||
# Determine the filename from the variant (e.g., "Q4_K_M" -> find matching file)
|
||||
gguf_filename = None
|
||||
if hf_variant:
|
||||
# Try common naming patterns
|
||||
try:
|
||||
from huggingface_hub import list_repo_files
|
||||
files = list_repo_files(hf_repo, token=hf_token)
|
||||
variant_lower = hf_variant.lower()
|
||||
for f in files:
|
||||
if f.endswith(".gguf") and variant_lower in f.lower():
|
||||
gguf_filename = f
|
||||
break
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not list repo files: {e}")
|
||||
|
||||
if not gguf_filename:
|
||||
# Fallback: construct common filename pattern
|
||||
# e.g., "unsloth/gemma-3-4b-it-GGUF" + "Q4_K_M" -> try model name
|
||||
repo_name = hf_repo.split("/")[-1].replace("-GGUF", "")
|
||||
gguf_filename = f"{repo_name}-{hf_variant}.gguf"
|
||||
|
||||
logger.info(f"Downloading GGUF: {hf_repo}/{gguf_filename}")
|
||||
try:
|
||||
local_path = hf_hub_download(
|
||||
repo_id=hf_repo,
|
||||
filename=gguf_filename,
|
||||
token=hf_token,
|
||||
)
|
||||
except Exception as e:
|
||||
raise RuntimeError(
|
||||
f"Failed to download GGUF file '{gguf_filename}' from {hf_repo}: {e}"
|
||||
)
|
||||
|
||||
logger.info(f"GGUF downloaded to: {local_path}")
|
||||
cmd = [
|
||||
binary,
|
||||
"-hf", hf_spec,
|
||||
"-m", local_path,
|
||||
"--port", str(self._port),
|
||||
"-c", str(n_ctx),
|
||||
"-ngl", str(n_gpu_layers),
|
||||
]
|
||||
if hf_token:
|
||||
cmd.extend(["--hf-token", hf_token])
|
||||
elif gguf_path:
|
||||
if not Path(gguf_path).is_file():
|
||||
raise FileNotFoundError(f"GGUF file not found: {gguf_path}")
|
||||
|
|
@ -186,16 +302,43 @@ class LlamaCppBackend:
|
|||
if n_threads is not None:
|
||||
cmd.extend(["--threads", str(n_threads)])
|
||||
|
||||
# Append mmproj for local vision models
|
||||
if mmproj_path:
|
||||
if not Path(mmproj_path).is_file():
|
||||
logger.warning(f"mmproj file not found: {mmproj_path}")
|
||||
else:
|
||||
cmd.extend(["--mmproj", mmproj_path])
|
||||
logger.info(f"Using mmproj for vision: {mmproj_path}")
|
||||
|
||||
logger.info(f"Starting llama-server: {' '.join(cmd)}")
|
||||
|
||||
# Set LD_LIBRARY_PATH so llama-server can find its shared libs
|
||||
# (libmtmd.so, libllama.so, etc.) which live next to the binary
|
||||
# Set library paths so llama-server can find its shared libs and CUDA DLLs
|
||||
import os
|
||||
import sys
|
||||
env = os.environ.copy()
|
||||
binary_dir = str(Path(binary).parent)
|
||||
existing_ld = env.get("LD_LIBRARY_PATH", "")
|
||||
env["LD_LIBRARY_PATH"] = f"{binary_dir}:{existing_ld}" if existing_ld else binary_dir
|
||||
|
||||
if sys.platform == "win32":
|
||||
# On Windows, CUDA DLLs (cublas64_12.dll, cudart64_12.dll, etc.)
|
||||
# must be on PATH. Add CUDA_PATH\bin if available.
|
||||
path_dirs = [binary_dir]
|
||||
cuda_path = os.environ.get("CUDA_PATH", "")
|
||||
if cuda_path:
|
||||
cuda_bin = os.path.join(cuda_path, "bin")
|
||||
if os.path.isdir(cuda_bin):
|
||||
path_dirs.append(cuda_bin)
|
||||
# Some CUDA installs put DLLs in bin\x64
|
||||
cuda_bin_x64 = os.path.join(cuda_path, "bin", "x64")
|
||||
if os.path.isdir(cuda_bin_x64):
|
||||
path_dirs.append(cuda_bin_x64)
|
||||
existing_path = env.get("PATH", "")
|
||||
env["PATH"] = ";".join(path_dirs) + ";" + existing_path
|
||||
else:
|
||||
# Linux: set LD_LIBRARY_PATH for shared libs next to the binary
|
||||
existing_ld = env.get("LD_LIBRARY_PATH", "")
|
||||
env["LD_LIBRARY_PATH"] = f"{binary_dir}:{existing_ld}" if existing_ld else binary_dir
|
||||
|
||||
self._stdout_lines = []
|
||||
self._process = subprocess.Popen(
|
||||
cmd,
|
||||
stdout=subprocess.PIPE,
|
||||
|
|
@ -204,15 +347,20 @@ class LlamaCppBackend:
|
|||
env=env,
|
||||
)
|
||||
|
||||
# Start background thread to drain stdout and prevent pipe deadlock
|
||||
self._stdout_thread = threading.Thread(
|
||||
target=self._drain_stdout, daemon=True, name="llama-stdout"
|
||||
)
|
||||
self._stdout_thread.start()
|
||||
|
||||
self._gguf_path = gguf_path
|
||||
self._hf_repo = hf_repo
|
||||
self._hf_variant = hf_variant
|
||||
self._is_vision = is_vision
|
||||
self._model_identifier = model_identifier
|
||||
|
||||
# HF mode: llama-server downloads before becoming healthy — need longer timeout
|
||||
timeout = 600.0 if hf_repo else 120.0
|
||||
if not self._wait_for_health(timeout=timeout):
|
||||
# Wait for llama-server to become healthy
|
||||
if not self._wait_for_health(timeout=120.0):
|
||||
self._kill_process()
|
||||
raise RuntimeError(
|
||||
"llama-server failed to start. "
|
||||
|
|
@ -256,6 +404,9 @@ class LlamaCppBackend:
|
|||
logger.warning(f"Error killing llama-server process: {e}")
|
||||
finally:
|
||||
self._process = None
|
||||
if self._stdout_thread is not None:
|
||||
self._stdout_thread.join(timeout=2)
|
||||
self._stdout_thread = None
|
||||
|
||||
def _cleanup(self):
|
||||
"""atexit handler to ensure llama-server is terminated."""
|
||||
|
|
@ -273,8 +424,10 @@ class LlamaCppBackend:
|
|||
while time.monotonic() < deadline:
|
||||
# Check if process crashed
|
||||
if self._process.poll() is not None:
|
||||
# Read remaining output for error info
|
||||
output = self._process.stdout.read() if self._process.stdout else ""
|
||||
# Give the drain thread a moment to collect final output
|
||||
if self._stdout_thread is not None:
|
||||
self._stdout_thread.join(timeout=2)
|
||||
output = "\n".join(self._stdout_lines[-50:])
|
||||
logger.error(
|
||||
f"llama-server exited with code {self._process.returncode}. "
|
||||
f"Output: {output[:2000]}"
|
||||
|
|
|
|||
|
|
@ -125,6 +125,7 @@ async def load_model(
|
|||
# Local mode: llama-server loads via -m <path>
|
||||
success = llama_backend.load_model(
|
||||
gguf_path=config.gguf_file,
|
||||
mmproj_path=config.gguf_mmproj_file,
|
||||
model_identifier=config.identifier,
|
||||
is_vision=config.is_vision,
|
||||
n_ctx=request.max_seq_length,
|
||||
|
|
|
|||
|
|
@ -423,6 +423,11 @@ 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 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.
|
||||
|
|
@ -438,6 +443,12 @@ def safe_num_proc(desired: Optional[int] = None) -> int:
|
|||
A safe integer ≥ 1.
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
|
||||
# Windows uses 'spawn' for multiprocessing — the overhead of re-importing
|
||||
# torch/transformers/unsloth per worker is typically slower than single-process.
|
||||
if sys.platform == "win32":
|
||||
return 1
|
||||
|
||||
if desired is None or not isinstance(desired, int):
|
||||
desired = max(1, os.cpu_count() // 3)
|
||||
|
|
|
|||
|
|
@ -422,6 +422,32 @@ def is_vision_model(model_name: str, hf_token: Optional[str] = None) -> bool:
|
|||
pass
|
||||
|
||||
|
||||
def _is_mmproj(filename: str) -> bool:
|
||||
"""Check if a GGUF filename is a vision projection (mmproj) file."""
|
||||
return "mmproj" in filename.lower()
|
||||
|
||||
|
||||
def detect_mmproj_file(path: str) -> Optional[str]:
|
||||
"""
|
||||
Find the mmproj (vision projection) GGUF file in a directory.
|
||||
|
||||
Args:
|
||||
path: Directory to search — or a .gguf file (uses its parent dir).
|
||||
|
||||
Returns:
|
||||
Full path to the mmproj .gguf file, or None if not found.
|
||||
"""
|
||||
p = Path(path)
|
||||
search_dir = p.parent if p.is_file() else p
|
||||
if not search_dir.is_dir():
|
||||
return None
|
||||
|
||||
for f in search_dir.glob("*.gguf"):
|
||||
if _is_mmproj(f.name):
|
||||
return str(f.resolve())
|
||||
return None
|
||||
|
||||
|
||||
def detect_gguf_model(path: str) -> Optional[str]:
|
||||
"""
|
||||
Check if the given local path is or contains a GGUF model file.
|
||||
|
|
@ -430,6 +456,9 @@ def detect_gguf_model(path: str) -> Optional[str]:
|
|||
1. path is a direct .gguf file path
|
||||
2. path is a directory containing .gguf files
|
||||
|
||||
Skips mmproj (vision projection) files — those must be passed via
|
||||
``--mmproj``, not ``-m``. Use :func:`detect_mmproj_file` instead.
|
||||
|
||||
Returns the full path to the .gguf file if found, None otherwise.
|
||||
For HuggingFace repo detection, use detect_gguf_model_remote() instead.
|
||||
"""
|
||||
|
|
@ -437,11 +466,16 @@ def detect_gguf_model(path: str) -> Optional[str]:
|
|||
|
||||
# Case 1: direct .gguf file
|
||||
if p.suffix == ".gguf" and p.is_file():
|
||||
if _is_mmproj(p.name):
|
||||
return None
|
||||
return str(p.resolve())
|
||||
|
||||
# Case 2: directory containing .gguf files
|
||||
# Case 2: directory containing .gguf files (skip mmproj)
|
||||
if p.is_dir():
|
||||
gguf_files = sorted(p.glob("*.gguf"), key=lambda f: f.stat().st_size, reverse=True)
|
||||
gguf_files = sorted(
|
||||
(f for f in p.glob("*.gguf") if not _is_mmproj(f.name)),
|
||||
key=lambda f: f.stat().st_size, reverse=True,
|
||||
)
|
||||
if gguf_files:
|
||||
return str(gguf_files[0].resolve())
|
||||
|
||||
|
|
@ -677,7 +711,8 @@ def scan_exported_models(exports_dir: str = "./exports") -> List[Tuple[str, str,
|
|||
continue
|
||||
|
||||
# Check for flat GGUF export (e.g. exports/gemma-3-4b-it-finetune-gguf/)
|
||||
gguf_files = list(run_dir.glob("*.gguf"))
|
||||
# Filter out mmproj (vision projection) files — they aren't loadable as main models
|
||||
gguf_files = [f for f in run_dir.glob("*.gguf") if not _is_mmproj(f.name)]
|
||||
if gguf_files:
|
||||
base_model = None
|
||||
export_meta = run_dir / "export_metadata.json"
|
||||
|
|
@ -907,6 +942,7 @@ class ModelConfig:
|
|||
is_lora: bool # Is this a lora adapter?
|
||||
is_gguf: bool = False # Is this a GGUF model?
|
||||
gguf_file: Optional[str] = None # Full path to the .gguf file (local mode)
|
||||
gguf_mmproj_file: Optional[str] = None # Full path to the mmproj .gguf file (vision projection)
|
||||
gguf_hf_repo: Optional[str] = None # HF repo ID for -hf mode (e.g. "unsloth/gemma-3-4b-it-GGUF")
|
||||
gguf_variant: Optional[str] = None # Quantization variant (e.g. "Q4_K_M")
|
||||
base_model: Optional[str] = None # Base model (for LoRAs)
|
||||
|
|
@ -1005,16 +1041,44 @@ class ModelConfig:
|
|||
if gguf_file:
|
||||
display_name = Path(gguf_file).stem
|
||||
logger.info(f"Detected local GGUF model: {gguf_file}")
|
||||
|
||||
# Detect vision: check if base model is vision, then look for mmproj
|
||||
mmproj_file = None
|
||||
gguf_is_vision = False
|
||||
gguf_dir = Path(gguf_file).parent
|
||||
|
||||
# Determine if this is a vision model from export metadata
|
||||
base_is_vision = False
|
||||
meta_path = gguf_dir / "export_metadata.json"
|
||||
if meta_path.exists():
|
||||
try:
|
||||
meta = json.loads(meta_path.read_text())
|
||||
base = meta.get("base_model")
|
||||
if base and is_vision_model(base, hf_token=hf_token):
|
||||
base_is_vision = True
|
||||
logger.info(f"GGUF base model '{base}' is a vision model")
|
||||
except Exception as e:
|
||||
logger.debug(f"Could not read export metadata: {e}")
|
||||
|
||||
# If vision (or mmproj happens to exist), find the mmproj file
|
||||
mmproj_file = detect_mmproj_file(gguf_file)
|
||||
if mmproj_file:
|
||||
gguf_is_vision = True
|
||||
logger.info(f"Detected mmproj for vision: {mmproj_file}")
|
||||
elif base_is_vision:
|
||||
logger.warning(f"Base model is vision but no mmproj file found in {gguf_dir}")
|
||||
|
||||
return cls(
|
||||
identifier=identifier,
|
||||
display_name=display_name,
|
||||
path=path,
|
||||
is_local=True,
|
||||
is_cached=True,
|
||||
is_vision=False,
|
||||
is_vision=gguf_is_vision,
|
||||
is_lora=False,
|
||||
is_gguf=True,
|
||||
gguf_file=gguf_file,
|
||||
gguf_mmproj_file=mmproj_file,
|
||||
)
|
||||
else:
|
||||
# Check if the HF repo contains GGUF files
|
||||
|
|
|
|||
|
|
@ -103,9 +103,6 @@ function ModelRow({
|
|||
{vramStatus === "tight" && (
|
||||
<span className="text-[9px] font-medium text-amber-400">TIGHT</span>
|
||||
)}
|
||||
{vramStatus === "fits" && (
|
||||
<span className="text-[9px] font-medium text-emerald-500/90">FIT</span>
|
||||
)}
|
||||
{meta ? (
|
||||
<span className="text-[10px] text-muted-foreground">{meta}</span>
|
||||
) : null}
|
||||
|
|
@ -253,9 +250,6 @@ function GgufVariantExpander({
|
|||
{fitStatus === "tight" && (
|
||||
<span className="text-[9px] font-medium text-amber-400">TIGHT</span>
|
||||
)}
|
||||
{fitStatus === "fits" && (
|
||||
<span className="text-[9px] font-medium text-emerald-500/90">FIT</span>
|
||||
)}
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
{formatBytes(v.size_bytes)}
|
||||
</span>
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@ import {
|
|||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
import { useTrainingRuntimeStore } from "@/features/training";
|
||||
import { Link, useRouterState } from "@tanstack/react-router";
|
||||
import { AnimatePresence, motion } from "motion/react";
|
||||
import { motion } from "motion/react";
|
||||
import { useState } from "react";
|
||||
import { TOUR_OPEN_EVENT } from "@/features/tour";
|
||||
|
||||
|
|
@ -58,9 +58,9 @@ export function Navbar() {
|
|||
|
||||
return (
|
||||
<header className="relative top-0 z-40 h-16 w-full">
|
||||
<div className="mx-auto flex h-full max-w-7xl items-center justify-between px-4 sm:px-6">
|
||||
<div className="mx-auto grid h-full max-w-7xl grid-cols-[1fr_auto_1fr] items-center px-4 sm:px-6">
|
||||
{/* Left: logo */}
|
||||
<Link to="/studio" className="flex items-center select-none">
|
||||
<Link to="/studio" className="flex items-center justify-self-start select-none">
|
||||
<img
|
||||
src="/blacklogo.png"
|
||||
alt="Unsloth"
|
||||
|
|
@ -117,23 +117,21 @@ export function Navbar() {
|
|||
/>
|
||||
)}
|
||||
<span className="relative z-10 flex items-center gap-1.5">
|
||||
<AnimatePresence mode="popLayout">
|
||||
{active && item.icon && (
|
||||
<motion.span
|
||||
key={item.href}
|
||||
initial={{ width: 0, opacity: 0 }}
|
||||
animate={{ width: "auto", opacity: 1 }}
|
||||
exit={{ width: 0, opacity: 0 }}
|
||||
transition={{ duration: 0.2, ease: [0.165, 0.84, 0.44, 1] }}
|
||||
className="overflow-hidden"
|
||||
>
|
||||
<HugeiconsIcon
|
||||
icon={item.icon}
|
||||
className="size-3.5 -mt-px"
|
||||
/>
|
||||
</motion.span>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
<span className="inline-flex size-3.5 items-center justify-center overflow-hidden">
|
||||
<motion.span
|
||||
initial={false}
|
||||
animate={{
|
||||
opacity: active ? 1 : 0,
|
||||
scale: active ? 1 : 0.9,
|
||||
}}
|
||||
transition={{ duration: 0.2, ease: [0.165, 0.84, 0.44, 1] }}
|
||||
>
|
||||
<HugeiconsIcon
|
||||
icon={item.icon}
|
||||
className="size-3.5 -mt-px"
|
||||
/>
|
||||
</motion.span>
|
||||
</span>
|
||||
{item.label}
|
||||
</span>
|
||||
</Link>
|
||||
|
|
@ -142,7 +140,7 @@ export function Navbar() {
|
|||
</nav>
|
||||
|
||||
{/* Right: docs/tour desktop */}
|
||||
<div className="hidden items-center gap-2 md:flex">
|
||||
<div className="hidden items-center justify-self-end gap-2 md:flex">
|
||||
<AnimatedThemeToggler
|
||||
className="flex h-9 w-9 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground [&_svg]:size-4"
|
||||
title="Toggle theme"
|
||||
|
|
@ -182,17 +180,20 @@ export function Navbar() {
|
|||
</HoverCardContent>
|
||||
</HoverCard>
|
||||
|
||||
{tourId ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={openTour}
|
||||
className="flex h-9 items-center gap-1.5 rounded-md px-3 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
||||
title="Tour"
|
||||
>
|
||||
<HugeiconsIcon icon={CursorInfo02Icon} className="size-4" />
|
||||
<span className="text-sm font-medium">Tour</span>
|
||||
</button>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
onClick={tourId ? openTour : undefined}
|
||||
className={cn(
|
||||
"flex h-9 items-center gap-1.5 rounded-md px-3 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground",
|
||||
!tourId && "invisible pointer-events-none",
|
||||
)}
|
||||
title="Tour"
|
||||
aria-hidden={!tourId}
|
||||
tabIndex={tourId ? 0 : -1}
|
||||
>
|
||||
<HugeiconsIcon icon={CursorInfo02Icon} className="size-4" />
|
||||
<span className="text-sm font-medium">Tour</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Right: mobile */}
|
||||
|
|
|
|||
|
|
@ -33,11 +33,17 @@ import {
|
|||
import { MODEL_TYPE_TO_HF_TASK } from "@/config/training";
|
||||
import {
|
||||
useDebouncedValue,
|
||||
useGpuInfo,
|
||||
useHfModelSearch,
|
||||
useHfTokenValidation,
|
||||
useInfiniteScroll,
|
||||
} from "@/hooks";
|
||||
import { formatCompact } from "@/lib/utils";
|
||||
import {
|
||||
type TrainingMethod as VramTrainingMethod,
|
||||
type VramFitStatus,
|
||||
buildModelVramMap,
|
||||
} from "@/lib/vram";
|
||||
import { useTrainingConfigStore } from "@/features/training";
|
||||
import type { TrainingMethod } from "@/types/training";
|
||||
import {
|
||||
|
|
@ -50,6 +56,7 @@ import { useEffect, useMemo, useRef, useState } from "react";
|
|||
import { useShallow } from "zustand/react/shallow";
|
||||
|
||||
export function ModelSelectionStep() {
|
||||
const gpu = useGpuInfo();
|
||||
const {
|
||||
modelType,
|
||||
selectedModel,
|
||||
|
|
@ -93,6 +100,24 @@ export function ModelSelectionStep() {
|
|||
|
||||
const resultIds = useMemo(() => hfResults.map((r) => r.id), [hfResults]);
|
||||
|
||||
// Match Studio behavior: only show exception signals (OOM/TIGHT) in training flows.
|
||||
const vramMap = useMemo(() => {
|
||||
const fitMap = buildModelVramMap(
|
||||
hfResults,
|
||||
trainingMethod as VramTrainingMethod,
|
||||
gpu,
|
||||
);
|
||||
const map = new Map<string, { status: VramFitStatus | null; detail: string | null }>();
|
||||
for (const r of hfResults) {
|
||||
const fit = fitMap.get(r.id);
|
||||
map.set(r.id, {
|
||||
status: fit?.status ?? null,
|
||||
detail: r.totalParams ? formatCompact(r.totalParams) : null,
|
||||
});
|
||||
}
|
||||
return map;
|
||||
}, [hfResults, gpu, trainingMethod]);
|
||||
|
||||
const comboboxAnchorRef = useRef<HTMLDivElement>(null);
|
||||
const { scrollRef, sentinelRef } = useInfiniteScroll(
|
||||
fetchMore,
|
||||
|
|
@ -218,19 +243,21 @@ export function ModelSelectionStep() {
|
|||
>
|
||||
<ComboboxList className="p-1 !max-h-none !overflow-visible">
|
||||
{(id: string) => {
|
||||
const r = hfResults.find((r) => r.id === id);
|
||||
const sizeLabel = r?.totalParams
|
||||
? formatCompact(r.totalParams)
|
||||
: null;
|
||||
const entry = vramMap.get(id);
|
||||
const sizeLabel = entry?.detail ?? null;
|
||||
const fitStatus = entry?.status ?? null;
|
||||
const exceeds = fitStatus === "exceeds";
|
||||
return (
|
||||
<ComboboxItem
|
||||
key={id}
|
||||
value={id}
|
||||
className="justify-between"
|
||||
className={`justify-between ${exceeds ? "opacity-50" : ""}`}
|
||||
>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild={true}>
|
||||
<span className="min-w-0 flex-1 truncate">
|
||||
<span
|
||||
className={`min-w-0 flex-1 truncate ${exceeds ? "line-through decoration-muted-foreground/50" : ""}`}
|
||||
>
|
||||
{id}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
|
|
@ -241,11 +268,23 @@ export function ModelSelectionStep() {
|
|||
{id}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
{sizeLabel ? (
|
||||
<span className="text-xs text-muted-foreground shrink-0">
|
||||
{sizeLabel}
|
||||
</span>
|
||||
) : null}
|
||||
<span className="flex items-center gap-1.5 shrink-0">
|
||||
{fitStatus === "exceeds" && (
|
||||
<span className="text-[9px] font-medium text-red-400">
|
||||
OOM
|
||||
</span>
|
||||
)}
|
||||
{fitStatus === "tight" && (
|
||||
<span className="text-[9px] font-medium text-amber-400">
|
||||
TIGHT
|
||||
</span>
|
||||
)}
|
||||
{sizeLabel ? (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{sizeLabel}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
</ComboboxItem>
|
||||
);
|
||||
}}
|
||||
|
|
|
|||
|
|
@ -37,8 +37,7 @@ import { formatCompact } from "@/lib/utils";
|
|||
import {
|
||||
type TrainingMethod as VramTrainingMethod,
|
||||
type VramFitStatus,
|
||||
checkVramFit,
|
||||
estimateLoadingVram,
|
||||
buildModelVramMap,
|
||||
} from "@/lib/vram";
|
||||
import {
|
||||
listLocalModels,
|
||||
|
|
@ -218,22 +217,23 @@ export function ModelSection() {
|
|||
// Keyed by model id so the render callback is a simple O(1) lookup.
|
||||
// Re-computes when the training method changes (QLoRA=4-bit vs LoRA/Full=fp16).
|
||||
const vramMap = useMemo(() => {
|
||||
const method = trainingMethod as VramTrainingMethod;
|
||||
const fitMap = buildModelVramMap(
|
||||
hfResults,
|
||||
trainingMethod as VramTrainingMethod,
|
||||
gpu,
|
||||
);
|
||||
const map = new Map<
|
||||
string,
|
||||
{ est: number; status: VramFitStatus | null; detail: string | null }
|
||||
>();
|
||||
for (const r of hfResults) {
|
||||
const detail = r.totalParams ? formatCompact(r.totalParams) : null;
|
||||
if (r.totalParams) {
|
||||
const est = estimateLoadingVram(r.totalParams, method);
|
||||
const status = gpu.available
|
||||
? checkVramFit(est, gpu.memoryTotalGb)
|
||||
: null;
|
||||
map.set(r.id, { est, status, detail });
|
||||
} else {
|
||||
map.set(r.id, { est: 0, status: null, detail });
|
||||
}
|
||||
const fit = fitMap.get(r.id);
|
||||
map.set(r.id, {
|
||||
est: fit?.est ?? 0,
|
||||
status: fit?.status ?? null,
|
||||
detail,
|
||||
});
|
||||
}
|
||||
return map;
|
||||
}, [hfResults, gpu, trainingMethod]);
|
||||
|
|
|
|||
|
|
@ -268,6 +268,7 @@
|
|||
}
|
||||
html {
|
||||
@apply font-sans;
|
||||
scrollbar-gutter: stable;
|
||||
}
|
||||
h1,
|
||||
h2,
|
||||
|
|
|
|||
|
|
@ -93,3 +93,32 @@ export function checkVramFit(
|
|||
if (ratio <= 1.0) return "tight";
|
||||
return "exceeds";
|
||||
}
|
||||
|
||||
export interface ModelVramMapInput {
|
||||
id: string;
|
||||
totalParams?: number;
|
||||
}
|
||||
|
||||
export interface ModelVramMapEntry {
|
||||
est: number;
|
||||
status: VramFitStatus | null;
|
||||
}
|
||||
|
||||
export function buildModelVramMap(
|
||||
models: ModelVramMapInput[],
|
||||
method: TrainingMethod,
|
||||
gpu: { available: boolean; memoryTotalGb: number },
|
||||
): Map<string, ModelVramMapEntry> {
|
||||
const map = new Map<string, ModelVramMapEntry>();
|
||||
for (const model of models) {
|
||||
if (!model.totalParams) {
|
||||
map.set(model.id, { est: 0, status: null });
|
||||
continue;
|
||||
}
|
||||
|
||||
const est = estimateLoadingVram(model.totalParams, method);
|
||||
const status = gpu.available ? checkVramFit(est, gpu.memoryTotalGb) : null;
|
||||
map.set(model.id, { est, status });
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue