587 lines
23 KiB
Python
587 lines
23 KiB
Python
# SPDX-License-Identifier: Apache-2.0
|
|
"""Tests for omlx.utils.model_loading.maybe_load_custom_quantization."""
|
|
|
|
import sys
|
|
import types
|
|
from unittest.mock import MagicMock
|
|
|
|
import pytest
|
|
|
|
from omlx.utils import model_loading
|
|
from omlx.utils.model_loading import (
|
|
maybe_apply_pre_load_patches,
|
|
maybe_load_custom_quantization,
|
|
)
|
|
|
|
|
|
def _write_config(tmp_path, body: str) -> str:
|
|
(tmp_path / "config.json").write_text(body)
|
|
return str(tmp_path)
|
|
|
|
|
|
def _write_mtp_index(tmp_path, has_mtp: bool) -> None:
|
|
"""Drop a stub ``model.safetensors.index.json`` next to config.json so
|
|
``_checkpoint_has_mtp_weights`` resolves deterministically in tests."""
|
|
keys = {"language_model.model.embed_tokens.weight": "model.safetensors"}
|
|
if has_mtp:
|
|
keys["language_model.mtp.fc.weight"] = "model.safetensors"
|
|
(tmp_path / "model.safetensors.index.json").write_text(
|
|
'{"metadata": {}, "weight_map": ' + str(keys).replace("'", '"') + "}"
|
|
)
|
|
|
|
|
|
class TestNoDispatch:
|
|
"""Cases where the dispatcher should return None and let the caller
|
|
fall back to the standard mlx-lm/mlx-vlm load path."""
|
|
|
|
def test_missing_config_returns_none(self, tmp_path):
|
|
# tmp_path has no config.json
|
|
assert maybe_load_custom_quantization(str(tmp_path), is_vlm=False) is None
|
|
|
|
def test_malformed_config_returns_none(self, tmp_path):
|
|
path = _write_config(tmp_path, "{not valid json")
|
|
assert maybe_load_custom_quantization(path, is_vlm=False) is None
|
|
|
|
def test_no_quantization_config_returns_none(self, tmp_path):
|
|
path = _write_config(tmp_path, '{"model_type": "llama"}')
|
|
assert maybe_load_custom_quantization(path, is_vlm=False) is None
|
|
|
|
def test_empty_quant_method_returns_none(self, tmp_path):
|
|
path = _write_config(tmp_path, '{"quantization_config": {}}')
|
|
assert maybe_load_custom_quantization(path, is_vlm=False) is None
|
|
|
|
def test_unknown_quant_method_returns_none(self, tmp_path):
|
|
# Methods we don't dispatch on (mlx-lm may or may not handle them
|
|
# natively; either way the dispatcher stays out of the way).
|
|
path = _write_config(
|
|
tmp_path,
|
|
'{"quantization_config": {"quant_method": "awq"}}',
|
|
)
|
|
assert maybe_load_custom_quantization(path, is_vlm=False) is None
|
|
|
|
|
|
def _install_paroquant_stub(monkeypatch, load_impl):
|
|
"""Register a minimal paroquant.inference.backends.mlx.load stub."""
|
|
names = [
|
|
"paroquant",
|
|
"paroquant.inference",
|
|
"paroquant.inference.backends",
|
|
"paroquant.inference.backends.mlx",
|
|
]
|
|
for name in names:
|
|
monkeypatch.setitem(sys.modules, name, types.ModuleType(name))
|
|
load_mod = types.ModuleType("paroquant.inference.backends.mlx.load")
|
|
load_mod.load = load_impl
|
|
monkeypatch.setitem(sys.modules, "paroquant.inference.backends.mlx.load", load_mod)
|
|
|
|
|
|
class TestParoquantDispatch:
|
|
"""Cases where quant_method == 'paroquant'."""
|
|
|
|
def test_paroquant_missing_raises_install_hint(self, tmp_path, monkeypatch):
|
|
# Force the import to fail: shadow the package with a sentinel that
|
|
# blocks submodule resolution. setitem(..., None) makes `import X`
|
|
# raise ImportError in the standard machinery.
|
|
for name in [
|
|
"paroquant",
|
|
"paroquant.inference",
|
|
"paroquant.inference.backends",
|
|
"paroquant.inference.backends.mlx",
|
|
"paroquant.inference.backends.mlx.load",
|
|
]:
|
|
monkeypatch.setitem(sys.modules, name, None)
|
|
|
|
path = _write_config(
|
|
tmp_path,
|
|
'{"quantization_config": {"quant_method": "paroquant"}}',
|
|
)
|
|
with pytest.raises(ImportError, match="paroquant"):
|
|
maybe_load_custom_quantization(path, is_vlm=False)
|
|
|
|
def test_paroquant_text_load_returns_tuple(self, tmp_path, monkeypatch):
|
|
def fake_load(model_path, force_text):
|
|
assert force_text is True
|
|
return "MODEL", "PROC", False
|
|
|
|
_install_paroquant_stub(monkeypatch, fake_load)
|
|
|
|
path = _write_config(
|
|
tmp_path,
|
|
'{"quantization_config": {"quant_method": "paroquant"}}',
|
|
)
|
|
result = maybe_load_custom_quantization(path, is_vlm=False)
|
|
assert result == ("MODEL", "PROC")
|
|
|
|
def test_paroquant_vlm_load_returns_tuple(self, tmp_path, monkeypatch):
|
|
def fake_load(model_path, force_text):
|
|
assert force_text is False
|
|
return "VLM_MODEL", "VLM_PROC", True
|
|
|
|
_install_paroquant_stub(monkeypatch, fake_load)
|
|
|
|
path = _write_config(
|
|
tmp_path,
|
|
'{"quantization_config": {"quant_method": "paroquant"}}',
|
|
)
|
|
result = maybe_load_custom_quantization(path, is_vlm=True)
|
|
assert result == ("VLM_MODEL", "VLM_PROC")
|
|
|
|
def test_paroquant_text_only_for_vlm_load_raises(self, tmp_path, monkeypatch):
|
|
# is_vlm=True but the loader returned (..., loaded_is_vlm=False).
|
|
def fake_load(model_path, force_text):
|
|
return "MODEL", "PROC", False
|
|
|
|
_install_paroquant_stub(monkeypatch, fake_load)
|
|
|
|
path = _write_config(
|
|
tmp_path,
|
|
'{"quantization_config": {"quant_method": "paroquant"}}',
|
|
)
|
|
with pytest.raises(ValueError, match="text-only"):
|
|
maybe_load_custom_quantization(path, is_vlm=True)
|
|
|
|
def test_quant_method_case_insensitive(self, tmp_path, monkeypatch):
|
|
# The dispatcher lowercases quant_method, so mixed-case configs
|
|
# (e.g. produced by other tooling) still hit the paroquant path.
|
|
captured = {}
|
|
|
|
def fake_load(model_path, force_text):
|
|
captured["called"] = True
|
|
return "M", "P", False
|
|
|
|
_install_paroquant_stub(monkeypatch, fake_load)
|
|
path = _write_config(
|
|
tmp_path,
|
|
'{"quantization_config": {"quant_method": "ParoQuant"}}',
|
|
)
|
|
assert maybe_load_custom_quantization(path, is_vlm=False) == ("M", "P")
|
|
assert captured["called"] is True
|
|
|
|
|
|
class TestLlama4PreLoadDispatch:
|
|
@pytest.mark.parametrize(
|
|
"body",
|
|
[
|
|
'{"model_type": "llama4", "text_config": {}}',
|
|
'{"model_type": "mllama", "text_config": {"model_type": "llama4"}}',
|
|
],
|
|
)
|
|
def test_llama4_attention_patch_applies(self, tmp_path, monkeypatch, body):
|
|
monkeypatch.setattr(model_loading, "_patch_mlx_lm_load_config", lambda: None)
|
|
monkeypatch.setitem(
|
|
sys.modules,
|
|
"omlx.patches.mlx_lm_mtp",
|
|
MagicMock(set_mtp_active=MagicMock()),
|
|
)
|
|
apply_mock = MagicMock(return_value=True)
|
|
monkeypatch.setitem(
|
|
sys.modules,
|
|
"omlx.patches.llama4_attention",
|
|
MagicMock(apply_llama4_attention_patch=apply_mock),
|
|
)
|
|
|
|
path = _write_config(tmp_path, body)
|
|
maybe_apply_pre_load_patches(path)
|
|
|
|
apply_mock.assert_called_once_with()
|
|
|
|
|
|
class TestLoadTextModel:
|
|
def test_forwards_trust_remote_code_when_mlx_lm_supports_it(
|
|
self, tmp_path, monkeypatch
|
|
):
|
|
path = _write_config(tmp_path, '{"model_type": "llama"}')
|
|
maybe_apply = MagicMock()
|
|
monkeypatch.setattr(model_loading, "maybe_apply_pre_load_patches", maybe_apply)
|
|
# Pin the capability flag so the test is deterministic regardless of the
|
|
# installed mlx-lm version (lm_load_compat reads this global at call time).
|
|
monkeypatch.setattr(model_loading, "_LM_LOAD_ACCEPTS_TRC", True)
|
|
|
|
load_mock = MagicMock(return_value=("MODEL", "TOKENIZER"))
|
|
monkeypatch.setitem(sys.modules, "mlx_lm", MagicMock(load=load_mock))
|
|
|
|
settings = types.SimpleNamespace(trust_remote_code=True)
|
|
result = model_loading.load_text_model(
|
|
path,
|
|
tokenizer_config={"trust_remote_code": True},
|
|
model_settings=settings,
|
|
)
|
|
|
|
assert result == ("MODEL", "TOKENIZER")
|
|
maybe_apply.assert_called_once_with(path, model_settings=settings)
|
|
load_mock.assert_called_once_with(
|
|
path,
|
|
tokenizer_config={"trust_remote_code": True},
|
|
trust_remote_code=True,
|
|
)
|
|
|
|
def test_omits_trust_remote_code_when_mlx_lm_lacks_it(self, tmp_path, monkeypatch):
|
|
# Some mlx-lm releases dropped ``trust_remote_code`` from ``load``.
|
|
# lm_load_compat must omit the kwarg there rather than raise TypeError.
|
|
path = _write_config(tmp_path, '{"model_type": "llama"}')
|
|
monkeypatch.setattr(
|
|
model_loading, "maybe_apply_pre_load_patches", MagicMock()
|
|
)
|
|
monkeypatch.setattr(model_loading, "_LM_LOAD_ACCEPTS_TRC", False)
|
|
|
|
load_mock = MagicMock(return_value=("MODEL", "TOKENIZER"))
|
|
monkeypatch.setitem(sys.modules, "mlx_lm", MagicMock(load=load_mock))
|
|
|
|
settings = types.SimpleNamespace(trust_remote_code=True)
|
|
result = model_loading.load_text_model(
|
|
path,
|
|
tokenizer_config={"trust_remote_code": True},
|
|
model_settings=settings,
|
|
)
|
|
|
|
assert result == ("MODEL", "TOKENIZER")
|
|
load_mock.assert_called_once_with(
|
|
path,
|
|
tokenizer_config={"trust_remote_code": True},
|
|
)
|
|
|
|
|
|
class TestVlmMtpPreLoadDispatch:
|
|
"""maybe_apply_pre_load_patches must wire the mlx-vlm MTP sanitize
|
|
patch alongside the runtime patch for MTP-capable VLM checkpoints.
|
|
|
|
The dense Qwen3.5/3.6 VLM runtime patch does not touch Model.sanitize;
|
|
it relies on apply_mlx_vlm_mtp_patch having installed the mtp.*
|
|
preservation first. If only the runtime patch runs, stock mlx-vlm
|
|
sanitize strips every mtp.* key and the MTP head loads at random
|
|
init (PR #1320).
|
|
|
|
MoE VLMs without declared MTP heads still need the sanitize replacement
|
|
so pre-converted switch_mlp weights load (issue #1261); runtime patch
|
|
must not run on that path."""
|
|
|
|
def _stub_patches(self, monkeypatch):
|
|
"""Replace the patch modules with mocks that record call order.
|
|
|
|
Returns the recorded-order list plus the sanitize/runtime/attach
|
|
mocks."""
|
|
calls: list[str] = []
|
|
sanitize_mock = MagicMock(side_effect=lambda: calls.append("sanitize") or True)
|
|
runtime_mock = MagicMock(side_effect=lambda: calls.append("runtime") or True)
|
|
attach_mock = MagicMock(
|
|
side_effect=lambda enabled: calls.append(f"attach={enabled}")
|
|
)
|
|
# Side-step the real mlx-lm load_config monkey-patch.
|
|
monkeypatch.setattr(model_loading, "_patch_mlx_lm_load_config", lambda: None)
|
|
monkeypatch.setitem(
|
|
sys.modules,
|
|
"omlx.patches.mlx_lm_mtp",
|
|
MagicMock(
|
|
set_mtp_active=MagicMock(),
|
|
apply_mlx_lm_mtp_patch=MagicMock(return_value=True),
|
|
),
|
|
)
|
|
monkeypatch.setitem(
|
|
sys.modules,
|
|
"omlx.patches.mlx_vlm_mtp",
|
|
MagicMock(
|
|
apply_mlx_vlm_mtp_patch=sanitize_mock,
|
|
apply_mlx_vlm_mtp_runtime_patch=runtime_mock,
|
|
set_mtp_attach_enabled=attach_mock,
|
|
),
|
|
)
|
|
return calls, sanitize_mock, runtime_mock, attach_mock
|
|
|
|
def test_sanitize_patch_runs_before_runtime_for_vlm_mtp(
|
|
self, tmp_path, monkeypatch
|
|
):
|
|
calls, sanitize_mock, runtime_mock, attach_mock = self._stub_patches(
|
|
monkeypatch
|
|
)
|
|
# qwen3_5 (dense VLM) declaring an MTP head under text_config.
|
|
path = _write_config(
|
|
tmp_path,
|
|
'{"model_type": "qwen3_5", "vision_config": {}, '
|
|
'"text_config": {"mtp_num_hidden_layers": 1}}',
|
|
)
|
|
_write_mtp_index(tmp_path, has_mtp=True)
|
|
settings = types.SimpleNamespace(mtp_enabled=True)
|
|
|
|
maybe_apply_pre_load_patches(path, model_settings=settings, for_vlm=True)
|
|
|
|
sanitize_mock.assert_called_once()
|
|
runtime_mock.assert_called_once()
|
|
attach_mock.assert_called_once_with(True)
|
|
# Ordering matters: the dense runtime patch assumes sanitize was
|
|
# already installed by apply_mlx_vlm_mtp_patch.
|
|
assert calls == ["attach=True", "sanitize", "runtime"]
|
|
|
|
def test_vlm_patches_applied_when_mtp_disabled_for_vlm(self, tmp_path, monkeypatch):
|
|
# Issue #1404: persisted ``mtp.*`` weights must still get a binding
|
|
# site on the LanguageModel tree when entering through VLMBatchedEngine
|
|
# even with mtp_enabled=False. Otherwise mlx-vlm's strict load_weights
|
|
# fails with "parameters not in model" and the engine falls back to
|
|
# LLM, silently dropping vision.
|
|
calls, sanitize_mock, runtime_mock, attach_mock = self._stub_patches(
|
|
monkeypatch
|
|
)
|
|
path = _write_config(
|
|
tmp_path,
|
|
'{"model_type": "qwen3_5", "vision_config": {}, '
|
|
'"text_config": {"mtp_num_hidden_layers": 1}}',
|
|
)
|
|
_write_mtp_index(tmp_path, has_mtp=True)
|
|
settings = types.SimpleNamespace(mtp_enabled=False)
|
|
|
|
maybe_apply_pre_load_patches(path, model_settings=settings, for_vlm=True)
|
|
|
|
sanitize_mock.assert_called_once()
|
|
runtime_mock.assert_called_once()
|
|
attach_mock.assert_called_once_with(True)
|
|
assert calls == ["attach=True", "sanitize", "runtime"]
|
|
|
|
def test_vlm_attach_disabled_when_config_declares_mtp_but_weights_missing(
|
|
self, tmp_path, monkeypatch
|
|
):
|
|
# Issue #1426: unsloth Qwen3.6 UD MLX builds declare
|
|
# mtp_num_hidden_layers=1 in config.json but ship no mtp.* weights.
|
|
# Attaching MTPModule there causes mlx-vlm strict load_weights to
|
|
# fail with "Missing N parameters: language_model.mtp.*", the
|
|
# engine falls back to LLM, and vision is silently dropped. The
|
|
# dispatcher must flip set_mtp_attach_enabled(False) so the runtime
|
|
# patch's __init__ wrap skips attachment for this load.
|
|
calls, sanitize_mock, runtime_mock, attach_mock = self._stub_patches(
|
|
monkeypatch
|
|
)
|
|
path = _write_config(
|
|
tmp_path,
|
|
'{"model_type": "qwen3_5_moe", "vision_config": {}, '
|
|
'"text_config": {"mtp_num_hidden_layers": 1}}',
|
|
)
|
|
_write_mtp_index(tmp_path, has_mtp=False)
|
|
settings = types.SimpleNamespace(mtp_enabled=False)
|
|
|
|
maybe_apply_pre_load_patches(path, model_settings=settings, for_vlm=True)
|
|
|
|
sanitize_mock.assert_called_once()
|
|
# Runtime patch itself still applies (process-wide class wrap is
|
|
# idempotent and harmless when there are no mtp.* weights to bind);
|
|
# the gate is what prevents MTPModule attachment.
|
|
runtime_mock.assert_called_once()
|
|
attach_mock.assert_called_once_with(False)
|
|
assert calls == ["attach=False", "sanitize", "runtime"]
|
|
|
|
def test_vlm_patches_skipped_when_not_for_vlm(self, tmp_path, monkeypatch):
|
|
# BatchedEngine / DFlashEngine / LLM loader paths must NOT touch
|
|
# mlx-vlm classes even when the model declares MTP heads. for_vlm
|
|
# defaults to False so they pass through without invoking mlx-vlm
|
|
# patches.
|
|
calls, sanitize_mock, runtime_mock, attach_mock = self._stub_patches(
|
|
monkeypatch
|
|
)
|
|
path = _write_config(
|
|
tmp_path,
|
|
'{"model_type": "qwen3_5", "vision_config": {}, '
|
|
'"text_config": {"mtp_num_hidden_layers": 1}}',
|
|
)
|
|
settings = types.SimpleNamespace(mtp_enabled=True)
|
|
|
|
maybe_apply_pre_load_patches(path, model_settings=settings)
|
|
|
|
sanitize_mock.assert_not_called()
|
|
runtime_mock.assert_not_called()
|
|
assert calls == []
|
|
|
|
def test_qwen36_moe_vlm_sanitize_when_no_mtp_heads(self, tmp_path, monkeypatch):
|
|
# mlx-lm Qwen3.6 MoE VLMs without MTP heads still need the mlx-vlm
|
|
# sanitize replacement so pre-converted switch_mlp weights load.
|
|
# Runtime MTP patch must NOT run — there is no mtp.* tree to bind.
|
|
calls, sanitize_mock, runtime_mock, attach_mock = self._stub_patches(
|
|
monkeypatch
|
|
)
|
|
path = _write_config(
|
|
tmp_path,
|
|
'{"model_type": "qwen3_5_moe", "vision_config": {}, '
|
|
'"text_config": {"mtp_num_hidden_layers": 0}}',
|
|
)
|
|
settings = types.SimpleNamespace(mtp_enabled=False)
|
|
|
|
maybe_apply_pre_load_patches(path, model_settings=settings, for_vlm=True)
|
|
|
|
sanitize_mock.assert_called_once()
|
|
runtime_mock.assert_not_called()
|
|
assert calls == ["sanitize"]
|
|
|
|
def test_qwen36_moe_vlm_sanitize_skipped_without_for_vlm(
|
|
self, tmp_path, monkeypatch
|
|
):
|
|
calls, sanitize_mock, runtime_mock, attach_mock = self._stub_patches(
|
|
monkeypatch
|
|
)
|
|
path = _write_config(
|
|
tmp_path,
|
|
'{"model_type": "qwen3_5_moe", "vision_config": {}, '
|
|
'"text_config": {"mtp_num_hidden_layers": 0}}',
|
|
)
|
|
settings = types.SimpleNamespace(mtp_enabled=False)
|
|
|
|
maybe_apply_pre_load_patches(path, model_settings=settings)
|
|
|
|
sanitize_mock.assert_not_called()
|
|
runtime_mock.assert_not_called()
|
|
assert calls == []
|
|
|
|
|
|
class TestCheckpointHasMtpWeights:
|
|
"""``_checkpoint_has_mtp_weights`` decides whether the mlx-vlm runtime
|
|
patch attaches ``MTPModule`` at load time. The scan must:
|
|
|
|
- return True when ``model.safetensors.index.json`` declares any key
|
|
under the ``(language_model.|model.)?mtp.`` prefix family;
|
|
- return False when no MTP-prefixed key is found;
|
|
- return False on missing / unreadable inputs (callers treat that as
|
|
"no MTP weights" — the conservative choice).
|
|
"""
|
|
|
|
def _write_index(self, tmp_path, weight_map: dict) -> None:
|
|
import json as _json
|
|
|
|
(tmp_path / "model.safetensors.index.json").write_text(
|
|
_json.dumps({"metadata": {}, "weight_map": weight_map})
|
|
)
|
|
|
|
def test_returns_true_when_index_has_language_model_mtp(self, tmp_path):
|
|
self._write_index(
|
|
tmp_path,
|
|
{
|
|
"language_model.model.embed_tokens.weight": "model.safetensors",
|
|
"language_model.mtp.fc.weight": "model.safetensors",
|
|
},
|
|
)
|
|
assert model_loading._checkpoint_has_mtp_weights(str(tmp_path)) is True
|
|
|
|
|
|
def test_returns_true_when_index_has_bare_mtp(self, tmp_path):
|
|
self._write_index(
|
|
tmp_path,
|
|
{"mtp.layers.0.self_attn.q_proj.weight": "model.safetensors"},
|
|
)
|
|
assert model_loading._checkpoint_has_mtp_weights(str(tmp_path)) is True
|
|
|
|
def test_returns_true_when_index_has_model_language_model_mtp(self, tmp_path):
|
|
# mlx-vlm HF-source layout before sanitize-time remap (oQ writes this).
|
|
self._write_index(
|
|
tmp_path,
|
|
{"model.language_model.mtp.norm.weight": "model.safetensors"},
|
|
)
|
|
assert model_loading._checkpoint_has_mtp_weights(str(tmp_path)) is True
|
|
|
|
def test_returns_false_when_index_lacks_mtp(self, tmp_path):
|
|
# Unsloth Qwen3.6 UD MLX layout: vision_tower + language_model.model.*
|
|
# but no language_model.mtp.* keys despite mtp_num_hidden_layers > 0
|
|
# in config.json (issue #1426).
|
|
self._write_index(
|
|
tmp_path,
|
|
{
|
|
"language_model.model.embed_tokens.weight": "model.safetensors",
|
|
"vision_tower.blocks.0.attn.proj.weight": "model.safetensors",
|
|
},
|
|
)
|
|
assert model_loading._checkpoint_has_mtp_weights(str(tmp_path)) is False
|
|
|
|
def test_returns_true_for_nextn_layout(self, tmp_path):
|
|
# DeepSeek-V3-style checkpoints (GLM-5.2) keep the MTP head as an
|
|
# extra decoder layer past num_hidden_layers, not under mtp.*.
|
|
import json as _json
|
|
|
|
(tmp_path / "config.json").write_text(
|
|
_json.dumps({"num_hidden_layers": 78, "num_nextn_predict_layers": 1})
|
|
)
|
|
self._write_index(
|
|
tmp_path,
|
|
{
|
|
"model.layers.0.self_attn.q_a_proj.weight": "model.safetensors",
|
|
"model.layers.78.eh_proj.weight": "model.safetensors",
|
|
},
|
|
)
|
|
assert model_loading._checkpoint_has_mtp_weights(str(tmp_path)) is True
|
|
|
|
def test_returns_false_for_nextn_config_without_weights(self, tmp_path):
|
|
# Config declares nextn layers but the checkpoint stripped them.
|
|
import json as _json
|
|
|
|
(tmp_path / "config.json").write_text(
|
|
_json.dumps({"num_hidden_layers": 78, "num_nextn_predict_layers": 1})
|
|
)
|
|
self._write_index(
|
|
tmp_path,
|
|
{"model.layers.0.self_attn.q_a_proj.weight": "model.safetensors"},
|
|
)
|
|
assert model_loading._checkpoint_has_mtp_weights(str(tmp_path)) is False
|
|
|
|
def test_returns_false_for_empty_dir(self, tmp_path):
|
|
# No index, no shards — caller treats as "no MTP weights".
|
|
assert model_loading._checkpoint_has_mtp_weights(str(tmp_path)) is False
|
|
|
|
def test_returns_false_for_nonexistent_path(self, tmp_path):
|
|
assert (
|
|
model_loading._checkpoint_has_mtp_weights(str(tmp_path / "does-not-exist"))
|
|
is False
|
|
)
|
|
|
|
def test_returns_false_on_malformed_index(self, tmp_path):
|
|
(tmp_path / "model.safetensors.index.json").write_text("{not valid")
|
|
# Falls through to safetensors-header scan; no shards exist, so
|
|
# the helper conservatively returns False.
|
|
assert model_loading._checkpoint_has_mtp_weights(str(tmp_path)) is False
|
|
|
|
def test_returns_true_when_later_shard_has_mtp(self, tmp_path):
|
|
from safetensors.numpy import save_file
|
|
import numpy as np
|
|
|
|
save_file(
|
|
{
|
|
"language_model.model.embed_tokens.weight": np.zeros(
|
|
(1,), dtype=np.float16
|
|
)
|
|
},
|
|
str(tmp_path / "model-00001-of-00002.safetensors"),
|
|
)
|
|
save_file(
|
|
{"language_model.mtp.fc.weight": np.zeros((1,), dtype=np.float16)},
|
|
str(tmp_path / "model-00002-of-00002.safetensors"),
|
|
)
|
|
|
|
assert model_loading._checkpoint_has_mtp_weights(str(tmp_path)) is True
|
|
|
|
|
|
class TestExpandPerLayerQuantKeys:
|
|
"""expand_per_layer_quant_keys adds runtime module-tree key variants."""
|
|
|
|
def test_adds_language_model_prefix_for_bare_key(self):
|
|
cfg = {
|
|
"quantization": {
|
|
"bits": 6,
|
|
"group_size": 64,
|
|
"lm_head": {"bits": 8, "group_size": 64},
|
|
}
|
|
}
|
|
|
|
model_loading.expand_per_layer_quant_keys(cfg)
|
|
|
|
assert cfg["quantization"]["language_model.lm_head"] == {
|
|
"bits": 8,
|
|
"group_size": 64,
|
|
}
|
|
|
|
def test_adds_swapped_prefix_variant_for_model_language_model_key(self):
|
|
key = "model.language_model.layers.0.linear_attn.in_proj_qkv"
|
|
cfg = {
|
|
"quantization": {
|
|
"bits": 6,
|
|
"group_size": 64,
|
|
key: {"bits": 8, "group_size": 64},
|
|
}
|
|
}
|
|
|
|
model_loading.expand_per_layer_quant_keys(cfg)
|
|
|
|
swapped = "language_model.model.layers.0.linear_attn.in_proj_qkv"
|
|
assert swapped in cfg["quantization"]
|
|
assert cfg["quantization"][swapped]["bits"] == 8
|