* Studio: resolve the repo-root MTP drafter after the MTP/ GGUF rename The Gemma 4 QAT GGUF repos renamed the higher-precision MTP/ subdir copies from gemma-4-...-<quant>-MTP.gguf to mtp-gemma-4-...-<quant>.gguf, so their basenames now start with the same mtp- prefix as the small repo-root drafter (mtp-gemma-4-E4B-it.gguf). The drafter selectors filtered candidates by a mtp- basename prefix and took the first in sort order. With the new names the MTP/ copies also match, and because MTP/ (uppercase) sorts before the lowercase root file, selection flipped to the large BF16 copy under MTP/ instead of the root drafter both functions document they should pick. Restrict both selectors, and the companion byte estimate, to root-level mtp-*.gguf so the MTP/ copies stay explicit-selection only: - core/inference/llama_cpp.py _pick_mtp (loader auto-download) - hub/utils/gguf_plan.py preferred_mtp_sibling (Hub variant plans) - routes/inference.py _remote_gguf_companion_bytes (VRAM headroom) Also reuse a drafter already in the local cache before downloading, so a device that already holds a copy on disk does not re-fetch it. Old-scheme names keep working (they have no root-level mtp- sibling to mis-select). Adds regression tests for the new naming, both selection paths, and the on-disk reuse. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Studio: gate MTP drafter cache reuse to offline mode Reuse the cached drafter only when HF is offline. Online, route back through _download_companion_gguf/hf_hub_download so the current revision is checked (etag) and a changed drafter is refetched, matching the offline-only cross-snapshot reuse already used for the main GGUF. This avoids pairing freshly downloaded weights with a stale cached draft. Make the reuse tests offline and add an online-skips-reuse test. * Studio: prefer a root MTP drafter across all cached snapshots Offline reuse scanned snapshots one at a time and returned the first snapshot that held any drafter, only preferring root within it. A newer partial snapshot with just the MTP/ copy could shadow the small root drafter in an older snapshot. Collect drafters across all snapshots and prefer any repo-root file before an MTP/ copy. * Studio: keep newest-first snapshot order when reusing cached drafters Collecting root candidates and sorting by absolute snapshot path could pick a drafter from an older snapshot. _iter_hf_cache_snapshots yields newest first and the main GGUF is resolved in that order, so preserve it (root still preferred over MTP/ copies) to avoid pairing a fresh main weight with a stale drafter revision. --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
207 lines
7.6 KiB
Python
207 lines
7.6 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
|
|
|
|
from __future__ import annotations
|
|
|
|
from dataclasses import dataclass
|
|
from typing import Optional, Sequence
|
|
|
|
from hub.utils.download_manifest import ExpectedFile
|
|
from hub.utils.gguf import (
|
|
extract_quant_label,
|
|
is_big_endian_gguf_path,
|
|
is_gguf_filename,
|
|
is_mmproj_filename,
|
|
is_mtp_drafter_path,
|
|
)
|
|
|
|
|
|
@dataclass(frozen = True)
|
|
class GgufVariantPlan:
|
|
main_filenames: frozenset[str]
|
|
target_filenames: tuple[str, ...]
|
|
main_hashes: frozenset[str]
|
|
required_hashes: frozenset[str]
|
|
companion_hashes: frozenset[str]
|
|
mmproj_filenames: frozenset[str]
|
|
mmproj_hashes: frozenset[str]
|
|
expected_files: tuple[ExpectedFile, ...]
|
|
main_size_bytes: int
|
|
download_size_bytes: int
|
|
|
|
|
|
def sibling_sha256(sibling) -> Optional[str]:
|
|
lfs = getattr(sibling, "lfs", None)
|
|
if isinstance(lfs, dict):
|
|
value = lfs.get("sha256")
|
|
else:
|
|
value = getattr(lfs, "sha256", None)
|
|
if isinstance(value, str) and value:
|
|
return value
|
|
blob_id = getattr(sibling, "blob_id", None)
|
|
return blob_id if isinstance(blob_id, str) and blob_id else None
|
|
|
|
|
|
def sibling_size(sibling) -> int:
|
|
size = getattr(sibling, "size", 0) or 0
|
|
try:
|
|
return int(size)
|
|
except (TypeError, ValueError):
|
|
return 0
|
|
|
|
|
|
def expected_file_from_sibling(sibling) -> Optional[ExpectedFile]:
|
|
name = getattr(sibling, "rfilename", None)
|
|
if not isinstance(name, str):
|
|
return None
|
|
return ExpectedFile(
|
|
path = name,
|
|
size = sibling_size(sibling),
|
|
sha256 = sibling_sha256(sibling),
|
|
)
|
|
|
|
|
|
def is_companion_gguf_path(path: str) -> bool:
|
|
"""Companion (non-main) GGUF downloaded alongside a variant: the vision
|
|
mmproj or the separate MTP drafter (Gemma 4)."""
|
|
return is_gguf_filename(path) and (is_mmproj_filename(path) or is_mtp_drafter_path(path))
|
|
|
|
|
|
def is_main_gguf_variant_path(path: str, variant: str) -> bool:
|
|
return (
|
|
is_gguf_filename(path)
|
|
and not is_mmproj_filename(path)
|
|
and not is_mtp_drafter_path(path)
|
|
and not is_big_endian_gguf_path(path, variant)
|
|
and extract_quant_label(path).lower() == variant.lower()
|
|
)
|
|
|
|
|
|
def _gguf_rfilename(sibling) -> Optional[str]:
|
|
"""The sibling's rfilename when it is a GGUF, else None."""
|
|
name = getattr(sibling, "rfilename", None)
|
|
if isinstance(name, str) and is_gguf_filename(name):
|
|
return name
|
|
return None
|
|
|
|
|
|
def mmproj_siblings(siblings: Sequence) -> list:
|
|
return [s for s in siblings if (name := _gguf_rfilename(s)) and is_mmproj_filename(name)]
|
|
|
|
|
|
def preferred_mmproj_sibling(siblings: Sequence) -> Optional[object]:
|
|
candidates = mmproj_siblings(siblings)
|
|
if not candidates:
|
|
return None
|
|
return next(
|
|
(s for s in candidates if extract_quant_label(getattr(s, "rfilename")).upper() == "F16"),
|
|
candidates[0],
|
|
)
|
|
|
|
|
|
def preferred_mtp_sibling(siblings: Sequence) -> Optional[object]:
|
|
"""The separate MTP drafter to fetch with every variant: the repo-root
|
|
``mtp-*.gguf`` copy unsloth ships for llama.cpp ``-hf`` auto-discovery
|
|
(Gemma 4). Same pick as the loader's drafter resolution (root-level
|
|
``mtp-`` prefix, first in sort order) so download and load resolve the same
|
|
file; the higher-precision ``MTP/`` subdir copies are for explicit
|
|
selection and are not auto-fetched. None for repos with the head baked into
|
|
the main GGUF (Qwen)."""
|
|
# Root-level only: the MTP/ subdir copies now share the mtp- prefix too.
|
|
candidates = sorted(
|
|
(
|
|
s
|
|
for s in siblings
|
|
if (name := _gguf_rfilename(s)) and "/" not in name and name.lower().startswith("mtp-")
|
|
),
|
|
key = lambda s: getattr(s, "rfilename"),
|
|
)
|
|
return candidates[0] if candidates else None
|
|
|
|
|
|
def build_gguf_variant_plans(siblings: Sequence) -> dict[str, GgufVariantPlan]:
|
|
main: dict[str, list] = {}
|
|
all_mmproj = mmproj_siblings(siblings)
|
|
all_mmproj_filenames = frozenset(
|
|
getattr(s, "rfilename")
|
|
for s in all_mmproj
|
|
if isinstance(getattr(s, "rfilename", None), str)
|
|
)
|
|
all_mmproj_hashes = frozenset(h for h in (sibling_sha256(s) for s in all_mmproj) if h)
|
|
companion = preferred_mmproj_sibling(siblings)
|
|
companion_expected = expected_file_from_sibling(companion) if companion is not None else None
|
|
mtp_sibling = preferred_mtp_sibling(siblings)
|
|
mtp_expected = expected_file_from_sibling(mtp_sibling) if mtp_sibling is not None else None
|
|
companions_expected = tuple(
|
|
file for file in (companion_expected, mtp_expected) if file is not None
|
|
)
|
|
|
|
for sibling in siblings:
|
|
name = _gguf_rfilename(sibling)
|
|
if name is None:
|
|
continue
|
|
# Companions are folded into every plan below; keep them out of the
|
|
# quant grouping so a drafter never lands in a variant's main files
|
|
# (the root mtp-*.gguf carries a quant label, e.g. Q8_0).
|
|
if is_mmproj_filename(name) or is_mtp_drafter_path(name):
|
|
continue
|
|
quant = extract_quant_label(name).lower()
|
|
if is_big_endian_gguf_path(name, quant):
|
|
continue
|
|
main.setdefault(quant, []).append(sibling)
|
|
|
|
plans: dict[str, GgufVariantPlan] = {}
|
|
for quant, target_main_siblings in main.items():
|
|
main_expected = tuple(
|
|
file
|
|
for sibling in target_main_siblings
|
|
if (file := expected_file_from_sibling(sibling)) is not None
|
|
)
|
|
expected_files = (*main_expected, *companions_expected)
|
|
plans[quant] = plan_from_expected_files(
|
|
quant,
|
|
expected_files,
|
|
all_mmproj_filenames = all_mmproj_filenames,
|
|
all_mmproj_hashes = all_mmproj_hashes,
|
|
)
|
|
return plans
|
|
|
|
|
|
def plan_from_expected_files(
|
|
variant: str,
|
|
expected_files: Sequence[ExpectedFile],
|
|
*,
|
|
all_mmproj_filenames: frozenset[str] | None = None,
|
|
all_mmproj_hashes: frozenset[str] | None = None,
|
|
) -> GgufVariantPlan:
|
|
expected = tuple(expected_files)
|
|
main_files = tuple(file for file in expected if is_main_gguf_variant_path(file.path, variant))
|
|
companion_files = tuple(file for file in expected if is_companion_gguf_path(file.path))
|
|
# Manifest-resume fallback for the mmproj fields below: companion_files
|
|
# also holds the MTP drafter, so keep an mmproj-only view.
|
|
mmproj_files = tuple(file for file in companion_files if is_mmproj_filename(file.path))
|
|
main_hashes = frozenset(file.sha256 for file in main_files if file.sha256)
|
|
companion_hashes = frozenset(file.sha256 for file in companion_files if file.sha256)
|
|
required_hashes = frozenset(file.sha256 for file in expected if file.sha256)
|
|
main_size = sum(max(0, int(file.size or 0)) for file in main_files)
|
|
download_size = sum(max(0, int(file.size or 0)) for file in expected)
|
|
return GgufVariantPlan(
|
|
main_filenames = frozenset(file.path for file in main_files),
|
|
target_filenames = tuple(file.path for file in expected),
|
|
main_hashes = main_hashes,
|
|
required_hashes = required_hashes,
|
|
companion_hashes = companion_hashes,
|
|
mmproj_filenames = (
|
|
all_mmproj_filenames
|
|
if all_mmproj_filenames is not None
|
|
else frozenset(file.path for file in mmproj_files)
|
|
),
|
|
mmproj_hashes = (
|
|
all_mmproj_hashes
|
|
if all_mmproj_hashes is not None
|
|
else frozenset(file.sha256 for file in mmproj_files if file.sha256)
|
|
),
|
|
expected_files = expected,
|
|
main_size_bytes = main_size,
|
|
download_size_bytes = download_size,
|
|
)
|