mirror of
https://github.com/unslothai/unsloth.git
synced 2026-07-10 00:08:58 +00:00
Some checks are pending
Core / Core (HF=default + TRL=default) (push) Waiting to run
Core / Core (HF=4.57.6 + TRL<1) (push) Waiting to run
Core / Core (HF=latest + TRL=latest) (push) Waiting to run
Core / llama.cpp build + smoke (push) Waiting to run
Cross-platform parity / parity (macos-latest) (push) Waiting to run
Cross-platform parity / parity (windows-latest) (push) Waiting to run
Lint CI / Source lint (Python + shell + YAML + JSON + safety nets) (push) Waiting to run
MLX CI on Mac M1 / dispatch (push) Waiting to run
Security audit / advisory audit (pip + npm + cargo) (push) Waiting to run
Security audit / pip scan-packages :: extras (push) Waiting to run
Security audit / pip scan-packages :: studio (push) Waiting to run
Security audit / pip scan-packages :: hf-stack (push) Waiting to run
Security audit / npm scan-packages (Studio frontend tarballs) (push) Waiting to run
Security audit / workflow-trigger lint (pull_request_target / cache-poisoning) (push) Waiting to run
Security audit / pytest tests/security (push) Waiting to run
Security audit / npm provenance + new install-script diff (push) Waiting to run
Studio API CI / Studio API & Auth Tests (push) Waiting to run
Backend CI / (Python 3.10) (push) Waiting to run
Backend CI / (Python 3.11) (push) Waiting to run
Backend CI / (Python 3.12) (push) Waiting to run
Backend CI / (Python 3.13) (push) Waiting to run
Backend CI / Repo tests (CPU) (push) Waiting to run
Frontend CI / Frontend build + bundle sanity (push) Waiting to run
Studio GGUF CI / OpenAI, Anthropic API tests (push) Waiting to run
Studio GGUF CI / Tool calling Tests (push) Waiting to run
Studio GGUF CI / JSON, images (push) Waiting to run
Studio load-orchestrator CI / test (push) Waiting to run
Mac Studio API CI / Studio API & Auth Tests (push) Waiting to run
Mac Studio GGUF CI / OpenAI, Anthropic API tests (push) Waiting to run
Mac Studio GGUF CI / Tool calling Tests (push) Waiting to run
Mac Studio GGUF CI / JSON, images (push) Waiting to run
Mac Studio Install Matrix CI / Install + load (macos-26) (push) Waiting to run
Mac Studio Install Matrix CI / Install + load (macos-15-intel) (push) Waiting to run
Mac Studio Install Matrix CI / Install + load (macos-14) (push) Waiting to run
Mac Studio Install Matrix CI / Install + load (macos-15) (push) Waiting to run
Mac Studio Install Matrix CI / Install + load (macos-26-intel) (push) Waiting to run
Mac Studio UI CI / Chat UI Tests (push) Waiting to run
Mac Studio Update CI / Studio Updating Tests (push) Waiting to run
Studio Tauri CI / Tauri Linux debug build (no codesign) (push) Waiting to run
Studio UI CI / Chat UI Tests (push) Waiting to run
Studio Update CI / Studio Updating Tests (push) Waiting to run
Windows Studio API CI / Studio API & Auth Tests (push) Waiting to run
Windows Studio GGUF CI / OpenAI, Anthropic API tests (push) Waiting to run
Windows Studio GGUF CI / Tool calling Tests (push) Waiting to run
Windows Studio GGUF CI / JSON, images (push) Waiting to run
Windows Studio UI CI / Chat UI Tests (push) Waiting to run
Windows Studio Update CI / Studio Updating Tests (push) Waiting to run
Wheel CI / Wheel build + content sanity + import smoke (push) Waiting to run
* Reduce and tighten comments and docstrings in tests Shorten verbose comments and docstrings across the test suite without changing any test logic. Remove narration that restates the next line, collapse long module and test docstrings to a single line, and drop banner separators. Keep regression context (issue and PR references, run ids), skip reasons, mocking and timing rationale, license headers, lint and type directives, and commented-out code. Comments and docstrings only: an AST signature check confirms no code, assertions, or string literals changed, and the suite byte-compiles cleanly. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
423 lines
15 KiB
Python
423 lines
15 KiB
Python
"""Text-only FastLanguageModel routing for vision-capable configs."""
|
|
|
|
import ast
|
|
import copy
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
|
|
REPO_ROOT = Path(__file__).resolve().parents[2]
|
|
LOADER_PATH = REPO_ROOT / "unsloth" / "models" / "loader.py"
|
|
VISION_PATH = REPO_ROOT / "unsloth" / "models" / "vision.py"
|
|
UTILS_PATH = REPO_ROOT / "unsloth" / "models" / "_utils.py"
|
|
|
|
|
|
def _source(path):
|
|
return path.read_text()
|
|
|
|
|
|
def _class_method(tree, class_name, method_name):
|
|
for node in tree.body:
|
|
if isinstance(node, ast.ClassDef) and node.name == class_name:
|
|
for item in node.body:
|
|
if isinstance(item, ast.FunctionDef) and item.name == method_name:
|
|
return item
|
|
raise AssertionError(f"{class_name}.{method_name} not found")
|
|
|
|
|
|
def _assigns_name(method, target_name, predicate):
|
|
"""True when the method contains `target_name = <value>` and predicate(value)."""
|
|
for node in ast.walk(method):
|
|
if not isinstance(node, ast.Assign):
|
|
continue
|
|
for target in node.targets:
|
|
if isinstance(target, ast.Name) and target.id == target_name:
|
|
if predicate(node.value):
|
|
return True
|
|
return False
|
|
|
|
|
|
def _calls_function(method, func_name):
|
|
"""True when the method calls `func_name(...)` (bare name, not attribute)."""
|
|
for node in ast.walk(method):
|
|
if (
|
|
isinstance(node, ast.Call)
|
|
and isinstance(node.func, ast.Name)
|
|
and node.func.id == func_name
|
|
):
|
|
return True
|
|
return False
|
|
|
|
|
|
def _names_in(node):
|
|
return {n.id for n in ast.walk(node) if isinstance(n, ast.Name)}
|
|
|
|
|
|
def _param_default(method, name):
|
|
# Default-value AST node for a named parameter, or None.
|
|
args = method.args
|
|
params = list(args.args) + list(args.kwonlyargs)
|
|
defaults = list(args.defaults) + list(args.kw_defaults)
|
|
return dict(zip([p.arg for p in params][-len(defaults) :], defaults)).get(name)
|
|
|
|
|
|
def _load_text_only_namespace():
|
|
# Exec the _utils text-only helpers into one namespace (no unsloth import),
|
|
# in dependency order so cross-references resolve.
|
|
source = _source(UTILS_PATH)
|
|
import transformers
|
|
from packaging.version import Version
|
|
|
|
ns = {
|
|
"copy": copy,
|
|
"Version": Version,
|
|
"transformers_version": transformers.__version__,
|
|
}
|
|
funcs = {
|
|
node.name: ast.get_source_segment(source, node)
|
|
for node in ast.parse(source).body
|
|
if isinstance(node, ast.FunctionDef)
|
|
}
|
|
for name in (
|
|
"resolve_model_class",
|
|
"_is_family_text_decoder",
|
|
"_remap_text_only_skip_modules",
|
|
"_get_text_only_config",
|
|
"_get_text_only_key_mapping",
|
|
"_apply_text_only_key_mapping",
|
|
):
|
|
if name in funcs:
|
|
exec(funcs[name], ns)
|
|
return ns
|
|
|
|
|
|
def _load_text_only_helper():
|
|
return _load_text_only_namespace()["_get_text_only_config"]
|
|
|
|
|
|
def test_gemma3_vision_config_resolves_to_text_config():
|
|
transformers = pytest.importorskip("transformers")
|
|
helper = _load_text_only_helper()
|
|
|
|
config = transformers.Gemma3Config()
|
|
text_config = helper(config, "google/gemma-3-27b-it")
|
|
|
|
assert isinstance(text_config, transformers.Gemma3TextConfig)
|
|
assert text_config.model_type == "gemma3_text"
|
|
model_class = transformers.AutoModelForCausalLM._model_mapping[type(text_config)]
|
|
assert model_class.__name__ == "Gemma3ForCausalLM"
|
|
|
|
|
|
def test_text_only_helper_rejects_configs_without_text_submodel():
|
|
helper = _load_text_only_helper()
|
|
|
|
class VisionOnlyConfig:
|
|
vision_config = object()
|
|
|
|
with pytest.raises(ValueError, match = "Cannot load vision-only as text-only"):
|
|
helper(VisionOnlyConfig(), "vision-only")
|
|
|
|
|
|
def test_fast_language_model_forwards_text_only_to_fast_model():
|
|
source = _source(LOADER_PATH)
|
|
method = _class_method(ast.parse(source), "FastLanguageModel", "from_pretrained")
|
|
|
|
# text_only defaults False (opt-in); both FastModel delegations forward it.
|
|
text_only_default = _param_default(method, "text_only")
|
|
assert isinstance(text_only_default, ast.Constant) and text_only_default.value is False
|
|
|
|
fast_model_calls = [
|
|
node
|
|
for node in ast.walk(method)
|
|
if isinstance(node, ast.Call)
|
|
and isinstance(node.func, ast.Attribute)
|
|
and node.func.attr == "from_pretrained"
|
|
and isinstance(node.func.value, ast.Name)
|
|
and node.func.value.id == "FastModel"
|
|
]
|
|
assert len(fast_model_calls) == 2
|
|
for call in fast_model_calls:
|
|
kw = [k for k in call.keywords if k.arg == "text_only"]
|
|
assert len(kw) == 1
|
|
assert isinstance(kw[0].value, ast.Name) and kw[0].value.id == "text_only"
|
|
|
|
|
|
def test_fast_model_text_only_does_not_override_explicit_auto_model():
|
|
# AST-based so formatting/refactors that keep the structure do not break it.
|
|
source = _source(LOADER_PATH)
|
|
method = _class_method(ast.parse(source), "FastModel", "from_pretrained")
|
|
|
|
text_only_default = _param_default(method, "text_only")
|
|
assert isinstance(text_only_default, ast.Constant) and text_only_default.value is False
|
|
|
|
# load_text_only is text_only AND a check that the caller did not pass auto_model.
|
|
def _is_guarded_bool(value):
|
|
names = _names_in(value)
|
|
has_none_check = any(
|
|
isinstance(n, ast.Compare) and any(isinstance(op, (ast.Is, ast.IsNot)) for op in n.ops)
|
|
for n in ast.walk(value)
|
|
)
|
|
return "text_only" in names and "auto_model" in names and has_none_check
|
|
|
|
assert _assigns_name(method, "load_text_only", _is_guarded_bool)
|
|
|
|
assert _calls_function(method, "_get_text_only_config")
|
|
|
|
def _forwards_kwarg(node):
|
|
return any(
|
|
isinstance(n, ast.Call)
|
|
and any(
|
|
kw.arg == "text_only"
|
|
and isinstance(kw.value, ast.Name)
|
|
and kw.value.id == "load_text_only"
|
|
for kw in n.keywords
|
|
)
|
|
for n in ast.walk(node)
|
|
)
|
|
|
|
assert _forwards_kwarg(method)
|
|
# Falls back to the full model unless the family has its own text decoder.
|
|
assert _calls_function(method, "_is_family_text_decoder")
|
|
assert _assigns_name(
|
|
method,
|
|
"load_text_only",
|
|
lambda v: isinstance(v, ast.Constant) and v.value is False,
|
|
)
|
|
|
|
|
|
def test_fast_base_model_text_only_bypasses_vision_auto_model():
|
|
source = _source(VISION_PATH)
|
|
method = _class_method(ast.parse(source), "FastBaseModel", "from_pretrained")
|
|
|
|
text_only_default = _param_default(method, "text_only")
|
|
assert isinstance(text_only_default, ast.Constant) and text_only_default.value is False
|
|
|
|
assert _assigns_name(
|
|
method,
|
|
"auto_model",
|
|
lambda v: isinstance(v, ast.Name) and v.id == "AutoModelForCausalLM",
|
|
)
|
|
# Text-only path: strip config, apply the family guard, inject the key remap.
|
|
assert _calls_function(method, "_get_text_only_config")
|
|
assert _calls_function(method, "_is_family_text_decoder")
|
|
assert _calls_function(method, "_apply_text_only_key_mapping")
|
|
|
|
|
|
def test_gemma3_text_only_model_class_resolves_and_has_no_vision_tower():
|
|
"""End-to-end: a tiny Gemma3 text-only model instantiates with text LM attrs and no vision tower."""
|
|
transformers = pytest.importorskip("transformers")
|
|
helper = _load_text_only_helper()
|
|
|
|
full_config = transformers.Gemma3Config()
|
|
text_config = helper(full_config, "google/gemma-3-27b-it")
|
|
|
|
# Shrink for cheap CPU instantiation.
|
|
text_config.num_hidden_layers = 1
|
|
text_config.hidden_size = 32
|
|
text_config.intermediate_size = 32
|
|
text_config.num_attention_heads = 2
|
|
text_config.num_key_value_heads = 1
|
|
text_config.head_dim = 16
|
|
text_config.vocab_size = 128
|
|
|
|
model_class = transformers.AutoModelForCausalLM._model_mapping[type(text_config)]
|
|
model = model_class(text_config)
|
|
|
|
assert hasattr(model, "lm_head"), "text-only Gemma3 model should expose lm_head"
|
|
|
|
# No vision tower / multimodal projector remains.
|
|
assert not hasattr(
|
|
model, "vision_tower"
|
|
), "text-only Gemma3 model should not have a vision_tower"
|
|
assert not hasattr(
|
|
model, "multi_modal_projector"
|
|
), "text-only Gemma3 model should not have a multi_modal_projector"
|
|
|
|
|
|
def test_helper_defined_once_in_utils_and_imported():
|
|
# _get_text_only_config defined only in _utils, imported by loader + vision.
|
|
def _defines(path):
|
|
return any(
|
|
isinstance(n, ast.FunctionDef) and n.name == "_get_text_only_config"
|
|
for n in ast.parse(_source(path)).body
|
|
)
|
|
|
|
def _imports(path):
|
|
return any(
|
|
isinstance(n, ast.ImportFrom)
|
|
and n.module == "_utils"
|
|
and any(a.name == "_get_text_only_config" for a in n.names)
|
|
for n in ast.walk(ast.parse(_source(path)))
|
|
)
|
|
|
|
assert _defines(UTILS_PATH)
|
|
assert not _defines(LOADER_PATH) and _imports(LOADER_PATH)
|
|
assert not _defines(VISION_PATH) and _imports(VISION_PATH)
|
|
|
|
|
|
def _load_util_func(name):
|
|
ns = _load_text_only_namespace()
|
|
if name not in ns:
|
|
raise AssertionError(f"{name} not found")
|
|
return ns[name]
|
|
|
|
|
|
def test_text_only_guard_predicate_across_vlm_families():
|
|
# Text-only taken only when the resolved class remaps VLM weights.
|
|
transformers = pytest.importorskip("transformers")
|
|
from transformers import AutoModelForCausalLM
|
|
|
|
resolve = _load_util_func("resolve_model_class")
|
|
is_family = _load_util_func("_is_family_text_decoder")
|
|
helper = _load_text_only_helper()
|
|
|
|
def takes_text_only(cfg):
|
|
text = helper(cfg, "x")
|
|
return resolve(AutoModelForCausalLM, text) is not None and is_family(
|
|
getattr(cfg, "model_type", ""), getattr(text, "model_type", "")
|
|
)
|
|
|
|
# Dedicated text decoder remaps language_model.* -> strip vision.
|
|
assert takes_text_only(transformers.Gemma3Config()) is True
|
|
|
|
# No text class (Qwen2-VL/Mllama) or a generic reused decoder that would
|
|
# load random weights (Llava/PaliGemma/Idefics3/InternVL) -> keep full model.
|
|
for name in [
|
|
"Qwen2VLConfig",
|
|
"Qwen2_5_VLConfig",
|
|
"MllamaConfig",
|
|
"LlavaConfig",
|
|
"PaliGemmaConfig",
|
|
"Idefics3Config",
|
|
"InternVLConfig",
|
|
]:
|
|
cfg_cls = getattr(transformers, name, None)
|
|
if cfg_cls is None:
|
|
continue
|
|
assert takes_text_only(cfg_cls()) is False, name
|
|
|
|
|
|
def test_text_only_helper_preserves_quantization_config():
|
|
# quantization_config must survive the strip so pre-quantized repos load. A
|
|
# sentinel object avoids a bitsandbytes dependency on transformers 4.51.3.
|
|
transformers = pytest.importorskip("transformers")
|
|
helper = _load_text_only_helper()
|
|
config = transformers.Gemma3Config()
|
|
sentinel = object()
|
|
config.quantization_config = sentinel
|
|
text_config = helper(config, "google/gemma-3-27b-it")
|
|
assert getattr(text_config, "quantization_config", None) is sentinel
|
|
# The parent's shared text sub-config must not be mutated.
|
|
assert getattr(config.get_text_config(), "quantization_config", None) is None
|
|
|
|
|
|
def test_text_only_key_mapping_targets_published_prefixes():
|
|
# Remap the published VLM decoder prefixes, applying only on transformers >=5
|
|
# (on 4.x base_model_prefix handles it and a mapping hurts).
|
|
transformers = pytest.importorskip("transformers")
|
|
get_key_mapping = _load_util_func("_get_text_only_key_mapping")
|
|
mapping = get_key_mapping(transformers.Gemma3Config(), transformers.Gemma3TextConfig())
|
|
if int(transformers.__version__.split(".")[0]) < 5:
|
|
assert mapping is None
|
|
else:
|
|
assert isinstance(mapping, dict)
|
|
assert mapping.get(r"^language_model\.model\.") == "model." # gemma3
|
|
assert mapping.get(r"^model\.language_model\.") == "model." # gemma3n
|
|
assert mapping.get(r"^language_model\.lm_head\.") == "lm_head."
|
|
|
|
|
|
def test_gemma3_text_only_loads_real_language_weights_from_vlm_checkpoint(tmp_path):
|
|
# PR #5816: text-only loading of a Gemma 3 VLM checkpoint must load real
|
|
# language weights, not random ones. Fails on tf >=5 without the key_mapping fix.
|
|
transformers = pytest.importorskip("transformers")
|
|
torch = pytest.importorskip("torch")
|
|
import shutil
|
|
from safetensors.torch import load_file, save_file
|
|
|
|
get_text_config = _load_text_only_helper()
|
|
get_key_mapping = _load_util_func("_get_text_only_key_mapping")
|
|
|
|
sentinel = 0.1234
|
|
text_cfg = transformers.Gemma3TextConfig(
|
|
hidden_size = 32,
|
|
intermediate_size = 64,
|
|
num_hidden_layers = 1,
|
|
num_attention_heads = 2,
|
|
num_key_value_heads = 1,
|
|
head_dim = 16,
|
|
vocab_size = 128,
|
|
max_position_embeddings = 128,
|
|
sliding_window = 64,
|
|
)
|
|
vision_cfg = transformers.SiglipVisionConfig(
|
|
hidden_size = 32,
|
|
intermediate_size = 64,
|
|
num_hidden_layers = 1,
|
|
num_attention_heads = 2,
|
|
image_size = 16,
|
|
patch_size = 8,
|
|
num_channels = 3,
|
|
)
|
|
full_config = transformers.Gemma3Config(
|
|
text_config = text_cfg.to_dict(),
|
|
vision_config = vision_cfg.to_dict(),
|
|
)
|
|
full_model = transformers.Gemma3ForConditionalGeneration(full_config)
|
|
|
|
state = full_model.state_dict()
|
|
text_q = [
|
|
k
|
|
for k in state
|
|
if "language_model" in k
|
|
and "vision" not in k
|
|
and k.endswith("layers.0.self_attn.q_proj.weight")
|
|
]
|
|
assert text_q, [k for k in state if "q_proj" in k][:5]
|
|
with torch.no_grad():
|
|
for k in text_q:
|
|
state[k].fill_(sentinel)
|
|
|
|
save_dir = tmp_path / "vlm"
|
|
full_model.save_pretrained(save_dir, safe_serialization = True)
|
|
|
|
# tf >=5 saves under an outer "model." prefix; strip it to reproduce the
|
|
# language_model.model.* layout the published Gemma 3 checkpoints use.
|
|
real_dir = tmp_path / "real"
|
|
real_dir.mkdir()
|
|
weights = {}
|
|
for f in save_dir.glob("*.safetensors"):
|
|
weights.update(load_file(str(f)))
|
|
for f in save_dir.glob("*.bin"):
|
|
weights.update(torch.load(f, map_location = "cpu", weights_only = True))
|
|
weights = {
|
|
(k[len("model.") :] if k.startswith("model.") else k): v.contiguous()
|
|
for k, v in weights.items()
|
|
}
|
|
for p in save_dir.iterdir():
|
|
if not p.name.endswith((".safetensors", ".bin", ".index.json")):
|
|
shutil.copy(p, real_dir / p.name)
|
|
save_file(weights, str(real_dir / "model.safetensors"))
|
|
|
|
text_config = get_text_config(full_config, "google/gemma-3-27b-it")
|
|
load_kwargs = {}
|
|
key_mapping = get_key_mapping(full_config, text_config)
|
|
if key_mapping is not None:
|
|
load_kwargs["key_mapping"] = key_mapping
|
|
model = transformers.AutoModelForCausalLM.from_pretrained(
|
|
real_dir,
|
|
config = text_config,
|
|
dtype = torch.float32,
|
|
local_files_only = True,
|
|
**load_kwargs,
|
|
)
|
|
|
|
loaded = model.state_dict()
|
|
q_key = [k for k in loaded if k.endswith("model.layers.0.self_attn.q_proj.weight")]
|
|
assert q_key, "text decoder q_proj weight missing from the loaded model"
|
|
assert float(loaded[q_key[0]].flatten()[0]) == pytest.approx(
|
|
sentinel
|
|
), "text weights were randomly initialized instead of loaded from the checkpoint"
|
|
assert not any(
|
|
"vision_tower" in n for n, _ in model.named_modules()
|
|
), "vision tower should be skipped on the text-only path"
|