Studio: account for mmproj VRAM in GGUF fit budget (#5825) (#5849)

* Studio: account for mmproj VRAM in GGUF fit budget (#5825)

Vision GGUFs load the mmproj projector onto the GPU via --mmproj
alongside the weights, but the context auto-sizing / GPU-selection
budget sized off _get_gguf_size_bytes(model_path), which counts only
the weight file(s). The projector was never added, so the budget was
too optimistic: context got mis-estimated and tight vision loads
spilled to system RAM / OOM'd.

Resolve the launch projector once before GPU selection and fold its
size into the fit budget. The same resolved path feeds both the budget
and the --mmproj launch flag, so the two cannot disagree. The summary
log now reports the projector size separately, keeping "GGUF size"
accurate.

Adds _mmproj_vram_bytes() + unit tests (no GPU / network / subprocess).

* Studio: simplify mmproj summary-log concatenation (#5825)

Address review: the summary log mixed explicit `+` with implicit
f-string concatenation. Extract the optional projector fragment into
`mmproj_note` so the logger.info uses uniform implicit concatenation.
No behavioral change.

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

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

* Studio: trim mmproj VRAM comments

---------

Co-authored-by: Lee Jackson <130007945+Imagineer99@users.noreply.github.com>
Co-authored-by: imagineer99 <samleejackson0@gmail.com>
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
This commit is contained in:
hoobnn 2026-06-12 22:04:08 +08:00 committed by GitHub
commit f033213c0b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 77 additions and 18 deletions

View file

@ -2996,6 +2996,16 @@ class LlamaCppBackend:
return str(mmproj)
def _mmproj_vram_bytes(self, launch_mmproj_path: Optional[str]) -> int:
"""Return resolved mmproj VRAM bytes, or 0 when absent/unreadable."""
if not launch_mmproj_path:
return 0
try:
return self._get_gguf_size_bytes(launch_mmproj_path)
except OSError as e:
logger.debug(f"Could not size mmproj {launch_mmproj_path}: {e}")
return 0
def _resolve_launch_mtp_path(self, *, mtp_draft_path: Optional[str]) -> Optional[str]:
"""Return mtp_draft_path iff it exists on disk, else None.
@ -3561,8 +3571,29 @@ class LlamaCppBackend:
effective_ctx = requested_ctx if requested_ctx > 0 else (self._context_length or 0)
max_available_ctx = self._context_length or effective_ctx
gpus: list[tuple[int, int]] = []
# Keep fit-budget and launch-flag mmproj resolution in sync.
launch_mmproj_path = None
if not extra_args_disable_mmproj(extra_args):
launch_mmproj_path = self._resolve_launch_mmproj_path(
model_path = model_path,
mmproj_path = mmproj_path,
)
# Need both a resolved mmproj AND the config vision flag; a stray
# mmproj passing the family-name heuristic must not flip a non-VLM
# GGUF into vision mode.
effective_is_vision = bool(launch_mmproj_path) and bool(is_vision)
if is_vision and not effective_is_vision:
logger.warning(
"Vision-capable GGUF loaded without a usable mmproj; "
"image input will be disabled for this session"
)
try:
model_size = self._get_gguf_size_bytes(model_path)
gguf_size = self._get_gguf_size_bytes(model_path)
# Include GPU-loaded mmproj in the fit budget (#5825).
mmproj_size = (
self._mmproj_vram_bytes(launch_mmproj_path) if effective_is_vision else 0
)
model_size = gguf_size + mmproj_size
gpus = self._get_gpu_free_memory()
# Resolve effective context: 0 means let llama-server use
@ -3802,8 +3833,12 @@ class LlamaCppBackend:
kv_cache_bytes = self._estimate_kv_cache_bytes(
effective_ctx, cache_type_kv, n_parallel = n_parallel
)
mmproj_note = (
f"mmproj: {mmproj_size / (1024**3):.1f} GB, " if mmproj_size else ""
)
logger.info(
f"GGUF size: {model_size / (1024**3):.1f} GB, "
f"GGUF size: {gguf_size / (1024**3):.1f} GB, "
f"{mmproj_note}"
f"est. KV cache: {kv_cache_bytes / (1024**3):.1f} GB, "
f"context: {effective_ctx}, "
f"GPUs free: {gpus}, selected: {gpu_indices}, fit: {use_fit}"
@ -3814,22 +3849,6 @@ class LlamaCppBackend:
tp_tensor_split = None
effective_ctx = requested_ctx # fall back to original
launch_mmproj_path = None
if not extra_args_disable_mmproj(extra_args):
launch_mmproj_path = self._resolve_launch_mmproj_path(
model_path = model_path,
mmproj_path = mmproj_path,
)
# Need both a resolved mmproj AND the config vision flag; a stray
# mmproj passing the family-name heuristic must not flip a non-VLM
# GGUF into vision mode.
effective_is_vision = bool(launch_mmproj_path) and bool(is_vision)
if is_vision and not effective_is_vision:
logger.warning(
"Vision-capable GGUF loaded without a usable mmproj; "
"image input will be disabled for this session"
)
# Audio input straight from the mmproj (clip.has_audio_encoder),
# independent of token names.
self._mmproj_has_audio = False

View file

@ -0,0 +1,40 @@
# 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 mmproj VRAM accounting in GGUF fit budgeting (#5825)."""
from __future__ import annotations
from pathlib import Path
from core.inference.llama_cpp import LlamaCppBackend
def _write(path: Path, n_bytes: int) -> Path:
path.parent.mkdir(parents = True, exist_ok = True)
path.write_bytes(b"\x00" * n_bytes)
return path
def _backend() -> LlamaCppBackend:
return LlamaCppBackend.__new__(LlamaCppBackend)
def test_counts_resolved_projector_size(tmp_path: Path):
mmproj = _write(tmp_path / "Qwen3.5-9B-BF16-mmproj.gguf", 1024)
got = _backend()._mmproj_vram_bytes(str(mmproj))
assert got == 1024
def test_zero_when_no_projector_resolved(tmp_path: Path):
assert _backend()._mmproj_vram_bytes(None) == 0
def test_zero_when_projector_missing_on_disk(tmp_path: Path):
missing = tmp_path / "Qwen3.5-9B-BF16-mmproj.gguf" # never created
got = _backend()._mmproj_vram_bytes(str(missing))
assert got == 0