mirror of
https://github.com/unslothai/unsloth.git
synced 2026-08-04 13:20:38 +00:00
* Studio: fall back to text-only when a vision projector hard-crashes llama-server The text-only mmproj fallback (#6075) only fired when llama-server printed a recognizable projector-format error ("Unknown projector type", exit -6). An installed llama.cpp that predates a model's projector can instead SIGSEGV (exit -11) with no parseable output, e.g. unsloth/Qwen3.5-4B-MTP-GGUF + mmproj-F16 on an older gfx1151 prebuilt: llama-server crashes on load, the --fit off retry crashes the same way, and load_model gives up with a hard 500 instead of dropping vision. Generalize the decision: a vision (--mmproj) launch killed by a signal (POSIX returncode < 0, e.g. -11 SIGSEGV / -6 SIGABRT; Windows 0xC0000000+ access violation) is treated like a projector incompatibility, so the load retries once text-only. The retry is skipped if a cancel/unload is pending, mirroring the MTP guard. Clean non-zero exits (bad GGUF, port bind) and hung processes keep their own handling; non-vision launches are unaffected. Reproduced and verified on gfx1151 (Radeon 8060S, ROCm 7.2.1): a current prebuilt (llama.cpp b9596) loads the exact model + args fine, confirming the crash is a stale prebuilt. With a wrapper that SIGSEGVs on --mmproj, Studio now recovers: the load returns 200 (is_vision=false) and serves at ~31 tok/s text-only instead of failing. New _is_signal_crash helper plus tests pin the decision. Also normalize a few em-dashes to ASCII punctuation in existing comments. * Studio: refine mmproj hard-crash fallback (signal scope + last argv) - Limit _is_signal_crash to genuine program faults (SIGSEGV, SIGABRT, SIGILL, SIGFPE, SIGBUS) and Windows 0xC0000000+ statuses. SIGKILL, SIGTERM and SIGINT no longer count, so an OOM-killer, unload or supervisor kill is not masked as a projector incompatibility. - Strip --mmproj from the last attempted argv so the text-only retry keeps --fit off / --spec-default instead of resurrecting the original spec flags (matters for MTP vision models on an older llama.cpp). - Drop stray temp files committed by mistake and gitignore the "~" dir so they cannot be re-added. * Studio: tighten comments in mmproj hard-crash fallback * Studio: retry --flash-attn off before dropping vision on a startup crash When llama-server hard-crashes at startup, the recovery chain now tries the least-destructive mitigation first. Flash-attention kernels SIGSEGV at load on some ROCm/GPU builds (often inside the vision tower's attention); disabling flash attention keeps BOTH vision and MTP, so a hard program fault with --flash-attn on now retries once with --flash-attn off before the MTP-drop or the text-only (mmproj-strip) fallbacks. _is_signal_crash already gates this to genuine faults (SIGSEGV/SIGABRT/SIGILL/SIGFPE/SIGBUS), so an OOM-kill or unload (SIGKILL/SIGTERM/SIGINT) does not trigger a retry. Field context: a gfx1151 user crashes loading a vision GGUF even on the latest prebuilt, so an update cannot help, and the same model and args load fine on another gfx1151 box, pointing at a runtime/flash-attn fault. New _with_flash_attn_off helper plus tests. Verified on hardware with a wrapper that SIGSEGVs on --flash-attn on: Studio recovers with is_vision=true (vision and MTP intact) instead of failing or losing vision. * Studio: name the OOM kill on a too-large model load When the OS kills llama-server with no diagnostic output (SIGKILL/SIGTERM, almost always the OOM killer, e.g. a BF16 model too large for the WSL VM's RAM cap), the recovery ladder correctly does not retry an external kill, so this is the message the user sees. It fell through to the generic "is the GGUF valid / out of memory" text. Make it actionable: name the signal and point at a smaller or more quantized GGUF, a lower context length, or raising the WSL memory limit. Output-based diagnoses still win and a hard fault keeps the generic fallback. * Studio: refuse a model too large for system RAM on a unified-memory APU On gfx1150/gfx1151 APUs the weights load into shared system RAM (GGML unified memory). _get_gpu_free_memory reports the full ROCm/APU budget as free (often ~100 GB), but under WSL the VM's RAM cap is the real ceiling. Studio trusted the budget, spawned a load larger than RAM, and the OS killed it mid-flight, taking the Studio process with it (a silent "Terminated" with no error, the model resident in RAM not VRAM). Add a pre-flight guard on the APU path: if the weights exceed available system RAM (psutil, then /proc/meminfo), refuse before spawning with a clear message (smaller/more-quantized GGUF, lower context, or raise the WSL memory limit). Weights only so KV/context auto-reduction is not double-counted; unknown RAM never refuses; non-APU and discrete-GPU paths are untouched. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Studio: refine recovery ladder (keep diagnosed errors, flip all flash-attn) Two review points on the hard-crash recovery ladder: 1. The signal-only text-only fallback stripped --mmproj on any hard fault, even when llama-server had already printed a non-projector cause (an OOM such as "cudaMalloc failed: out of memory", an unsupported architecture, or a tensor-parallel limit). That masked the real error and told the user to update llama.cpp for vision. New _output_has_nonprojector_diagnostic gates the signal path: it fires only when no such marker is present, so a bare SIGSEGV with no output still retries text-only, but a diagnosed OOM surfaces the real error instead of silently dropping vision. 2. _with_flash_attn_off only flipped the first --flash-attn. llama.cpp is last-wins, so a leftover enable from extra_args (--flash-attn on, -fa on, or the = form) could keep flash attention on and re-crash the retry. It now flips every occurrence and returns None only when nothing is flippable. test_llama_cpp_mmproj_fallback.py and the classification/APU suites: 103 passed. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Studio: pass the text-only retry's exit code to the failure classifier When the text-only fallback retry itself fails, read its exit code before _kill_process() clears it and forward it to _classify_llama_start_failure, so an OS-killed retry surfaces the actionable out-of-memory message instead of the generic one (matching the primary failure path). * Studio: scope APU RAM guard to selected GPUs, count MTP drafter, neutral SIGTERM Three refinements to the startup recovery work in this PR: - The unified-memory APU RAM guard fired whenever any visible GPU was a gfx1150/gfx1151 APU, so on a mixed APU+dGPU host it could refuse a valid load placed on the discrete GPU. Scope _amd_apu_wants_unified_memory to the selected gpu_indices (physical ids, mapped via CUDA_VISIBLE_DEVICES like _is_datacenter_gpu); None still means every visible GPU. Applied to both the RAM guard and the GGML_CUDA_ENABLE_UNIFIED_MEMORY env set. - The RAM guard counted only the main GGUF plus mmproj, so a separate MTP drafter (also resident in unified system RAM, even when offloaded to CPU) could push the load past the RAM cap and still get OS-killed mid-load. Add the drafter weights to the APU RAM total. - The startup classifier reported SIGTERM (-15) as 'most likely out of memory', but SIGTERM is also how an unload/cancel or a supervisor stops the server. Keep the OOM wording for SIGKILL (-9, the OOM killer) and report -15 neutrally. Tests updated/added accordingly. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Studio: address review on the APU guard and decode-probe ladder - Map APU physical ids via the active ROCm mask (HIP, then ROCR, then CUDA), mirroring _get_gpu_memory, so a HIP_VISIBLE_DEVICES-selected APU is matched. - Only add the MTP drafter to the APU RAM total when MTP will actually engage, so a stale LLAMA_ARG_SPEC_DRAFT_MODEL cannot refuse a non-MTP load. - After an MTP first-decode hard fault, retry --flash-attn off (keeps MTP) before dropping speculative decoding, matching the startup rung. - Fold the --flash-attn= / -fa= rewrite into one branch. Tests: tensor-parallel decode-probe assertion updated for the FA-off rung. * Studio: tighten two comments in the APU guard and RAM preflight * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Studio: refine flash-attn retry and APU RAM guard per review - _with_flash_attn_off now decides on the effective last-wins value: it returns None when FA is already off (no wasted retry), and neutralizes a bare --flash-attn / -fa (which llama.cpp reads as on) so the retry cannot re-enable it. Length is preserved so downstream index slices stay valid. - _amd_apu_wants_unified_memory uses 'gpu_indices is not None' so an empty selection is respected (not treated as all-visible). - The APU RAM refusal now checks the base model only (main + mmproj); an optional MTP drafter is dropped by the existing MTP-drop fallback rather than causing a hard pre-spawn refusal of an otherwise loadable model. Tests: bare-flag / effective-off / empty-selection / HIP-mask cases added. --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
173 lines
7.2 KiB
Python
173 lines
7.2 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
|
|
|
|
"""Tests for LlamaCppBackend._classify_llama_start_failure.
|
|
|
|
When llama-server exits before becoming healthy, load_model turns its
|
|
captured stdout/stderr into a user-facing reason. A diffusion/image GGUF
|
|
(FLUX, Qwen-Image, ...) is a valid file with plenty of memory, so the
|
|
generic "invalid file or out of memory" message is misleading (issue
|
|
#5842). These tests pin the classification.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import sys
|
|
import types as _types
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
_BACKEND_DIR = str(Path(__file__).resolve().parent.parent)
|
|
if _BACKEND_DIR not in sys.path:
|
|
sys.path.insert(0, _BACKEND_DIR)
|
|
|
|
# Match sibling tests' stubbing so the module imports in a lightweight
|
|
# env without fastapi.
|
|
_loggers_stub = _types.ModuleType("loggers")
|
|
_loggers_stub.get_logger = lambda name: __import__("logging").getLogger(name)
|
|
sys.modules.setdefault("loggers", _loggers_stub)
|
|
# Give the structlog stub a real get_logger: a bare ModuleType poisons
|
|
# sys.modules for later tests that call structlog.get_logger at import time.
|
|
_structlog_stub = _types.ModuleType("structlog")
|
|
_structlog_stub.get_logger = lambda *a, **k: __import__("logging").getLogger("structlog")
|
|
sys.modules.setdefault("structlog", _structlog_stub)
|
|
if not hasattr(sys.modules["structlog"], "get_logger"):
|
|
sys.modules["structlog"].get_logger = _structlog_stub.get_logger
|
|
|
|
from core.inference.llama_cpp import LlamaCppBackend # noqa: E402
|
|
|
|
_classify = LlamaCppBackend._classify_llama_start_failure
|
|
|
|
# Real llama-server failure lines (lower-cased downstream anyway).
|
|
_QWEN_IMAGE_OUT = (
|
|
"load_model: loading model 'qwen-image-edit-2511-Q4_K_M.gguf'\n"
|
|
"llama_model_load: error loading model: unknown model architecture: 'qwen_image'\n"
|
|
"llama_model_load_from_file_impl: failed to load model"
|
|
)
|
|
_OOM_OUT = (
|
|
"ggml_backend_cuda_buffer_type_alloc_buffer: allocating 12000.00 MiB on "
|
|
"device 0: cudaMalloc failed: out of memory"
|
|
)
|
|
|
|
|
|
class TestDiffusionArchitectures:
|
|
def test_qwen_image_routes_to_images_page(self):
|
|
msg = _classify(_QWEN_IMAGE_OUT, "/models/qwen-image.gguf", "local/qwen-image")
|
|
assert "diffusion" in msg.lower()
|
|
assert "Images page" in msg
|
|
assert "qwen_image" in msg
|
|
# Must NOT keep blaming memory / file validity.
|
|
assert "out of memory" not in msg.lower()
|
|
assert "enough memory" not in msg.lower()
|
|
|
|
# Parametrize over the production set so new arches are auto-covered.
|
|
@pytest.mark.parametrize("arch", sorted(LlamaCppBackend._DIFFUSION_ARCHES))
|
|
def test_every_diffusion_arch_is_recognised(self, arch):
|
|
out = f"error loading model: unknown model architecture: '{arch}'"
|
|
msg = _classify(out, f"/models/{arch}.gguf", f"local/{arch}")
|
|
assert "diffusion" in msg.lower()
|
|
assert "Images page" in msg
|
|
assert arch in msg
|
|
|
|
|
|
class TestUnsupportedNonDiffusionArchitecture:
|
|
def test_unknown_llm_arch_says_unsupported_not_oom(self):
|
|
out = "error loading model: unknown model architecture: 'some_new_llm'"
|
|
msg = _classify(out, "/models/x.gguf", "local/x")
|
|
assert "some_new_llm" in msg
|
|
assert "architecture" in msg.lower()
|
|
# Specific, not the misleading memory message.
|
|
assert "enough memory" not in msg.lower()
|
|
assert "diffusion" not in msg.lower()
|
|
|
|
# Exact match: a chat arch merely containing a diffusion token (wan,
|
|
# sd1, flux, ...) must not be routed to the Images page.
|
|
@pytest.mark.parametrize(
|
|
"arch",
|
|
[
|
|
"taiwan", # contains "wan"
|
|
"swan_llm", # contains "wan"
|
|
"fluxion", # contains "flux"
|
|
"sd1234", # contains "sd1"
|
|
"sd3_chat", # contains "sd3"
|
|
"aura2_text", # contains "aura"
|
|
"cosmos_reason", # contains "cosmos"
|
|
"qwen_image_text", # contains "qwen_image"
|
|
],
|
|
)
|
|
def test_arch_containing_diffusion_token_is_not_misrouted(self, arch):
|
|
out = f"error loading model: unknown model architecture: '{arch}'"
|
|
msg = _classify(out, f"/models/{arch}.gguf", f"local/{arch}")
|
|
assert arch in msg
|
|
assert "does not support" in msg.lower()
|
|
assert "diffusion" not in msg.lower()
|
|
assert "Images page" not in msg
|
|
|
|
|
|
class TestOllamaAndFallback:
|
|
_OLLAMA_GGUF = (
|
|
f"/home/u/.ollama{__import__('os').sep}ollama_links" f"{__import__('os').sep}m.gguf"
|
|
)
|
|
|
|
def test_ollama_compat_message_still_works(self):
|
|
out = "llama_model_load: error loading model: key not found"
|
|
msg = _classify(out, self._OLLAMA_GGUF, "ollama/llama3")
|
|
assert "Ollama" in msg
|
|
|
|
def test_ollama_unknown_arch_keeps_ollama_guidance(self):
|
|
# Ollama + non-diffusion unknown arch keeps the Ollama hint, not the
|
|
# generic llama.cpp "unsupported" message.
|
|
out = "error loading model: unknown model architecture: 'some_new_llm'"
|
|
msg = _classify(out, self._OLLAMA_GGUF, "ollama/some-new")
|
|
assert "Ollama" in msg
|
|
assert "directly through Ollama" in msg
|
|
assert "does not support" not in msg.lower()
|
|
|
|
def test_ollama_diffusion_arch_still_routes_to_images(self):
|
|
# Diffusion routing wins over the Ollama hint.
|
|
out = "error loading model: unknown model architecture: 'flux'"
|
|
msg = _classify(out, self._OLLAMA_GGUF, "ollama/flux")
|
|
assert "diffusion" in msg.lower()
|
|
assert "Images page" in msg
|
|
|
|
def test_generic_oom_keeps_memory_message(self):
|
|
msg = _classify(_OOM_OUT, "/models/big.gguf", "local/big")
|
|
assert "enough memory" in msg.lower()
|
|
assert "diffusion" not in msg.lower()
|
|
|
|
def test_empty_output_is_safe(self):
|
|
msg = _classify("", None, None)
|
|
assert "llama-server failed to start" in msg
|
|
|
|
|
|
class TestOsKillReturncode:
|
|
"""SIGKILL (-9) with no diagnostic output is the OOM killer and gets a named,
|
|
actionable message; SIGTERM (-15) is also unload/cancel/supervisor stop, so it
|
|
stays neutral; a recognized output still wins; a hard fault (-11) keeps the
|
|
generic fallback."""
|
|
|
|
def test_sigkill_with_no_output_names_oom(self):
|
|
msg = _classify("", "/models/big-bf16.gguf", "local/big", -9)
|
|
assert "signal 9" in msg
|
|
assert "out of memory" in msg.lower()
|
|
assert ".wslconfig" in msg
|
|
assert "GGUF file is valid" not in msg
|
|
|
|
def test_sigterm_is_neutral_not_oom(self):
|
|
msg = _classify("", "/models/big-bf16.gguf", "local/big", -15)
|
|
assert "signal 15" in msg
|
|
assert "terminated" in msg.lower()
|
|
assert "out of memory" not in msg.lower()
|
|
|
|
def test_specific_output_wins_over_os_kill_code(self):
|
|
msg = _classify(_QWEN_IMAGE_OUT, "/models/qwen-image.gguf", "local/qwen-image", -9)
|
|
assert "diffusion" in msg.lower()
|
|
assert "out of memory" not in msg.lower()
|
|
|
|
def test_signal_crash_code_keeps_generic_message(self):
|
|
# -11 is handled by the retry ladder; if it reaches here with no output
|
|
# it gets the generic fallback, not the OOM message.
|
|
msg = _classify("", "/models/x.gguf", "local/x", -11)
|
|
assert "GGUF file is valid" in msg
|
|
assert "out of memory" not in msg.lower()
|